changeset 249:2b09f8e98cfe inbox

merge default -> inbox
author Alain Mazy <am@orthanc.team>
date Fri, 11 Jul 2025 18:41:47 +0200
parents ff21632f3ab6 (diff) d18a5deb19cf (current diff)
children f1c8f36e0b87
files NEWS Plugin/AuthorizationWebService.cpp Plugin/BaseAuthorizationService.h Plugin/IAuthorizationService.h Plugin/Plugin.cpp
diffstat 9 files changed, 1116 insertions(+), 152 deletions(-) [+]
line wrap: on
line diff
--- a/NEWS	Fri Jul 11 18:20:32 2025 +0200
+++ b/NEWS	Fri Jul 11 18:41:47 2025 +0200
@@ -5,6 +5,17 @@
 => Recommended SDK version: 1.12.4 <=
 => Minimum SDK version: 1.11.3 <=
 
+* New configuration "ExtraPermissions" to ADD new permissions to
+  the default "Permissions" entries.
+* Improved handling of "Anonymous" user profiles (when no auth-tokens
+  are provided):  The plugin will now request the auth-service to
+  get an anonymous user profile even if there are no auth-tokens in the
+  HTTP request.
+* The User profile can now contain a "groups" field if the auth-service
+  provides it.
+* The User profile can now contain an "id" field if the auth-service
+  provides it.
+* New experimental feature: audit-logs (TODO)
 * Fixed a security issue: the entries in the cache token->permissions were kept too long in the cache
   allowing users to have access to generic routes even with an expired token.
   These entries are now stored maximum for 10 seconds.
--- a/Plugin/AuthorizationWebService.cpp	Fri Jul 11 18:20:32 2025 +0200
+++ b/Plugin/AuthorizationWebService.cpp	Fri Jul 11 18:41:47 2025 +0200
@@ -34,6 +34,9 @@
   static const char* PERMISSIONS = "permissions";
   static const char* AUTHORIZED_LABELS = "authorized-labels";
   static const char* USER_NAME = "name";
+  static const char* GROUPS = "groups";
+  static const char* USER_ID = "user-id";
+
   
 
   bool AuthorizationWebService::IsGrantedInternal(unsigned int& validity,
@@ -339,8 +342,10 @@
   {
     jsonProfile = Json::objectValue;
     jsonProfile[USER_NAME] = profile.name;
+    jsonProfile[USER_ID] = profile.userId;
     Orthanc::SerializationToolbox::WriteSetOfStrings(jsonProfile, profile.authorizedLabels, AUTHORIZED_LABELS);
     Orthanc::SerializationToolbox::WriteSetOfStrings(jsonProfile, profile.permissions, PERMISSIONS);
+    Orthanc::SerializationToolbox::WriteSetOfStrings(jsonProfile, profile.groups, GROUPS);
   }
     
   void AuthorizationWebService::FromJson(UserProfile& profile, const Json::Value& jsonProfile)
@@ -368,6 +373,19 @@
     {
       profile.authorizedLabels.insert(jsonProfile[AUTHORIZED_LABELS][i].asString());
     }
+
+    if (jsonProfile.isMember(GROUPS) && jsonProfile[GROUPS].isArray())
+    {
+      for (Json::ArrayIndex i = 0; i < jsonProfile[GROUPS].size(); ++i)
+      {
+        profile.groups.insert(jsonProfile[GROUPS][i].asString());
+      }
+    }
+
+    if (jsonProfile.isMember(USER_ID) && jsonProfile[USER_ID].isString())
+    {
+      profile.userId = jsonProfile[USER_ID].asString();
+    }
   }
 
 
--- a/Plugin/BaseAuthorizationService.h	Fri Jul 11 18:20:32 2025 +0200
+++ b/Plugin/BaseAuthorizationService.h	Fri Jul 11 18:41:47 2025 +0200
@@ -100,24 +100,5 @@
       return false;
     }
 
-    virtual bool HasAnonymousUserPermission(const std::set<std::string>& anyOfPermissions) ORTHANC_OVERRIDE
-    {
-      if (anyOfPermissions.size() == 0)
-      {
-        return true;
-      }
-
-      UserProfile anonymousUserProfile;
-      anonymousUserProfile.tokenType = TokenType_None;
-
-      for (std::set<std::string>::const_iterator it = anyOfPermissions.begin(); it != anyOfPermissions.end(); ++it)
-      {
-        if (HasUserPermissionInternal(*it, anonymousUserProfile))
-        {
-          return true;
-        }
-      }
-      return false;
-    }
   };
 }
--- a/Plugin/Enumerations.cpp	Fri Jul 11 18:20:32 2025 +0200
+++ b/Plugin/Enumerations.cpp	Fri Jul 11 18:41:47 2025 +0200
@@ -84,4 +84,31 @@
       throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange, std::string("Invalid access level: ") + tmp);
     }
   }
+
+  OrthancPluginResourceType StringToResourceType(const char* type)
+  {
+    std::string s(type);
+    Orthanc::Toolbox::ToUpperCase(s);
+
+    if (s == "PATIENT" || s == "PATIENTS")
+    {
+      return OrthancPluginResourceType_Patient;
+    }
+    else if (s == "STUDY" || s == "STUDIES")
+    {
+      return OrthancPluginResourceType_Study;
+    }
+    else if (s == "SERIES")
+    {
+      return OrthancPluginResourceType_Series;
+    }
+    else if (s == "INSTANCE"  || s == "IMAGE" || 
+             s == "INSTANCES" || s == "IMAGES")
+    {
+      return OrthancPluginResourceType_Instance;
+    }
+
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange, std::string("Invalid resource type '") + type + "'");
+  }
+
 }
--- a/Plugin/Enumerations.h	Fri Jul 11 18:20:32 2025 +0200
+++ b/Plugin/Enumerations.h	Fri Jul 11 18:41:47 2025 +0200
@@ -21,6 +21,7 @@
 #pragma once
 
 #include <Enumerations.h>
+#include <orthanc/OrthancCPlugin.h>
 
 namespace OrthancPlugins
 {
@@ -46,4 +47,6 @@
   std::string EnumerationToString(AccessLevel level);
 
   AccessLevel StringToAccessLevel(const std::string& level);
+
+  OrthancPluginResourceType StringToResourceType(const char* type);
 }
--- a/Plugin/IAuthorizationService.h	Fri Jul 11 18:20:32 2025 +0200
+++ b/Plugin/IAuthorizationService.h	Fri Jul 11 18:41:47 2025 +0200
@@ -61,8 +61,10 @@
     struct UserProfile
     {
       std::string name;
+      std::string userId;
       std::set<std::string> permissions;
       std::set<std::string> authorizedLabels;
+      std::set<std::string> groups;
 
       // the source token key/value that identified the user
       TokenType   tokenType;
@@ -95,8 +97,6 @@
     virtual bool HasUserPermission(const std::set<std::string>& anyOfPermissions,
                                    const UserProfile& profile) = 0;
 
-    virtual bool HasAnonymousUserPermission(const std::set<std::string>& anyOfPermissions) = 0;
-
     virtual bool CreateToken(CreatedToken& response,
                              const std::string& tokenType, 
                              const std::string& id, 
--- a/Plugin/Plugin.cpp	Fri Jul 11 18:20:32 2025 +0200
+++ b/Plugin/Plugin.cpp	Fri Jul 11 18:41:47 2025 +0200
@@ -38,6 +38,7 @@
 // Configuration of the authorization plugin
 static bool resourceTokensEnabled_ = false;
 static bool userTokensEnabled_ = false;
+static bool enableAuditLogs_ = true;
 static std::unique_ptr<OrthancPlugins::IAuthorizationParser> authorizationParser_;
 static std::unique_ptr<OrthancPlugins::IAuthorizationService> authorizationService_;
 static std::unique_ptr<OrthancPlugins::PermissionParser> permissionParser_;
@@ -64,7 +65,77 @@
   OrthancPluginSendHttpStatus(context, output, 403, message, strlen(message));
 }
 
+static const char* KEY_USER_DATA = "UserData";
+static const char* KEY_USER_ID = "AuditLogsUserId";
 
+static bool GetUserIdFromUserData(std::string& userId, const Json::Value& payload)
+{
+  if (payload.isMember(KEY_USER_DATA) && payload[KEY_USER_DATA].isObject() 
+    && payload[KEY_USER_DATA].isMember(KEY_USER_ID) && payload[KEY_USER_DATA][KEY_USER_ID].isString())
+  {
+    userId = payload[KEY_USER_DATA][KEY_USER_ID].asString();
+    return true;
+  }
+  return false;
+}
+
+static void SetUserIdInUserdata(Json::Value& payload, const std::string& userId)
+{
+  if (!payload.isMember(KEY_USER_DATA))
+  {
+    payload[KEY_USER_DATA] = Json::objectValue;
+  }
+  payload[KEY_USER_DATA][KEY_USER_ID] = userId;
+}
+
+struct AuditLog
+{
+  std::string userId;
+  OrthancPluginResourceType resourceType;
+  std::string resourceId;
+  std::string action;
+  Json::Value logData;
+
+  AuditLog(const std::string& userId,
+           const OrthancPluginResourceType& resourceType,
+           const std::string& resourceId,
+           const std::string& action,
+           const Json::Value& logData) :
+    userId(userId),
+    resourceType(resourceType),
+    resourceId(resourceId),
+    action(action),
+    logData(logData)
+  {
+  }
+};
+
+static void RecordAuditLog(const std::string& userId,
+                           const OrthancPluginResourceType& resourceType,
+                           const std::string& resourceId,
+                           const std::string& action,
+                           const Json::Value& logData)
+{
+  LOG(WARNING) << "AUDIT-LOG: " << userId << " / " << action << " on " << resourceType << ":" << resourceId << ", " << logData.toStyledString();
+}
+
+static void RecordAuditLog(const AuditLog& auditLog)
+{
+  RecordAuditLog(auditLog.userId,
+                 auditLog.resourceType,
+                 auditLog.resourceId,
+                 auditLog.action,
+                 auditLog.logData);
+}
+
+
+static void RecordAuditLogs(const std::list<AuditLog>& auditLogs)
+{
+  for (std::list<AuditLog>::const_iterator it = auditLogs.begin(); it != auditLogs.end(); ++it)
+  {
+    RecordAuditLog(*it);
+  }
+}
 
 class TokenAndValue
 {
@@ -273,6 +344,46 @@
   return false;
 }
 
+static bool TestRequiredPermissions(bool& hasUserRequiredPermissions, 
+                                    const std::set<std::string>& requiredPermissions, 
+                                    const OrthancPlugins::IAuthorizationService::UserProfile& profile,
+                                    const std::string& msg, 
+                                    const char* uri,
+                                    OrthancPluginHttpMethod method,
+                                    const OrthancPlugins::AssociativeArray& getArguments
+                                    )
+{
+  if (authorizationService_->HasUserPermission(requiredPermissions, profile))
+  {
+    LOG(INFO) << msg << " -> granted to user '" << profile.name << "'";
+    hasUserRequiredPermissions = true;
+
+    // check labels permissions
+    std::string msg2 = std::string("Testing whether user has the authorized_labels to access '") + uri + "'";
+
+    bool hasAuthorizedLabelsForResource = false;
+    if (CheckAuthorizedLabelsForResource(hasAuthorizedLabelsForResource, uri, method, getArguments, profile))
+    {
+      if (hasAuthorizedLabelsForResource)
+      {
+        LOG(INFO) << msg2 << " -> granted";
+      }
+      else
+      {
+        LOG(INFO) << msg2 << " -> not granted";
+        return false; // the labels for this resource prevents access -> stop checking now !
+      }
+    }
+  }
+  else
+  {
+    LOG(INFO) << msg << " -> not granted";
+    hasUserRequiredPermissions = false;
+  }
+  
+  return true;
+}
+
 static int32_t FilterHttpRequests(OrthancPluginHttpMethod method,
                                   const char *uri,
                                   const char *ip,
@@ -313,6 +424,7 @@
     // Based on the tokens, check if the user has access based on its permissions and the mapping between urls and permissions
     ////////////////////////////////////////////////////////////////
     bool hasUserRequiredPermissions = false;
+    std::string userId;
 
     if (permissionParser_.get() != NULL &&
       authorizationService_.get() != NULL) 
@@ -323,21 +435,20 @@
       {
         if (authTokens.empty())
         {
-          std::string msg = std::string("Testing whether anonymous user has any of the required permissions '") + JoinStrings(requiredPermissions) + "'";
-          
+          std::string msg = std::string("Testing whether anonymous user has any of the required permissions '") + JoinStrings(requiredPermissions) + "' required to match '" + matchedPattern + "'";
+
+          OrthancPlugins::IAuthorizationService::UserProfile anonymousProfile;
+          unsigned int validityNotUsed;
+          authorizationService_->GetUserProfile(validityNotUsed, anonymousProfile, OrthancPlugins::Token(OrthancPlugins::TokenType_None, ""), "");
+          userId = "anonymous";
+
           LOG(INFO) << msg; 
-
-          if (authorizationService_->HasAnonymousUserPermission(requiredPermissions))
+          if (!TestRequiredPermissions(hasUserRequiredPermissions, requiredPermissions, anonymousProfile, msg, uri, method, getArguments))
           {
-            LOG(INFO) << msg << " -> granted";
-            hasUserRequiredPermissions = true;
+            return 0; // the labels for this resource prevents access -> stop checking now !
           }
-          else
-          {
-            LOG(INFO) << msg << " -> not granted";
-            hasUserRequiredPermissions = false;
-            // continue in order to check if there is a resource token that could grant access to the resource
-          }
+
+          // continue in order to check if there is a resource token that could grant access to the resource
         }
         else
         {
@@ -345,38 +456,21 @@
           {
             std::string msg = std::string("Testing whether user has the required permissions '") + JoinStrings(requiredPermissions) + "' based on the HTTP header '" + authTokens[i].GetToken().GetKey() + "' required to match '" + matchedPattern + "'";
 
-            // LOG(INFO) << msg;
+            LOG(INFO) << msg;
             OrthancPlugins::IAuthorizationService::UserProfile profile;
             unsigned int validityNotUsed;
             authorizationService_->GetUserProfile(validityNotUsed, profile, authTokens[i].GetToken(), authTokens[i].GetValue());
 
-            if (authorizationService_->HasUserPermission(requiredPermissions, profile))
+            if (!profile.userId.empty())
             {
-              LOG(INFO) << msg << " -> granted";
-              hasUserRequiredPermissions = true;
-
-              // check labels permissions
-              msg = std::string("Testing whether user has the authorized_labels to access '") + uri + "' based on the HTTP header '" + authTokens[i].GetToken().GetKey() + "'";
+              userId = profile.userId;
+            }
 
-              bool hasAuthorizedLabelsForResource = false;
-              if (CheckAuthorizedLabelsForResource(hasAuthorizedLabelsForResource, uri, method, getArguments, profile))
-              {
-                if (hasAuthorizedLabelsForResource)
-                {
-                  LOG(INFO) << msg << " -> granted";
-                }
-                else
-                {
-                  LOG(INFO) << msg << " -> not granted";
-                  return 0; // the labels for this resource prevents access -> stop checking now !
-                }
-              }
+            if (!TestRequiredPermissions(hasUserRequiredPermissions, requiredPermissions, profile, msg, uri, method, getArguments))
+            {
+              return 0; // the labels for this resource prevents access -> stop checking now !
             }
-            else
-            {
-              LOG(INFO) << msg << " -> not granted";
-              hasUserRequiredPermissions = false;
-            }
+
           }
         }
       }
@@ -478,34 +572,96 @@
 {
   try
   {
-    if (authorizationParser_.get() == NULL)
+    if (authorizationParser_.get() == NULL || !enableAuditLogs_)
     {
       return OrthancPluginErrorCode_Success;
     }
-    
-    if (changeType == OrthancPluginChangeType_Deleted)
+
+    switch(changeType)
     {
-      switch (resourceType)
+      case OrthancPluginChangeType_JobSuccess:
       {
-        case OrthancPluginResourceType_Patient:
-          authorizationParser_->Invalidate(Orthanc::ResourceType_Patient, resourceId);
-          break;
+        Json::Value job;
+        if (OrthancPlugins::RestApiGet(job, std::string("/jobs/") + resourceId, false))
+        {
+          if (job["Type"].asString() == "ResourceModification")
+          {
+            Json::Value jobContent = job["Content"];
+            std::string sourceResourceId = jobContent["ParentResources"][0].asString();
+            std::string modifiedResourceId = jobContent["ID"].asString();
+            OrthancPluginResourceType resourceType = OrthancPlugins::StringToResourceType(jobContent["Type"].asString().c_str());
+
+            bool isAnonymization = jobContent.isMember("IsAnonymization") && jobContent["IsAnonymization"].asBool();
+            LOG(WARNING) << jobContent.toStyledString();
+
+            if (isAnonymization)
+            {
+              std::string userId;
+              if (GetUserIdFromUserData(userId, job))
+              {
+                // attach a log to the source study
+                Json::Value logData;
+                logData["ModifiedResourceId"] = modifiedResourceId;
+                logData["ModifiedResourceType"] = resourceType;
+
+                RecordAuditLog(userId, 
+                               resourceType,
+                               sourceResourceId, 
+                               (isAnonymization ? "success-anonymization" : "success-modification-job"), 
+                               logData);
+                
+                // attach a log to the modified study
+                if (sourceResourceId != modifiedResourceId)
+                {
+                  Json::Value logData;
+                  logData["SourceResourceId"] = sourceResourceId;
+                  logData["SourceResourceType"] = resourceType;
 
-        case OrthancPluginResourceType_Study:
-          authorizationParser_->Invalidate(Orthanc::ResourceType_Study, resourceId);
-          break;
+                  RecordAuditLog(userId, 
+                                 resourceType,
+                                 modifiedResourceId, 
+                                 (isAnonymization ? "new-study-from-anonymization-job" : "new-study-from-modification-job"), 
+                                 logData);
+                }
+              }
+            }            
+          }
 
-        case OrthancPluginResourceType_Series:
-          authorizationParser_->Invalidate(Orthanc::ResourceType_Series, resourceId);
-          break;
+        }
+
+        return OrthancPluginErrorCode_Success;
+      }
+      case OrthancPluginChangeType_JobFailure:
+      {
+        return OrthancPluginErrorCode_Success;
+      }
 
-        case OrthancPluginResourceType_Instance:
-          authorizationParser_->Invalidate(Orthanc::ResourceType_Instance, resourceId);
-          break;
+      case OrthancPluginChangeType_Deleted:
+      {
+        switch (resourceType)
+        {
+          case OrthancPluginResourceType_Patient:
+            authorizationParser_->Invalidate(Orthanc::ResourceType_Patient, resourceId);
+            break;
+
+          case OrthancPluginResourceType_Study:
+            authorizationParser_->Invalidate(Orthanc::ResourceType_Study, resourceId);
+            break;
 
-        default:
-          break;
+          case OrthancPluginResourceType_Series:
+            authorizationParser_->Invalidate(Orthanc::ResourceType_Series, resourceId);
+            break;
+
+          case OrthancPluginResourceType_Instance:
+            authorizationParser_->Invalidate(Orthanc::ResourceType_Instance, resourceId);
+            break;
+
+          default:
+            break;
+        }
       }
+      default:
+        return OrthancPluginErrorCode_Success;    
     }
        
     return OrthancPluginErrorCode_Success;
@@ -527,6 +683,47 @@
   }
 }
 
+bool GetUserProfileInternal_(OrthancPlugins::IAuthorizationService::UserProfile& profile, 
+                             const OrthancPlugins::AssociativeArray& headers,
+                             const OrthancPlugins::AssociativeArray& getArguments,
+                             bool ignoreEmptyValues)
+{
+  for (std::set<OrthancPlugins::Token>::const_iterator
+          token = tokens_.begin(); token != tokens_.end(); ++token)
+  {
+    OrthancPlugins::IAuthorizationService::UserProfile tryProfile;
+
+    std::string value;
+    switch (token->GetType())
+    {
+      case OrthancPlugins::TokenType_HttpHeader:
+        headers.GetValue(value, token->GetKey());
+        break;
+
+      case OrthancPlugins::TokenType_GetArgument:
+        getArguments.GetValue(value, token->GetKey());
+        break;
+
+      default:
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
+    }
+    
+    if (ignoreEmptyValues && value.empty())
+    {
+      continue;
+    }
+
+    unsigned int validity; // not used
+    if (authorizationService_->GetUserProfile(validity, tryProfile, *token, value))
+    {
+      profile = tryProfile;
+      return true;
+    }
+  }
+
+  return false;
+}
+
 
 bool GetUserProfileInternal(OrthancPlugins::IAuthorizationService::UserProfile& profile, const OrthancPluginHttpRequest* request)
 {
@@ -537,41 +734,14 @@
     (request->getCount, request->getKeys, request->getValues, true);
 
   // Loop over all the authorization tokens stored in the HTTP
-  // headers, until finding one that is granted
-  for (std::set<OrthancPlugins::Token>::const_iterator
-          token = tokens_.begin(); token != tokens_.end(); ++token)
+  // headers, until finding one that is granted.
+  // But, first process only the tokens with a value to avoid getting identified as anonymous too fast !
+  if (GetUserProfileInternal_(profile, headers, getArguments, true))
   {
-    OrthancPlugins::IAuthorizationService::UserProfile tryProfile;
-
-    std::string value;
-
-    bool hasValue = false;
-    switch (token->GetType())
-    {
-      case OrthancPlugins::TokenType_HttpHeader:
-        hasValue = headers.GetValue(value, token->GetKey());
-        break;
-
-      case OrthancPlugins::TokenType_GetArgument:
-        hasValue = getArguments.GetValue(value, token->GetKey());
-        break;
-
-      default:
-        throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
-    }
-    
-    if (hasValue)
-    {
-      unsigned int validity; // not used
-      if (authorizationService_->GetUserProfile(validity, tryProfile, *token, value))
-      {
-        profile = tryProfile;
-        return true;
-      }
-    }
+    return true;
   }
 
-  return false;
+  return GetUserProfileInternal_(profile, headers, getArguments, false);
 }
 
 void AdjustToolsFindQueryLabels(Json::Value& query, const OrthancPlugins::IAuthorizationService::UserProfile& profile)
@@ -921,6 +1091,325 @@
   ToolsFindOrCountResources(output, url, request, "/tools/count-resources", false);
 }
 
+void UploadInstancesWithAuditLogs(OrthancPluginRestOutput* output,
+                                  const char* url,
+                                  const OrthancPluginHttpRequest* request)
+{
+  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+  // always forward to core
+  OrthancPlugins::RestApiClient coreApi(url, request);
+  coreApi.ExecuteAndForwardAnswer(context, output);
+
+  if (request->method == OrthancPluginHttpMethod_Post)
+  {
+    OrthancPlugins::IAuthorizationService::UserProfile profile;
+    Json::Value coreResponse;
+
+    if (GetUserProfileInternal(profile, request) && coreApi.GetAnswerJson(coreResponse))
+    {
+      RecordAuditLog(profile.userId, OrthancPluginResourceType_Study, coreResponse["ParentStudy"].asString(), "uploaded-instance", coreResponse["ID"].asString());
+    }
+    else
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_ForbiddenAccess, "Auth plugin: no user profile found, unable to handle POST to /instances with audit logs enabled.");
+    }
+  }
+}
+
+
+void ModifyAnonymizeWithAuditLogs(OrthancPluginRestOutput* output,
+                                  const char* url,
+                                  const OrthancPluginHttpRequest* request,
+                                  bool isModification)
+{
+  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+  OrthancPluginResourceType resourceType = OrthancPlugins::StringToResourceType(request->groups[0]);
+  std::string resourceId = request->groups[1];
+
+  OrthancPlugins::RestApiClient coreApi(url, request);
+
+  Json::Value payload;
+  if (!OrthancPlugins::ReadJson(payload, request->body, request->bodySize))
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, "A JSON payload was expected");
+  }
+
+  // Either there is a userId in UserData or the request comes from a user with profile
+  OrthancPlugins::IAuthorizationService::UserProfile profile;
+  std::string userId;
+
+  // LOG(WARNING) << payload.toStyledString();
+
+  if (GetUserProfileInternal(profile, request) && !profile.userId.empty())
+  {
+    userId = profile.userId;  
+  } 
+  else if (!GetUserIdFromUserData(userId, payload))
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_ForbiddenAccess, "Auth plugin: no user profile or UserData found, unable to handle anonymize/modify with audit logs enabled.");
+  }
+
+  if ((payload.isMember("Synchronous") && !payload["Synchronous"].asBool())
+    || (payload.isMember("Asynchronous") && !payload["Asynchronous"].asBool()))
+  {
+    // add UserData to the job payload to know who has modified the data.  The handling of the log will then happen in the OnChange handler
+    SetUserIdInUserdata(payload, userId);
+
+    // in any case, record that this resource is being modified/anonymized and record the payload
+    RecordAuditLog(userId, 
+                   resourceType, 
+                   resourceId, 
+                   (isModification ? "start-modification-job" : "start-anonymization-job"), 
+                   payload);
+
+    if (coreApi.Execute())
+    {
+      coreApi.ForwardAnswer(context, output);
+    }
+  }
+  else
+  {
+    Json::Value coreResponse;
+
+    // if it is synchronous, perform the modification and record the log directly
+    if (coreApi.Execute())
+    {
+      coreApi.ForwardAnswer(context, output);
+    }
+    
+    if (coreApi.GetAnswerJson(coreResponse))
+    {
+      LOG(WARNING) << "TODO AUDIT-LOG " << coreResponse.toStyledString(); // TODO 
+    }
+  }
+}
+
+void ModifyWithAuditLogs(OrthancPluginRestOutput* output,
+                         const char* url,
+                         const OrthancPluginHttpRequest* request)
+{
+  ModifyAnonymizeWithAuditLogs(output, url, request, true);
+}
+
+void AnonymizeWithAuditLogs(OrthancPluginRestOutput* output,
+                            const char* url,
+                            const OrthancPluginHttpRequest* request)
+{
+  ModifyAnonymizeWithAuditLogs(output, url, request, false);
+}
+
+void LabelWithAuditLogs(OrthancPluginRestOutput* output,
+                        const char* url,
+                        const OrthancPluginHttpRequest* request)
+{
+  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+  OrthancPluginResourceType resourceType = OrthancPlugins::StringToResourceType(request->groups[0]);
+  std::string resourceId = request->groups[1];
+  std::string label = request->groups[2];
+
+  OrthancPlugins::RestApiClient coreApi(url, request);
+
+  if (request->method == OrthancPluginHttpMethod_Get && coreApi.Execute())
+  {
+    coreApi.ForwardAnswer(context, output);
+    return;
+  }
+  else
+  {
+    OrthancPlugins::IAuthorizationService::UserProfile profile;
+    std::string userId;
+
+    if (GetUserProfileInternal(profile, request) && !profile.userId.empty())
+    {
+      userId = profile.userId;  
+    }
+    else
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_ForbiddenAccess, "Auth plugin: no user profile or UserData found, unable to delete/put label with audit logs enabled.");
+    }
+    
+    std::string action;
+    if (request->method == OrthancPluginHttpMethod_Delete)
+    {
+      action = "deleted-label";
+    }
+    else if (request->method == OrthancPluginHttpMethod_Put)
+    {
+      action = "added-label";
+    }
+    
+    if (coreApi.Execute())
+    {
+      RecordAuditLog(userId, 
+                     resourceType, 
+                     resourceId, 
+                     action, 
+                     label);
+      
+      coreApi.ForwardAnswer(context, output);
+      return;
+    }
+  }
+}
+
+
+OrthancPluginResourceType IdentifyResourceType(const std::string& resourceId)
+{
+  Json::Value v;
+
+  if (OrthancPlugins::RestApiGet(v, "/studies/" + resourceId, false))
+  {
+    return OrthancPluginResourceType_Study;
+  }
+  if (OrthancPlugins::RestApiGet(v, "/patients/" + resourceId, false))
+  {
+    return OrthancPluginResourceType_Patient;
+  }
+  if (OrthancPlugins::RestApiGet(v, "/series/" + resourceId, false))
+  {
+    return OrthancPluginResourceType_Series;
+  }
+  if (OrthancPlugins::RestApiGet(v, "/instances/" + resourceId, false))
+  {
+    return OrthancPluginResourceType_Instance;
+  }
+
+  return OrthancPluginResourceType_None;
+}
+
+
+void GetResourceDeletionAuditLogs(std::list<AuditLog>& auditLogs,
+                                  OrthancPluginResourceType resourceType,
+                                  const std::string& resourceId,
+                                  const OrthancPluginHttpRequest* request)
+{
+  OrthancPlugins::IAuthorizationService::UserProfile profile;
+  std::string userId;
+
+  if (GetUserProfileInternal(profile, request) && !profile.userId.empty())
+  {
+    userId = profile.userId;  
+  }
+  else
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_ForbiddenAccess, "Auth plugin: no user profile or UserData found, unable to delete a resource with audit logs enabled.");
+  }
+  
+  Json::Value logData;
+
+  switch (resourceType)
+  {
+    case OrthancPluginResourceType_Patient:
+      {
+        auditLogs.push_back(AuditLog(userId, resourceType, resourceId, "deleted-patient", logData));
+
+        // find all child studies and add the log to each of them
+        Json::Value patient;
+        if (OrthancPlugins::RestApiGet(patient, "/patients/" + resourceId, false))
+        {
+          for (Json::ArrayIndex i = 0; i < patient["Studies"].size(); ++i)
+          {
+            auditLogs.push_back(AuditLog(userId, OrthancPluginResourceType_Study, patient["Studies"][i].asString(), "deleted-parent-patient", Json::nullValue));
+          }
+        }
+      
+      }; break;
+    case OrthancPluginResourceType_Study:
+      {
+        auditLogs.push_back(AuditLog(userId, resourceType, resourceId, "deleted-study", logData));
+      }; break;
+    case OrthancPluginResourceType_Series:
+      {
+        auditLogs.push_back(AuditLog(userId, resourceType, resourceId, "deleted-series", logData));
+
+        // add a log in the parent study 
+        Json::Value parentStudy;
+        if (OrthancPlugins::RestApiGet(parentStudy, "/series/" + resourceId + "/study", false))
+        {
+          auditLogs.push_back(AuditLog(userId, OrthancPluginResourceType_Study, parentStudy["ID"].asString(), "deleted-child-series", Json::nullValue));
+        }
+      }; break;
+    case OrthancPluginResourceType_Instance:
+      {
+        auditLogs.push_back(AuditLog(userId, resourceType, resourceId, "deleted-instance", logData));
+
+        // add a log in the parent study 
+        Json::Value parentStudy;
+        if (OrthancPlugins::RestApiGet(parentStudy, "/instances/" + resourceId + "/study", false))
+        {
+          auditLogs.push_back(AuditLog(userId, OrthancPluginResourceType_Study, parentStudy["ID"].asString(), "deleted-child-instance", Json::nullValue));
+        }
+      }; break;
+    default:
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+  }
+}
+
+void DeleteResourceWithAuditLogs(OrthancPluginRestOutput* output,
+                                 const char* url,
+                                 const OrthancPluginHttpRequest* request)
+{
+  assert(request->method == OrthancPluginHttpMethod_Delete);
+
+  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+  OrthancPluginResourceType resourceType = OrthancPlugins::StringToResourceType(request->groups[0]);
+  std::string resourceId = request->groups[1];
+  
+  std::list<AuditLog> auditLogs;
+  GetResourceDeletionAuditLogs(auditLogs, resourceType, resourceId, request);
+
+  OrthancPlugins::RestApiClient coreApi(url, request);
+  
+  if (coreApi.Execute())
+  {
+    RecordAuditLogs(auditLogs);
+
+    coreApi.ForwardAnswer(context, output);
+    return;
+  }
+}
+
+
+void BulkDeleteWithAuditLogs(OrthancPluginRestOutput* output,
+                             const char* url,
+                             const OrthancPluginHttpRequest* request)
+{
+  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+  if (request->method != OrthancPluginHttpMethod_Post)
+  {
+    OrthancPluginSendMethodNotAllowed(context, output, "POST");
+  }
+
+  Json::Value payload;
+  if (!OrthancPlugins::ReadJson(payload, request->body, request->bodySize) || !payload.isMember("Resources"))
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, "A JSON payload was expected");
+  }
+
+  std::list<AuditLog> auditLogs;
+
+  for (Json::ArrayIndex i = 0; i < payload["Resources"].size(); ++i)
+  {
+    std::string resourceId = payload["Resources"][i].asString();
+    OrthancPluginResourceType resourceType = IdentifyResourceType(resourceId);
+    GetResourceDeletionAuditLogs(auditLogs, resourceType, resourceId, request);
+  }
+
+  OrthancPlugins::RestApiClient coreApi(url, request);
+  
+  if (coreApi.Execute())
+  {
+    RecordAuditLogs(auditLogs);
+
+    coreApi.ForwardAnswer(context, output);
+    return;
+  }
+
+}
+
+
 void ToolsLabels(OrthancPluginRestOutput* output,
                  const char* /*url*/,
                  const OrthancPluginHttpRequest* request)
@@ -990,7 +1479,7 @@
   if (request->method != OrthancPluginHttpMethod_Get)
   {
     OrthancPlugins::RestApiClient coreApi(url, request);
-    coreApi.Forward(context, output);
+    coreApi.ExecuteAndForwardAnswer(context, output);
   }
   else
   {
@@ -1020,6 +1509,20 @@
   FilterLabelsFromGetCoreUrl(output, url, request, FilterLabelsInResourceObject);
 }
 
+void GetOrDeleteMainResource(OrthancPluginRestOutput* output,
+                                    const char* url,
+                                    const OrthancPluginHttpRequest* request)
+{
+  if (enableAuditLogs_ && request->method == OrthancPluginHttpMethod_Delete)
+  {
+    DeleteResourceWithAuditLogs(output, url, request);
+  }
+  else
+  {
+    FilterLabelsFromSingleResource(output, url, request);
+  }
+}
+
 void FilterLabelsFromResourceList(OrthancPluginRestOutput* output,
                                     const char* url,
                                     const OrthancPluginHttpRequest* request)
@@ -1216,6 +1719,8 @@
       Json::Value jsonProfile;
       jsonProfile["name"] = profile.name;
       jsonProfile["permissions"] = Json::arrayValue;
+      jsonProfile["groups"] = Json::arrayValue;
+      
       for (std::set<std::string>::const_iterator it = profile.permissions.begin(); it != profile.permissions.end(); ++it)
       {
         jsonProfile["permissions"].append(*it);
@@ -1224,6 +1729,15 @@
       {
         jsonProfile["authorized-labels"].append(*it);
       }
+      for (std::set<std::string>::const_iterator it = profile.groups.begin(); it != profile.groups.end(); ++it)
+      {
+        jsonProfile["groups"].append(*it);
+      }
+
+      if (!profile.userId.empty())
+      {
+        jsonProfile["user-id"] = profile.userId;
+      }
 
       OrthancPlugins::AnswerJson(jsonProfile, output);
     }
@@ -1494,6 +2008,12 @@
             (new OrthancPlugins::PermissionParser(dicomWebRoot, oe2Root));
 
           permissionParser_->Add(pluginConfiguration.GetJson()[PERMISSIONS], authorizationParser_.get());
+
+          static const char* const EXTRA_PERMISSIONS = "ExtraPermissions";
+          if (pluginConfiguration.GetJson().isMember(EXTRA_PERMISSIONS))
+          {
+            permissionParser_->Add(pluginConfiguration.GetJson()[EXTRA_PERMISSIONS], authorizationParser_.get());
+          }
         }
         else
         {
@@ -1663,7 +2183,7 @@
           (new OrthancPlugins::CachedAuthorizationService
            (webService.release(), factory));
 
-        if (!urlTokenValidation.empty())
+        if (!urlTokenValidation.empty() || enableAuditLogs_)
         {
           OrthancPluginRegisterOnChangeCallback(context, OnChangeCallback);
         }
@@ -1682,10 +2202,8 @@
           OrthancPlugins::RegisterRestCallback<AuthSettingsRoles>("/auth/settings/roles", true);
           OrthancPlugins::RegisterRestCallback<GetPermissionList>("/auth/settings/permissions", true);
 
-          OrthancPlugins::RegisterRestCallback<FilterLabelsFromSingleResource>("/instances/([^/]*)", true);
-          OrthancPlugins::RegisterRestCallback<FilterLabelsFromSingleResource>("/series/([^/]*)", true);
-          OrthancPlugins::RegisterRestCallback<FilterLabelsFromSingleResource>("/studies/([^/]*)", true);
-          OrthancPlugins::RegisterRestCallback<FilterLabelsFromSingleResource>("/patients/([^/]*)", true);
+          OrthancPlugins::RegisterRestCallback<GetOrDeleteMainResource>("/(patients|studies|series)/([^/]*)", true);  // this includes auditLogs
+
           OrthancPlugins::RegisterRestCallback<FilterLabelsFromSingleResource>("/instances/([^/]*)/patient", true);
           OrthancPlugins::RegisterRestCallback<FilterLabelsFromSingleResource>("/instances/([^/]*)/study", true);
           OrthancPlugins::RegisterRestCallback<FilterLabelsFromSingleResource>("/instances/([^/]*)/series", true);
@@ -1704,6 +2222,24 @@
           OrthancPlugins::RegisterRestCallback<FilterLabelsFromResourceList>("/patients/([^/]*)/instances", true);
           OrthancPlugins::RegisterRestCallback<FilterLabelsFromResourceList>("/patients/([^/]*)/series", true);
           OrthancPlugins::RegisterRestCallback<FilterLabelsFromResourceList>("/patients/([^/]*)/studies", true);
+
+          if (enableAuditLogs_)
+          {
+            OrthancPlugins::RegisterRestCallback<UploadInstancesWithAuditLogs>("/instances", true);
+            OrthancPlugins::RegisterRestCallback<AnonymizeWithAuditLogs>("/(patients|studies|series)/([^/]*)/anonymize", true);
+            OrthancPlugins::RegisterRestCallback<ModifyWithAuditLogs>("/(patients|studies|series)/([^/]*)/modify", true);
+            OrthancPlugins::RegisterRestCallback<LabelWithAuditLogs>("/(patients|studies|series)/([^/]*)/labels/([^/]*)", true);
+            OrthancPlugins::RegisterRestCallback<BulkDeleteWithAuditLogs>("/tools/bulk-delete", true);
+
+            // TODO
+            // OrthancPlugins::RegisterRestCallback<BulkModifyWithAuditLogs>("/tools/bulk-modify", true);
+            // /modalities/move
+            // /modalities/store
+            // /archive + create-archive
+            // /media + create-media + create-media-extended
+            // /bulk-anonymize + bulkd-modify
+
+          }
         }
 
         if (!urlTokenCreationBase.empty())
--- a/Resources/Orthanc/Plugins/OrthancPluginCppWrapper.cpp	Fri Jul 11 18:20:32 2025 +0200
+++ b/Resources/Orthanc/Plugins/OrthancPluginCppWrapper.cpp	Fri Jul 11 18:41:47 2025 +0200
@@ -220,28 +220,6 @@
   }
 
 
-#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 7, 0)
-  MemoryBuffer::MemoryBuffer(const void* buffer,
-                             size_t size)
-  {
-    uint32_t s = static_cast<uint32_t>(size);
-    if (static_cast<size_t>(s) != size)
-    {
-      ORTHANC_PLUGINS_THROW_EXCEPTION(NotEnoughMemory);
-    }
-    else if (OrthancPluginCreateMemoryBuffer(GetGlobalContext(), &buffer_, s) !=
-             OrthancPluginErrorCode_Success)
-    {
-      ORTHANC_PLUGINS_THROW_EXCEPTION(NotEnoughMemory);
-    }
-    else
-    {
-      memcpy(buffer_.data, buffer, size);
-    }
-  }
-#endif
-
-
   void MemoryBuffer::Clear()
   {
     if (buffer_.data != NULL)
@@ -253,6 +231,41 @@
   }
 
 
+#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 7, 0)
+  void MemoryBuffer::Assign(const void* buffer,
+                            size_t size)
+  {
+    uint32_t s = static_cast<uint32_t>(size);
+    if (static_cast<size_t>(s) != size)
+    {
+      ORTHANC_PLUGINS_THROW_EXCEPTION(NotEnoughMemory);
+    }
+
+    Clear();
+
+    if (OrthancPluginCreateMemoryBuffer(GetGlobalContext(), &buffer_, s) !=
+        OrthancPluginErrorCode_Success)
+    {
+      ORTHANC_PLUGINS_THROW_EXCEPTION(NotEnoughMemory);
+    }
+    else
+    {
+      if (size > 0)
+      {
+        memcpy(buffer_.data, buffer, size);
+      }
+    }
+  }
+#endif
+
+#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 7, 0)
+  void MemoryBuffer::Assign(const std::string& s)
+  {
+    Assign(s.empty() ? NULL : s.c_str(), s.size());
+  }
+#endif
+
+
   void MemoryBuffer::Assign(OrthancPluginMemoryBuffer& other)
   {
     Clear();
@@ -673,7 +686,7 @@
   {
     OrthancString str;
     str.Assign(OrthancPluginDicomBufferToJson
-               (GetGlobalContext(), GetData(), GetSize(), format, flags, maxStringLength));
+               (GetGlobalContext(), reinterpret_cast<const char*>(GetData()), GetSize(), format, flags, maxStringLength));
     str.ToJson(target);
   }
 
@@ -1566,7 +1579,7 @@
     {
       if (!answer.IsEmpty())
       {
-        result.assign(answer.GetData(), answer.GetSize());
+        result.assign(reinterpret_cast<const char*>(answer.GetData()), answer.GetSize());
       }
       return true;
     }
@@ -2052,6 +2065,26 @@
             DoPost(target, index, uri, body, headers));
   }
 
+  bool OrthancPeers::DoPost(Json::Value& target,
+                            size_t index,
+                            const std::string& uri,
+                            const std::string& body,
+                            const HttpHeaders& headers, 
+                            unsigned int timeout) const
+  {
+    MemoryBuffer buffer;
+
+    if (DoPost(buffer, index, uri, body, headers, timeout))
+    {
+      buffer.ToJson(target);
+      return true;
+    }
+    else
+    {
+      return false;
+    }
+  }
+
 
   bool OrthancPeers::DoPost(Json::Value& target,
                             size_t index,
@@ -2099,6 +2132,17 @@
                             const std::string& body,
                             const HttpHeaders& headers) const
   {
+    return DoPost(target, index, uri, body, headers, timeout_);
+  }
+
+
+  bool OrthancPeers::DoPost(MemoryBuffer& target,
+                            size_t index,
+                            const std::string& uri,
+                            const std::string& body,
+                            const HttpHeaders& headers,
+                            unsigned int timeout) const
+  {
     if (index >= index_.size())
     {
       ORTHANC_PLUGINS_THROW_PLUGIN_ERROR_CODE(OrthancPluginErrorCode_ParameterOutOfRange);
@@ -2117,7 +2161,7 @@
     OrthancPluginErrorCode code = OrthancPluginCallPeerApi
       (GetGlobalContext(), *answer, NULL, &status, peers_,
        static_cast<uint32_t>(index), OrthancPluginHttpMethod_Post, uri.c_str(),
-       pluginHeaders.GetSize(), pluginHeaders.GetKeys(), pluginHeaders.GetValues(), body.empty() ? NULL : body.c_str(), body.size(), timeout_);
+       pluginHeaders.GetSize(), pluginHeaders.GetKeys(), pluginHeaders.GetValues(), body.empty() ? NULL : body.c_str(), body.size(), timeout);
 
     if (code == OrthancPluginErrorCode_Success)
     {
@@ -4168,6 +4212,11 @@
     {
       path_ += "?" + getArguments;
     }
+
+    if (request->bodySize > 0 && request->body != NULL)
+    {
+      requestBody_.assign(reinterpret_cast<const char*>(request->body), request->bodySize);
+    }
   }
 #endif
 
@@ -4235,9 +4284,17 @@
     }
   }
 
-  void RestApiClient::Forward(OrthancPluginContext* context, OrthancPluginRestOutput* output)
-  {
-    if (Execute() && httpStatus_ == 200)
+  void RestApiClient::ExecuteAndForwardAnswer(OrthancPluginContext* context, OrthancPluginRestOutput* output)
+  {
+    if (Execute())
+    {
+      ForwardAnswer(context, output);
+    }
+  }
+
+  void RestApiClient::ForwardAnswer(OrthancPluginContext* context, OrthancPluginRestOutput* output)
+  {
+    if (httpStatus_ == 200)
     {
       const char* mimeType = NULL;
       for (HttpHeaders::const_iterator h = answerHeaders_.begin(); h != answerHeaders_.end(); ++h)
@@ -4316,4 +4373,209 @@
     }
   }
 #endif
+
+
+#if HAS_ORTHANC_PLUGIN_KEY_VALUE_STORES == 1
+  KeyValueStore::Iterator::Iterator(OrthancPluginKeysValuesIterator  *iterator) :
+    iterator_(iterator)
+  {
+    if (iterator_ == NULL)
+    {
+      ORTHANC_PLUGINS_THROW_EXCEPTION(InternalError);
+    }
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_KEY_VALUE_STORES == 1
+  KeyValueStore::Iterator::~Iterator()
+  {
+    OrthancPluginFreeKeysValuesIterator(OrthancPlugins::GetGlobalContext(), iterator_);
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_KEY_VALUE_STORES == 1
+  bool KeyValueStore::Iterator::Next()
+  {
+    uint8_t done;
+    OrthancPluginErrorCode code = OrthancPluginKeysValuesIteratorNext(OrthancPlugins::GetGlobalContext(), &done, iterator_);
+
+    if (code != OrthancPluginErrorCode_Success)
+    {
+      ORTHANC_PLUGINS_THROW_PLUGIN_ERROR_CODE(code);
+    }
+    else
+    {
+      return (done != 0);
+    }
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_KEY_VALUE_STORES == 1
+  std::string KeyValueStore::Iterator::GetKey() const
+  {
+    const char* s = OrthancPluginKeysValuesIteratorGetKey(OrthancPlugins::GetGlobalContext(), iterator_);
+    if (s == NULL)
+    {
+      ORTHANC_PLUGINS_THROW_EXCEPTION(InternalError);
+    }
+    else
+    {
+      return s;
+    }
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_KEY_VALUE_STORES == 1
+  void KeyValueStore::Iterator::GetValue(std::string& value) const
+  {
+    OrthancPlugins::MemoryBuffer valueBuffer;
+    OrthancPluginErrorCode code = OrthancPluginKeysValuesIteratorGetValue(OrthancPlugins::GetGlobalContext(), *valueBuffer, iterator_);
+
+    if (code != OrthancPluginErrorCode_Success)
+    {
+      ORTHANC_PLUGINS_THROW_PLUGIN_ERROR_CODE(code);
+    }
+    else
+    {
+      valueBuffer.ToString(value);
+    }
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_KEY_VALUE_STORES == 1
+  void KeyValueStore::Store(const std::string& key,
+                            const void* value,
+                            size_t valueSize)
+  {
+    if (static_cast<size_t>(static_cast<uint32_t>(valueSize)) != valueSize)
+    {
+      ORTHANC_PLUGINS_THROW_EXCEPTION(NotEnoughMemory);
+    }
+
+    OrthancPluginErrorCode code = OrthancPluginStoreKeyValue(OrthancPlugins::GetGlobalContext(), storeId_.c_str(),
+                                                             key.c_str(), value, static_cast<uint32_t>(valueSize));
+    if (code != OrthancPluginErrorCode_Success)
+    {
+      ORTHANC_PLUGINS_THROW_PLUGIN_ERROR_CODE(code);
+    }
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_KEY_VALUE_STORES == 1
+  bool KeyValueStore::GetValue(std::string& value,
+                               const std::string& key)
+  {
+    uint8_t found = false;
+    OrthancPlugins::MemoryBuffer valueBuffer;
+    OrthancPluginErrorCode code = OrthancPluginGetKeyValue(OrthancPlugins::GetGlobalContext(), &found,
+                                                           *valueBuffer, storeId_.c_str(), key.c_str());
+
+    if (code != OrthancPluginErrorCode_Success)
+    {
+      ORTHANC_PLUGINS_THROW_PLUGIN_ERROR_CODE(code);
+    }
+    else if (found)
+    {
+      valueBuffer.ToString(value);
+      return true;
+    }
+    else
+    {
+      return false;
+    }
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_KEY_VALUE_STORES == 1
+  void KeyValueStore::DeleteKey(const std::string& key)
+  {
+    OrthancPluginErrorCode code = OrthancPluginDeleteKeyValue(OrthancPlugins::GetGlobalContext(),
+                                                              storeId_.c_str(), key.c_str());
+
+    if (code != OrthancPluginErrorCode_Success)
+    {
+      ORTHANC_PLUGINS_THROW_PLUGIN_ERROR_CODE(code);
+    }
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_KEY_VALUE_STORES == 1
+  KeyValueStore::Iterator* KeyValueStore::CreateIterator()
+  {
+    return new Iterator(OrthancPluginCreateKeysValuesIterator(OrthancPlugins::GetGlobalContext(), storeId_.c_str()));
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_QUEUES == 1
+  void Queue::Enqueue(const void* value,
+                      size_t valueSize)
+  {
+    if (static_cast<size_t>(static_cast<uint32_t>(valueSize)) != valueSize)
+    {
+      ORTHANC_PLUGINS_THROW_EXCEPTION(NotEnoughMemory);
+    }
+
+    OrthancPluginErrorCode code = OrthancPluginEnqueueValue(OrthancPlugins::GetGlobalContext(),
+                                                            queueId_.c_str(), value, static_cast<uint32_t>(valueSize));
+
+    if (code != OrthancPluginErrorCode_Success)
+    {
+      ORTHANC_PLUGINS_THROW_PLUGIN_ERROR_CODE(code);
+    }
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_QUEUES == 1
+  bool Queue::DequeueInternal(std::string& value,
+                              OrthancPluginQueueOrigin origin)
+  {
+    uint8_t found = false;
+    OrthancPlugins::MemoryBuffer valueBuffer;
+
+    OrthancPluginErrorCode code = OrthancPluginDequeueValue(OrthancPlugins::GetGlobalContext(), &found,
+                                                            *valueBuffer, queueId_.c_str(), origin);
+
+    if (code != OrthancPluginErrorCode_Success)
+    {
+      ORTHANC_PLUGINS_THROW_PLUGIN_ERROR_CODE(code);
+    }
+    else if (found)
+    {
+      valueBuffer.ToString(value);
+      return true;
+    }
+    else
+    {
+      return false;
+    }
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_QUEUES == 1
+  uint64_t Queue::GetSize()
+  {
+    uint64_t size = 0;
+    OrthancPluginErrorCode code = OrthancPluginGetQueueSize(OrthancPlugins::GetGlobalContext(), queueId_.c_str(), &size);
+
+    if (code != OrthancPluginErrorCode_Success)
+    {
+      ORTHANC_PLUGINS_THROW_PLUGIN_ERROR_CODE(code);
+    }
+    else
+    {
+      return size;
+    }
+  }
+#endif
 }
--- a/Resources/Orthanc/Plugins/OrthancPluginCppWrapper.h	Fri Jul 11 18:20:32 2025 +0200
+++ b/Resources/Orthanc/Plugins/OrthancPluginCppWrapper.h	Fri Jul 11 18:41:47 2025 +0200
@@ -134,6 +134,14 @@
 #  define HAS_ORTHANC_PLUGIN_LOG_MESSAGE  0
 #endif
 
+#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 12, 8)
+#  define HAS_ORTHANC_PLUGIN_KEY_VALUE_STORES  1
+#  define HAS_ORTHANC_PLUGIN_QUEUES            1
+#else
+#  define HAS_ORTHANC_PLUGIN_KEY_VALUE_STORES  0
+#  define HAS_ORTHANC_PLUGIN_QUEUES            0
+#endif
+
 
 // Macro to tag a function as having been deprecated
 #if (__cplusplus >= 201402L)  // C++14
@@ -203,13 +211,6 @@
   public:
     MemoryBuffer();
 
-#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 7, 0)
-    // This constructor makes a copy of the given buffer in the memory
-    // handled by the Orthanc core
-    MemoryBuffer(const void* buffer,
-                 size_t size);
-#endif
-
     ~MemoryBuffer()
     {
       Clear();
@@ -220,6 +221,16 @@
       return &buffer_;
     }
 
+#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 7, 0)
+    // Copy of the given buffer into the memory managed by the Orthanc core
+    void Assign(const void* buffer,
+                size_t size);
+#endif
+
+#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 7, 0)
+    void Assign(const std::string& s);
+#endif
+
     // This transfers ownership from "other" to "this"
     void Assign(OrthancPluginMemoryBuffer& other);
 
@@ -227,11 +238,11 @@
 
     OrthancPluginMemoryBuffer Release();
 
-    const char* GetData() const
+    const void* GetData() const
     {
       if (buffer_.size > 0)
       {
-        return reinterpret_cast<const char*>(buffer_.data);
+        return buffer_.data;
       }
       else
       {
@@ -855,6 +866,13 @@
                 const HttpHeaders& headers) const;
 
     bool DoPost(MemoryBuffer& target,
+                size_t index,
+                const std::string& uri,
+                const std::string& body,
+                const HttpHeaders& headers,
+                unsigned int timeout) const;
+
+    bool DoPost(MemoryBuffer& target,
                 const std::string& name,
                 const std::string& uri,
                 const std::string& body,
@@ -867,6 +885,13 @@
                 const HttpHeaders& headers) const;
 
     bool DoPost(Json::Value& target,
+                size_t index,
+                const std::string& uri,
+                const std::string& body,
+                const HttpHeaders& headers,
+                unsigned int timeout) const;
+
+    bool DoPost(Json::Value& target,
                 const std::string& name,
                 const std::string& uri,
                 const std::string& body,
@@ -1589,10 +1614,14 @@
       return requestBody_;
     }
 
+    // Execute only
     bool Execute();
 
+    // Forward response as is
+    void ForwardAnswer(OrthancPluginContext* context, OrthancPluginRestOutput* output);
+
     // Execute and forward the response as is
-    void Forward(OrthancPluginContext* context, OrthancPluginRestOutput* output);
+    void ExecuteAndForwardAnswer(OrthancPluginContext* context, OrthancPluginRestOutput* output);
 
     uint16_t GetHttpStatus() const;
 
@@ -1604,4 +1633,101 @@
     bool GetAnswerJson(Json::Value& output) const;
   };
 #endif
+
+
+#if HAS_ORTHANC_PLUGIN_KEY_VALUE_STORES == 1
+  class KeyValueStore : public boost::noncopyable
+  {
+  public:
+    class Iterator : public boost::noncopyable
+    {
+    private:
+      OrthancPluginKeysValuesIterator  *iterator_;
+
+    public:
+      Iterator(OrthancPluginKeysValuesIterator  *iterator);
+
+      ~Iterator();
+
+      bool Next();
+
+      std::string GetKey() const;
+
+      void GetValue(std::string& target) const;
+    };
+
+  private:
+    std::string storeId_;
+
+  public:
+    explicit KeyValueStore(const std::string& storeId) :
+      storeId_(storeId)
+    {
+    }
+
+    const std::string& GetStoreId() const
+    {
+      return storeId_;
+    }
+
+    void Store(const std::string& key,
+               const void* value,
+               size_t valueSize);
+
+    void Store(const std::string& key,
+               const std::string& value)
+    {
+      Store(key, value.empty() ? NULL : value.c_str(), value.size());
+    }
+
+    bool GetValue(std::string& value,
+                  const std::string& key);
+
+    void DeleteKey(const std::string& key);
+
+    Iterator* CreateIterator();
+  };
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_QUEUES == 1
+  class Queue : public boost::noncopyable
+  {
+  private:
+    std::string queueId_;
+
+    bool DequeueInternal(std::string& value, OrthancPluginQueueOrigin origin);
+
+  public:
+    explicit Queue(const std::string& queueId) :
+      queueId_(queueId)
+    {
+    }
+
+    const std::string& GetQueueId() const
+    {
+      return queueId_;
+    }
+
+    void Enqueue(const void* value,
+                 size_t valueSize);
+
+    void Enqueue(const std::string& value)
+    {
+      Enqueue(value.empty() ? NULL : value.c_str(), value.size());
+    }
+
+    bool DequeueBack(std::string& value)
+    {
+      return DequeueInternal(value, OrthancPluginQueueOrigin_Back);
+    }
+
+    bool DequeueFront(std::string& value)
+    {
+      return DequeueInternal(value, OrthancPluginQueueOrigin_Front);
+    }
+
+    uint64_t GetSize();
+  };
+#endif
 }