changeset 269:4272817fa3fd inbox

integration mainline->inbox
author Sebastien Jodogne <s.jodogne@gmail.com>
date Fri, 08 Aug 2025 17:27:01 +0200
parents ac7d3395bb80 (diff) b50110b037cd (current diff)
children 18881ed67640
files Resources/Orthanc/Plugins/OrthancPluginCppWrapper.h
diffstat 11 files changed, 1098 insertions(+), 142 deletions(-) [+]
line wrap: on
line diff
--- a/NEWS	Fri Aug 08 17:26:31 2025 +0200
+++ b/NEWS	Fri Aug 08 17:27:01 2025 +0200
@@ -1,3 +1,25 @@
+Pending changes in the mainline
+===============================
+
+* 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
+  - Enabled by the "EnableAuditLogs" configuration.
+  - Audit-logs are currently handled by the PostgreSQL plugin and can be 
+    browsed through the route /auth/audit-logs.
+  - New default permission "audit-logs" to grant access to the 
+    "/auth/audit-logs" route.
+
+
+
 2025-07-14 - v 0.9.4
 ====================
 
--- a/Plugin/AuthorizationWebService.cpp	Fri Aug 08 17:26:31 2025 +0200
+++ b/Plugin/AuthorizationWebService.cpp	Fri Aug 08 17:27:01 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,8 +373,86 @@
     {
       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();
+    }
   }
 
+  bool AuthorizationWebService::GetUserProfileFromUserId(unsigned int& validity,
+                                                         UserProfile& profile /* out */,
+                                                         const std::string& userId)
+  {
+    if (userProfileUrl_.empty())
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest, "Can not get user profile if the 'WebServiceUserProfileUrl' is not configured");
+    }
+
+    Json::Value body;
+
+    body["user-id"] = userId;
+
+    if (!identifier_.empty())
+    {
+      body["identifier"] = identifier_;
+    }
+    else
+    {
+      body["identifier"] = Json::nullValue;
+    }
+    
+    std::string bodyAsString;
+    Orthanc::Toolbox::WriteFastJson(bodyAsString, body);
+
+    try
+    {
+      HttpClient authClient;
+      authClient.SetUrl(userProfileUrl_);
+      if (!username_.empty())
+      {
+        authClient.SetCredentials(username_, password_);
+      }
+      authClient.SetBody(bodyAsString);
+      authClient.SetMethod(OrthancPluginHttpMethod_Post);
+      authClient.AddHeader("Content-Type", "application/json");
+      authClient.AddHeader("Expect", "");
+      authClient.SetTimeout(10);
+
+      Json::Value jsonProfile;
+      OrthancPlugins::HttpHeaders answerHeaders;
+      authClient.Execute(answerHeaders, jsonProfile);
+
+      if (jsonProfile.isNull())
+      {
+        validity = 60;
+        return false;
+      }
+      else if (!jsonProfile.isMember(VALIDITY) ||
+        jsonProfile[VALIDITY].type() != Json::intValue)
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+                                        "Syntax error in the result of the Auth Web service, the format of the UserProfile is invalid");
+      }
+      validity = jsonProfile[VALIDITY].asUInt();
+    
+      FromJson(profile, jsonProfile);
+
+      return true;
+    }
+    catch (Orthanc::OrthancException& ex)
+    {
+      return false;
+    }
+  }
 
 
   bool AuthorizationWebService::GetUserProfileInternal(unsigned int& validity,
--- a/Plugin/AuthorizationWebService.h	Fri Aug 08 17:26:31 2025 +0200
+++ b/Plugin/AuthorizationWebService.h	Fri Aug 08 17:27:01 2025 +0200
@@ -111,5 +111,8 @@
     
     static void FromJson(UserProfile& profile, const Json::Value& input);
 
+    virtual bool GetUserProfileFromUserId(unsigned int& validity,
+                                          UserProfile& profile /* out */,
+                                          const std::string& userId) ORTHANC_OVERRIDE;
   };
 }
--- a/Plugin/BaseAuthorizationService.h	Fri Aug 08 17:26:31 2025 +0200
+++ b/Plugin/BaseAuthorizationService.h	Fri Aug 08 17:27:01 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/CachedAuthorizationService.cpp	Fri Aug 08 17:26:31 2025 +0200
+++ b/Plugin/CachedAuthorizationService.cpp	Fri Aug 08 17:27:01 2025 +0200
@@ -66,7 +66,8 @@
   CachedAuthorizationService::CachedAuthorizationService(BaseAuthorizationService* decorated /* takes ownership */,
                                                          ICacheFactory& factory) :
     decorated_(decorated),
-    cache_(factory.Create())
+    cache_(factory.Create()),
+    cacheUserId_(factory.Create())
   {
     if (decorated_.get() == NULL)
     {
@@ -114,7 +115,61 @@
     }
   }
 
-  
+  bool CachedAuthorizationService::GetUserProfileFromUserId(unsigned int& validityNotUsed,
+                                                            UserProfile& profile /* out */,
+                                                            const std::string& userId)
+  {
+    assert(decorated_.get() != NULL);
+
+    std::string key = "user-id-" + userId;
+    std::string serializedProfile;
+
+    if (cacheUserId_->Retrieve(serializedProfile, key))
+    {
+      // Return the previously cached profile
+      Json::Value jsonProfile;
+      
+      Orthanc::Toolbox::ReadJson(jsonProfile, serializedProfile);
+      
+      AuthorizationWebService::FromJson(profile, jsonProfile);
+
+      return true;
+    }        
+    else
+    {
+      unsigned int validity;
+
+      if (decorated_->GetUserProfileFromUserId(validity, profile, userId))
+      {
+        Json::Value jsonProfile;
+
+        AuthorizationWebService::ToJson(jsonProfile, profile);
+        Orthanc::Toolbox::WriteFastJson(serializedProfile, jsonProfile);
+
+        cacheUserId_->Store(key, serializedProfile, validity);
+        
+        return true;
+      }
+      else // if no user was found, store it as a profile where the user name is the user id
+      {
+        validity = 60;
+        profile.userId = userId;
+        profile.name = userId;
+
+        Json::Value jsonProfile;
+
+        AuthorizationWebService::ToJson(jsonProfile, profile);
+        Orthanc::Toolbox::WriteFastJson(serializedProfile, jsonProfile);
+
+        cacheUserId_->Store(key, serializedProfile, validity);
+        
+        return true;
+      }
+    }
+
+    return false;
+  }  
+
   bool CachedAuthorizationService::GetUserProfileInternal(unsigned int& validityNotUsed,
                                                           UserProfile& profile /* out */,
                                                           const Token* token,
--- a/Plugin/CachedAuthorizationService.h	Fri Aug 08 17:26:31 2025 +0200
+++ b/Plugin/CachedAuthorizationService.h	Fri Aug 08 17:27:01 2025 +0200
@@ -37,6 +37,7 @@
   private:
     std::unique_ptr<BaseAuthorizationService>  decorated_;
     std::unique_ptr<ICache>   cache_;
+    std::unique_ptr<ICache>   cacheUserId_;
 
     std::string ComputeKey(OrthancPluginHttpMethod method,
                            const AccessedResource& access,
@@ -58,6 +59,10 @@
                                         const Token* token,
                                         const std::string& tokenValue) ORTHANC_OVERRIDE;
 
+    virtual bool GetUserProfileFromUserId(unsigned int& validity /* out */,
+                                          UserProfile& profile /* out */,
+                                          const std::string& userId) ORTHANC_OVERRIDE;
+
     virtual bool HasUserPermissionInternal(const std::string& permission,
                                            const UserProfile& profile) ORTHANC_OVERRIDE;
 
--- a/Plugin/DefaultConfiguration.json	Fri Aug 08 17:26:31 2025 +0200
+++ b/Plugin/DefaultConfiguration.json	Fri Aug 08 17:27:01 2025 +0200
@@ -128,8 +128,10 @@
             // permission settings
             ["put", "^/auth/settings/roles$", "admin-permissions"],
             ["get", "^/auth/settings/roles$", "admin-permissions"],
-            ["get", "^/auth/settings/permissions$", "admin-permissions"]
- 
+            ["get", "^/auth/settings/permissions$", "admin-permissions"],
+
+            // audit-logs
+            ["get", "^/auth/audit-logs$", "admin-permissions|audit-logs"]
         ]
     }
 }
\ No newline at end of file
--- a/Plugin/Enumerations.cpp	Fri Aug 08 17:26:31 2025 +0200
+++ b/Plugin/Enumerations.cpp	Fri Aug 08 17:27:01 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 Aug 08 17:26:31 2025 +0200
+++ b/Plugin/Enumerations.h	Fri Aug 08 17:27:01 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 Aug 08 17:26:31 2025 +0200
+++ b/Plugin/IAuthorizationService.h	Fri Aug 08 17:27:01 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;
@@ -89,14 +91,16 @@
                                 const Token& token,
                                 const std::string& tokenValue) = 0;
 
+    virtual bool GetUserProfileFromUserId(unsigned int& validity /* out */,
+                                          UserProfile& profile /* out */,
+                                          const std::string& userId) = 0;
+
     virtual bool GetAnonymousUserProfile(unsigned int& validity /* out */,
                                          UserProfile& profile /* out */) = 0;
 
     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 Aug 08 17:26:31 2025 +0200
+++ b/Plugin/Plugin.cpp	Fri Aug 08 17:27:01 2025 +0200
@@ -31,6 +31,7 @@
 #include <Toolbox.h>
 #include <SerializationToolbox.h>
 #include <EmbeddedResources.h>
+#include "Enumerations.h"
 
 #define ORTHANC_PLUGIN_NAME  "authorization"
 
@@ -38,6 +39,7 @@
 // Configuration of the authorization plugin
 static bool resourceTokensEnabled_ = false;
 static bool userTokensEnabled_ = false;
+static bool enableAuditLogs_ = false;
 static std::unique_ptr<OrthancPlugins::IAuthorizationParser> authorizationParser_;
 static std::unique_ptr<OrthancPlugins::IAuthorizationService> authorizationService_;
 static std::unique_ptr<OrthancPlugins::PermissionParser> permissionParser_;
@@ -65,6 +67,136 @@
 }
 
 
+static void MergeJson(Json::Value &a,
+                      const Json::Value &b)
+{
+  // The semantics of this function is not generic enough to be included in the Orthanc framework
+  if (!a.isObject() || !b.isObject())
+  {
+    return;
+  }
+
+  Json::Value::Members members = b.getMemberNames();
+
+  for (size_t i = 0; i < members.size(); i++)
+  {
+    std::string key = members[i];
+
+    if (!a[key].isNull() &&
+        a[key].type() == Json::objectValue &&
+        b[key].type() == Json::objectValue)
+    {
+      MergeJson(a[key], b[key]);
+    }
+    else
+    {
+      a[key] = b[key];
+    }
+  }
+}
+
+
+static const char* KEY_USER_DATA = "UserData";
+static const char* KEY_USER_ID = "AuditLogsUserId";
+static const char* KEY_PAYLOAD = "Payload";
+static const char* KEY_BEFORE_TAGS = "TagsBeforeModification";
+
+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();
+
+  if (enableAuditLogs_)
+  {
+    // This function should not be called if audit logs are disabled
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+  }
+
+#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 12, 9)
+  // Audit logs are only available since Orthanc 1.12.9
+  std::string serializedLogData;
+  const void* logDataPtr = NULL;
+  uint32_t logDataSize = 0;
+  
+  if (!logData.isNull())
+  {
+    Orthanc::Toolbox::WriteFastJson(serializedLogData, logData);
+    logDataPtr = reinterpret_cast<const void*>(serializedLogData.c_str());
+    logDataSize = serializedLogData.size();
+  }
+
+  OrthancPluginAuditLog(OrthancPlugins::GetGlobalContext(),
+                        userId.c_str(),
+                        resourceType,
+                        resourceId.c_str(),
+                        action.c_str(),
+                        logDataPtr,
+                        logDataSize);
+#endif
+}
+
+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 +405,88 @@
   return false;
 }
 
+static void RecordResourceAccessInternal(const OrthancPlugins::IAuthorizationService::UserProfile& profile,
+                                         const OrthancPlugins::IAuthorizationParser::AccessedResources& accesses,
+                                         const std::string& action,
+                                         const Json::Value& logData)
+{
+  for (OrthancPlugins::IAuthorizationParser::AccessedResources::const_iterator it = accesses.begin(); it != accesses.end(); ++it)
+  {
+    if (it->GetLevel() == OrthancPlugins::AccessLevel_Study)
+    {
+      RecordAuditLog(profile.userId, OrthancPluginResourceType_Study, it->GetOrthancId(), action, logData);
+    }
+  }
+}
+
+static void RecordResourceAccess(const OrthancPlugins::IAuthorizationService::UserProfile& profile,
+                                 const std::string& uri,
+                                 OrthancPluginHttpMethod method,
+                                 const OrthancPlugins::AssociativeArray& getArguments)
+{
+  // Identify the resource
+  OrthancPlugins::IAuthorizationParser::AccessedResources accesses;
+
+  if (authorizationParser_->Parse(accesses, uri, getArguments.GetMap()))
+  {
+    boost::smatch what;
+
+    // Identify the action
+    boost::regex archive("^/(patients|studies|series|instances)/([a-f0-9-]+)/(archive|media)$");
+    
+    if (boost::regex_match(uri, what, archive))
+    {
+      RecordResourceAccessInternal(profile, accesses, "download", Json::nullValue);
+    }
+  }
+
+}
+
+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";
+
+        if (enableAuditLogs_)
+        {
+          RecordResourceAccess(profile, uri, method, getArguments);
+        }
+      }
+      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 +527,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 +538,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 +559,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 +675,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 +786,62 @@
   }
 }
 
+bool GetUserNameFromUserId(std::string& userName, 
+                           const std::string& userId)
+{
+  unsigned int validity; // not used
+  OrthancPlugins::IAuthorizationService::UserProfile profile;
+
+  if (authorizationService_->GetUserProfileFromUserId(validity, profile, userId))
+  {
+    userName = profile.name;
+    return true;
+  }
+
+  return false;
+}
+
+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 +852,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 +1209,461 @@
   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()))
+  {
+    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(logData, userId);
+
+    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);
+
+    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 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)
+{
+  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 (!enableAuditLogs_ || request->method == OrthancPluginHttpMethod_Get)
+  {
+    coreApi.ExecuteAndForwardAnswer(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;
+    }
+  }
+}
+
+
+void GetAuditLogs(OrthancPluginRestOutput* output,
+                  const char* url,
+                  const OrthancPluginHttpRequest* request)
+{
+  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+  bool isOutputCsv = false;
+
+  OrthancPlugins::HttpHeaders requestHeaders;
+  OrthancPlugins::GetHttpHeaders(requestHeaders, request);
+
+  OrthancPlugins::GetArguments getArguments;
+  OrthancPlugins::GetGetArguments(getArguments, request);
+
+  if (getArguments.find("format") != getArguments.end())
+  {
+    isOutputCsv = getArguments["format"] == "csv";
+  }
+  
+  if (!isOutputCsv && (requestHeaders.find("accept") != requestHeaders.end()))
+  {
+    std::string acceptHeader = requestHeaders["accept"];
+    Orthanc::Toolbox::ToLowerCase(acceptHeader);
+    
+    isOutputCsv = acceptHeader.find("text/csv") != std::string::npos;
+  }
+
+  OrthancPlugins::RestApiClient coreApi("/plugins/postgresql/audit-logs", request);
+  coreApi.SetAfterPlugins(true);
+  coreApi.SetRequestHeader("Accept", "application/json"); // the postgresql plugin only knows about the json format
+
+  if (request->method != OrthancPluginHttpMethod_Get)
+  {
+    OrthancPluginSendMethodNotAllowed(context, output, "GET");
+    return;
+  }
+
+  Json::Value response;
+
+  if (coreApi.Execute() && coreApi.GetAnswerJson(response))
+  {
+    // transform the response: replace user-id by user-name
+    for (Json::ArrayIndex i = 0; i < response.size(); ++i)
+    {
+      const std::string& userId = response[i]["UserId"].asString();
+      std::string userName;
+      if (GetUserNameFromUserId(userName, userId))
+      {
+        response[i]["UserName"] = userName;
+      }
+      else
+      {
+        response[i]["UserName"] = userId;
+      }
+    }
+
+    if (!isOutputCsv)
+    {
+      OrthancPlugins::AnswerJson(response, output);
+    }
+    else
+    {
+      std::vector<std::string> lines;
+
+      std::vector<std::string> firstLineColumns;
+      firstLineColumns.push_back("Timestamp");
+      firstLineColumns.push_back("UserId");
+      firstLineColumns.push_back("UserName");
+      firstLineColumns.push_back("ResourceId");
+      firstLineColumns.push_back("Action");
+      firstLineColumns.push_back("LogData");
+
+      std::string firstLine;
+      Orthanc::Toolbox::JoinStrings(firstLine, firstLineColumns,";");
+      lines.push_back(firstLine);
+
+      for (Json::ArrayIndex i = 0; i < response.size(); ++i)
+      {
+        std::vector<std::string> lineColumns;
+        std::string line;
+
+        const Json::Value& log = response[i];
+        lineColumns.push_back(log["Timestamp"].asString());
+        lineColumns.push_back(log["UserId"].asString());
+        lineColumns.push_back(log["UserName"].asString());
+        lineColumns.push_back(log["ResourceId"].asString());
+        lineColumns.push_back(log["Action"].asString());
+        
+        std::string logData;
+        Orthanc::Toolbox::WriteFastJson(logData, log["LogData"]);
+        boost::replace_all(logData, "\n", "");
+        lineColumns.push_back(logData);
+
+        Orthanc::Toolbox::JoinStrings(line, lineColumns,";");
+        lines.push_back(line);
+      }
+      
+      std::string csv;
+      Orthanc::Toolbox::JoinStrings(csv, lines, "\n");
+
+      OrthancPluginSetHttpHeader(context, output, "Content-disposition", "filename=\"audit-logs.csv\"");
+      OrthancPlugins::AnswerString(csv, "text/csv", output);
+    }
+  }
+}
+
+
+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)
@@ -1020,6 +1763,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 +1973,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 +1983,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);
     }
@@ -1299,31 +2067,6 @@
 }
 
 
-void MergeJson(Json::Value &a, const Json::Value &b) {                                                                        
-                                                                                                                  
-  if (!a.isObject() || !b.isObject())
-  {
-    return;
-  }
-
-  Json::Value::Members members = b.getMemberNames();
-
-  for (size_t i = 0; i < members.size(); i++)
-  {
-    std::string key = members[i];
-    
-    if (!a[key].isNull() && a[key].type() == Json::objectValue && b[key].type() == Json::objectValue)
-    {
-      MergeJson(a[key], b[key]);
-    } 
-    else
-    {
-      a[key] = b[key];
-    }
-  }
-}
-
-
 extern "C"
 {
   ORTHANC_PLUGINS_API int32_t OrthancPluginInitialize(OrthancPluginContext* context)
@@ -1425,6 +2168,9 @@
         }
 #endif
 
+        enableAuditLogs_ = pluginConfiguration.GetBooleanValue("EnableAuditLogs", false);
+
+
         pluginConfiguration.LookupSetOfStrings(uncheckedResources_, "UncheckedResources", false);
         pluginConfiguration.LookupListOfStrings(uncheckedFolders_, "UncheckedFolders", false);
 
@@ -1494,6 +2240,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 +2415,7 @@
           (new OrthancPlugins::CachedAuthorizationService
            (webService.release(), factory));
 
-        if (!urlTokenValidation.empty())
+        if (!urlTokenValidation.empty() || enableAuditLogs_)
         {
           OrthancPluginRegisterOnChangeCallback(context, OnChangeCallback);
         }
@@ -1682,10 +2434,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 +2454,27 @@
           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);
+            OrthancPlugins::RegisterRestCallback<BulkModifyAnonymizeWithAuditLogs>("/tools/bulk-modify", true);
+            OrthancPlugins::RegisterRestCallback<BulkModifyAnonymizeWithAuditLogs>("/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())
+
+            // TODO
+            // /modalities/move
+            // /modalities/store
+            // /archive + create-archive
+            // /media + create-media + create-media-extended
+
+          }
         }
 
         if (!urlTokenCreationBase.empty())