瀏覽代碼

add logging and transaction to orga creation, review internal requests

Olivier Massot 1 年之前
父節點
當前提交
982f31bda3

+ 49 - 0
config/packages/monolog.yaml

@@ -103,6 +103,55 @@ monolog:
             formatter:      monolog.formatter.html
             content_type:   text/html
 
+        ### --- Admin operations ---
+        # Log fichier (niveau debug)
+        admin_file:
+            type: rotating_file
+            path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
+            level: debug
+            max_files: 7
+            formatter: monolog.formatter.message
+            channels: ['cron']
+
+        # Rapport par mail
+        admin_info:
+            type:           fingers_crossed
+            action_level:   info
+            handler:        cron_info_deduplicated
+            channels: ['admin']
+        admin_info_deduplicated:
+            type: deduplication
+            # the time in seconds during which duplicate entries are discarded (default: 60)
+            time: 10
+            handler: admin_info_mailer
+        admin_info_mailer:
+            type:           symfony_mailer
+            from_email:     "mail.report@opentalent.fr"
+            to_email:       "exploitation@opentalent.fr"
+            subject:        "Administration - Execution Report"
+            level:          info
+            content_type:   text/html
+
+        # Log par mail en cas d'erreur critique
+        admin_critical:
+            type:           fingers_crossed
+            action_level:   critical
+            handler:        admin_critical_deduplicated
+            channels: ['admin']
+        admin_critical_deduplicated:
+            type: deduplication
+            # the time in seconds during which duplicate entries are discarded (default: 60)
+            time: 10
+            handler: admin_critical_mailer
+        admin_critical_mailer:
+            type:           symfony_mailer
+            from_email:     "mail.report@opentalent.fr"
+            to_email:       "exploitation@opentalent.fr"
+            subject:        "Administration - Critical Error"
+            level:          critical
+            formatter:      monolog.formatter.html
+            content_type:   text/html
+
         # uncomment to get logging in your browser
         # you may have to allow bigger header sizes in your Web server configuration
         #firephp:

+ 7 - 7
doc/internal_requests.md

@@ -2,24 +2,24 @@
 
 ### Principe général
 
-Les requêtes internes sont des requêtes envoyées de ap2i vers opentalent-platform ou dans le sens inverse, par 
-exemple pour demander un fichier.
+Les requêtes internes sont des requêtes échangées entre les services internes à l'entreprise (maestro, ap2i, 
+opentalent-platform...), par exemple pour demander un fichier.
 
-Ces requêtes ne sont pas protégées par l'authentification Symfony standard, car elles doivent pouvoir être exécutées 
-en dehors du cadre d'une requête utilisateur, par exemple lors d'une exécution en ligne de commande ou lors 
-d'un processus asynchrone exécuté par messenger.
+Ces requêtes ne sont pas systématiquement protégées par l'authentification Symfony standard, car elles doivent 
+pouvoir être exécutées en dehors du cadre d'une requête utilisateur, par exemple lors d'une exécution en ligne 
+de commande ou lors d'un processus asynchrone exécuté par messenger.
 
 Pour éviter tout risque de sécurité lié à ces routes :
 
 * on restreint leur accès aux ips internes
-* on conditionne l'autorisation à la présence d'un token
+* on conditionne l'autorisation à la présence d'un token ou à une authentification en tant que super-admin.
 * on limite les routes concernées
 
 Ainsi, si l'on prend l'exemple d'une requête `/internal/download/123` sur ap2i :
 
 * Un utilisateur dans le VPN qui ferait un curl à cette adresse recevra une erreur 500 à cause du token manquant
 * Un utilisateur hors VPN, même s'il connaissait le token, recevra une erreur 500, car n'ayant pas une ip autorisée
-* Une requête issue de la V1 sera autorisée sans authentification
+* Une requête issue de la V1 avec le bon token et provenant d'une ip interne sera autorisée sans authentification
 
 ### Ip internes 
 

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

@@ -428,6 +428,7 @@ class Organization
         if ($settings->getOrganization() !== $this) {
             $settings->setOrganization($this);
         }
+
         $this->settings = $settings;
 
         return $this;

+ 0 - 6
src/Entity/Organization/Parameters.php

@@ -225,13 +225,7 @@ class Parameters
 
     public function setOrganization(Organization $organization): self
     {
-        // set the owning side of the relation if necessary
-        if ($organization->getParameters() !== $this) {
-            $organization->setParameters($this);
-        }
-
         $this->organization = $organization;
-
         return $this;
     }
 

+ 0 - 5
src/Entity/Organization/Settings.php

@@ -53,11 +53,6 @@ class Settings
 
     public function setOrganization(Organization $organization): self
     {
-        // set the owning side of the relation if necessary
-        if ($organization->getSettings() !== $this) {
-            $organization->setSettings($this);
-        }
-
         $this->organization = $organization;
 
         return $this;

+ 1 - 1
src/Security/Voter/InternalRequestsVoter.php

@@ -37,6 +37,6 @@ class InternalRequestsVoter extends Voter
         $clientIp = $request->server->get('REMOTE_ADDR');
         $internalRequestsToken = $request->headers->get('internal-requests-token') ?? null;
 
-        return $internalRequestsToken && $this->internalRequestsService->isAllowed($clientIp, $internalRequestsToken);
+        return $this->internalRequestsService->isAllowed($clientIp, $internalRequestsToken);
     }
 }

+ 139 - 59
src/Service/Organization/OrganizationFactory.php

@@ -21,18 +21,22 @@ use App\Repository\Core\CountryRepository;
 use App\Repository\Network\NetworkRepository;
 use App\Repository\Organization\OrganizationRepository;
 use App\Service\Dolibarr\DolibarrApiService;
-use App\Service\Typo3\BindFileService;
 use App\Service\Typo3\SubdomainService;
 use App\Service\Typo3\Typo3Service;
 use App\Service\Utils\DatesUtils;
+use Doctrine\ORM\EntityManagerInterface;
 use Elastica\Param;
-use libphonenumber\PhoneNumber;
+use libphonenumber\PhoneNumberUtil;
+use Psr\Log\LoggerInterface;
 use Symfony\Component\String\ByteString;
 use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
+use Symfony\Contracts\Service\Attribute\Required;
 use Throwable;
 
 class OrganizationFactory
 {
+    private LoggerInterface $logger;
+
     public function __construct(
         private readonly SubdomainService $subdomainService,
         private readonly OrganizationRepository $organizationRepository,
@@ -40,9 +44,16 @@ class OrganizationFactory
         private readonly NetworkRepository $networkRepository,
         private readonly Typo3Service $typo3Service,
         private readonly DolibarrApiService $dolibarrApiService,
-        private readonly BindFileService $bindFileService
+        private readonly EntityManagerInterface $entityManager
     ) {}
 
+    #[Required]
+    /** @see https://symfony.com/doc/current/logging/channels_handlers.html#how-to-autowire-logger-channels */
+    public function setLoggerInterface(LoggerInterface $adminLogger): void
+    {
+        $this->logger = $adminLogger;
+    }
+
     /**
      * Créé une nouvelle organisation à partir des données contenues dans une OrganizationCreationRequest
      *
@@ -53,74 +64,146 @@ class OrganizationFactory
      */
     public function create(OrganizationCreationRequest $organizationCreationRequest): Organization
     {
-        // Création de l'organisation
-        $organization = $this->makeOrganization($organizationCreationRequest);
-
-        // Création des Parameters
-        $parameters = $this->makeParameters($organizationCreationRequest);
-        $organization->setParameters($parameters);
-
-        // Création des Settings
-        $settings = $this->makeSettings($organizationCreationRequest);
-        $organization->setSettings($settings);
-
-        // Création de l'adresse postale
-        $organizationAddressPostal = $this->makePostalAddress($organizationCreationRequest);
-        $organization->addOrganizationAddressPostal($organizationAddressPostal);
-
-        // Création du point de contact
-        $contactPoint = $this->makeContactPoint($organizationCreationRequest);
-        $organization->addContactPoint($contactPoint);
-
-        // Rattachement au réseau
-        $networkOrganization = $this->makeNetworkOrganization($organizationCreationRequest);
-        $organization->addNetworkOrganization($networkOrganization);
-
-        // Créé l'admin
-        $adminAccess = $this->makeAdminAccess($organizationCreationRequest);
-        $adminAccess->setOrganization($organization);
+        $this->logger->info(
+            'Start the creation of a new organization named ' . $organizationCreationRequest->getName()
+        );
 
-        // Création des cycles
-        foreach ($this->makeCycles() as $cycle) {
-            $cycle->setOrganization($organization);
-            $organization->addCycle($cycle);
+        $this->validateSubdomain($organizationCreationRequest->getSubdomain());
+        $this->logger->info("Subdomain is valid and available : " . $organizationCreationRequest->getSubdomain());
+
+        $this->entityManager->beginTransaction();
+
+        try {
+            // Création de l'organisation
+            $organization = $this->makeOrganization($organizationCreationRequest);
+            $this->entityManager->persist($organization);
+            $this->logger->debug(" - Organization created");
+
+            // Création des Parameters
+            $parameters = $this->makeParameters($organizationCreationRequest);
+            $organization->setParameters($parameters);
+            $parameters->setOrganization($organization);
+            $this->entityManager->persist($parameters);
+            $this->logger->debug(" - Parameters created");
+
+            // Création des Settings
+            $settings = $this->makeSettings($organizationCreationRequest);
+            $organization->setSettings($settings);
+            $this->entityManager->persist($settings);
+            $this->logger->debug(" - Settings created");
+
+            // Création de l'adresse postale
+            $organizationAddressPostal = $this->makePostalAddress($organizationCreationRequest);
+            $organization->addOrganizationAddressPostal($organizationAddressPostal);
+            $this->entityManager->persist($organizationAddressPostal);
+            $this->logger->debug(" - OrganizationAddressPostal created");
+
+            // Création du point de contact
+            $contactPoint = $this->makeContactPoint($organizationCreationRequest);
+            $organization->addContactPoint($contactPoint);
+            $this->entityManager->persist($contactPoint);
+            $this->logger->debug(" - ContactPoint created");
+
+            // Rattachement au réseau
+            $networkOrganization = $this->makeNetworkOrganization($organizationCreationRequest);
+            $organization->addNetworkOrganization($networkOrganization);
+            $this->entityManager->persist($networkOrganization);
+            $this->logger->debug(" - NetworkOrganization created");
+
+            // Créé l'admin
+            $adminAccess = $this->makeAdminAccess($organizationCreationRequest);
+            $adminAccess->setOrganization($organization);
+            $this->entityManager->persist($adminAccess);
+            $this->logger->debug(" - Admin access created");
+
+            // Création des cycles
+            foreach ($this->makeCycles() as $cycle) {
+                $cycle->setOrganization($organization);
+                $organization->addCycle($cycle);
+                $this->entityManager->persist($cycle);
+            }
+            $this->logger->debug(" - Cycles created");
+
+            // Création du président (si renseigné)
+            $presidentCreationRequest = $organizationCreationRequest->getPresident();
+            if ($presidentCreationRequest !== null) {
+                $presidentAccess = $this->makeAccess($presidentCreationRequest);
+                $organization->addAccess($presidentAccess);
+                $this->entityManager->persist($presidentAccess);
+                $this->logger->debug(" - President access created");
+            }
+
+            // Création du directeur (si renseigné)
+            $directorCreationRequest = $organizationCreationRequest->getDirector();
+            if ($directorCreationRequest !== null) {
+                $directorAccess = $this->makeAccess($directorCreationRequest);
+                $organization->addAccess($directorAccess);
+                $this->entityManager->persist($directorAccess);
+                $this->logger->debug(" - Director access created");
+            }
+
+            $this->entityManager->flush();
+            $this->entityManager->commit();
+            $this->logger->debug(" - New records commited");
+            $this->logger->info("Organization created in the DB");
+
+        } catch (\Exception $e) {
+            $this->logger->critical("An error happened, operation cancelled : " . $e);
+            $this->entityManager->rollback();
+            throw $e;
         }
 
-        // Création du sous domaine
+        // Création et enregistrement du sous-domaine
         $this->subdomainService->addNewSubdomain(
             $organization,
             $organizationCreationRequest->getSubdomain(),
             true
         );
-
-        // Création du président (si renseigné)
-        $presidentCreationRequest = $organizationCreationRequest->getPresident();
-        if ($presidentCreationRequest !== null) {
-            $presidentAccess = $this->makeAccess($presidentCreationRequest);
-            $organization->addAccess($presidentAccess);
-        }
-
-        // Création du directeur (si renseigné)
-        $directorCreationRequest = $organizationCreationRequest->getDirector();
-        if ($directorCreationRequest !== null) {
-            $directorAccess = $this->makeAccess($directorCreationRequest);
-            $organization->addAccess($directorAccess);
-        }
+        $this->logger->info("Subdomain created and activated");
 
         // Création du site typo3
         if ($organizationCreationRequest->getCreateWebsite()) {
-            $this->typo3Service->createSite($organization->getId());
+            $response = $this->typo3Service->createSite($organization->getId());
+
+            // TODO: revoir l'utilité du champs cmsId
+            $rootPageUid = json_decode($response->getContent(), true);
+            $organization->setCmsId($rootPageUid);
+            $this->entityManager->persist($organization);
+            $this->entityManager->flush();
+            $this->logger->info("New typo3 website created (root uid : " . $rootPageUid . ")");
+        } else {
+            $this->logger->warning("Typo3 website creation was not required");
         }
 
         // Création de la société Dolibarr
-        $this->dolibarrApiService->createSociety($organization);
-
-        // Mise à jour du fichier Bind
-        $this->bindFileService->registerSubdomain($organizationCreationRequest->getSubdomain());
+        $dolibarrId = $this->dolibarrApiService->createSociety($organization);
+        $this->logger->info("New dolibarr structure created (uid : " . $dolibarrId . ")");
 
         return $organization;
     }
 
+    /**
+     * Vérifie la disponibilité et la validité d'un sous domaine
+     *
+     * @param string $subdomainValue
+     * @return void
+     * @throws \Exception
+     */
+    protected function validateSubdomain(string $subdomainValue): void
+    {
+        if (!$this->subdomainService->isValidSubdomain($subdomainValue)) {
+            throw new \RuntimeException("Not a valid subdomain :" . $subdomainValue);
+        }
+
+        if ($this->subdomainService->isReservedSubdomain($subdomainValue)) {
+            throw new \RuntimeException("This subdomain is not available :" . $subdomainValue);
+        }
+
+        if ($this->subdomainService->isRegistered($subdomainValue)) {
+            throw new \RuntimeException("This subdomain is already registered :" . $subdomainValue);
+        }
+    }
+
     /**
      * Créé une nouvelle instance d'organisation
      *
@@ -142,14 +225,11 @@ class OrganizationFactory
      *
      * @param OrganizationCreationRequest $organizationCreationRequest The organization creation request
      * @return Parameters The created Parameters object
-     * @throws TransportExceptionInterface If there is an error with the transport
      * @throws Throwable If there is an error
      */
     protected function makeParameters(OrganizationCreationRequest $organizationCreationRequest): Parameters
     {
-        $parameters = new Parameters();
-
-        return $parameters;
+        return new Parameters();
     }
 
     /**
@@ -202,8 +282,8 @@ class OrganizationFactory
      */
     protected function makeContactPoint(OrganizationCreationRequest $organizationCreationRequest): ContactPoint
     {
-        $phoneNumber = new PhoneNumber();
-        $phoneNumber->unserialize($organizationCreationRequest->getPhoneNumber());
+        $phoneUtil = PhoneNumberUtil::getInstance();
+        $phoneNumber = $phoneUtil->parse($organizationCreationRequest->getPhoneNumber());
 
         $contactPoint = new ContactPoint();
         $contactPoint->setContactType(ContactPointTypeEnum::PRINCIPAL);

+ 15 - 4
src/Service/Security/InternalRequestsService.php

@@ -2,6 +2,9 @@
 
 namespace App\Service\Security;
 
+use App\Entity\Access\Access;
+use Symfony\Bundle\SecurityBundle\Security;
+
 /**
  * Identify and allow internal requests between api v1 and v2.
  *
@@ -21,7 +24,8 @@ class InternalRequestsService
     ];
 
     public function __construct(
-        readonly private string $internalRequestsToken
+        readonly private string $internalRequestsToken,
+        private Security $security
     ) {
     }
 
@@ -43,16 +47,23 @@ class InternalRequestsService
      * Compare the given token to the expected one, and return true if they are identical
      * An empty token can not be valid.
      */
-    protected function tokenIsValid(string $token): bool
+    protected function tokenIsValid(?string $token): bool
     {
         return $token && $token === $this->internalRequestsToken;
     }
 
+    public function isSuperAdmin(): bool
+    {
+        /** @var Access $user */
+        $user = $this->security->getUser();
+        return $user && $user->getSuperAdminAccess();
+    }
+
     /**
      * Is the given request a valid internal request, which shall be responded even without authentication.
      */
-    public function isAllowed(string $ip, string $token): bool
+    public function isAllowed(string $ip, ?string $token): bool
     {
-        return $this->isInternalIp($ip) && $this->tokenIsValid($token);
+        return $this->isInternalIp($ip) && ($this->isSuperAdmin() || $this->tokenIsValid($token));
     }
 }