changeset 314:331809444cec

Added support for /tools/bulk-modify and /tools/bulk-anonymize
author Alain Mazy <am@orthanc.team>
date Fri, 20 Mar 2026 19:13:46 +0100
parents be8457e5aabc
children e8bb9ecc4484
files NEWS Plugin/DefaultConfiguration.json Plugin/Plugin.cpp
diffstat 3 files changed, 147 insertions(+), 58 deletions(-) [+]
line wrap: on
line diff
--- a/NEWS	Fri Mar 20 17:06:41 2026 +0100
+++ b/NEWS	Fri Mar 20 19:13:46 2026 +0100
@@ -3,6 +3,7 @@
 
 * Now recording audit-logs when uploading a zip.
 * New default permissions for sending emails when sharing studies.
+* Added support for /tools/bulk-modify and /tools/bulk-anonymize
 * Fix: in /tools/bulk-delete, a user was able to delete resources
   he does not have access to.
 
--- a/Plugin/DefaultConfiguration.json	Fri Mar 20 17:06:41 2026 +0100
+++ b/Plugin/DefaultConfiguration.json	Fri Mar 20 19:13:46 2026 +0100
@@ -119,6 +119,8 @@
             // modifications/anonymization
             ["post", "^/(patients|studies|series|instances)/([a-f0-9-]+)/(modify|merge)(.*)$", "all|modify"],
             ["post", "^/(patients|studies|series|instances)/([a-f0-9-]+)/anonymize(.*)$", "all|anonymize"],
+            ["post" , "^/tools/bulk-modify$", "all|modify"],
+            ["post" , "^/tools/bulk-anonymize$", "all|anonymize"],
 
             // labels
             ["get", "^/tools/labels$", "all|view|edit-labels"],
--- a/Plugin/Plugin.cpp	Fri Mar 20 17:06:41 2026 +0100
+++ b/Plugin/Plugin.cpp	Fri Mar 20 19:13:46 2026 +0100
@@ -1021,7 +1021,7 @@
 }
 
 
-void CheckHasAccessToAllResourcesInPayload(OrthancPluginRestOutput* output, const OrthancPluginHttpRequest* request)
+bool CheckHasAccessToAllResourcesInPayload(OrthancPluginRestOutput* output, const OrthancPluginHttpRequest* request)
 {
   // make sur the user has access to all resources listed in "Resources" (with potentialy a "Level" field to help identify the resources)
 
@@ -1033,13 +1033,13 @@
 
   if (HasAccessToAllLabels(profile))  // these guys can do whatever they want
   {
-    return;
+    return true;
   }
 
   Json::Value payload;
   if (!OrthancPlugins::ReadJson(payload, request->body, request->bodySize) || !payload.isMember("Resources") || !payload["Resources"].isArray())
   {
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, "A JSON payload with a 'Resources' field was expected");
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, "A JSON payload with a 'Resources' string array field was expected");
   }
 
   Orthanc::ResourceType levelInPayload = Orthanc::ResourceType_Instance; // random value
@@ -1076,9 +1076,13 @@
 
     if (!granted)
     {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_ForbiddenAccess, "Auth plugin: the user does not have access to resource " + resourceId);
+      LOG(WARNING) << "Auth plugin: the user does not have access to resource " << resourceId;
+      OrthancPlugins::AnswerHttpError(Orthanc::HttpStatus_403_Forbidden, output);
+      return false;
     }
   }
+
+  return true;
 }
 
 
@@ -1305,6 +1309,49 @@
   }
 }
 
+void RecordAuditLogsForStartOfModificationJob(Json::Value& payload,
+                                              const std::string& userId,
+                                              OrthancPluginResourceType resourceType,
+                                              const std::string& resourceId,
+                                              const Json::Value& resourcesIds,
+                                              bool isModification
+                                              )
+{
+  Json::Value logData;
+  logData[KEY_PAYLOAD] = payload;
+  
+  // 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);
+  payload["UserData"]["ResourcesIds"] = resourcesIds;
+  // std::string modifiedPayload = payload.toStyledString();
+  // coreApi.SetRequestBody(modifiedPayload);
+
+  if (isModification)
+  {
+    // log the tags before modification (but not for anonymizations)
+    Json::Value resourceBefore;
+    if (resourceType == OrthancPluginResourceType_Study && OrthancPlugins::RestApiGet(resourceBefore, "/studies/" + resourceId, false))
+    {
+      Json::Value studyTagsBefore = resourceBefore["MainDicomTags"];
+      Json::Value patientTagsBefore = resourceBefore["PatientMainDicomTags"];
+      MergeJson(studyTagsBefore, patientTagsBefore);
+
+      logData[KEY_BEFORE_TAGS] = studyTagsBefore;
+    }
+    else
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError, "Auth plugin: TODO: unable to handle anonymize/modify other levels than studies with audit logs enabled.");
+    }
+  }
+
+  // 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"), 
+                  logData);
+
+}
 
 void ModifyAnonymizeWithAuditLogs(OrthancPluginRestOutput* output,
                                   const char* url,
@@ -1323,12 +1370,11 @@
     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
+  // Either there is a userId in UserData (that can be used only for the audit logs); this happens when the request is sent e.g from a python plugin
+  // or the request comes from a user with a profile
   OrthancPlugins::IAuthorizationService::UserProfile profile;
   std::string userId;
 
-  // LOG(WARNING) << payload.toStyledString();
-
   if (GetUserProfileInternal(profile, request) && !profile.userId.empty())
   {
     userId = profile.userId;  
@@ -1339,40 +1385,12 @@
   }
 
   if ((payload.isMember("Synchronous") && !payload["Synchronous"].asBool())
-    || (payload.isMember("Asynchronous") && !payload["Asynchronous"].asBool()))
+    || (payload.isMember("Asynchronous") && payload["Asynchronous"].asBool()))
   {
-    Json::Value logData;
-    logData[KEY_PAYLOAD] = payload;
-    
-    // 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);
-    std::string modifiedPayload = payload.toStyledString();
-    coreApi.SetRequestBody(modifiedPayload);
-
-    if (isModification)
-    {
-      // log the tags before modification (but not for anonymizations)
-      Json::Value resourceBefore;
-      if (resourceType == OrthancPluginResourceType_Study && OrthancPlugins::RestApiGet(resourceBefore, "/studies/" + resourceId, false))
-      {
-        Json::Value studyTagsBefore = resourceBefore["MainDicomTags"];
-        Json::Value patientTagsBefore = resourceBefore["PatientMainDicomTags"];
-        MergeJson(studyTagsBefore, patientTagsBefore);
-
-        logData[KEY_BEFORE_TAGS] = studyTagsBefore;
-      }
-      else
-      {
-        throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError, "Auth plugin: TODO: unable to handle anonymize/modify other levels than studies with audit logs enabled.");
-      }
-    }
-
-    // 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"), 
-                   logData);
+    Json::Value emptyResourcesIds;
+    Json::Value modifiedPayload = payload;
+    RecordAuditLogsForStartOfModificationJob(modifiedPayload, userId, resourceType, resourceId, emptyResourcesIds, isModification);
+    coreApi.SetRequestBody(modifiedPayload.toStyledString());
 
     if (coreApi.Execute())
     {
@@ -1381,6 +1399,38 @@
   }
   else
   {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "Synchronous modifications/anonymizations are currently not supported when audit-logs are enabled, use Asynchronous:true");
+  }
+}
+
+
+template <bool enableAudiLogs, bool isModification>
+void BulkModifyAnonymizeWithAuditLogs(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");
+  }
+
+  if (!CheckHasAccessToAllResourcesInPayload(output, request))
+  {
+    return;
+  }
+
+  OrthancPlugins::RestApiClient coreApi(url, request);
+
+  if (!enableAudiLogs)
+  { // now that we have checked that we could access all resources, simply forward to core
     Json::Value coreResponse;
 
     // if it is synchronous, perform the modification and record the log directly
@@ -1388,22 +1438,46 @@
     {
       coreApi.ForwardAnswer(context, output);
     }
-    
-    if (coreApi.GetAnswerJson(coreResponse))
-    {
-      LOG(WARNING) << "TODO AUDIT-LOG synchronous modification " << coreResponse.toStyledString(); // TODO 
-    }
+    return;
+  }
+  else
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "Bulkd modifications/anonymizations are currently not supported when audit-logs are enabled");
+
+    // this code needs to be polished in the OnChange to map the new modifiedResources on the originalResources
+    // // Either there is a userId in UserData (that can be used only for the audit logs); this happens when the request is sent e.g from a python plugin
+    // // or the request comes from a user with a profile
+    // OrthancPlugins::IAuthorizationService::UserProfile profile;
+    // std::string userId;
+
+    // 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()))
+    // {
+    //   Json::Value modifiedPayload = payload;
+    //   RecordAuditLogsForStartOfModificationJob(modifiedPayload, userId, resourceType, resourceId, payload["Resources"], isModification);
+    //   coreApi.SetRequestBody(modifiedPayload.toStyledString());
+
+    //   if (coreApi.Execute())
+    //   {
+    //     coreApi.ForwardAnswer(context, output);
+    //   }
+    // }
+    // else
+    // {
+    //   throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "Synchronous modifications/anonymizations are currently not supported when audit-logs are enabled, use Asynchronous:true");
+    // }
   }
 }
 
-void BulkModifyAnonymizeWithAuditLogs(OrthancPluginRestOutput* output,
-                                      const char* url,
-                                      const OrthancPluginHttpRequest* request)
-{
-  throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "Auth plugin: Not implemented: Currently unable to perform bulk modification/anonymization with audit logs enabled.");
-}
-
-
 void ModifyWithAuditLogs(OrthancPluginRestOutput* output,
                          const char* url,
                          const OrthancPluginHttpRequest* request)
@@ -1732,7 +1806,10 @@
     throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, "A JSON payload was expected");
   }
 
-  CheckHasAccessToAllResourcesInPayload(output, request);
+  if (!CheckHasAccessToAllResourcesInPayload(output, request))
+  {
+    return;
+  }
 
   std::list<AuditLog> auditLogs;
 
@@ -1750,7 +1827,10 @@
   
   if (coreApi.Execute())
   {
-    RecordAuditLogs(auditLogs);
+    if (enableAudiLogs)
+    {
+      RecordAuditLogs(auditLogs);
+    }
 
     coreApi.ForwardAnswer(context, output);
     return;
@@ -1842,11 +1922,15 @@
 
     OrthancPlugins::RestApiClient coreApi(url, request);
 
-    if (coreApi.Execute() && coreApi.GetAnswerJson(response))
+    if (coreApi.Execute() && coreApi.GetHttpStatus() == 200 && coreApi.GetAnswerJson(response))
     {
       jsonLabelsFilter(response, profile);
       OrthancPlugins::AnswerJson(response, output);
     }
+    else
+    {
+      OrthancPlugins::AnswerHttpError(coreApi.GetHttpStatus(), output);
+    }
   }
 }
 
@@ -2564,8 +2648,8 @@
             OrthancPlugins::RegisterRestCallback<ModifyWithAuditLogs>("/(patients|studies|series)/([^/]*)/modify", true);
             OrthancPlugins::RegisterRestCallback<LabelWithAuditLogs>("/(patients|studies|series)/([^/]*)/labels/([^/]*)", true);
             OrthancPlugins::RegisterRestCallback<BulkDeleteWithAuditLogs<true> >("/tools/bulk-delete", true);
-            OrthancPlugins::RegisterRestCallback<BulkModifyAnonymizeWithAuditLogs>("/tools/bulk-modify", true);
-            OrthancPlugins::RegisterRestCallback<BulkModifyAnonymizeWithAuditLogs>("/tools/bulk-anonymize", true);
+            OrthancPlugins::RegisterRestCallback<BulkModifyAnonymizeWithAuditLogs<true, true> >("/tools/bulk-modify", true);
+            OrthancPlugins::RegisterRestCallback<BulkModifyAnonymizeWithAuditLogs<true, false> >("/tools/bulk-anonymize", true);
             OrthancPlugins::RegisterRestCallback<GetAuditLogs>("/auth/audit-logs", true);
 
             // Note: other "actions" that do not modify the data like download-archive are logged in the HTTP filter (see RecordResourceAccess())
@@ -2580,6 +2664,8 @@
           else
           {
             OrthancPlugins::RegisterRestCallback<BulkDeleteWithAuditLogs<false> >("/tools/bulk-delete", true);
+            OrthancPlugins::RegisterRestCallback<BulkModifyAnonymizeWithAuditLogs<false, true> >("/tools/bulk-modify", true);
+            OrthancPlugins::RegisterRestCallback<BulkModifyAnonymizeWithAuditLogs<false, false> >("/tools/bulk-anonymize", true);
           }
         }