浏览代码

doc on security, refactor voters, better entities docstrings (ongoing)

Olivier Massot 2 年之前
父节点
当前提交
c28dd2149f

+ 93 - 0
doc/security.md

@@ -0,0 +1,93 @@
+# Security
+
+## Authentification Symfony
+
+### Fonctionnement de base
+
+L'authentification se fait via une requête POST envoyée à l'adresse `/login_check` avec le body suivant : 
+
+    {
+        "username": "login",
+        "password": "password"
+    }
+
+En cas de succès, la requête renvoie un token qui servira ensuite à l'utilisateur à s'identifier.
+
+Les requêtes suivantes devront posséder les headers suivants : 
+
+* `x-accessid` : l'id de l'utilisateur (ou Access)
+* `authorization` : une chaine de caractères de la forme "BEARER XXXXX", où XXXXX est le token retourné par la requête de login
+
+### Connexion Switch
+
+Certains utilisateurs (admins, familles) peuvent prendre le rôle d'un autre utilisateur via la connexion switch.
+
+Pour ce faire, un nouveau header doit être ajouté aux requêtes : 
+
+* `x-switch-user`: l'id de l'utilisateur dont on veut prendre le rôle
+
+
+
+## Roles et Modules
+
+Les droits d'un utilisateur sont conditionnés à différents critères, dont : 
+
+* Les **modules** que possède l'organisation à laquelle il est appartient
+* Les **rôles** de cet utilisateur au sein de cette organisation
+
+On peut obtenir la liste des modules de l'organisation et des rôles de l'utilisateur actif en son sein au moyen de la 
+requête : `/api/my_profile`
+
+
+### Modules
+
+Les modules d'une organisation dépendent du produit acheté par celle-ci et des éventuels modules complémentaires. Ces deux 
+informations sont stoquées dans la table `Settings`.
+
+Le fichier `config/opentalent/products.yaml` définit :
+
+- L'appartenance des _modules_ aux _products_
+- L'appartenance des _entities_ aux _modules_
+
+De plus, le fichier `config/opentalent/modulesbyconditions.yaml` complète cette configuration en définissant des modules
+présentant des conditions particulières (appartenance à la CMF en particulier)
+
+A chaque requête effectuée, la classe `\App\Security\Voter\ModuleVoter` vérifie si la ressource demandée appartient à un
+module possédé par l'organisation de l'utilisateur. Si ce n'est pas le cas, une erreur `AccessDeniedHttpException` est levée.
+
+
+
+## Sécurité des ressources Api-Platform
+
+> See : https://api-platform.com/docs/core/security/
+
+La sécurité des ApiResources peut être définie de manière globale pour la ressource, ou pour chaque opération (Get, 
+GetCollection, Put, Post, Delete).
+
+Exemple : 
+
+    #[ApiResource(
+        operations: [
+            new Get(
+                security: '(is_granted("ROLE_ORGANIZATION_VIEW") or is_granted("ROLE_ORGANIZATION")) and object.getOrganization().getId() == user.getOrganization().getId()'
+            ),
+            new Put(
+                security: 'is_granted("ROLE_ORGANIZATION") and object.getOrganization().getId() == user.getOrganization().getId()'
+            )
+        ],
+    )]
+
+Dans certains cas plus complexes (ex: Access), cette configuration peut être déplacée dans un fichier de configuration
+situé dans le répertoire `~/config/api_platform/` et portant le nom de la ressource.
+
+
+## Extensions Doctrine
+
+## Voters
+
+## Internal Requests
+
+## Cas particuliers 
+
+### Les Fichiers
+

+ 3 - 0
src/ApiResources/Cotisation/Cotisation.php

@@ -12,6 +12,9 @@ use Symfony\Component\Validator\Constraints as Assert;
 
 /**
  * Classe resource qui contient les informations des cotisations de la 5.9
+ *
+ * Security :
+ *   * @see App\Security\Voter\CotisationVoter
  */
 #[ApiResource(operations: [])]
 class Cotisation implements ApiResourcesInterface

+ 4 - 1
src/Entity/Booking/Course.php

@@ -25,8 +25,11 @@ use Doctrine\Common\Collections\Collection;
  * @todo : migration table tag_booking
  *
  * Classe Course qui permet de gérer les cours de la structure.
+ *
+ * Security :
+ *   * @see App\Doctrine\Booking\CurrentCoursesExtension
  */
-#[ApiResource(operations: [])] // @see App\Doctrine\Booking\CurrentCoursesExtension
+#[ApiResource(operations: [])]
 //#[Auditable]
 #[ORM\Entity(repositoryClass: CourseRepository::class)]
 #[ORM\Table(name: 'Booking')]

+ 7 - 1
src/Entity/Core/AddressPostal.php

@@ -17,7 +17,13 @@ use Doctrine\ORM\Mapping as ORM;
 use App\Entity\Person\PersonAddressPostal;
 use Symfony\Component\Serializer\Annotation\Groups;
 
-#[ApiResource(operations: [])] // @see App\Doctrine\Core\AllowedAddressPostalExtension
+/**
+ * Adresse postale d'une organisation ou d'une personne
+ *
+ * Security :
+ *   * @see App\Doctrine\Core\AllowedAddressPostalExtension
+ */
+#[ApiResource(operations: [])]
 //#[Auditable]
 #[ORM\Entity(repositoryClass: AddressPostalRepository::class)]
 class AddressPostal

+ 3 - 0
src/Entity/Core/BankAccount.php

@@ -21,6 +21,9 @@ use Symfony\Component\Validator\Constraints as Assert;
 
 /**
  * Données bancaire d'une Person ou d'une Organization
+ *
+ * Security :
+ *   * @see App\Security\Voter\BankAccountVoter
  */
 #[ApiResource(operations: [])]
 //#[Auditable]

+ 3 - 0
src/Entity/Core/ContactPoint.php

@@ -25,6 +25,9 @@ use App\Validator\Core as OpentalentAssert;
 
 /**
  * Données de contact d'une Person ou d'une Organization ou d'un lieu
+ *
+ * Security :
+ *   * @see App\Security\Voter\ContactPointVoter
  */
 #[ApiResource(operations: [])]
 //#[Auditable]

+ 6 - 0
src/Entity/Core/File.php

@@ -28,6 +28,12 @@ use Symfony\Component\Serializer\Annotation\Groups;
 use Symfony\Component\Validator\Constraints as Assert;
 use App\Enum\Core\FileStatusEnum;
 
+/**
+ * Fichier, généré ou uploadé, appartenant à une organisation ou à une personne.
+ *
+ * Security :
+ *   * @see App\Security\Voter\FileVoter
+ */
 #[ApiResource(
     operations: [
         new Get(

+ 5 - 2
src/Entity/Core/Notification.php

@@ -24,7 +24,10 @@ use Symfony\Component\Serializer\Annotation\Context;
 /**
  * @todo : A la suite de la migration, il faut supprimer le nom de la table pour avoir une table Notification, et supprimer l'attribut discr.
  *
- * Classe Notification. qui permet de gérer les notifications aux utilisateurs.
+ * Notification à un utilisateur
+ *
+ * Security :
+ *   * @see App\Doctrine\Core\CurrentUserNotificationExtension
  */
 #[ApiResource(
     operations: [
@@ -35,7 +38,7 @@ use Symfony\Component\Serializer\Annotation\Context;
             order: ['id' => 'DESC']
         )
     ]
-)] // @see App\Doctrine\Core\CurrentUserNotificationExtension
+)]
 //#[Auditable]
 #[ORM\Entity(repositoryClass: NotificationRepository::class)]
 class Notification extends AbstractInformation

+ 4 - 1
src/Entity/Core/NotificationUser.php

@@ -16,6 +16,9 @@ use Doctrine\ORM\Mapping as ORM;
 /**
  * Les NotificationUser permettent de garder la trace des notifications et des tips
  * qui ont été lues par les utilisateurs
+ *
+ * Security :
+ *   * @see App\Doctrine\Core\CurrentUserNotificationUserExtension
  */
 #[ApiResource(
     operations: [
@@ -25,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM;
                        and object.getNotification().getRecipientAccess().getOrganization().getId() == user.getOrganization().getId()'
         )
     ]
-)] // @see // @see App\Doctrine\Core\CurrentUserNotificationUserExtension
+)]
 //#[Auditable]
 #[ORM\Entity(repositoryClass: NotificationUserRepository::class)]
 class NotificationUser

+ 4 - 1
src/Entity/Education/Cycle.php

@@ -19,8 +19,11 @@ use Doctrine\Common\Collections\Collection;
 /**
  * Enum des cycles éducatifs, utilisés par les EducationCurriculum
  * NB: le nombre de cycles est fixé à 6, mais chaque Organization peut en modifier le label
+ *
+ * Security :
+ *   * @see App\Doctrine\Education\CurrentCycleExtension
  */
-#[ApiResource(operations: [])] // @see App\Doctrine\Education\CurrentCycleExtension
+#[ApiResource(operations: [])]
 //#[Auditable]
 #[ORM\Entity(repositoryClass: CycleRepository::class)]
 class Cycle

+ 5 - 1
src/Entity/Education/EducationNotationConfig.php

@@ -21,8 +21,12 @@ use Symfony\Component\Validator\Constraints as Assert;
 
 /**
  * Configuration des grilles d'évaluation
+ *
+ * Security :
+ *   * @see App\Doctrine\Education\CurrentEducationNotationConfigExtension
+ *
  */
-#[ApiResource(operations: [])] // @see App\Doctrine\Education\CurrentEducationNotationConfigExtension
+#[ApiResource(operations: [])]
 //#[Auditable]
 #[ORM\Entity(repositoryClass: EducationNotationConfigRepository::class)]
 #[OrganizationDefaultValue(fieldName: "organization")]

+ 4 - 1
src/Entity/Network/NetworkOrganization.php

@@ -16,8 +16,11 @@ use Symfony\Component\Serializer\Annotation\Groups;
 
 /**
  * Fait le lien entre une Organization et un Network
+ *
+ * Security :
+ *   * @see App\Doctrine\Network\CurrentNetworkOrganizationExtension
  */
-#[ApiResource(operations: [])] // @see App\Doctrine\Network\CurrentNetworkOrganizationExtension
+#[ApiResource(operations: [])]
 //#[Auditable]
 #[ORM\Entity(repositoryClass: NetworkOrganizationRepository::class)]
 #[DateTimeConstraintAware(startDateFieldName: "startDate", endDateFieldName: "endDate")]

+ 4 - 1
src/Entity/Organization/Organization.php

@@ -48,8 +48,11 @@ use Symfony\Component\Validator\Constraints as Assert;
 
 /**
  * Structure, organisation
+ *
+ * Security :
+ *   * @see App\Doctrine\Organization\CurrentOrganizationExtension
  */
-#[ApiResource(operations: [])] // @see App\Doctrine\Organization\CurrentOrganizationExtension
+#[ApiResource(operations: [])] //
 //#[Auditable]
 #[ORM\Entity(repositoryClass: OrganizationRepository::class)]
 class Organization

+ 7 - 1
src/Entity/Organization/OrganizationAddressPostal.php

@@ -19,11 +19,17 @@ use Symfony\Component\Validator\Constraints as Assert;
 use Symfony\Component\Serializer\Annotation\Groups;
 use App\Validator\Organization as OpentalentAssert;
 
+/**
+ * Fait le lien entre une adresse postal et une organisation
+ *
+ * Security :
+ *   * @see App\Doctrine\Organization\CurrentOrganizationAddressPostalExtension
+ */
 #[ApiResource(
     normalizationContext: ['groups' => ['address']],
     denormalizationContext: ['groups' => ['address']],
     operations: []
-)] // @see App\Doctrine\Organization\CurrentOrganizationAddressPostalExtension
+)]
 //#[Auditable]
 #[ORM\Entity(repositoryClass: OrganizationAddressPostalRepository::class)]
 #[OrganizationDefaultValue(fieldName: "organization")]

+ 5 - 2
src/Entity/Organization/OrganizationArticle.php

@@ -12,9 +12,12 @@ use App\Repository\Organization\OrganizationArticleRepository;
 use Doctrine\ORM\Mapping as ORM;
 
 /**
- * Fait le lien entre une Organization et un coup de projecteur
+ * Fait le lien entre une Organization et un article (ou coup de projecteur, tel qu'affiché dans l'annuaire des structures)
+ *
+ * Security :
+ *   *  @see App\Doctrine\Organization\CurrentOrganizationArticleExtension
  */
-#[ApiResource(operations: [])] // @see App\Doctrine\Organization\CurrentOrganizationArticleExtension
+#[ApiResource(operations: [])]
 //#[Auditable]
 #[ORM\Entity(repositoryClass: OrganizationArticleRepository::class)]
 class OrganizationArticle

+ 27 - 0
src/Security/Voter/AbstractVoter.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Security\Voter;
+
+use App\Entity\Access\Access;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+use Symfony\Component\Security\Core\User\UserInterface;
+
+abstract class AbstractVoter extends Voter
+{
+    protected ?UserInterface $user = null;
+
+    public function __construct(
+        private Security $security
+    ) {
+        /** @var Access $user */
+        $user = $token->getUser();
+
+        // If the user is not anonymous, remember it
+        if ($user instanceof UserInterface) {
+            $this->user = $user;
+        }
+    }
+
+
+}

+ 199 - 0
src/Security/Voter/FileVoter.php

@@ -0,0 +1,199 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Security\Voter;
+
+use App\Entity\Access\Access;
+use App\Entity\Core\BankAccount;
+use App\Entity\Core\File;
+use AppBundle\Entity\Organization\Organization;
+use AppBundle\Entity\Person\Person;
+use AppBundle\Enum\Core\FileTypeEnum;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\Security\Core\User\UserInterface;
+
+/**
+ * Contrôle l'accès à l'entité File
+ */
+class FileVoter extends AbstractVoter
+{
+    protected const FILE_READ = 'FILE_READ';
+    protected const FILE_EDIT = 'FILE_EDIT';
+    protected const FILE_CREATE = 'FILE_CREATE';
+    protected const FILE_DELETE = 'FILE_DELETE';
+
+    public function __construct(
+        private Security $security
+    ) {}
+
+    protected function supports(string $attribute, $subject): bool
+    {
+        return $subject instanceof File && in_array($attribute, [self::FILE_READ, self::FILE_EDIT, self::FILE_DELETE]);
+    }
+
+    /**
+     * Retourne True si l'utilisateur a le droit d'effectuer l'opération demandée
+     *
+     * @param string $attribute
+     * @param mixed $subject
+     * @param TokenInterface $token
+     * @return bool
+     */
+    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+    {
+        if(!$this->isAvailabilityDatePassed($subject)) {
+            // Le fichier n'est pas encore disponible TODO: à revoir
+            return false;
+        }
+
+        switch ($attribute) {
+            case self::FILE_READ:
+                return $this->canView($subject);
+            case self::FILE_EDIT:
+                return $this->canEdit($subject, $user);
+            case self::FILE_CREATE:
+                return $this->canCreate($subject, $user);
+            case self::FILE_DELETE:
+                return $this->canDelete($subject, $user);
+        }
+
+        return false;
+    }
+
+    /**
+     * Returns True if the current user has the right to GET this record
+     *
+     * @param $subject \AppBundle\Entity\Core\File
+     * @param $user
+     * @return boolean
+     */
+    public function canView(File $subject): bool
+    {
+        // File has public visibility
+        if ($subject->getVisibility() === FileVisibilityEnum::EVERYBODY) {
+            return true;
+        }
+
+        // Is this an internal request? (@see https://gitlab.2iopenservice.com/opentalent/ap2i/-/blob/develop/doc/internal_requests.md)
+        $clientIp = $_SERVER['REMOTE_ADDR'];
+        $internalRequestToken = $_SERVER['HTTP_INTERNAL_REQUESTS_TOKEN'] ?? '';
+        if ($this->internalRequestsService->isAllowed($clientIp, $internalRequestToken)) {
+            return true;
+        }
+
+        // If the user has not logged in, the file is not available
+        if (!$this->user) {
+            return false;
+        }
+
+        // If the logged user is in accessUser of File
+        if ($subject->getAccessPersons()->count() !== 0) {
+            foreach ($subject->getAccessPersons() as $accessPerson) {
+                if ($user->getId() == $accessPerson->getId()) {
+                    return true;
+                }
+            }
+        }
+
+        /**
+         * If the logged user is in accessRole of File
+         */
+        if(0 !== count($subject->getAccessRoles())
+            &&(
+                ($subject->getOrganization() instanceof Organization && $subject->getOrganization()->getId() === $this->accessService->getAccess()->getOrganization()->getId())
+                || ($subject->getPerson() instanceof Person && $subject->getPerson()->getId() === $this->accessService->getAccess()->getPerson()->getId())
+            )
+        ){
+            foreach ($subject->getAccessRoles() as $accessRole){
+                if($this->accessService->hasRole($accessRole)){
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    }
+
+
+    /**
+     * @param $subject \AppBundle\Entity\Core\File
+     * @param $user
+     * @return boolean
+     */
+    public function canEdit($subject, $user)
+    {
+        /**
+         * If user is not logged, the file is not available
+         */
+        if (!$user instanceof Person) {
+            return false;
+        } /**
+         * If user has ROLE_FILE
+         */
+        elseif ($this->accessService->hasRole('ROLE_FILE')
+            && (
+                ($subject->getOrganization() instanceof Organization && $subject->getOrganization()->getId() === $this->accessService->getAccess()->getOrganization()->getId())
+                || ($subject->getPerson() instanceof Person && $subject->getPerson()->getId() === $this->accessService->getAccess()->getPerson()->getId())
+            )
+        ) {
+            return true;
+        } /**
+         * If proprietary person of file is same of logged user
+         */
+        elseif ($subject->getPerson() instanceof Person && $subject->getPerson()->getId() === $user->getId()) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * @param $subject File
+     * @param $user
+     * @return boolean
+     */
+    public function canCreate($subject, $user)
+    {
+        /**
+         * If user is not logged, the file is not available
+         */
+        if (!$user instanceof Person) {
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * @param $subject File
+     * @param $user
+     * @return boolean
+     */
+    public function canDelete($subject, $user){
+        return $this->canEdit($subject, $user);
+    }
+
+    /**
+     * @param $subject
+     * @return bool
+     * @throws \Exception
+     */
+    private function isAvailabilityDatePassed($subject){
+        $isAvailabilityDatePassed = true;
+
+        $today = new \DateTime();
+        if (!empty($subject->getAvailabilityDate()) && $subject->getAvailabilityDate() > $today) {
+            $userHasRole = false;
+            //  Si l'utilisateur a les droits suffisant pour voir le fichier meme si la date n'est pas passé: exemple: liste des factures
+            if($subject->getType() === FileTypeEnum::BILL && $this->roleServiceUtils->checkIfUserHasRoles($this->accessService->getRoles(), ['ROLE_BILLACCOUNTING'])){
+                $userHasRole = true;
+            }
+            if(!$userHasRole)
+                $isAvailabilityDatePassed = false;
+        }
+
+        return $isAvailabilityDatePassed;
+    }
+
+}