1888b70623
Auto Tag Develop / tag (push) Successful in 11s
## Contexte
ERP-102 — Découvert pendant ERP-64. Connecté avec un rôle **métier** (bureau / compta / commerciale), `GET /api/categories` et `GET /api/sites` renvoient **403**, alors que `/tva_modes`, `/payment_delays`, `/payment_types`, `/banks` renvoient 200.
Conséquences : page **Création client** inutilisable (le `Promise.all` rejetait → **tous** les selects vides) et **filtres Catégories/Sites vides** au répertoire.
## Cause
La `security` des `GetCollection`/`Get` de `Category` et `Site` exigeait `catalog.categories.view` / `sites.view` — permissions d'**administration** du Catalogue / des Sites. Or ces référentiels sont **transverses** : tout rôle qui gère un tiers doit pouvoir les lire.
## Correctif back — Option C (permission de lecture-référentiel dédiée)
Choix d'archi retenu parmi les 3 du ticket :
- **Pourquoi pas A** (`... or is_granted('commercial.clients.view')`) : coupler `Category`/`Site` à une permission **Commercial** viole l'esprit de la règle ABSOLUE n°1 et ne scale pas (M2 Fournisseurs devrait rajouter un OR).
- **Pourquoi pas B** (donner `.view` aux rôles métier) : `.view` = accès admin → items sidebar admin Catégories/Sites exposés à une commerciale.
- **C** : nouvelle permission `catalog.categories.read_ref` / `sites.read_ref`, distincte de `.view` (pas d'item sidebar) et de `.manage`. Chaque permission appartient à **son** module → isolement inter-module préservé, **réutilisable tel quel par M2 Fournisseurs**. C'est la « permission référentiel lisible » que le ticket pointe lui-même.
Détail :
- `CatalogModule` / `SitesModule` : déclaration des deux permissions `read_ref`.
- `Category` / `Site` : security lecture (liste + item) = `view OR read_ref`.
- `RbacSeeder` (matrice § 2.7) : `read_ref` attaché à bureau / compta / commerciale ; usine reste sans accès.
## Durcissement front (résilience — requis dans tous les cas)
`useClientReferentials.loadCommon` : `Promise.all` → **`Promise.allSettled`** avec affectation isolée par référentiel. L'échec d'un endpoint ne vide plus que **son** select, plus la totalité du formulaire.
## Tests (TDD)
- `ClientRBACMatrixTest::testBusinessRolesCanReadCategoriesAndSitesReferentials` — bureau/compta/commerciale listent `/categories` et `/sites` (200), usine reste 403.
- `SitesModuleTest` — set de permissions porté à 4 codes.
- `useClientReferentials.spec` (Vitest) — un référentiel en échec ne vide que son select.
## Vérifications
- `make test` (back) : **467/467** ✓
- `make nuxt-test` (front) : **131/131** ✓
- `make php-cs-fixer` : conforme ✓
## Note miroirs RBAC
`config/sidebar.php` / `personas.ts` / `SeedE2ECommand.php` **non touchés** : `read_ref` n'ajoute aucun item sidebar, le persona E2E `user-full` lit déjà via `.view`, et aucun persona ne modélise un rôle métier seul. Pas de nouveau test E2E (règle n°7 : bug attrapé avant prod). La source de vérité de la matrice (`RbacSeeder`) est mise à jour et couverte par `ClientRBACMatrixTest`.
Closes ERP-102.
---------
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #53
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
123 lines
4.7 KiB
PHP
123 lines
4.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Module\Sites\Infrastructure\ApiPlatform\Extension;
|
|
|
|
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
|
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
|
|
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
|
use ApiPlatform\Metadata\Operation;
|
|
use App\Module\Core\Domain\Entity\User;
|
|
use App\Module\Sites\Domain\Entity\Site;
|
|
use Doctrine\ORM\QueryBuilder;
|
|
use Symfony\Bundle\SecurityBundle\Security;
|
|
|
|
use function sprintf;
|
|
|
|
/**
|
|
* Extension API Platform qui restreint les collections et items de la
|
|
* resource Site (/api/sites) aux seuls sites auxquels l'utilisateur
|
|
* authentifie est rattache (ticket module Sites — prevention de la fuite
|
|
* de donnees cross-tenant).
|
|
*
|
|
* `Site` n'implemente pas `SiteAwareInterface` (ce serait circulaire : un
|
|
* site ne s'appartient pas a lui-meme). Cette extension complementaire
|
|
* cible specifiquement `Site::class` et applique un filtre IN sur les IDs
|
|
* des sites de l'utilisateur.
|
|
*
|
|
* Comportement selon les cas :
|
|
* - resource != Site::class → no-op (les autres resources sont
|
|
* gerees par SiteScopedQueryExtension) ;
|
|
* - is_granted('sites.bypass_scope') → pas de filtre (admin / bypass) ;
|
|
* - is_granted('sites.read_ref') → pas de filtre (lecture-referentiel
|
|
* transverse complet, ERP-102) ;
|
|
* - user non authentifie → no-op (API Platform renvoie 401 avant) ;
|
|
* - user sans aucun site → WHERE 1 = 0 (aucun acces) ;
|
|
* - cas normal → WHERE site.id IN (:allowedSites).
|
|
*
|
|
* Consequence anti-enumeration : GET /api/sites/{id} retourne 404 et non
|
|
* 403 si l'item existe mais n'appartient pas aux sites de l'utilisateur
|
|
* (comportement natif API Platform quand Doctrine retourne null).
|
|
*/
|
|
final class SiteCollectionScopedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
|
|
{
|
|
public function __construct(
|
|
private readonly Security $security,
|
|
) {}
|
|
|
|
public function applyToCollection(
|
|
QueryBuilder $queryBuilder,
|
|
QueryNameGeneratorInterface $queryNameGenerator,
|
|
string $resourceClass,
|
|
?Operation $operation = null,
|
|
array $context = [],
|
|
): void {
|
|
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
|
}
|
|
|
|
public function applyToItem(
|
|
QueryBuilder $queryBuilder,
|
|
QueryNameGeneratorInterface $queryNameGenerator,
|
|
string $resourceClass,
|
|
array $identifiers,
|
|
?Operation $operation = null,
|
|
array $context = [],
|
|
): void {
|
|
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
|
}
|
|
|
|
/**
|
|
* Applique le filtre IN sur les IDs de sites autorises si les conditions
|
|
* d'application sont remplies. No-op sinon.
|
|
*/
|
|
private function applyScope(
|
|
QueryBuilder $queryBuilder,
|
|
QueryNameGeneratorInterface $queryNameGenerator,
|
|
string $resourceClass,
|
|
): void {
|
|
// 1) Cette extension cible uniquement la resource Site.
|
|
if (Site::class !== $resourceClass) {
|
|
return;
|
|
}
|
|
|
|
// 2) Admin ou user avec bypass explicite : visibilite globale.
|
|
if ($this->security->isGranted('sites.bypass_scope')) {
|
|
return;
|
|
}
|
|
|
|
// 2bis) Lecture-referentiel transverse (ERP-102) : `sites.read_ref` donne
|
|
// acces a la LISTE COMPLETE des sites (selects d'adresse des modules Tiers).
|
|
// Sans ce bypass, le cloisonnement par site rattache reduirait le select
|
|
// aux seuls sites de l'utilisateur (voire a rien s'il n'en a aucun) et le
|
|
// referentiel ne serait plus "transverse". `read_ref` est une lecture seule :
|
|
// il ouvre la visibilite sans permettre la moindre ecriture.
|
|
if ($this->security->isGranted('sites.read_ref')) {
|
|
return;
|
|
}
|
|
|
|
// 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont).
|
|
$user = $this->security->getUser();
|
|
if (!$user instanceof User) {
|
|
return;
|
|
}
|
|
|
|
$rootAlias = $queryBuilder->getRootAliases()[0];
|
|
|
|
// 4) User sans aucun site rattache -> aucun acces possible.
|
|
$siteIds = $user->getSites()->map(fn (Site $s) => $s->getId())->toArray();
|
|
if (empty($siteIds)) {
|
|
$queryBuilder->andWhere('1 = 0');
|
|
|
|
return;
|
|
}
|
|
|
|
// 5) Cas normal : restriction aux sites autorises de l'utilisateur.
|
|
$param = $queryNameGenerator->generateParameterName('allowedSites');
|
|
$queryBuilder
|
|
->andWhere(sprintf('%s.id IN (:%s)', $rootAlias, $param))
|
|
->setParameter($param, $siteIds)
|
|
;
|
|
}
|
|
}
|