-
Notifications
You must be signed in to change notification settings - Fork 0
FR Authorization
L'autorisation est le cœur de Kengela (@kengela/authz-core + @kengela/adapter-expr-cel). Cette
page couvre la grammaire des permissions, les grants et relations, l'écriture de policies
déclaratives (CEL), le conditional access, les obligations / step-up et les decision logs.
Une permission est une chaîne pointée plane.resource.action, où resource peut compter
plusieurs segments. Elle est compatible avec des catalogues d'autorisation reels varies.
data.cashier.register.read
│ │ │ └── action
│ │ └──────────── resource (multi-segments)
│ └──────────────────── (segment de resource)
└───────────────────────── plane : platform | control | data | public
Segments valides : ^[a-z0-9*_-]+$, au moins 2 segments (sinon PermissionSyntaxError).
Un grant couvre une permission requise selon ces règles :
| Motif du grant | Signification |
|---|---|
segment * non terminal
|
joker sur exactement un segment |
segment * terminal
|
joker de préfixe (couvre tous les segments restants) |
| segment littéral | égalité stricte de segment |
| (sans joker terminal) | les longueurs doivent être égales |
Exemples :
| Grant | Couvre | Ne couvre pas |
|---|---|---|
data.cashier.* |
data.cashier.register.read |
data.orders.read |
data.*.read |
data.orders.read |
data.a.b.read (joker = 1 segment) |
data.cashier.read |
data.cashier.read |
tout le reste |
Comment la permission requise est construite. Le PDP forme la permission à vérifier comme
`${resource.type}.${action}`. Ainsi, pourresource.type = 'data.orders'etaction = 'read', la permission requise estdata.orders.read.
Un grant est un droit avec provenance et expiration :
interface Grant {
readonly permission: PermissionString;
readonly scope: Scope; // own ⊂ unit ⊂ subtree ⊂ tenant ⊂ global
readonly source: 'MANUAL' | 'IDP' | 'DELEGATION';
readonly expiresAt?: Date; // grant expiré = inopérant (exclu au check)
}La portée (Scope) d'un grant et la relation organisationnelle (OrgRelation) résolue entre
l'acteur et la ressource sont comparées par rang :
| Rang | Scope | Relation couverte |
|---|---|---|
| 0 | own |
self |
| 1 | unit |
unit |
| 2 | subtree |
subtree |
| 3 | tenant |
tenant |
| 4 | global |
none (aucun lien org : seul global couvre) |
Un droit accordé à une portée couvre toutes les portées plus étroites :
scopeCoversRelation(grantScope, relation) est vrai ssi SCOPE_RANK[grantScope] >= relationRank(relation). C'est ce qui permet à un tenant de couvrir un self, mais jamais
l'inverse.
La relation est résolue par un RelationResolver que fournit l'application (sur son organigramme) :
interface RelationResolver {
resolveRelation(principal: Principal, resource: ResourceRef): Promise<OrgRelation>;
}Les grants sont chargés par un AuthorizationRepository :
interface AuthorizationRepository {
loadGrantsForUser(userId: UserId, tenantId: TenantId): Promise<readonly Grant[]>;
loadRole(roleKey: string, tenantId: TenantId): Promise<Role | null>;
}| Classe | Ce qu'elle tranche | Dépendances |
|---|---|---|
RbacDecisionPoint |
RBAC seul : grants × relation |
grants, relations, log?, clock?
|
LayeredDecisionPoint |
RBAC plancher + policies ABAC + conditional access + step-up |
grants, relations, policies, expr, log?, clock?
|
import { RbacDecisionPoint, LayeredDecisionPoint } from '@kengela/authz-core';Les deux implémentent PolicyDecisionPoint, dont checkMany() traite un lot de requêtes (évite le
N+1 sur le filtrage de collections).
Une Policy cible un couple (resource, action) (avec * en joker) et porte une liste de règles :
interface Policy {
readonly resource: string; // type de ressource, ou '*'
readonly action: string; // action, ou '*'
readonly rules: readonly PolicyRule[];
}
interface PolicyRule {
readonly effect: 'allow' | 'deny' | 'step_up';
readonly scope?: Scope; // restreint la règle à une portée
readonly when?: string; // condition CEL ; absente = toujours vrai
readonly obligations?: readonly Obligation[];
readonly reason?: string;
}Les policies sont fournies par un PolicyStore (fichiers versionnés en CI, overrides tenant en base,
ou hybride) :
interface PolicyStore {
loadPolicies(tenantId: TenantId): Promise<readonly Policy[]>;
}Autoriser la lecture d'une commande seulement si elle appartient à l'agence de l'acteur :
const policy: Policy = {
resource: 'data.orders',
action: 'read',
rules: [
{
effect: 'allow',
when: 'resource.attributes.agencyId == principal.agencyId',
},
],
};Rappel du gate ABAC : dès qu'une règle allow existe pour (resource, action), il faut qu'au
moins une matche, sinon DENY no_matching_allow. Les attributs de la ressource (agencyId,
ownerId, amount, ...) proviennent de resource.attributes et sont évalués par CEL.
@kengela/adapter-expr-cel implémente ExpressionEnginePort au-dessus de
@marcbachmann/cel-js. Le contexte exposé aux expressions
est { principal, resource, env, tenant } :
interface ExpressionContext {
readonly principal: Principal;
readonly resource: ResourceRef;
readonly env: AuthContext & { readonly now: number };
readonly tenant?: Readonly<Record<string, unknown>>;
}import { CelExpressionEngine } from '@kengela/adapter-expr-cel';
const expr = new CelExpressionEngine(); // horloge système par défaut
const engine = new CelExpressionEngine({ clock }); // horloge injectable (tests déterministes)Une expression doit retourner un booléen ; sinon CelEvaluationError est levée (et le PDP la
rattrape en deny condition_error). Les compilations sont mises en cache.
Trois fonctions sont injectées pour les conditions temporelles (échéance, business-hours) :
| Fonction CEL | Retour | Sens |
|---|---|---|
now() |
int (epoch ms) | horodatage courant (via Clock) |
daysUntil(x) |
int | jours calendaires jusqu'à x (bigint/number/Date/ISO) |
businessDaysBetween(a, b) |
int | jours ouvrés (lun-ven), bornes incluses |
// La ressource expire dans plus de 7 jours ?
const rule: PolicyRule = { effect: 'allow', when: 'daysUntil(resource.attributes.dueDate) > 7' };La fonction CEL matches compilerait une RegExp non bornée : une regex catastrophique
((a+)+) provoquerait un backtracking exponentiel (ReDoS → DoS du PDP) sur une entrée adverse. La
doctrine Kengela borne toute regex ; matches est donc rejeté à la compilation (fail-closed)
par assertNoUnboundedRegex(). Exprimez les conditions d'accès via ==, in, startsWith,
contains :
// ❌ rejeté : CelEvaluationError « matches interdite »
'resource.attributes.name.matches("(a+)+")';
// ✅ équivalents sûrs
'resource.attributes.tier in ["gold", "platinum"]';
'resource.attributes.code.startsWith("EU-")';Une règle step_up matchée transforme la décision en STEP_UP porteur d'obligations :
interface Obligation {
readonly type: 'require_mfa' | 'require_passkey' | 'reauthenticate' | 'notify';
readonly params?: Readonly<Record<string, unknown>>;
}Exemple : les remboursements exigent une passkey et un contexte peu risqué :
const refundPolicy: Policy = {
resource: 'data.orders',
action: 'refund',
rules: [
{
effect: 'step_up',
when: 'principal.mfaLevel != "passkey"',
obligations: [{ type: 'require_passkey' }],
reason: 'refund_needs_passkey',
},
{
effect: 'deny',
when: 'env.riskScore > 80',
reason: 'refund_high_risk',
},
],
};Rappel deny-wins : si les deux règles matchent, le deny (risque élevé) l'emporte sur le step_up.
Côté application, step_up déclenche un défi (relancer une MFA/passkey), pas un refus définitif.
Avec @kengela/nestjs, cela devient une StepUpRequiredException (voir
05-nestjs-integration.md).
Chaque décision peut être tracée pour l'audit via DecisionLogSink :
interface DecisionLogSink {
record(entry: {
readonly request: AccessRequest;
readonly decision: Decision;
readonly at: number;
}): Promise<void> | void;
}Une Decision porte tout le nécessaire à l'audit :
interface Decision {
readonly effect: 'allow' | 'deny' | 'step_up';
readonly obligations?: readonly Obligation[];
readonly matchedPolicy?: string;
readonly reason: string; // 'rbac_grant', 'no_grant', 'no_matching_allow', 'condition_error', ...
readonly signals?: Readonly<Record<string, unknown>>; // { relation, crossTenant? }
}const log: DecisionLogSink = {
record({ request, decision, at }) {
console.log(
at,
request.principal.userId,
`${request.resource.type}.${request.action}`,
decision.effect,
decision.reason,
decision.signals,
);
},
};
const pdp = new LayeredDecisionPoint({ grants, relations, policies, expr, log });Les signals capturent notamment la relation résolue et le drapeau crossTenant : de quoi
reconstruire pourquoi un accès a été accordé ou refusé.
🇬🇧 English
- Home
- Getting started
- Architecture
- Authorization
- Authentication
- Identity federation
- Compliance & PII
- NestJS integration
- Developing an adapter
- Security
Recipes
- NestJS + native + Prisma
- better-auth
- SCIM / Entra
- LDAP / AD
- RBAC / ABAC
- PII / GDPR
- Combo: better-auth + PII
- Combo: SCIM/Entra + authz
- Combo: full stack
🇫🇷 Français
- Accueil
- Prise en main
- Architecture
- Autorisation
- Authentification
- Fédération d'identité
- Conformité & PII
- Intégration NestJS
- Développer un adapter
- Sécurité
Recettes