Page tree
Skip to end of metadata
Go to start of metadata

Introduction

Cytomine est une application web accessible par des utilisateurs (User). Il est donc primordiable de permettre à des utilisateurs d'accéder à certaines ressources et pas à d'autres.
Exemple:

  • L'utilisateur u peut accéder au projet p,
  • L'utilisateur u ne peut éditer l'annotation a,
  • L'utilisateur u peut supprimer une image du projet p.

Le modèle de données des ACL contient les classes suivantes:

DomainDescriptionMain properties
SecUserUn user dans Cytomine

String username
String password 

AclSidUn user pour la gestion des ACL

String sid (=username)

AclClassUn type de domaine pour la gestion ACLString className
AclObjectIdentityUne instance de domaine pour la gestion ACLLong objectId
AclClass aclClass 
AclEntryUne autorisation sur un domaine pour un utilisateurAclObjectIdentity aclObjectIdentity
AclSid sid 
int mask 

 

 

SecUser, User et UserJob

Deux types d'utilisateurs peuvent accéder et modifier les données de Cytomine: un utilisateur humain (User) ou une exécution d'un programme (UserJob). Les deux classes héritent de la classe SecUser.

L'utilisateur effectuant la requête est accessible via la méthode getCurrentUser() du service CytomineService.

SecUser user = cytomineService.getCurrentUser() //human or job
println user.username + " is connected!"

 

  • Soit le User se connecte directement à Cytomine (via l'interface web ou via un client),
  • Soit le User lance un programme qui initialisera un nouveau UserJob et se connectera à Cytomine.

Un mécanisme transparent a été mis en place pour que les UserJob héritent automatiquement des droits du User qui les a lancés. Autrement dit, si un User peut accéder à des données ou les modifier, les programmes qu'il lance auront automatiquement les même droits.

Nous ne parlerons donc plus dans la suite de ce document des UserJob étant donné que la gestion des droits est uniquement réalisé via le User qui les a créé .

User/Admin

Un utilisateur connecté à Cytomine peut être un Cytomine User ou un Cytomine Admin.
L'administrateur de Cytomine doit pouvoir accéder à toutes les données et doit pouvoir faire toutes les modifications.

La méthode CurrentRoleService.findCurrentRole renverra l'ensemble des roles du SecUser ("ROLE_USER" et/ou "ROLE_ADMIN").
 

ACL

Gestion des droits dans Cytomine

La gestion des ACL permet de définir les droits d'accès aux ressources pour chaque utilisateur. Cela permettra par exemple ne lister que les projets accessibles à l'utilisateur courant ou d'empêcher un utilisateur de modifier une ontologie alors qu'il n'en a pas l'autorisation.

Nous utilisons le plugin Grails Spring Security ACL. Il permet de créer des associations domain-user-permission afin d'établir que le user à la permission sur un domain.

Une implémentation "naïve" aurait été, pour chaque ressource dans notre base de données, d'ajouter une nouvelle association de permission pour tous les utilisateurs qui pourraient y accéder. 
Si on prend uniquement le cas des annotations, pour un projet avec 20 users et 10000 annotations, sachant que tous les utilisateurs d'un projet peuvent accéder aux annotations de celui-ci, nous aurions dû:

  • Pour chaque nouvel utilisateur: ajouter 10000 lignes pour lui attribuer les droits sur chaque annotation,
  • Pour chaque nouvelle annotation, ajouter 20 lignes pour attribuer les droits à chaque utilisateur,
  • Lors d'un listing des annotations accessibles du projet, nous aurions dû, pour chacune des 10000 annotations, vérifier les droits pour l'utilisateur courant via une requête dans la base de données.

Cette implémentation n'aurait pas été acceptable en terme de performance. Nous avons donc implémenter un système de gestion des droits en fonction d'un nombre restreint de domaine. Il est en effet possible de définir des droits uniquement sur des domaines qui "contiennent" d'autres domaines: des 'containers'.

Par exemple:

  • Un projet contient des annotations, des images,...
  • Une ontologie contient des termes, des relations entre les termes,...
  • ...

Nous ajouterons uniquement des droits sur les 'containers' (projets, ontologies,...) et pas sur les ressources 'simples' (annotations, termes,...). Si nous reprenons l'exemple du projet avec 10000 annotations et 20 users:

  • Pour chaque nouvel utilisateur: ajouter 1 ligne pour lui attribuer les droits sur le projet,
  • Pour chaque nouvelle annotation, ne rien faire,
  • Lors d'un listing des annotations accessibles du projet, il sera juste nécessaire de vérifier avant le listing si l'utilisateur à accès au projet.

Pour détecter si une ressource est 'simple' ou 'container', il faut se poser la question suivante de manière récursive:
"Est-ce que la ressource est contenue entièrement dans une autre ressource?"
Exemple:
UserAnnotation => "Est-ce que la ressource est contenue entièrement dans une autre ressource?" Oui, ImageInstance.
ImageInstance => "Est-ce que la ressource est contenue entièrement dans une autre ressource?" Oui, Project.
Project => "Est-ce que la ressource est contenue entièrement dans une autre ressource?" Non.

UserAnnotation et ImageInstance = ressources 'simples'
Project = ressource 'container'

Définition des droits pour les utilisateurs

Pour les accès et modification des images d'un projet (ImageInstance), nous auront 4 types d'utilisateurs:

  • Admin cytomine: peut tout faire,
  • Utilisateur qui a accès au projet: peut tout faire, 
  • Utilisateur n'ayant pas accès au projet: ne peut rien faire car il n'a pas accès au projet,
  • Utilisateur anonyme, non connecté à Cytomine: ne peut rien faire.

Admin Cytomine

Utilisateur qui bénéficie d'un droit au projet

Utilisateur connecté

Anonyme

(tick) READ
(tick) ADD
(tick) UPDATE
(tick) DELETE

(tick) READ
(tick) ADD
(tick) UPDATE
(tick) DELETE

(error) READ
(error) ADD
(error) UPDATE
(error) DELETE

(error) READ
(error) ADD
(error) UPDATE
(error) DELETE

 
Nous aurions aussi pu définir que seul un utilisateur ayant les droits d'administration sur un projet (a ne pas confondre avec un admin Cytomine) peut ajout/modifier ou supprimer les images.

Admin Cytomine

Utilisateur admin du projet

Utilisateur qui bénéficie d'un droit au projet

Utilisateur connecté

Anonyme

(tick) READ
(tick) ADD
(tick) UPDATE
(tick) DELETE

(tick) READ
(tick) ADD
(tick) UPDATE
(tick) DELETE

(tick) READ
(error) ADD
(error) UPDATE
(error) DELETE

(error) READ
(error) ADD
(error) UPDATE
(error) DELETE

(error) READ
(error) ADD
(error) UPDATE
(error) DELETE

 

Mécanisme des droits

Les droits sont principalement gérés dans 3 endroits:

  • PermissionService pour l'ajout et la suppression des droits sur une ressource 'container',
  • CytomineDomain, via sa méthode boolean hasPermission(stringPermission), pour vérifier si l'utilisateur courant à certains droits sur l'instance du domaine (ex: project.hasPermission('READ')),
  • SecurityACLService qui contiendra de nombreuses méthodes utilisant notamment CytomineDomain.hasPermission.

PermissionService:

void addPermission(def domain, String username, Permission permission)
void deletePermission(CytomineDomain domain, String username, Permission permission)

CytomineDomain:

boolean hasPermission(Permission permission) //return true if current user has this permission on the current domain

SecurityACLService:

static void check(def id, Class classObj, Permission permission)
static void check(def id, Class classObj, String method, Permission permission)
static void check(def id, String className, Permission permission) 
static void check(CytomineDomain domain, Permission permission)
static public List<Storage> getStorageList(SecUser user)
static public List<Ontology> getOntologyList(SecUser user)
static public List<Project> getProjectList(SecUser user)
static public def checkAdmin(SecUser user) 
static public def checkUser(SecUser user)
static public def checkIsSameUser(SecUser user,SecUser currentUser)
static public def checkIsCreator(CytomineDomain domain,SecUser currentUser) 
static public def checkIsNotSameUser(SecUser user,SecUser currentUser)
static public def checkIfUserIsMemberGroup(SecUser user, Group group)
...

 

  • Les méthodes check prennent en argument une ressource (via objet ou via id et class) et une permission. Elles vérifient que l'utilisateur courant à ce type de permission sur la ressource et renvoie une exception dans le cas contraire.
    La méthode check qui prend 4 arguments, effectuera l'appel à la méthode dont le nom est passé en argument depuis l'objet passé en argument. Elle sera très utile si on ne dispose pas d'un lien directe vers la ressource container.
  • Les méthodes getXXXList(SecUser user) permettent de lister les ressources containers accessibles par un utilisateur. Elles sont beaucoup plus efficace que des PostFilter.

Avant de procéder a l'ajout ou la vérification des droits, nous devons effectuer quelques modifications au mécanisme actuelle.

Mécanisme à implémenter pour une ressource 'simple'

Afin de lier automatiquement une ressource 'simple' à son container (ex: Une annotation à son projet), nous pourrons avoir une méthode container() renvoyant le domaine 'container' (annotation.container()).
Par exemple:

  • annotation.container() => annotation.project,
  • project.container() => this,
  • term.container() => term.ontology,

Il sera simple de récupérer les domaines 'containers' de chaque ressource afin de vérifier les droits pour l'utilisateur courant.

En principe, CytomineDomain contient déjà les méthodes container() pour les 'container' (elle renvoit null par défaut). Il sera alors nécessaire de la surchargée dans la ressource 'simple'.
Par exemple, pour ImageInstance:

class ImageInstance extends CytomineDomain{
	
	...
	public CytomineDomain container() {
		return this.project;
	}
	...

}

Une deuxième méthode qu'il peut être utile d'implémenter est userDomainCreator(). Elle doit renvoyer l'utilisateur qui est considérer comme le créateur de la ressource. Spring ACL gère aussi cette information mais vu que nous ne n'enregistrons auprès de lui que les domaines 'containers', cette information ne sera pas disponible pour les ressources 'simple'. AnnotationFilter.userDomainCreator() doit donc renvoyer l'utilisateur qui a créé le filtre. Cette méthode ne doit être implémentée que si cela est nécessaire pour la gestion des droits.

Mécanisme à implémenter pour une ressource 'container'

Nous prendrons comme exemple l'ajout de la gestion des droits pour les ontologies (Ontology), une liste de plusieurs termes (Term).

  • Ressource 'container': Ontology,
  • Ressource 'simple': Term (si l'utilisateur a accès à l'ontologie, il a accès aux termes).

Dans Ontology:

...
public CytomineDomain container() {
return this
}
...

Dans Term:

...
public CytomineDomain container() {
return this.ontology
}
...

Pour notre exemple, les termes sont une simple liste. En pratique, les termes ont des relations entre eux (Parent, Sononym,...).
Une relation entre des termes est définie selon (Relation,Term1,Term2).
Pour RelationTerm, le domain 'container' est Ontology et la méthode container de RelationTerm renverra this.term1.container() (ou this.term2.container(), car une contrainte défini que term1.ontology==term2.ontology)

Les 'containers' de nos ressources sont maintenant accessible. Nous devons ensuite implémenter le mécanisme de contrôle des droits pour un container.

Ajouts des droits

En pratique l'ajout des droits est assez rare et ne s'appliquera généralement qu'aux ressources containers. Nous avons intégrer l'ajout d'une permission pour un domaine et un utilisateur via le service PermissionService.

void addPermission(def domain, String username, Permission permission)
void deletePermission(CytomineDomain domain, String username, Permission permission)

Exemple: si un User est ajouté au projet, nous appellerons permissionService.addPermission(project, user.username, Permission.READ).

Dans le cas d'Ontology, nous ajouterons le droit d'administrateur au créateur de l'ontologie en utilisant le mécanisme de déclencheur Cytomine dans ontologyService:
 

def afterAdd(def domain, def response) {
	aclUtilService.addPermission(domain, cytomineService.currentUser.username, BasePermission.ADMINISTRATION)
}

La méthode afterAdd est automatiquement appelée après la création de la nouvelle ressource.

Les ontologies sont des listes de termes utilisés dans les projets. Chaque utilisateur d'un projet de l'ontologie devra donc pouvoir au moins consulter cette ontologie.
Lors de l'ajout d'un utilisateur à un projet, nous ajoutons également les droits à l'ontologie du projet (idem pour la suppression).

permissionService.addPermission(project,user.username,READ)
permissionService.addPermission(project.ontology,user.username,READ)

Contrôles des droits

Les contrôles des droits sont effectués dans les services.
Il est possible, soit:

  • s'utiliser les méthodes de SecurityACL,
  • d'utiliser l'annotation Java @PreAuthorize("expr bool") au dessus d'une méthode, si "expr bool" vaut false, la méthode ne sera pas appelée et une exception sera déclenchée,
  • d'utiliser l'annotation Java @PostFilter() au dessus d'une méthode de listings, il vérifiera si l'utilisateur à les droits sur chaque ressource renvoyée par la méthode,

Les deux dernières méthodes sont à éviter.

  • PreAuthorize dans un service forcera le développeur à redémarrer Grails à chaque modification das ce service. 
  • PostFilter est très inefficace: si la méthode de listing renvoie 10000 objets, il y aura 10000 contrôles (et donc 10000 requêtes SQL).

Pour la vérification, nous utiliserons le cas des ImageInstance (une image d'un projet).

Méthode de consultation

Pour les méthodes list, nous utiliserons généralement SecurityACL.check en utilisant un des paramètres de la méthode.
Par exemple:

def list(Project project) {
    SecurityACL.check(project,READ) //throw ForbiddenException if user cannot access
	//...request...
	return images
}

Si et seulement si l'utilisateur a un droit d'accès en lecture (ou plus) au projet ou si c'est un admin cytomine, il pourra continuer son parcours dans la fonction.

Dans le cas d'un listing des projets accessibles à l'utilisateur, il nous est impossible de filtrer sur une ressource.
Nous utiliserons SecurityACL.getProjectList(currentUser) pour récupérer uniquement les projets accessibles. 

Pour les méthodes Read/Get, nous ne disposons pas encore de la resource avant de rentrer dans la fonction. Si l'utilisateur n'a pas de droit de lecture, nous déclencherons une exception dans la méthode après la lecture de la ressource.

def read(def id) {
	def image = ImageInstance.read(id)
	if(image) {
		SecurityACL.check(image.container(),READ) //=image.project
	}
	image
}

La méthode check effectuera simplement un image.container().hasPermission('READ') et déclenche une exception si la fonction retourne false et i l'utilisateur n'est pas admin.

Méthode d'ajout

Pour l'ajout, nous ne pourrons effectuer de contrôle que sur les paramètres de la requête (vu que la ressource n'existe pas encore).
La seule donnée que nous pouvons utiliser pour l'ajout d'une image à un projet est json.project qui est le projet défini dans le JSON en paramètre de la requête.

def add(def json) {
    SecurityACL.check(json.project,Project,READ)
    ...
}

Dans l'exemple d'ImageInstance, la ressource container (Project) est passé dans le JSON en paramètre. Mais ce n'est pas toujours le cas. Dans AnnotationTerm, nous passons uniquement l'annotation concernée. Dans ce cas, nous pourrions utiliser:
SecurityACL.check(json.annotation,UserAnnotation,"container",READ) qui effectuera la vérification sur UserAnnotation.read(json.annotation).container(), et donc sur Project. 

 

Méthode d'édition/suppression

Contrairement à l'ajout, nous pouvons disposer de la ressource pour la modification et la suppression.

L'appel au méthode du service par le RestController sera:

//update
def domain = service.retrieve(json) //imageInstanceService.retrieve
def result = service.update(domain,json) //imageInstanceService.update
 
//delete
def domain = service.retrieve(json)
def result = service.update(domain)
def update(ImageInstance domain, def jsonNewData) {
   SecurityACL.check(domain.container(),READ)
   ...
}

def delete(ImageInstance domain, Transaction transaction = null, Task task = null, boolean printMessage = true) {
   SecurityACL.check(domain.container(),READ)
   ...
}

Si pour modifier ou supprimer une image, il faut être administrateur du projet, il suffira de modifier READ par ADMINISTRATION.

 

Tests de sécurité

Il est particulièrement complexe de tester la sécurité de manière manuelle. Il faut en effet tenter chaque type de requête (listing, create, update,...) avec plusieurs types de compte (cytomine admin, utilisateur ayant accès au projet, utilisateur n'ayant pas accès au projet, anonyme,...).

Les tests classiques sont effectué via un admin cytomine (qui peut tout faire) et il n'est pas souhaitable de les complexifier en multipliant chaque requête avec différents type d'utilisateurs.
Nous avons donc mis en place un package spécial pour les tests: be.cytomine.security.

L'idée est assez simple:

  • pour chaque ressource, nous créons une classe de test,
  • pour chaque type d'utilisateur pertinent, nous créons une méthode dans cette classe.

Pour définir les différents types d'utilisateur, nous nous baserons sur les tableaux de droits.

Nous prendrons l'exemple des termes (Term) d'une ontologie (Ontology). Le tableau ci-dessous, nous indique que:

  • Les admin Cytomine et l'utilisateur qui a créé l'ontologie peuvent tout faire avec ses termes,
  • L'utilisateur d'un projet utilisant une ontologie peut y avoir accès ou ajouter une nouvelle ontologie,
  • Un utilisateur connecté à Cytomine peut ajouter une ontologie,
  • Un utilisateur non connecté ne peut rien faire.

Admin Cytomine

Utilisateur qui a créé l'ontologie du terme

Utilisateur d'un projet utilisant l'ontologie

Utilisateur connecté

Anonyme

(tick) READ
(tick) ADD
(tick) UPDATE
(tick) DELETE

(tick) READ
(tick) ADD
(tick) UPDATE
(tick) DELETE

(tick) READ
(tick) ADD
(error) UPDATE
(error) DELETE

(error) READ
(tick) ADD
(error) UPDATE
(error) DELETE

(error) READ
(error) ADD
(error) UPDATE
(error) DELETE

Le résultat en code HTTP de chaque requête pourrait se traduire par:

Admin Cytomine

Utilisateur qui a créé l'ontologie du terme

Utilisateur d'un projet utilisant l'ontologie

Utilisateur connecté

Anonyme

(tick) 200
(tick) 200
(tick) 200
(tick) 200

(tick) 200
(tick) 200
(tick) 200
(tick) 200

(tick) 200
(tick) 200
(error) 403
(error) 403

(error) 403
(tick) 200
(error) 403
(error) 403

(error) 401
(error) 401
(error) 401
(error) 401

Le code 401 signifie qu'une authentification est nécessaire. Le code 403 signifie que l'utilisateur ne peut effectuer la requête.

Nous avons donc 5 types d'utilisateurs et autant de méthodes de tests.

class TermSecurityTests extends SecurityTestsAbstract {
 
	void testTermSecurityForCytomineAdmin()
 
	void testTermSecurityForOntologyCreator()
 
	void testTermSecurityForOntologyUser()
 
	void testTermSecurityForSimpleUser()
 
	void testTermSecurityForAnonymous()
 
}

Chacune des méthodes aura la structure suivante:

void testTermSecurityForXXX() {

      //init user
 
	  //create new term
 
	  //add some right on term.ontology (if needed)
 
	  //check if create == good HTTP Code
 
	  //check if show == good HTTP Code
 
  	  //check if term is or not in list
 
	  //check if update == good HTTP Code
 
	  //check if delete == good HTTP Code
}

Par exemple, pour le test du créateur de l'ontologie:

  void testTermSecurityForOntologyCreator() {
  
      User user1 = BasicInstance.createOrGetBasicUser(USERNAME1,PASSWORD1)
     
      //User 1 create new ontology
      def result = OntologyAPI.create(BasicInstance.getBasicOntologyNotExist().encodeAsJSON(),USERNAME1,PASSWORD1)
 
	  //Init term data
      def termToAdd = BasicInstance.getBasicTermNotExist()
      termToAdd.ontology = result.data
 
	  //test if create == 200
      result = TermAPI.create(termToAdd.encodeAsJSON(),USERNAME1,PASSWORD1)
      assert 200 == result.code
      Term term = result.data

      //check if user 1 can access (read or list)/update/delete
      assert (200 == TermAPI.show(term.id,USERNAME1,PASSWORD1).code)
      assert (true ==TermAPI.containsInJSONList(term.id,JSON.parse(TermAPI.listByOntology(term.ontology.id,USERNAME1,PASSWORD1).data)))
      assert (200 == TermAPI.update(term.id,term.encodeAsJSON(),USERNAME1,PASSWORD1).code)
      assert (200 == TermAPI.delete(term.id,USERNAME1,PASSWORD1).code)
  }

Pour les autres types d'utilisateur:

  • Admin Cytomine: créer une ontologie avec un simple utilisateur mais effectué chaque requête de tests avec les login/pass d'un utilisateur admin.
  • Créateur de l'ontologie: voir ci-dessus.
  • Utilisateur de l'ontologie: créer une ontologie par user1, ajouter un user2 à un projet de l'ontologie, vérifier si user2 peut récupérer l'ontologie via un show ou un list.  (On peut aller plus loin et retirer l'user2 du projet et tester si l'ontologie est bien inaccessible).
  • Utilisateur cytomine: créer une ontologie par user1, tester si user2 ne peut rien effectuer sur l'ontologie.
  • Anonyme: juste effectuer les requêtes avec un mauvais login/pass et vérifier si chacune renvoie bien 401.

 

  • No labels