Choisir le contrôle d'accès, avec des préréglages commentés
Résumé. Cinq questions décident du modèle d'autorisation d'un projet : si les droits dépendent
de l'élément, si les utilisateurs partagent des éléments entre eux, si les droits suivent des
propriétés, si des tiers agissent avec un identifiant limité, et si plusieurs clients partagent le
déploiement. Les réponses choisissent des rôles (RBAC), des politiques sur attributs (ABAC), des
relations (ReBAC, d'après Zanzibar), ou une combinaison, et l'endroit où les relations sont
évaluées. Chaque modèle a ensuite un préréglage commenté plus bas : les permissions comme
vocabulaire typé, les attributions ou les règles, une politique (policy) sur un élément, et une
recherche restreinte à ce que l'acteur peut lire. Les raisons derrière les modèles sont dans
Authentification et autorisation ; l'API est
le README de @ddd-ts-kit/auth.
Les cinq questions
Section intitulée « Les cinq questions »Posez-les dans l'ordre ; le premier « oui » qui change la réponse l'emporte, et les suivants s'y ajoutent.
| # | Question | Oui | Non |
|---|---|---|---|
| 1 | Un droit dépend-il de quel élément, et pas seulement de l'action ? (« peut modifier cet agenda ») | passez à 2 | RBAC : des rôles accordent des actions |
| 2 | Les utilisateurs partagent-ils des éléments entre eux, délèguent-ils, ou héritent-ils de droits le long d'une hiérarchie (dossiers, équipes, cabinet, puis bureau, puis agenda) ? Une liste doit-elle montrer « tout ce que je peux atteindre » parmi de nombreux éléments ? | ReBAC (Zanzibar) | passez à 3 |
| 3 | Les droits suivent-ils des propriétés (service, montant, statut, heure de la journée), ou demandent-ils des refus explicites qui l'emportent sur les attributions ? | ABAC, par-dessus les rôles | RBAC plus des politiques : une poignée de règles « seul celui qui... » |
| 4 | Des tiers agissent-ils avec un identifiant limité à certaines opérations (un client mobile, une intégration partenaire) ? | ajoutez des scopes : HasScope sur les routes |
rien à ajouter |
| 5 | Plusieurs clients partagent-ils le déploiement ? | ajoutez une stratégie de multitenance (Multitenance) et gardez les rôles par tenant | rien à ajouter |
Ensuite, pour ReBAC, l'endroit où vivent les relations : le magasin de tuples PostgreSQL du projet (dans la transaction de la commande, rien d'autre à faire tourner) jusqu'à ce que les listes ou les vérifications le dépassent, puis OpenFGA, le modèle Zanzibar complet en tant que service, derrière le même port (Moteurs d'autorisation les compare).
| Produit typique | Réponse |
|---|---|
| Back-office d'une seule entreprise, fonctions métier | RBAC |
| Outil de prise de rendez-vous où « seul le patient qui a réservé peut annuler » | RBAC plus une politique |
| Validation de notes de frais selon le montant et le service, avec une règle des quatre yeux | ABAC par-dessus les rôles |
| Documents, dossiers, partage, équipes, délégation | ReBAC |
| SaaS pour de nombreuses entreprises, chacune avec ses administrateurs et ses membres | RBAC avec la vérification du tenant, tables partagées ; ReBAC à l'intérieur d'un tenant si les utilisateurs partagent |
| API publique pour des partenaires | n'importe lequel des modèles ci-dessus, plus des scopes |
Ce que tous les préréglages ont en commun
Section intitulée « Ce que tous les préréglages ont en commun »Quel que soit le modèle, une requête HTTP rencontre les mêmes quatre points de contrôle, et le
modèle ne change que l'adaptateur derrière le port Authorizer :
| Point de contrôle | Demande | Décide avec | Refus |
|---|---|---|---|
| garde du groupe de routes | ce client peut-il seulement appeler ces routes ? | la session : Authenticated, HasRole, HasScope, TenantMember |
401 ou 403 avant le contrôleur, signalé par onGuardRefusal |
| exigence du cas d'usage | cet acteur peut-il exécuter cette commande ou cette requête, sur cet élément ? | @Requires(permission, { resource, context }), vérifié par le middleware du bus via Authorizer.check |
ForbiddenError (FORBIDDEN), avant l'ouverture de l'unité de travail |
| politique dans le gestionnaire | une règle sur l'élément chargé l'autorise-t-elle ? | Access.authorize(actor, permission, appointment) : les politiques de la permission, puis l'authorizer |
ForbiddenError, dont la raison est le nom de la politique |
| port de lecture | quels éléments cet acteur peut-il lister ? | Access.scope(actor, permission, type), un AccessScope |
rien n'est refusé : les lignes que l'acteur ne peut pas lire ne sont jamais sélectionnées |
Les permissions sont déclarées une fois par contexte, dans ses mots, et sont typées : une exigence ne peut nommer qu'une permission qui existe.
import { definePermissions } from '@ddd-ts-kit/auth';import { AuthorizedCommand, type Actor } from '@ddd-ts-kit/kernel';import { Requires } from '@ddd-ts-kit/kernel/declarations';
// contexts/billing/application/authorization/billing.permissions.tsexport const billingPermissions = definePermissions('billing', { 'invoice.read': { description: 'See invoices and their lines' }, 'invoice.issue': { description: 'Make an invoice final and send it' }, 'invoice.void': { description: 'Cancel an issued invoice' },});// billingPermissions['invoice.void'].id vaut 'billing:invoice.void' ;// billingPermissions['invoice.delete'] ne compile pas : cette permission n'existe pas.
// contexts/billing/application/commands/void-invoice/void-invoice.command.ts@Requires(billingPermissions['invoice.void'], { resource: (command: VoidInvoice) => ({ type: 'invoice', id: command.invoiceId }),})export class VoidInvoice extends AuthorizedCommand { constructor( actor: Actor, readonly invoiceId: string, ) { super(actor); }}L'acteur est le premier argument de la commande : la route ou le consommateur qui la construit dit qui agit, et le gestionnaire ne lit jamais « l'utilisateur courant ». La règle vit sur le cas d'usage, pas dans le contrôleur :
// Au lieu de : un test de rôle dans un contrôleur, par lequel un job, un consommateur ou un autre transport ne passe jamaisif (!request.user.roles.includes('accountant')) { throw new ForbiddenException();}await this.invoices.void(request.params.invoiceId);
// Écrivez : le contrôleur construit la commande ; l'exigence ci-dessus est vérifiée par le busawait commandBus.execute(new VoidInvoice(actorOf(context.principal), params.invoiceId));Une recherche ne charge jamais pour filtrer ensuite : le gestionnaire demande le périmètre de
l'acteur et le port de lecture le transforme en requête. scoped du noyau joint le périmètre aux
critères par un ET, sur les champs que le port de lecture nomme (ids: 'id' pour une liste
d'identifiants, filter: { tenantId: 'tenantId' } pour la vérification du tenant), et des
critères qu'un appelant affine ensuite gardent la restriction.
import { Access, type PermissionSet } from '@ddd-ts-kit/auth';import { AuthorizedQuery, scoped, type Actor, type Criteria, type Page, type ScopedCriteria } from '@ddd-ts-kit/kernel';import { QueryHandler, Requires } from '@ddd-ts-kit/kernel/declarations';
declare const billingPermissions: PermissionSet<'billing', 'invoice.read' | 'invoice.issue' | 'invoice.void'>;
export interface InvoiceLine { readonly id: string; readonly tenantId: string; readonly customer: string; readonly total: number;}
// application/ports/invoice-reader.port.ts : un port de lecture n'accepte que des critères restreints par un périmètreexport abstract class InvoiceReader { abstract search(criteria: ScopedCriteria<InvoiceLine>): Promise<Page<InvoiceLine>>;}
@Requires(billingPermissions['invoice.read'])export class SearchInvoices extends AuthorizedQuery<Page<InvoiceLine>> { constructor( actor: Actor, readonly criteria: Criteria<InvoiceLine>, ) { super(actor); }}
@QueryHandler(SearchInvoices)export class SearchInvoicesHandler implements QueryHandler<SearchInvoices> { constructor( private readonly invoices: InvoiceReader, private readonly access: Access, ) {}
async execute(query: SearchInvoices): Promise<Page<InvoiceLine>> { const scope = await this.access.scope(query.actor, billingPermissions['invoice.read'], 'invoice');
return this.invoices.search(scoped(query.criteria, scope, { ids: 'id', filter: { tenantId: 'tenantId' } })); }}// Au lieu de : toutes les factures chargées, puis filtrées en mémoire (lent, et une page n'est plus une page)const visible = (await this.invoices.all()).filter((invoice) => canRead(query.actor, invoice));AccessScope |
Donné par | Ce que le port de lecture ajoute |
|---|---|---|
{ kind: 'all' } |
un rôle qui accorde la permission, sans vérification du tenant | rien |
{ kind: 'filter', filter: { tenantId } } |
un rôle, avec la vérification du tenant | tenant_id = :tenantId |
{ kind: 'ids', ids } |
les relations | id IN (...) |
{ kind: 'none' } |
n'importe quel modèle, et toujours pour l'acteur anonyme | une page vide, sans requête |
Préréglage 1 : des rôles, et des politiques pour les quelques règles sur un élément
Section intitulée « Préréglage 1 : des rôles, et des politiques pour les quelques règles sur un élément »Les rôles sont un vocabulaire typé : un rôle qui n'existe pas ne peut pas être accordé, et l'authorizer refuse, dès sa construction, une attribution qui ne pourrait jamais correspondre, un rôle hérité inconnu et un cycle dans la hiérarchie.
import { RoleBasedAuthorizer, tenantOfContext, type Grant, type PermissionSet } from '@ddd-ts-kit/auth';
declare const billingPermissions: PermissionSet<'billing', 'invoice.read' | 'invoice.issue' | 'invoice.void'>;
// platform/auth/roles.tsexport const ROLES = ['admin', 'accountant', 'clerk', 'system'] as const;export type Role = (typeof ROLES)[number];
export const roles: Readonly<Record<Role, readonly Grant[]>> = { admin: ['*'], accountant: [billingPermissions['invoice.void']], clerk: [billingPermissions['invoice.read'], billingPermissions['invoice.issue']], system: [billingPermissions['invoice.issue']], // les jobs et les consommateurs qui envoient la commande};
export const authorizer = new RoleBasedAuthorizer({ roles, inherits: { accountant: ['clerk'] }, // un comptable a tout ce qu'a un employé tenantOf: tenantOfContext, // active la vérification du tenant crossTenantRoles: ['admin'], // peut agir dans tous les tenants systemRole: 'system', // le rôle de systemActor(...), aucun par défaut});Ce qu'il répond, pour un employé (clerk) du tenant acme :
| Demande | Décision |
|---|---|
invoice.read, sans tenant nommé |
autorisé |
invoice.void |
refusé, NO_MATCHING_ROLE |
invoice.issue pour le tenant globex (l'exigence passe context: { tenantId: 'globex' }) |
refusé, TENANT_MISMATCH |
invoice.read en liste |
{ kind: 'filter', filter: { tenantId: 'acme' } } |
Une règle a besoin de l'élément chargé : une politique, pure et testée unitairement, que le gestionnaire interroge.
import { Access, type PermissionSet } from '@ddd-ts-kit/auth';import { AuthorizedCommand, NotFoundError, type Actor, type Clock } from '@ddd-ts-kit/kernel';import { CommandHandler, Policy } from '@ddd-ts-kit/kernel/declarations';
declare const billingPermissions: PermissionSet<'billing', 'invoice.read' | 'invoice.issue' | 'invoice.void'>;declare class Invoice { readonly id: string; readonly issuedBy: string; void(at: Date): void;}declare abstract class InvoiceRepository { abstract findById(id: string): Promise<Invoice | undefined>; abstract save(invoice: Invoice): Promise<void>;}declare class VoidInvoice extends AuthorizedCommand { readonly invoiceId: string;}
// application/authorization/only-issuer-voids.policy.ts@Policy(billingPermissions['invoice.void'], { name: 'billing.only-issuer-voids' })export class OnlyIssuerVoidsPolicy { appliesTo(subject: unknown): boolean { return subject instanceof Invoice; }
allows(actor: Actor, invoice: Invoice): boolean { return invoice.issuedBy === actor.id; }}
// application/commands/void-invoice/void-invoice.handler.ts@CommandHandler(VoidInvoice)export class VoidInvoiceHandler implements CommandHandler<VoidInvoice> { constructor( private readonly invoices: InvoiceRepository, private readonly access: Access, private readonly clock: Clock, ) {}
async execute(command: VoidInvoice): Promise<void> { const invoice = await this.invoices.findById(command.invoiceId); if (invoice === undefined) { throw new NotFoundError(`Invoice ${command.invoiceId} not found`); } // les politiques de la permission d'abord, puis l'authorizer ; ForbiddenError si l'un des deux dit non await this.access.authorize(command.actor, billingPermissions['invoice.void'], invoice); invoice.void(this.clock.now()); await this.invoices.save(invoice); }}La spec de la politique n'a besoin de rien d'autre que la politique : new OnlyIssuerVoidsPolicy().allows(actor, invoice) répond true quand la facture a été émise par cet
acteur, sans authorizer, sans conteneur et sans base de données.
Les listes : un rôle qui accorde la permission lit all, restreint au tenant par la
vérification du tenant ({ kind: 'filter', filter: { tenantId } }).
Préréglage 2 : des politiques sur attributs par-dessus les rôles
Section intitulée « Préréglage 2 : des politiques sur attributs par-dessus les rôles »Les règles lisent des attributs de l'acteur (tirés de son jeton), de la ressource (à travers un
port ResourceAttributes, une projection que les politiques interrogent) et du moment. Un forbid
qui correspond l'emporte sur tous les permit ; sans permit qui corresponde, la réponse est non.
import { PolicyBasedAuthorizer, ResourceAttributes, allOf, forbid, not, permit, type AttributePolicy, type Condition, type PermissionSet, type ResourceAttributeValues, type ResourceRef,} from '@ddd-ts-kit/auth';import type { Clock } from '@ddd-ts-kit/kernel';
declare const billingPermissions: PermissionSet<'billing', 'invoice.read' | 'invoice.issue' | 'invoice.void'>;declare const clock: Clock;
// platform/auth/attribute-policies.tsconst sameDepartment: Condition = ({ actor, attributes }) => actor.attributes['department'] === attributes['department'];const large: Condition = ({ attributes }) => Number(attributes['amount']) > 10_000;const approvedTwice: Condition = ({ attributes }) => attributes['approvals'] === 2;
export const attributePolicies: readonly AttributePolicy[] = [ permit('administrators-do-everything', ['*'], ({ actor }) => actor.roles.includes('admin')), permit('read-own-department', [billingPermissions['invoice.read']], sameDepartment), permit('managers-void', [billingPermissions['invoice.void']], ({ actor }) => actor.attributes['manager'] === true), forbid('no-large-void-without-four-eyes', [billingPermissions['invoice.void']], allOf(large, not(approvedTwice))),];
// infrastructure : les attributs d'une facture, lus dans une projection que tient le contexte billinginterface InvoiceAttributeRow { readonly department: string; readonly amount: number; readonly approvals: number;}
export class ProjectedInvoiceAttributes implements ResourceAttributes { constructor(private readonly rows: ReadonlyMap<string, InvoiceAttributeRow>) {}
async of(resource: ResourceRef): Promise<ResourceAttributeValues | undefined> { const row = resource.type === 'invoice' ? this.rows.get(resource.id) : undefined;
return row === undefined ? undefined : { department: row.department, amount: row.amount, approvals: row.approvals }; }}
export const authorizer = new PolicyBasedAuthorizer({ clock, // les politiques voient `now` par le port Clock, jamais par new Date() attributes: new ProjectedInvoiceAttributes(new Map()), policies: attributePolicies,});Un responsable du service financier annule une facture de 12 000 approuvée une fois : le permit
managers-void correspond, mais le forbid no-large-void-without-four-eyes aussi, et la réponse
est EXPLICIT_DENY.
Les listes : un prédicat n'est pas une requête, donc Access.scope lève
ListingNotSupportedError en ABAC pur. Donnez au port de lecture la même condition sous forme de
filtre (le service de l'acteur), ou listez par les rôles et décidez de chaque élément avec les
politiques.
Préréglage 3 : des relations (Zanzibar)
Section intitulée « Préréglage 3 : des relations (Zanzibar) »Le schéma dit qui est quoi pour quel élément, et en dérive les permissions par réécriture : le propriétaire, un membre d'un groupe avec lequel l'élément est partagé, quiconque peut modifier le dossier parent.
import { definePermissions } from '@ddd-ts-kit/auth';import { InMemoryRelationTupleStore, RelationBasedAuthorizer, defineSchema, from, union } from '@ddd-ts-kit/auth/rebac';import type { Actor } from '@ddd-ts-kit/kernel';
export const docsPermissions = definePermissions('docs', { 'document.edit': { description: 'Change a document' }, 'document.view': { description: 'Read a document' }, 'folder.view': { description: 'Read a folder and list what it holds' },});
// platform/auth/relationship-schema.tsexport const relationshipSchema = defineSchema({ user: {}, system: {}, team: { relations: { member: ['user', 'team#member'] } }, folder: { relations: { owner: ['user'], viewer: ['user', 'team#member'], parent: ['folder'] }, permissions: { edit: union('owner', from('parent', 'edit')), view: union('edit', 'viewer', from('parent', 'view')) }, }, document: { relations: { owner: ['user'], editor: ['user', 'team#member'], delegate: ['user'], parent: ['folder'] }, permissions: { edit: union('owner', 'editor', 'delegate', from('parent', 'edit')), view: union('edit', from('parent', 'view')), }, },});
// platform/auth/permission-mapping.ts : quelle permission du schéma chaque permission de cas d'usage représenteexport const mapping = { document: { [docsPermissions['document.edit'].id]: 'edit', [docsPermissions['document.view'].id]: 'view' }, folder: { [docsPermissions['folder.view'].id]: 'view' },};
// Le magasin PostgreSQL du projet en production ; celui en mémoire ici et dans les tests.const store = new InMemoryRelationTupleStore();export const authorizer = new RelationBasedAuthorizer({ schema: relationshipSchema, store, mapping });
export async function adaReadsTheQuarterlyReport(): Promise<void> { await store.write([ { object: 'team:finance', relation: 'member', subject: 'user:ada' }, { object: 'folder:reports', relation: 'viewer', subject: 'team:finance#member' }, { object: 'document:q3', relation: 'parent', subject: 'folder:reports' }, ]);
const ada: Actor = { id: 'ada', type: 'user', roles: [], attributes: {} }; const q3 = { type: 'document', id: 'q3' };
await authorizer.check({ actor: ada, permission: docsPermissions['document.view'], resource: q3 }); // { allowed: true } : ada est membre de finance, finance voit reports, reports est le parent de q3 await authorizer.check({ actor: ada, permission: docsPermissions['document.edit'], resource: q3 }); // { allowed: false, reason: 'NO_RELATION' } : rien ne fait d'elle une propriétaire, une éditrice ou une déléguée await authorizer.listAccessible({ actor: ada, permission: docsPermissions['document.view'], resourceType: 'document' }); // { kind: 'ids', ids: ['q3'] }}Les tuples s'écrivent à travers un port, dans les mots de l'application, dans la transaction de la commande quand le magasin est celui de PostgreSQL. Le gestionnaire dit ce qui s'est passé (« cet utilisateur possède ce document ») ; seul l'adaptateur connaît les tuples :
import { RelationTupleStore } from '@ddd-ts-kit/auth/rebac';
export abstract class DocumentGrants { abstract ownerAssigned(documentId: string, ownerId: string): Promise<void>; abstract delegatedTo(documentId: string, delegateId: string): Promise<void>;}
// infrastructure/external/tuple-document-grants.tsexport class TupleDocumentGrants implements DocumentGrants { constructor(private readonly tuples: RelationTupleStore) {}
ownerAssigned(documentId: string, ownerId: string): Promise<void> { return this.tuples.write([{ object: `document:${documentId}`, relation: 'owner', subject: `user:${ownerId}` }]); }
delegatedTo(documentId: string, delegateId: string): Promise<void> { return this.tuples.write([{ object: `document:${documentId}`, relation: 'delegate', subject: `user:${delegateId}` }]); }}Les listes viennent par construction : listAccessible répond les identifiants que l'acteur
atteint, que le port de lecture transforme en id in (...). La délégation est une relation de
plus (delegate), et une hiérarchie aussi (parent) ; rien ne change dans les cas d'usage quand
on en ajoute une.
Le modèle combiné de l'exemple
Section intitulée « Le modèle combiné de l'exemple »examples/scheduling superpose deux modèles derrière l'unique port
(RolesAndRelationshipsAuthorizer) : une courte liste d'attributions de rôles décide d'abord
(l'administrateur d'un réseau, l'équipe de la plateforme, le libre-service d'un patient), puis les
relations décident du reste : un cabinet avec ses responsables et ses praticiens, un bureau tenu ou délégué, un agenda
d'un bureau, un rendez-vous d'un agenda et de son patient. Un droit sur un élément est toujours
une relation, jamais un rôle ; une liste ne vaut all que pour une attribution sans condition,
sinon ce sont les identifiants qu'atteignent les relations. Un acteur système (un job planifié,
un gestionnaire d'événement d'intégration, nommé <context>.<what it does>) ne tient aucun rôle :
il peut utiliser les permissions de son propre contexte sur ce contexte, et tout ce qu'il demande
d'autre va aux relations. Le cabinet n'y est pas un tenant : les patients prennent rendez-vous
dans plusieurs cabinets, donc la frontière entre cabinets est une relation décidée élément par
élément. Le réseau de cabinets est le tenant, une frontière de stockage sous toutes les relations
(ADR 0003 de l'exemple).
| Qui demande | Ce qui décide | Exemple |
|---|---|---|
| l'acteur anonyme | toujours refusé, ANONYMOUS |
une session expirée |
un acteur système de notification |
son propre contexte, sans tuple | le job notification.send-due-reminders qui envoie les rappels arrivés à échéance |
| un administrateur de la plateforme | une attribution de rôle, sans condition, liste all |
ouvrir un cabinet, lire le journal d'audit |
| un patient qui prend rendez-vous pour lui-même | une attribution de rôle avec une condition (selfService dans le context de l'exigence) |
prendre un rendez-vous |
| un praticien, un responsable, un assistant | les relations | modifier un rendez-vous d'un agenda de son bureau |
Les scopes et les gardes à la bordure
Section intitulée « Les scopes et les gardes à la bordure »Un scope limite le client qui présente un identifiant, pas l'acteur : une intégration partenaire
avec invoices:read ne peut pas émettre de facture, quoi que puisse faire son utilisateur.
Déclarez le vocabulaire une fois, et protégez les routes par une garde :
declare module '@ddd-ts-kit/kernel' { interface SessionScopes { readonly 'invoices:read': true; readonly 'invoices:write': true; }}
@RouteGroup({ prefix: '/invoices', guards: [HasScope('invoices:read')], errors: ['FORBIDDEN'] })export class InvoicesRoutes {}Une fois que SessionScopes déclare le vocabulaire, HasScope('invoice:read') (une faute de
frappe) ne compile plus. Les authentificateurs JWT et Keycloak remplissent Session.scopes à
partir de la claim scope (ou scp). Chaque refus d'une garde (HasRole, HasScope,
TenantMember, un tenant auquel l'appelant n'appartient pas) est signalé par onGuardRefusal
du pipeline HTTP, que auditGuardRefusals de @ddd-ts-kit/audit écrit dans le journal d'audit
comme une décision refusée, à côté des refus des cas d'usage.
Limites et quand ne pas l'utiliser
Section intitulée « Limites et quand ne pas l'utiliser »- Les préréglages sont des points de départ. Les rôles, les règles et le schéma d'une vraie organisation viennent de ses membres ; les préréglages montrent la forme, pas le contenu.
- Un modèle trop riche pour le problème coûte tous les jours. Des tuples à garder synchronisés, des projections d'attributs à maintenir : commencez par des rôles et des politiques, et faites passer un contexte aux relations quand le partage apparaît.
- Les scopes ne sont pas des permissions. Un scope n'accorde jamais ce que l'acteur n'a pas le droit de faire ; il ne fait que retirer.
Voir aussi
Section intitulée « Voir aussi »- Authentification et autorisation
- Moteurs d'autorisation
- Multitenance
- Journal d'audit
- La skill
ddd-ts-kit:access-control