documentation
Kohinos v3 Doc générée le commit 8d60b1904

API Platform — ressources, opérations, sécurité, sérialisation

Kohinos expose une API REST via API Platform 3.x, principalement destinée à des usages internes : future application mobile adhérent, intégrations partenaires sous contrôle, outils d'administration. Aucune opération financière sensible (transfert eMLc, cotisation, prélèvement SEPA, redistribution SSA) n'est exécutable via l'API publique : ces flux restent pilotés par les services métier et leurs contrôleurs internes, sous workflow d'admin et invariants vérifiés (cf. invariants financiers).

Ressources exposées & opérations

flowchart LR
  CLIENT["Client API
(mobile, partenaire)"] -->|API-AUTH-TOKEN| FW[Firewall ^/api] FW --> AUTH[ApiKeyAuthenticator] AUTH --> RES{Ressources} RES --> USR[User · GET/POST/PUT] RES --> ADH[Adherent · GET/POST/PUT] RES --> PRE[Prestataire · GET/POST/PUT] RES --> CMP[Comptoir · GET/POST/PUT] RES --> GRP[Groupe / Groupeprestataire] RES --> SOL[SolidoumeItem · GET/POST/PUT] RES --> COT[Cotisation* · GET/POST/PUT] RES --> CMS[Page / News / Faq / Rubrique] RES --> DOC[Document · GET/POST/PUT/DELETE] RES --> GEO[GeolocPrestataire · GET/POST/PUT] RES --> MAP["Map · GET /public/poi/all"] RES -.NON exposé.-> MONEY[("Flux · Operation
Sepa* · Don*")]
Seules les ressources structurelles (entités d'identité, catalogue prestataire, contenu, SSA en lecture/écriture limitée) sont exposées. Tout ce qui touche au mouvement de monnaie reste hors API publique.

Catalogue des ressources

Ce tableau reflète l'état du code actuel (attributs #[ApiResource] dans src/Entity/). La colonne sécurité liste les rôles acceptés pour la collection ; les opérations individuelles peuvent restreindre davantage (cf. section sécurité).

EntitéOpérationsSécurité (collection)Groupes
User GET, GET coll., POST, PUT ROLE_SONATA_USER_GERER_LIST read / write
Adherent GET, GET coll., POST, PUT ROLE_ADMIN_ADHERENT_GERER_LIST · auto-accès via ROLE_API & object.user == user read:admin / write
Prestataire GET, GET coll., POST, PUT ROLE_ADMIN_PRESTATAIRE_GERER_LIST ou ROLE_API read:public / read:admin / write
Comptoir GET, GET coll., POST, PUT ROLE_ADMIN_COMPTOIR_GERER_LIST ou ROLE_API read / write (max_depth)
Groupe GET, GET coll., POST, PUT ROLE_ADMIN_GROUPE_GERER_LIST ou ROLE_API read / write
Groupeprestataire GET, GET coll., POST, PUT ROLE_ADMIN_PRESTATAIRE_GERER_LIST ou ROLE_API read / write
TypePrestataire GET, GET coll., POST, PUT ROLE_ADMIN_PRESTATAIRE_GERER_LIST ou ROLE_API read / write
EtatPrestataire GET, GET coll., POST, PUT ROLE_ADMIN_PRESTATAIRE_GERER_LIST read / write
ContactPrestataire GET, GET coll., POST, PUT ROLE_ADMIN_PRESTATAIRE_GERER_LIST read / write
ContactComptoir GET, GET coll., POST, PUT ROLE_ADMIN_COMPTOIR_GERER_LIST ou ROLE_API read (max_depth) / write
GeolocPrestataire GET, GET coll., POST, PUT ROLE_ADMIN_PRESTATAIRE_GERER_LIST ou ROLE_API read / write · ressource-level security
Map GET coll. uniquement : /public/poi/all ROLE_ADMIN_PRESTATAIRE_GERER_LIST ou ROLE_API read · contrôleur custom MapController, read: false, pagination désactivée
SolidoumeItem GET, GET coll., POST, PUT ROLE_ADMIN_SOLIDOUME_LIST · POST/PUT auto-accès object.user == user read / write (max_depth)
CotisationAdherent GET, GET coll., POST, PUT ROLE_ADMIN_ADHERENT_COTISATIONS_GERER_LIST read / write (max_depth)
CotisationPrestataire GET, GET coll., POST, PUT ROLE_ADMIN_PRESTATAIRE_COTISATIONS_GERER_LIST read / write (max_depth)
CotisationInfos GET, GET coll., POST, PUT ROLE_ADMIN_ADHERENT_COTISATIONS_GERER_LIST ou ROLE_ADMIN_PRESTATAIRE_COTISATIONS_GERER_LIST read / write
Page GET, GET coll., POST, PUT ROLE_ADMIN_PAGE_GERER_LIST read / write
News GET, GET coll., POST, PUT ROLE_ADMIN_NEWS_GERER_LIST read / write
Faq GET, GET coll., POST, PUT ROLE_ADMIN_FAQ_GERER_LIST read / write
Rubrique GET, GET coll., POST, PUT ROLE_ADMIN_PRESTATAIRE_GERER_LIST ou ROLE_API read / write
Document GET, GET coll., POST, PUT, DELETE ROLE_ADMIN_DOCUMENT_GERER_LIST read / write

Filtres déclaratifs. Aucun #[ApiFilter] n'est aujourd'hui posé sur les entités : les filtres SQL transverses sont gérés en amont par les SQL filters Doctrine, notamment App\Filter\EnabledFilter (auto-applique enabled = true aux entités utilisant EnablableEntityTrait, à l'exception de Flux).

Sécurité

Firewall & authentification

Deux firewalls dans config/packages/security.yaml :

FirewallPatternModeAuthenticators
api ^/api stateless ApiKeyAuthenticator (header API-AUTH-TOKEN) · fallback HTTP Basic via api_email_user_provider
main ^/ session LoginAuthenticator, EmailTokenAuthenticator, remember-me

Le mode JWT n'est pas utilisé. Kohinos s'en tient à une clé API par utilisateur (champ User::apiKey, indexé idx_user_api_key) résolue par UserRepository::findOneByApiKey(). Cohérent avec la priorité 2 souveraineté (cf. conventions) : pas de service d'émission de tokens tiers.

Hiérarchie de rôles

RôleHérite deUsage API
ROLE_APIROLE_USERAccès lecture aux ressources publiques (Prestataire, Comptoir, Groupe, Map…) via clé API.
ROLE_SUPER_ADMINROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH, ROLE_APIAccès complet, mais doit rester un compte humain — pas un compte de service.

Distinction importante : ROLE_API n'est pas un sous-ensemble de ROLE_SUPER_ADMIN — c'est l'inverse. Un compte de service mobile porte ROLE_API seul et doit rester sans privilège admin.

Décorateurs security au niveau opération

Toutes les ressources définissent security: par opération (et parfois au niveau ressource). Exemple représentatif sur Adherent :

#[ApiResource(
    operations: [
        new GetCollection(
            normalizationContext: ['groups' => ['read:admin'], 'enable_max_depth' => true],
            security: "is_granted('ROLE_ADMIN_ADHERENT_GERER_LIST')"
        ),
        new Post(security: "is_granted('ROLE_ADMIN_ADHERENT_GERER_EDIT')"),
        new Get(
            normalizationContext: ['groups' => ['read:admin'], 'enable_max_depth' => true],
            security: "is_granted('ROLE_ADMIN_ADHERENT_GERER_LIST')
                       or (is_granted('ROLE_API') and object.user == user)"
        ),
        new Put(
            security: "is_granted('ROLE_ADMIN_ADHERENT_GERER_EDIT') or object.user == user",
            securityPostDenormalize: "is_granted('ROLE_ADMIN_ADHERENT_GERER_EDIT')
                                      or (object.user == user and previous_object.user == user)"
        ),
    ],
)]

Le securityPostDenormalize est critique : il s'exécute après désérialisation et compare previous_object à object. Sans lui, un adhérent pourrait via PUT modifier le champ user d'une autre fiche pour s'y rattacher. La règle Kohinos : tout PUT sur ressource possédée par un utilisateur doit avoir un securityPostDenormalize.

Voters Symfony

Quand la décision dépasse l'appartenance simple, on s'appuie sur des Voters dédiés :

Référencer un voter depuis API Platform : security: "is_granted('VIEW_SSA', object)". Le subject object est l'instance, comparée par le voter.

Sérialisation — groupes

Convention de nommage observée dans le code :

GroupeSensExposition typique
readLecture standardChamps publics non-sensibles d'une fiche.
read:publicLecture anonyme/apiCatalogue prestataire, géoloc, comptoirs ouverts au public.
read:adminLecture adminChamps internes (statut, audit, relations sensibles).
writeCréation/éditiondenormalizationContext par défaut.
user:readLecture User spécifiqueIdentifiant User::id.

Pose sur les champs :

#[ORM\Column(type: 'uuid', unique: true)]
#[Groups(['user:read', 'read'])]
protected $id;

#[ORM\Column(length: 150)]
#[Groups(['read', 'write'])]
protected $name;

#[ORM\Column(name: 'iban_encrypted', type: 'text', nullable: true)]
// PAS de #[Groups(...)] — l'IBAN n'est JAMAIS sérialisé par l'API
private ?string $ibanEncrypted = null;

L'option 'enable_max_depth' => true est utilisée sur Adherent, Comptoir, ContactComptoir, SolidoumeItem, CotisationAdherent, CotisationPrestataire pour borner la profondeur de sérialisation des graphes — combiner avec #[MaxDepth(n)] sur les relations.

Validation

Symfony Validator s'applique automatiquement aux POST/PUT API Platform. Les contraintes courantes sont posées en attributs #[Assert\*] :

use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

#[UniqueEntity(fields: 'email', message: 'Cet email est déjà utilisé !')]
#[UniqueEntity(fields: 'raison', message: 'Le prestataire avec cette raison existe déjà.')]
class Prestataire { /* … */ }

Les erreurs de validation remontent en 422 Unprocessable Entity avec un payload Hydra/Problem-JSON listant les violations par propertyPath.

Pagination

Configuration globale dans config/packages/api_platform.yaml :

api_platform:
  collection:
    pagination:
      enabled: true
  defaults:
    pagination_items_per_page: 30
    pagination_maximum_items_per_page: 100
    pagination_client_items_per_page: true
  eager_loading:
    max_joins: 40

Le client peut donc demander ?itemsPerPage=60 jusqu'à la borne dure de 100. Pour les ressources à fort volume (audit, historique de flux quand il sera exposé en lecture seule), envisager la partial pagination au cas par cas :

new GetCollection(paginationPartial: true)

La Map est l'exception : paginationEnabled: false car le contrôleur custom MapController retourne un payload géoloc compact.

State processors & providers

Aujourd'hui Kohinos s'appuie principalement sur les extensions Doctrine par défaut d'API Platform (collection auto, item par identifiant, persist via EM). Le dossier src/State/ n'existe pas encore.

Cas d'usage à venir pour passer en State Processor custom :

Squelette type :

final class SolidoumeItemArchiveProcessor implements ProcessorInterface
{
    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly SolidoumeArchiveService $archiver,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
    {
        // $data est l'entité désérialisée
        $this->archiver->archive($data);   // soft-delete daté
        $this->em->flush();
        return $data;
    }
}

Branché sur l'opération : new Delete(processor: SolidoumeItemArchiveProcessor::class).

Cas particulier : Map & contrôleur custom

Map illustre le pattern ressource API sans Doctrine :

#[ApiResource(
    operations: [
        new GetCollection(
            uriTemplate: '/public/poi/all',
            controller: MapController::class,
            paginationEnabled: false,
            security: "is_granted('ROLE_ADMIN_PRESTATAIRE_GERER_LIST') or is_granted('ROLE_API')",
            read: false,
        ),
    ]
)]

read: false désactive le provider Doctrine : c'est le contrôleur qui construit la réponse (agrégation géoloc multi-tables). Pratique pour les read models qui ne correspondent pas 1-1 à une entité.

Anti-patterns

⚠ À éviter absolument
  • Exposer une entité financière sans security: strict. Toute ressource touchant à Flux, Operation, SepaMandate, SepaTransfer, Don* doit rester hors API publique. Si lecture nécessaire (mobile), exposer un read model agrégé via State Provider, pas l'entité.
  • Oublier un groupe de sérialisation sur un nouveau champ sensible. Tout #[ORM\Column] sans #[Groups] est inerte côté API — c'est le bon défaut. À l'inverse, ajouter 'read' par réflexe expose le champ à toutes les routes GET. Lire deux fois avant.
  • Pagination non bornée. Ne jamais désactiver pagination_maximum_items_per_page. Aujourd'hui : 100.
  • Modifier un champ d'intégrité via PUT. flux.hash, operation.amount, sepa_mandate.umr, solidoume_item.lastMonthPayed — aucun ne doit être writable. À défaut de processor, ne pas mettre 'write' sur leur #[Groups].
  • Ne se reposer que sur security: niveau ressource. Toujours poser security: au niveau de chaque opération — un attribut hérité peut sauter à une régression de version.
  • Renvoyer un objet possédant des relations non-bornées. Sans enable_max_depth + #[MaxDepth], on peut sérialiser un graphe gigantesque (timeout, fuite d'info).

Tests d'API

API Platform fournit ApiPlatform\Symfony\Bundle\Test\ApiTestCase qui hérite de WebTestCase. Règles Kohinos :

final class PrestataireApiTest extends ApiTestCase
{
    public function testAnonymousIsRejected(): void
    {
        static::createClient()->request('GET', '/api/prestataires');
        $this->assertResponseStatusCodeSame(401);
    }

    public function testRoleApiCanListPrestataires(): void
    {
        $apiKey = self::getContainer()->get(UserRepository::class)
            ->findOneByUsername('mobile-bot')->getApiKey();

        static::createClient()->request('GET', '/api/prestataires', [
            'headers' => ['API-AUTH-TOKEN' => $apiKey],
        ]);
        $this->assertResponseIsSuccessful();
        $this->assertJsonContains(['@type' => 'hydra:Collection']);
    }
}

Pour aller plus loin

Sources
  • config/packages/api_platform.yaml — config globale (pagination, formats, mapping).
  • config/packages/security.yaml — firewalls api + main, hiérarchie des rôles.
  • src/Security/ApiKeyAuthenticator.php · ApiKeyUserProvider.php · ApiSuperAdminVoter.php
  • src/Security/Voter/SsaVoter.php, ExportVoter.php
  • src/Filter/EnabledFilter.php — SQL filter Doctrine, applique enabled = true.
  • src/Entity/*.php — toute entité exposée porte un #[ApiResource].
  • src/Controller/MapController.php — exemple de ressource avec contrôleur custom.
  • Invariants financiers — pourquoi rien de monétaire ne passe par l'API.
  • Cryptographie — chiffrement IBAN, jamais sérialisé.
  • Conventions — règles de souveraineté et choix d'auth.