Story OP-010: Backend — Activation du routing multi-agence (AgencyScopeFilter)¶
Module: operator-console
Slice: Architecture §11 OP-003
Side: [BACKEND]
Version target: V0.0.2
Priority: 11
Depends on: OP-002 (création), OP-007 (édition), TENANT-002 (agency_id sur contracts ING)
Can develop concurrently with: aucune (active une fonctionnalité transverse)
Merge order: Before OP-011 (frontend TopBar selector)
Estimated complexity: S
PRD User Stories: US-OP-02 AC3 (filtre par agence), V0.0.2 envelope (routing multi-agence)
Wireframe: N/A — backend only
Objective¶
Activer AgencyScopeFilter (codé désactivé en OP-000) sur toutes les requêtes OP : un agency_operator ne voit / modifie que les données de sa propre agence ; les cabinet_admin / supervisor voient toutes les agences. Toutes les colonnes agency_id sur événements audit OP sont propagées. Élargit le RBAC édition d'OP-007 pour autoriser agency_operator sur son agence.
Backend Scope¶
Entities¶
Aucune modification de schéma (l'agency_id est déjà présent sur campaigns et operator_notes depuis OP-000).
Migrations¶
Aucune.
Service Layer¶
AgencyScopeFilter(déjà codé OP-000) — activation via propertyop.agency-scope.enabled=trueen dev/staging/prod. Le test OP-000 vérifiaitenabled=false; OP-010 ajoute les testsenabled=true.CampaignService.createDraft(...): litagencyIddepuiscurrentTenant.agencyId()au lieu du fallback "agence par défaut" (TenantConfigCache.getDefaultAgencyId(tenantId)). Pour cabinet_admin/supervisor (sans agency_id JWT), validation requise : body doit conteniragencyIdexplicite (sinon 400OP_AGENCY_REQUIRED).CampaignListController.list(...): filtreagencyIdajouté en query param (cabinet_admin/supervisor) + retourne la colonneagencyName(déjà présente).CampaignContractEditController.patch(...): RBAC étendu —agency_operatorpeut éditer SIcampaign.agency_id == currentTenant.agencyId(). Sinon 403.CampaignSenderService.send(...): même RBAC.ContractEligibilityQueryAdapter(OP-000) — litagencyIddu contexte ; appel INGfindEligible(tenantId, agencyId, windowDays)avec agency_id réel.AnomalyQueryAdapter(consommé par OP-007 garde et OP-014) — pareil.
API Endpoints¶
Endpoints existants étendus :
- GET /campaigns?agencyId=... (cabinet_admin/supervisor uniquement ; agency_operator ignore le param)
- GET /contracts?eligible=true (filtré agence pour agency_operator)
- POST /campaigns body : { "type":"SCHEDULED", "agencyId"?: "uuid" } — agencyId requis pour cabinet_admin/supervisor.
Nouveau :
| Method | Path | Request | Response | Auth |
|--------|------|---------|----------|------|
| GET | /api/operator/agencies | — | { data: [{id, name}] } (toutes les agences du tenant) | Tous OP |
Validation Rules¶
agencyIdUUID valide.- Pour cabinet_admin/supervisor :
agencyIddoit appartenir au tenant courant (validation TENANT). - Erreur
OP_AGENCY_REQUIRED400 si cabinet_admin/supervisor appelle POST /campaigns sansagencyId.
Multi-Tenant Considerations¶
- L'
AgencyScopeFilter(AOP @AgencyScoped) intercepte toutes les requêtes JdbcClient OP annotées : findEligibleContracts→ annotée.findCampaignsByStatus→ annotée.findCampaignContracts→ annotée.findAnomalies→ annotée.- CRITIQUE : si filter activé mais
currentTenant.agencyId() == null(role non-AGENCY_OPERATOR), le filter est bypass (transparent). - Profil BYO : même comportement (datasource résolue par tenant ; filter purement WHERE clause).
Audit & Logging¶
- Tous les events OP émis incluent
agency_iddans le payload (OperatorAuditPublisherenrichit automatiquement). Tester sur 14 types.
Acceptance Criteria¶
AC-001: agency_operator ne voit que les campagnes de son agence
Given un tenant avec agence A (3 campagnes) et agence B (2 campagnes)
When un agency_operator d'agence A appelle GET /campaigns
Then la réponse contient les 3 campagnes de A
And aucune campagne de B n'est dans la réponse.
AC-002: cabinet_admin peut filtrer par agence ou voir tout
Given le même tenant
When cabinet_admin appelle GET /campaigns sans filtre
Then 5 campagnes retournées (3+2)
When cabinet_admin appelle GET /campaigns?agencyId=B
Then 2 campagnes retournées.
AC-003: agency_operator peut éditer son contrat, pas celui d'une autre agence
Given un cc d'une campagne agence A
When agency_operator d'agence A appelle PATCH → 200 OK
When agency_operator d'agence B appelle PATCH → 403 OP_ROLE_FORBIDDEN.
AC-004: POST /campaigns sans agencyId par cabinet_admin → 400
Given cabinet_admin
When POST /campaigns body={"type":"SCHEDULED"} (pas d'agencyId)
Then 400 OP_AGENCY_REQUIRED message "L'agence cible est obligatoire pour ce rôle."
Compliance Rules¶
- ARTCI minimisation + scoping :
agency_operatorne peut accéder aux données d'autres agences.VÉRIFIÉ. - OP-ADR-04 :
agency_idactivé V0.0.2 sans migration.VÉRIFIÉ.
Standards & Conventions¶
docs/standards/multi-tenant-model.md— agency scoping pattern.docs/standards/critical-rules.md— rule #1 + agency variant.docs/standards/java-spring-guidelines.md—@PreAuthorizeSpEL.
Testing Requirements¶
Unit Tests¶
AgencyScopeFilterTest(extension OP-000) : enabled=true + role=AGENCY_OPERATOR → injection ; enabled=true + role=CABINET_ADMIN → bypass.CampaignServiceCreateAgencyTest: agencyId requis pour cabinet_admin.
Integration Tests¶
AgencyIsolationITAC-001..AC-004 (réutilise §13.5 arch).- Test : 14 types audit avec agency_id présent dans payload.
What QA Will Validate¶
- Smoke : 2 agency_operator de 2 agences ne voient jamais les données de l'autre.
- cabinet_admin peut switcher via param.
Out of Scope¶
- Frontend TopBar selector → OP-011.
- Cross-agence consolidation V2.
- ContractMutationService côté ING (changements ING gérés par ING module).
Definition of Done¶
- [ ]
op.agency-scope.enabled=trueactivé en dev/staging/prod - [ ] AC-001..AC-004 passent
- [ ] AgencyScopeFilter intercepte tous les repositories OP annotés (5 méthodes min)
- [ ] Tous les audit events incluent agency_id
- [ ] GET /agencies endpoint disponible
- [ ] POST /campaigns refuse cabinet_admin sans agencyId
- [ ] PATCH RBAC étendu agency_operator
- [ ] Tenant isolation préservée
- [ ] Tests intégration verts