@ddd-ts-kit/auth
Authentication and authorization behind ports: who is calling, what they may do, and on which
element, without a role test in a controller or an if (user.isAdmin) in the domain. The
authorization model (roles, attribute policies, relationships in the manner of Google Zanzibar)
is the project's choice, behind one port.
npm i @ddd-ts-kit/authnpm i jose # only for @ddd-ts-kit/auth/jwt| Entry | Holds | Imports |
|---|---|---|
@ddd-ts-kit/auth |
permissions, requires, AuthorizationMap, authorizationMiddleware, the ports Authorizer, Access, Authenticator and SessionAuthenticator, policies and PolicyAccess, RoleBasedAuthorizer, PolicyBasedAuthorizer, HTTP glue |
the kernel only |
@ddd-ts-kit/auth/rebac |
schema DSL, relation tuples, RelationTupleStore, RelationBasedAuthorizer |
the kernel only |
@ddd-ts-kit/auth/jwt |
JwtAuthenticator, keycloakAuthenticator |
jose (optional peer), the only file that does |
@ddd-ts-kit/auth/di |
authorizationModule, authorizationMapOf, and again the classes the module binds: Authorizer, Authenticator, Access, AuthorizationMap |
@ddd-ts-kit/di (optional peer) |
@ddd-ts-kit/auth/testing |
anActor() and its ActorBuilder, AllowAll, DenyAll, RecordingAuthorizer, FakeAuthenticator, the two contract suites |
@ddd-ts-kit/testing (optional peer), the runner you pass |
The actor model lives in @ddd-ts-kit/kernel: Actor, anonymousActor(), systemActor(name),
AuthorizedCommand, AuthorizedQuery, the ActorContext port and its two implementations.
Three responsibilities, three places
Section titled “Three responsibilities, three places”| Responsibility | Question | Where it lives | With what |
|---|---|---|---|
| Authentication | who is calling? | the platform, at the boundary (src/platform/auth, routes, consumers) |
an adapter of Authenticator verifies the credential and produces an Actor, and a SessionAuthenticator the whole Session: plain data, never the token |
| Authorization | may this actor do this, on this? | the application layer of each context (application/authorization/) |
permissions declared as data, one requirement per use case checked by a bus middleware, policies for rules that need loaded state, all through the Authorizer port |
| Invariants | is this a valid state of the business? | the domain | aggregates and DomainErrors. "An invoice cannot be paid twice" is not a permission |
Nothing is reachable by default. A command or a query that declares neither a permission nor that it is public is refused, at run time by the middleware and, when you ask for it, when the application is created.
Guards are not a fourth place. A guard of @ddd-ts-kit/http (Authenticated, HasRole(..), a
guard of the project) is a control at the edge over a set of routes: it reads the caller and
the request, and refuses before the controller runs. It never replaces the permission of a use
case, which a job, an event handler or another transport meets without passing any guard, and
it never loads an element to decide. The rule is presentation/guard-at-the-edge.
The smallest working example
Section titled “The smallest working example”Before the complete example, the mechanism in one file, with no container and no HTTP: a permission, a command that carries its actor, the requirement, a role model and the guard on a command bus of the kernel. Run it as it is: the comments say what it prints.
import { AuthorizationMap, ForbiddenError, RoleBasedAuthorizer, UnauthenticatedError, authorizationMiddleware, definePermissions, requires, type DecisionRecord,} from '@ddd-ts-kit/auth';import { AuthorizedCommand, InMemoryCommandBus, anonymousActor, type Actor } from '@ddd-ts-kit/kernel';
// 1. The permissions of the context, as data.const billingPermissions = definePermissions('billing', { 'invoice.void': { description: 'Void an issued invoice' },});
// 2. A use case that carries its actor.class VoidInvoice extends AuthorizedCommand { constructor( actor: Actor, readonly invoiceId: string, ) { super(actor); }}
// 3. What each use case requires. A message missing from the map is refused.const map = AuthorizationMap.of(requires(VoidInvoice, billingPermissions['invoice.void']));
// 4. The model: here, roles. Only a controller may void.const authorizer = new RoleBasedAuthorizer({ roles: { controller: [billingPermissions['invoice.void']], clerk: [] },});
// 5. The guard runs on the bus, before the handler.const decisions: DecisionRecord[] = [];const commands = new InMemoryCommandBus() .use(authorizationMiddleware({ authorizer, map, onDecision: (record) => void decisions.push(record) })) .register(VoidInvoice, { execute: async (command) => console.log(`voided ${command.invoiceId}`) });
const grace: Actor = { id: 'grace', type: 'user', roles: ['controller'], attributes: {} };const bob: Actor = { id: 'bob', type: 'user', roles: ['clerk'], attributes: {} };
await commands.execute(new VoidInvoice(grace, 'inv-1')); // prints "voided inv-1"
const refused = await commands.execute(new VoidInvoice(bob, 'inv-1')).catch((error: unknown) => error);console.log(refused instanceof ForbiddenError && refused.reason); // NO_MATCHING_ROLE, answered 403
const nobody = await commands.execute(new VoidInvoice(anonymousActor(), 'inv-1')).catch((error: unknown) => error);console.log(nobody instanceof UnauthenticatedError && nobody.code); // UNAUTHORIZED, answered 401
console.log(decisions.map(({ actorId, decision }) => `${actorId}: ${decision.allowed}`));// [ 'grace: true', 'bob: false', 'anonymous: false' ]The handler never saw bob nor the anonymous caller, and holds no if about roles. Every
piece has its place in a real project: the permissions in application/authorization/, the
command next to its handler, the requirement on the message or in the module, the model and the
guard in the composition root, where authorizationModule installs them for you. The complete
example below puts each one there.
A complete example
Section titled “A complete example”1. Permissions, declared by the context that owns them
Section titled “1. Permissions, declared by the context that owns them”import { definePermissions } from '@ddd-ts-kit/auth';
export const billingPermissions = definePermissions('billing', { 'invoice.issue': { description: 'Issue an invoice to a customer' }, 'invoice.read': { description: 'Read invoices' }, 'invoice.void': { description: 'Void an issued invoice' },});// billingPermissions['invoice.issue'].id === 'billing:invoice.issue', typed as that literalPermissionRegistry.of(billingPermissions, catalogPermissions) lists them all (all(),
contexts(), inContext(name), find(id)): documentation and administration screens are
generated from it, never written by hand. The names are checked when they are declared: a
context name is one lower-case word, a permission name lower-case words separated by dots
(each word starts with a letter; digits and dashes are allowed). Anything else throws AuthorizationSetupError when the file is
loaded, and so does PermissionRegistry.of for an id declared twice: a mistake found at
start-up, never a refusal found in production.
2. Use cases that carry their actor
Section titled “2. Use cases that carry their actor”import { AuthorizedCommand, type Actor } from '@ddd-ts-kit/kernel';
export class VoidInvoice extends AuthorizedCommand { constructor( actor: Actor, readonly invoiceId: string, ) { super(actor); }}The actor is the first constructor argument: a call site cannot forget it, and the compiler
refuses requires on a message that has none. Command and Query stay for public use cases.
3. The requirement, on the message or next to the handler registration
Section titled “3. The requirement, on the message or next to the handler registration”A project generated by the CLI uses the declared style by default: the requirement is written
on the message with @Requires or @Public, shown below, and there is no module file. The
explicit style writes it in <context>.module.ts:
import { Access } from '@ddd-ts-kit/auth';import { defineContextModule } from '@ddd-ts-kit/di';import { Clock, IdGenerator } from '@ddd-ts-kit/kernel';
export const billingModule = defineContextModule({ name: 'billing', register(builder) { builder .command(IssueInvoice) .requires(billingPermissions['invoice.issue']) .handledBy(IssueInvoiceHandler, [InvoiceRepository, IdGenerator]); builder .command(VoidInvoice) .requires(billingPermissions['invoice.void'], { resource: (command) => ({ type: 'invoice', id: command.invoiceId }), }) .handledBy(VoidInvoiceHandler, [InvoiceRepository, Access, Clock]); builder.query(GetCatalog).public().handledBy(GetCatalogHandler, [Catalog]); },});The handler checks nothing for the permission: the guard of the bus decides it before the
handler runs. It asks Access only when a policy refines that permission, once it holds the
element (section 4). resource names the element the right bears on, context passes facts of
the message the authorizer may need (the tenant it targets).
The same requirement said on the message itself, with the declarations of the kernel, for a declared context:
import { Requires } from '@ddd-ts-kit/kernel/declarations';
@Requires(billingPermissions['invoice.void'], { resource: (command: VoidInvoice) => ({ type: 'invoice', id: command.invoiceId }),})export class VoidInvoice extends AuthorizedCommand { /* ... */ }
// void-invoice.handler.ts: Access comes from '@ddd-ts-kit/auth', which the application layer may import@CommandHandler(VoidInvoice)export class VoidInvoiceHandler implements CommandHandler<VoidInvoice> { /* ... */ }@Public() is the counterpart of .public(). Both forms feed the same authorization map and
the same authorization: 'required' check; written on the builder, a requirement wins over
the one of the message. A policy class declared with
@Policy(permission, { name: 'invoice.only-issuer-voids' }) (an allows(actor, subject, { now })
method, and appliesTo(subject) when it must tell its subjects apart) is evaluated by
Access next to the policies given to authorizationModule.
The ports of this package are abstract classes, and the class is what a constructor parameter, a
binding or a test names: Authorizer, Access, Authenticator, SessionAuthenticator and
ResourceAttributes from @ddd-ts-kit/auth, RelationTupleStore from
@ddd-ts-kit/auth/rebac. An adapter implements its port and never extends it; a plain
object with the same methods stands for it in a test.
4. A policy, for a rule that needs the loaded element
Section titled “4. A policy, for a rule that needs the loaded element”import { definePolicy, type PermissionSet } from '@ddd-ts-kit/auth';
declare const billingPermissions: PermissionSet<'billing', 'invoice.void'>;declare class Invoice { readonly issuedBy: string; readonly issuedAt: Date;}declare function isSameDay(left: Date, right: Date): boolean;
export const onlyIssuerVoidsSameDay = definePolicy<Invoice>({ name: 'invoice.only-issuer-voids-same-day', permission: billingPermissions['invoice.void'], appliesTo: (subject): subject is Invoice => subject instanceof Invoice, allows: (actor, invoice, { now }) => actor.id === invoice.issuedBy && isSameDay(invoice.issuedAt, now),});export class VoidInvoiceHandler { 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 not found'); } await this.access.authorize(command.actor, billingPermissions['invoice.void'], invoice); invoice.void(this.clock.now()); await this.invoices.save(invoice); }}Access.authorize asks every policy of the permission, then the authorizer, and raises
ForbiddenError with a reason code: the name of the policy that answered false, the code
the policy or the authorizer gave, or SUBJECT_MISMATCH when the subject is not one appliesTo
accepts. The anonymous actor is refused before any policy is asked, with UnauthenticatedError
(401), as on the bus. A policy is a plain object: its spec calls
onlyIssuerVoidsSameDay.allows(actor, invoice, { now }) and needs nothing else.
A protected use case meets two checkpoints. The guard of the bus decides on the action, before
the handler and before any transaction; Access decides on the element, once the handler has
loaded it. Each refusal has its error, its code and its HTTP status:
UndeclaredUseCaseError and MissingActorError are not refusals: they say the code is wrong,
fail the first test that sends the message, and are never answered as a 403.
Access is the port the handler depends on; PolicyAccess is its implementation, which
authorizationModule binds. PolicyAccessOptions.resourceOf says how a subject names its
resource (invoice => ({ type: 'invoice', id: invoice.id })) when the authorizer decides per
element. By default only a plain { type, id } is taken as a resource reference. A
PolicyAccess built by hand without an authorizer refuses, with NO_POLICY, a permission that
no policy answers for: deny by default holds there too.
5. The composition root
Section titled “5. The composition root”import { createApplication, kernelModule } from '@ddd-ts-kit/di';import { RoleBasedAuthorizer } from '@ddd-ts-kit/auth';import { authorizationModule } from '@ddd-ts-kit/auth/di';import { JwtAuthenticator } from '@ddd-ts-kit/auth/jwt';
const app = createApplication({ modules: [ kernelModule(), billingModule, authorizationModule({ authorizer: new RoleBasedAuthorizer({ roles: { admin: ['*'], accountant: [billingPermissions['invoice.issue'], billingPermissions['invoice.read']], controller: ['billing:*'], }, inherits: { controller: ['accountant'] }, }), authenticator: new JwtAuthenticator({ jwksUri: config.oidc.jwksUri, issuer: config.oidc.issuer, audience: 'billing-api', }), policies: [onlyIssuerVoidsSameDay], onDecision: (record) => auditTrail.append(record), }), ], authorization: 'required',});The module binds Authorizer, Access (a PolicyAccess over the authorizer, the policies and
the Clock of the application), the AuthorizationMap built from what the use cases declared
and, when one is given, Authenticator. Each is resolved by its class:
const authorizer = app.resolve(Authorizer);const declared = app.resolve(AuthorizationMap).declarations(); // for documentation and administration screensAn authenticator that has dependencies of its own is bound by a module of the platform instead of being given as an option:
import { Authenticator } from '@ddd-ts-kit/auth';import { defineContextModule } from '@ddd-ts-kit/di';
export const authModule = defineContextModule({ name: 'auth', register(builder) { builder.bind(Authenticator).toClass(SessionCookieAuthenticator, [SessionStore]); },});authorizationModule binds the guard that the command and query buses of kernelModule() run
before anything else, the unit of work included: a refused command opens no transaction. A bus
of your own, given through kernelModule({ commandBus }), has to run the guard itself:
.use((message, next, info) => resolve(UseCaseGuard).check(message, next, info)), UseCaseGuard
being the port of @ddd-ts-kit/di the module binds.
When a policy refines the permission of a use case, the guard also wraps its handler, last on the
bus and inside the unit of work: a handler that returns without asking Access about that
permission has its result refused with PolicyNotAskedError and its change rolled back, since the
rule of the policy was never applied. A bus of your own runs that part around its handler, with
.aroundHandler((message, next, info) => resolve(UseCaseGuard).within?.(message, next, info) ?? next()).
Without the container, policiesAskedMiddleware({ map, refined }) is the same check. Only the
questions put to PolicyAccess are seen: a project that binds its own Access passes
policiesAsked: false.
authorization: 'required' makes createApplication throw a WiringError
(UNDECLARED_AUTHORIZATION) naming every command and query that has neither .requires(...)
nor .public(), and another (AUTHORIZATION_NOT_ENFORCED) when no module binds a guard. The
default is 'optional' for now, so existing modules keep working; with it, an undeclared use
case is still refused when it is sent, with UndeclaredUseCaseError, as soon as
authorizationModule is listed.
onDecision is the audit hook. It receives { actorId, permissionId, resource?, decision, useCase?, kind? } (kind is command or query, as the bus says) for every decision, allowed or refused, and a hook that throws refuses the request:
no decision goes unrecorded.
Without @ddd-ts-kit/di, the same pieces are assembled by hand:
const map = AuthorizationMap.of( requires(IssueInvoice, billingPermissions['invoice.issue']), requires(VoidInvoice, billingPermissions['invoice.void'], { resource: (command) => ({ type: 'invoice', id: command.invoiceId }), }), publicUseCase(GetCatalog),);const guard = authorizationMiddleware({ authorizer, map, onDecision });const commandBus = new InMemoryCommandBus().use(guard).use(unitOfWorkMiddleware(unitOfWork));const queryBus = new InMemoryQueryBus().use(guard);6. A route
Section titled “6. A route”import { actorOf } from '@ddd-ts-kit/auth';
export const voidInvoiceRoute = (commandBus: CommandBus) => defineRoute( { method: 'POST', path: '/invoices/:invoiceId/void', operationId: 'voidInvoice', summary: 'Void an invoice', responses: { 204: { description: 'Voided' } }, errors: ['UNAUTHORIZED', 'FORBIDDEN', 'NOT_FOUND'], security: ['bearer'], }, async ({ params, context }) => { await commandBus.execute(new VoidInvoice(actorOf(context.principal), params.invoiceId)); return { status: 204 }; }, );import { Authenticator, principalResolver } from '@ddd-ts-kit/auth';import { ExecutionScope } from '@ddd-ts-kit/kernel';
const options = { resolvePrincipal: principalResolver(app.resolve(Authenticator)), // answers a Session executionScope: app.resolve(ExecutionScope),};// passed to the adapter of @ddd-ts-kit/http: mountExpress(server, routes, options)The route maps a request to a command and nothing else. It contains no role, no ownership
test and no status code for a refusal: UnauthenticatedError and InvalidCredentialError
carry the code UNAUTHORIZED, ForbiddenError carries FORBIDDEN, the codes the default
error mapping of @ddd-ts-kit/http answers with 401 and 403.
principalResolver reads Authorization: Bearer <token> from the framework request; a header
that is present and is not a well-formed bearer credential is refused, never treated as an
anonymous call. It answers a kernel Session (the anonymous session without credential), which
the pipeline puts, with its actor, in the execution context of the request, and which the
executionScope option makes ambient for infrastructure: ActorContext of kernelModule()
reads its actor from that scope (see "The current actor"). An integration event handler and a
scheduled job open nothing themselves: @ddd-ts-kit/di runs each event in an execution
context of origin event whose actor is systemActor('<consumer name>'), and the scheduler
runs each job in one of origin schedule whose actor is systemActor('<context>.<job-name>').
The handler or the job puts that same actor in the command it sends:
commands.execute(new SendReminders(systemActor('billing.send-reminders'), execution.scheduledAt)).
| Reading | Gives |
|---|---|
actorOf(context.principal) |
the actor, anonymous when there is none; also accepts the context itself |
sessionOf(context) |
the session: who, since when, until when, how it was authenticated, impersonatedBy |
context.execution |
the whole execution context: correlation id, tenant, locale, deadline |
inActorContext(actorContext, handler) keeps working for a project that has an ActorContext
and no execution scope: it runs the handler inside ActorContext.run.
Authentication
Section titled “Authentication”Actors and sessions
Section titled “Actors and sessions”Authenticator answers an Actor. SessionAuthenticator extends it with
authenticateSession(credential): Promise<Session>, for an adapter whose credential knows more
than who: the session id, when it was issued and when it expires, the method and strength of
the authentication, who is impersonating. A separate port rather than a required method, so an
adapter that only knows actors (an API key table, a development header) stays three lines; the
boundary does not care which one it gets:
import { resolveSession } from '@ddd-ts-kit/auth';
const session = await resolveSession(authenticator, credential);// a SessionAuthenticator answers its own session; for a plain Authenticator the session is// derived from the actor and says only what is known: id of the actor as session id, no// issue instant (the epoch), no expiry, method `token`JSON Web Tokens
Section titled “JSON Web Tokens”JwtAuthenticator verifies the signature, the issuer, the audience, the expiry and the
algorithm (asymmetric families only unless told otherwise; alg: none is always refused), and
is a SessionAuthenticator. Nothing of the token is kept in the actor or in the session. The
verification key, which may be a shared secret, is not a property of the authenticator:
logging or serializing the object shows no key.
| Field of the session | Claim |
|---|---|
id |
sid, else jti, else the subject and the issue instant |
issuedAt, expiresAt |
iat, exp |
authenticationMethod |
password when amr lists pwd, else token; or the authenticationMethod option, a value or a function of the claims |
authenticationStrength |
mfa when amr lists mfa (RFC 8176), assuranceLevel from acr |
impersonatedBy |
the sub of the act claim (RFC 8693), or the claim named by impersonatorClaim: the id itself, or an object holding it under sub or id |
locale, timeZone |
locale, zoneinfo |
scopes |
scope, the space-separated list of OAuth 2.0, else scp as a list; absent when the token grants none. HasScope of @ddd-ts-kit/http reads them at the edge |
extensions |
what toSessionExtensions(claims) answers |
import { JwtAuthenticator } from '@ddd-ts-kit/auth/jwt';
new JwtAuthenticator({ jwksUri: 'https://id.example.com/realms/shop/protocol/openid-connect/certs', // or key: publicKey issuer: 'https://id.example.com/realms/shop', audience: 'shop-api', algorithms: ['RS256'], // default: RS256, PS256, ES256, EdDSA clockTolerance: 5, // seconds, default 0 tenantClaim: 'tenant_id', // the default toActor: (claims) => actor, // replaces the default mapping});The default mapping takes the subject as the actor, its roles (below), the tenant claim (a text
claim is the tenant; a list names the tenant when it holds one, and the tenants attribute of
an actor of several, among which a request names the one it works in), and the standard claims
of OpenID Connect as attributes: username (preferred_username), email, and emailVerified
(email_verified), which a use case that trusts the address (an invitation sent to it) reads.
authenticate(undefined) answers the anonymous actor. A token that is expired, not valid yet,
for another audience or issuer, signed with another key, altered, unsigned (alg: none), or
using an algorithm outside the allow-list raises InvalidCredentialError (code UNAUTHORIZED)
with a reason code: TOKEN_EXPIRED, CLAIM_MISMATCH, BAD_SIGNATURE, ALGORITHM_NOT_ALLOWED,
UNKNOWN_KEY, MALFORMED_TOKEN, UNSUPPORTED_TOKEN, NO_SUBJECT. A key set that cannot be
fetched is not a refusal: the error is left as it is and answered as a 5xx.
The default mapping for OIDC: id from sub (required), type: 'user', tenantId from the
tenant claim when it is a string, no attribute, and roles from the first claim present among
realm_access.roles (realm roles), roles (a list or space-separated words) and scope. They
are never merged: a scope a client asked for must not pass for a role. The actor keeps nothing
of the token. Map service accounts to type: 'service' with toActor.
Keycloak
Section titled “Keycloak”import { keycloakAuthenticator } from '@ddd-ts-kit/auth/jwt';
const authenticator = keycloakAuthenticator({ baseUrl: 'https://id.example.com', // without the realm realm: 'shop', audience: 'shop-api', // what the `aud` claim must name clientId: 'shop-api', // optional: its client roles join the realm roles tenantClaim: 'organization', // optional, default `tenant_id`});It derives the issuer (<baseUrl>/realms/<realm>) and the key set
(<issuer>/protocol/openid-connect/certs, fetched and cached, rotated keys followed), and maps
an access token as follows:
| Field | Claim |
|---|---|
actor.id |
sub |
actor.type |
service when preferred_username starts with service-account- (a client using the client credentials grant), else user |
actor.roles |
realm_access.roles, plus resource_access.<clientId>.roles when clientId is given, each once |
actor.attributes |
username from preferred_username, email, emailVerified from email_verified, groups from the groups claim (or groupsClaim), and tenants when the tenant claim names several |
actor.tenantId |
the claim named by tenantClaim: a text claim, or the organization claim (a list of aliases, or an object keyed by alias) when it names exactly one organization; several are the tenants attribute |
session.id |
sid, the session of the realm; jti for a service account, which has none |
session.authenticationMethod |
sso for a person, service for a service account |
session.authenticationStrength |
assuranceLevel from acr |
session.impersonatedBy |
the claim named by impersonatorClaim (default act) |
What to configure in the realm, for these claims to be in the access token:
- A client for the API (
shop-api): client authentication on, no flow enabled (it only receives tokens: what used to be called bearer-only). It holds the client roles, if you use them. - An audience mapper. By default the
audof an access token does not name your API (it isaccount, or absent), and the token is refused withCLAIM_MISMATCH. On the client the users sign in with (the front end), or in a client scope it has by default: add a mapper of type Audience, Included Client Audienceshop-api, Add to access token on. - The roles mappers. The built-in client scope
roles, a default scope of every client, already writesrealm_access.roles(mapper realm roles) andresource_access.<client>.roles(mapper client roles). Check that the front-end client kept it, and that Full scope allowed is on, or that the roles you expect are in its scope mappings: Keycloak only writes the roles in the scope of the client that asked for the token. - Groups, if you authorize on them: a mapper of type Group Membership, Token Claim Name
groups, Add to access token on. Full group path decides between/finance/europeandeurope; the attribute holds what the mapper writes. - The tenant, one of: a mapper of type User Attribute writing
tenant_idfrom an attribute of the user; or, with Organizations enabled in the realm, the client scopeorganizationrequested by the front end, andtenantClaim: 'organization'. A user who belongs to several organizations gets no default tenant from that claim: they are thetenantsof the actor, and each request names the one it works in (x-tenant-id, which the HTTP adapter refuses with 403 for a tenant the actor does not hold). - Impersonation, if support staff impersonate users from the admin console: Keycloak
keeps the administrator in the session notes of the impersonated session. A mapper of type
User Session Note, User Session Note
IMPERSONATOR_ID, Token Claim Nameimpersonator_id, withimpersonatorClaim: 'impersonator_id', puts it insession.impersonatedBy, whichlogFieldsOfcarries to every log line.
preferred_username and email come from the built-in profile and email scopes. The
preset never calls Keycloak beyond the key set: a revoked session is refused when its access
token expires, so keep the access token lifespan short.
Choosing a model
Section titled “Choosing a model”The three authorizers implement the same Authorizer port and pass the same contract suite.
| Roles (RBAC) | Attribute policies (ABAC) | Relationships (ReBAC) | |
|---|---|---|---|
| Class | RoleBasedAuthorizer |
PolicyBasedAuthorizer |
RelationBasedAuthorizer (/rebac) |
| The question it answers | does one of the actor's roles grant this action? | do the attributes of the actor, the resource and the moment satisfy a rule? | is there a path of stored relationships from this element to the actor? |
| Choose it when | rights follow job functions and bear on actions, the same for every element | rights depend on properties: department, amount, status, time of day, with explicit refusals | rights are per element and shared: ownership, teams, folders, "whoever can edit the parent" |
| Granularity | action, plus a tenant check | element, through attributes you supply | element, by construction |
| Data to maintain | a role table in code, roles in the token | a ResourceAttributes port (projection or cache) |
relation tuples, written when aggregates are created or shared |
| Cost of a check | constant, no I/O | one attribute read | several tuple reads, bounded by a depth limit |
Listing (listAccessible) |
all, none, or a tenant filter: constant |
not available: a predicate is not a query | ids, by reverse expansion: grows with what the actor can reach |
| Typical denial reasons | NO_MATCHING_ROLE, TENANT_MISMATCH |
NO_MATCHING_POLICY, EXPLICIT_DENY |
NO_RELATION, RESOURCE_REQUIRED, UNMAPPED_PERMISSION, DEPTH_EXCEEDED |
What changes when you switch. The adapter given to authorizationModule, and the data it
needs. From RBAC to ReBAC: add resource: to the requires of the use cases that did not name
one (a relationship is always to something), map each permission to a schema permission, and
start writing tuples. Permissions, commands, queries, handlers, policies, routes and tests of
handlers do not change. Models can also be layered: Access asks the instance policies and
then the authorizer, so "roles for actions, policies for the few per-element rules" needs no
tuple store at all, and is the place to start.
Every authorizer refuses the anonymous actor (ANONYMOUS), and an actor of type system has no
right unless the model gives it one.
What every signed-in person may do. Some permissions belong to everyone who signed in,
whatever the model says of them: provisioning one's own account, opening an organisation,
accepting an invitation addressed to oneself. The policies of the context then decide on the
element. SignedInGrantsAuthorizer answers them before the model, for any model:
new SignedInGrantsAuthorizer({ grants: ['identity:*'], // the same grants as a role: permissions, ids, <context>:*, * otherwise: new RoleBasedAuthorizer({ roles }), // decides everything else, listings included});Only an actor of type user holds these grants: a system, a service and the anonymous actor go
to the model. What they cover lists all; another listing is the model's, and a model that
cannot list makes it fail with ListingNotSupportedError. The identity preset of the CLI writes
this wrapper in createAuthorizer and its grant in signed-in-grants.ts.
import { RoleBasedAuthorizer, tenantOfContext, type PermissionSet } from '@ddd-ts-kit/auth';
declare const billingPermissions: PermissionSet<'billing', 'invoice.read' | 'invoice.void'>;
export const authorizer = new RoleBasedAuthorizer({ roles: { admin: ['*'], controller: ['billing:*'], clerk: [billingPermissions['invoice.read']], automation: [billingPermissions['invoice.read']], }, inherits: { controller: ['clerk'] }, // a controller has all a clerk has tenantOf: tenantOfContext, // turns the tenant check on crossTenantRoles: ['admin'], // may act in every tenant systemRole: 'automation', // the role of systemActor(...), none by default});A grant is a permission, its id, <context>:* or *. A grant that could never match, an
unknown inherited role and a cycle in the hierarchy are refused at construction, and so are a
cross-tenant role or a system role that no role declares and crossTenantRoles without
tenantOf: each would silently do nothing.
With tenantOf, the tenant a request targets is read from the context of the requirement:
requires(X, permission, { context: (command) => ({ tenantId: command.tenantId }) }). It is
refused when the actor does not belong to it (its tenantId, or its tenants for an actor of
several), when it is not a tenant id at all, and when it is another tenant than the one the work
runs in. The guard of the bus and Access give the authorizer that tenant of the work
(workTenantId) from the execution scope, which authorizationModule wires on its own. A system
actor belongs to the tenant its work runs in (a per-tenant job, the event of a tenant) and to
none outside of one. A request that targets no tenant passes: the repositories keep it to the
tenant of the work (see Multitenancy).
A list is filtered on the tenant of the work, when the actor belongs to it, else on the one tenant of an actor that has one; an actor of several tenants outside of a tenant's work lists nothing.
Attribute policies
Section titled “Attribute policies”import { PolicyBasedAuthorizer, allOf, forbid, not, permit, type Condition, type PermissionSet, type ResourceAttributes,} from '@ddd-ts-kit/auth';import type { Clock } from '@ddd-ts-kit/kernel';
declare const billingPermissions: PermissionSet<'billing', 'invoice.read' | 'invoice.void'>;declare const clock: Clock;declare const invoiceAttributes: ResourceAttributes;
const sameDepartment: Condition = ({ actor, attributes }) => actor.attributes['department'] === attributes['department'];const isLarge: Condition = ({ attributes }) => Number(attributes['amount']) > 10_000;const secondApproval: Condition = ({ context }) => context['secondApproval'] === true;
export const authorizer = new PolicyBasedAuthorizer({ clock, // time comes from the Clock port attributes: invoiceAttributes, // ResourceAttributes: of(resource) => attributes policies: [ permit('invoice.read-own-department', [billingPermissions['invoice.read']], sameDepartment), permit('invoice.managers-void', ['billing:invoice.void'], ({ actor }) => actor.attributes['manager'] === true), forbid('invoice.no-large-void', [billingPermissions['invoice.void']], allOf(isLarge, not(secondApproval))), ],});A matching forbid wins over every permit; with no matching permit the answer is no.
Conditions compose with anyOf, allOf and not, and see { actor, permission, resource?, attributes, context, now }.
Relationships
Section titled “Relationships”import { definePermissions } from '@ddd-ts-kit/auth';import { InMemoryRelationTupleStore, RelationBasedAuthorizer, defineSchema, exclusion, from, union,} from '@ddd-ts-kit/auth/rebac';
export const docsPermissions = definePermissions('docs', { 'document.edit': { description: 'Change a document' }, 'document.view': { description: 'Read a document' }, 'folder.view': { description: 'Read a folder' },});
const schema = defineSchema({ user: {}, group: { relations: { member: ['user', 'group#member'] } }, folder: { relations: { owner: ['user'], viewer: ['user', 'group#member'], parent: ['folder'] }, permissions: { edit: union('owner', from('parent', 'edit')), view: union('edit', 'viewer', from('parent', 'view')), }, }, document: { relations: { owner: ['user'], editor: ['user', 'group#member'], banned: ['user'], parent: ['folder'] }, permissions: { edit: exclusion(union('owner', 'editor', from('parent', 'edit')), 'banned'), view: union('edit', from('parent', 'view')), }, },});
export const tupleStore = new InMemoryRelationTupleStore(); // a SQL store in production
export const authorizer = new RelationBasedAuthorizer({ schema, store: tupleStore, mapping: { document: { [docsPermissions['document.edit'].id]: 'edit', [docsPermissions['document.view'].id]: 'view' }, folder: { [docsPermissions['folder.view'].id]: 'view' }, },});| Zanzibar concept | Here |
|---|---|
relation tuple object#relation@subject |
{ object: 'document:1', relation: 'editor', subject: 'user:ada' } |
| userset as a subject | subject: 'group:eng#member', declared as 'group#member' in the relation |
| computed userset | a name in a rewrite: union('owner', 'editor'), view: union('edit', ...) |
| tuple to userset | from('parent', 'edit'): whoever may edit the parent |
| union, intersection, exclusion | union(...), intersection(...), exclusion(base, subtract) |
| Check | authorizer.check({ actor, permission, resource }) |
| Expand | authorizer.expand({ actor, permission, resource }) |
| reverse lookup | authorizer.listAccessible({ actor, permission, resourceType }) |
Three stored facts are enough to see a derived right. Ada is a member of the group finance, the members of finance view the folder reports, and reports is the parent of the document q3:
import type { PermissionSet } from '@ddd-ts-kit/auth';import type { InMemoryRelationTupleStore, RelationBasedAuthorizer } from '@ddd-ts-kit/auth/rebac';import type { Actor } from '@ddd-ts-kit/kernel';
// the schema, the store and the authorizer of the block abovedeclare const docsPermissions: PermissionSet<'docs', 'document.edit' | 'document.view' | 'folder.view'>;declare const tupleStore: InMemoryRelationTupleStore;declare const authorizer: RelationBasedAuthorizer;
const ada: Actor = { id: 'ada', type: 'user', roles: [], attributes: {} };
await tupleStore.write([ { object: 'group:finance', relation: 'member', subject: 'user:ada' }, { object: 'folder:reports', relation: 'viewer', subject: 'group:finance#member' }, { object: 'document:q3', relation: 'parent', subject: 'folder:reports' },]);
const q3 = { type: 'document', id: 'q3' };
await authorizer.check({ actor: ada, permission: docsPermissions['document.view'], resource: q3 });// { allowed: true }: view of q3 includes view of its parent, which includes the viewers of reportsawait authorizer.check({ actor: ada, permission: docsPermissions['document.edit'], resource: q3 });// { allowed: false, reason: 'NO_RELATION' }: no path leads to an owner or an editorawait authorizer.listAccessible({ actor: ada, permission: docsPermissions['document.view'], resourceType: 'document' });// { kind: 'ids', ids: ['q3'] }No tuple says "ada may view q3": the right is derived, so moving q3 to another folder or removing ada from finance changes it at once, with one tuple written or deleted.
defineSchema validates when the schema is defined: an unknown type, a userset of a relation
that does not exist, a rewrite naming something undeclared, a from over something that is not
a relation to objects or towards a type lacking the permission, one name used for a relation
and a permission, an empty union or intersection, and a rewrite that reads no tuple and could
never terminate (edit: 'view', view: 'edit'). A loop that goes through tuples (folders in
folders, groups in groups) is legitimate: it is cut at run time by cycle protection and by
maxDepth (default 16). When the depth limit cuts an evaluation the answer is DEPTH_EXCEEDED,
never a guess.
An actor is the subject <type>:<id> (user:ada, service:billing-worker); subjectOf
changes that. SchemaCheckedTupleStore wraps a store and refuses, on write, a tuple the schema
does not describe.
expand answers "why is this allowed" for debugging: the whole evaluation tree, every branch
visited, each node saying which object and relation were evaluated, how (relation, direct,
userset, computed, union, intersection, exclusion, from, cycle, depth-limit),
with which tuple and with what answer.
The cost of listing, honestly. listAccessible returns { kind: 'ids' } by reverse
expansion: starting from the actor it reads the tuples of every subject it can reach (the
actor, its groups, the objects those lead to, and their usersets), which costs one store read
per reached subject, then runs one check per object of the requested type it met. That is fine
for an actor reaching up to a few thousand tuples. It is not a search index: beyond listLimit
reached subjects (default 10000) it throws ReverseExpansionLimitError rather than return a
partial list. An application with large shared hierarchies should delegate listing to a
dedicated service, or keep a projection of accessible ids.
What of Zanzibar is not implemented.
- No zookies or consistency tokens: every check reads the store as it is now. Consistency is that of your store; the "new enemy" problem is avoided only when tuples are written in the transaction of the change they describe (below) and read from the primary.
- No caveats or conditional tuples. A condition on attributes belongs to a policy.
- No distributed evaluation, no check or expand caching, no request deduplication or hedging,
no parallel fan-out: the evaluation is sequential, in process, bounded by
maxDepth. - No public wildcard subject (
user:*), no namespace versioning or schema migration tooling, no watch API or changelog. expandis the trace of one check for one actor, not the userset tree of Zanzibar's Expand.
Delegating to a dedicated service. OpenFGA and SpiceDB implement the full model.
@ddd-ts-kit/openfga answers the RelationTupleStore and Authorizer
ports on OpenFGA, with the authorization model generated from the same defineSchema and both
adapters held to the contract suites of this package; a project moves from
RelationBasedAuthorizer to OpenFgaAuthorizer by binding. SpiceDB maps the same two methods
to CheckPermission and LookupResources, and the tuple writes of the next section to
WriteRelationships; no adapter is shipped for it. With either service, a tuple write is a
remote call that cannot share the transaction: it goes after the commit, retried. Hold an adapter
to authorizerContract in an integration test, and carry the consistency token the service
returns in the context of the request if you need read-your-writes.
Keeping relationships in sync
Section titled “Keeping relationships in sync”The domain and the handlers never write tuples. The application declares a port in its own words; the adapter knows the model.
export abstract class DocumentGrants { abstract ownerAssigned(documentId: string, ownerId: string): Promise<void>;
abstract sharedWithGroup(documentId: string, groupId: string): Promise<void>;}
// application/commands/create-document/create-document.handler.tsasync execute(command: CreateDocument): Promise<string> { const document = Document.create(this.ids.next(), command.title); await this.grants.ownerAssigned(document.id, command.actor.id); await this.documents.save(document); return document.id;}
// 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}` }]); }}The command bus runs the handler in the unit of work, and a SQL-backed RelationTupleStore
writes in that transaction: the document and its owner tuple are committed together or not at
all. An event handler reacting to a domain event of the aggregate (DocumentShared) can call
the same port: domain events are dispatched at the end of the unit of work of the command,
before its commit, so with a SQL-backed store its tuple commits or rolls back with the aggregate too. Only a store
that is not in that transaction (a remote authorization service) makes the write a second
step, which must then be idempotent (writing a tuple twice changes nothing) and retried,
through the outbox. test/relationship-sync.spec.ts is the tested version of the write from
the handler, with the rollback case; hold your SQL store to relationTupleStoreContract.
The current actor
Section titled “The current actor”Use cases get it explicitly. It is data of the command or query (command.actor), put
there by the route or the consumer. Handlers and the domain never read an ambient actor: a use
case then says who acts in its signature, a test passes whoever it wants, and a consumer or a
job is not at the mercy of a context nobody set.
Infrastructure reads the execution scope. Audit columns and tenant scoping apply to every
statement and should not be threaded through every method of every repository. The
ExecutionScope of the kernel is opened once per request, consumed event or job run at the
boundary (the executionScope option of @ddd-ts-kit/http, the integration event handlers
and the scheduled jobs of @ddd-ts-kit/di), and read by adapters only, through the three
helpers of the kernel:
import { stampCreation, stampUpdate, tenantScopeOf } from '@ddd-ts-kit/kernel';
@Repository(InvoiceRepository)export class DrizzleInvoiceRepository implements InvoiceRepository { constructor( private readonly context: Database, private readonly scope: ExecutionScope, private readonly clock: Clock, ) {}
async findById(id: string): Promise<Invoice | undefined> { const [row] = await this.context .executor() .select() .from(invoices) .where(and(eq(invoices.id, id), eq(invoices.tenantId, tenantScopeOf(this.scope).tenantId))); return row === undefined ? undefined : toInvoice(row); }
// save: { ...toRow(invoice), ...tenantScopeOf(this.scope), ...stampCreation(this.scope, this.clock) } // on insert, { ...toRow(invoice), ...stampUpdate(this.scope, this.clock) } on update}The whole repository, tested, is in the README of each adapter package (@ddd-ts-kit/drizzle,
knex, prisma, typeorm). kernelModule() binds ExecutionScope to an
AsyncLocalStorageExecutionScope, which follows the asynchronous call chain so concurrent
requests never mix, and ActorContext to a ScopedActorContext over it: the current actor is
the actor of the current execution context, so the two cannot disagree: ActorContext is a
view of the scope, and an adapter written against it keeps working. New adapters, and the
ones the CLI generates, name ExecutionScope. require() throws NoExecutionContextError (or
NoCurrentActorError) when a boundary opened nothing, and tenantScopeOf throws
NoCurrentTenantError for work that names no tenant: wiring mistakes, found by the first test.
This is defence in depth, not authorization: the tenant filter of a repository stops a bug from
leaking rows, the permission is still decided in the application layer.
Fields reserved to some callers
Section titled “Fields reserved to some callers”A field of an answer or of a request body that only some callers may see or set is not a role
test in a serializer: it is marked on its schema with withFieldAccess of @ddd-ts-kit/http,
and decided by the Access of this package, as a use case is: fieldDeciderOf({ access, clock })
takes an Access as it is, a permission is asked with decideOn (its policies, then the
authorizer), and a policy written with definePolicy can be named on the field directly.
Access.decideOn({ actor, permission, subject, context, recorded }) is decide with what a
use case adds through @Requires (context), and recorded: false for a decision that only
shapes an answer, a field shown or left out, which then does not reach onDecision; a field set
in a body and a form opened stay recorded. decideOn is optional on an Access of the project:
the decider then asks decide, which records everything. The how-to is in the README of
@ddd-ts-kit/http ("Fields reserved to some callers").
Filtering lists
Section titled “Filtering lists”A list is filtered by the query, never in memory after loading. A page filtered after it was read shows fewer rows than asked, and its total counts rows the actor may not see:
The handler asks for a scope and hands it to the read port:
async execute(query: SearchInvoices): Promise<readonly InvoiceSummary[]> { const scope = await this.access.scope(query.actor, billingPermissions['invoice.read'], 'invoice'); return this.invoices.search(scope, query.page);}function whereOf(scope: AccessScope): SQL | undefined { switch (scope.kind) { case 'all': return undefined; case 'none': return sql`false`; case 'ids': return inArray(invoices.id, [...scope.ids]); case 'filter': return and(...Object.entries(scope.filter).map(([column, value]) => eq(columnOf(column), value))); }}AccessScope |
Given by | The read port |
|---|---|---|
{ kind: 'all' } |
RBAC when the role grants the permission | adds nothing |
{ kind: 'none' } |
any model, and always for the anonymous actor | returns an empty page without querying |
{ kind: 'filter', filter } |
RBAC with the tenant check: { tenantId } |
adds one equality per entry, on columns it chooses to map |
{ kind: 'ids', ids } |
ReBAC | adds id IN (...), or joins a temporary table when the list is long |
Access.scope throws ListingNotSupportedError when the authorizer has no listAccessible
(attribute policies): better a loud failure than a list silently filtered by a guess.
Testing
Section titled “Testing”import { anActor, AllowAll, RecordingAuthorizer, authorizerContract } from '@ddd-ts-kit/auth/testing';
const accountant = anActor().named('ada').ofTenant('acme').withRole('accountant').build();- Handlers.
createTestApplication({ modules: [billingModule] })allows every use case: a handler spec is not about rights.actorsets who infrastructure sees throughActorContext(default: the usertest-user);app.actorContext.actAs(actor)changes it. The actor of a use case is the one the test puts in the command. - Rights of a context. List
authorizationModule({ authorizer })with the real model, or with aRecordingAuthorizerto assert what was asked as data (authorizer.checks); replace the authorizer of a composed application withfake(Authorizer, new AllowAll()). Addauthorization: 'required'so that a use case without declaration fails the spec at wiring. - Policies. Plain objects: call
policy.allows(actor, subject, { now }), as below. - Boundaries.
FakeAuthenticator({ 'token-of-ada': ada })behindprincipalResolver; a credential may stand for a whole session,{ 'token-of-support': aSession().of(ada).impersonatedBy('support-7').build() }. - Your own adapters. Hold an
Authorizer(an OpenFGA adapter, a custom model) toauthorizerContract(runner, { name, create })and a tuple store torelationTupleStoreContract(runner, { name, create }), with{ describe, it, expect, beforeEach }of your runner.
A policy spec needs no application, no container and no fake: the policy, two actors, a subject and an instant.
import { definePermissions, definePolicy } from '@ddd-ts-kit/auth';import { anActor } from '@ddd-ts-kit/auth/testing';import { describe, expect, it } from 'vitest';
const billingPermissions = definePermissions('billing', { 'invoice.void': { description: 'Void an issued invoice' },});
interface IssuedInvoice { readonly issuedBy: string;}
const onlyIssuerVoids = definePolicy<IssuedInvoice>({ name: 'invoice.only-issuer-voids', permission: billingPermissions['invoice.void'], allows: (actor, invoice) => actor.id === invoice.issuedBy,});
describe('only the issuer voids an invoice', () => { const now = new Date('2026-01-01T09:00:00.000Z'); const issuedByAda: IssuedInvoice = { issuedBy: 'ada' };
it('lets the issuer void it', () => { expect(onlyIssuerVoids.allows(anActor().named('ada').build(), issuedByAda, { now })).toBe(true); });
it('refuses anybody else, whatever their roles', () => { const admin = anActor().named('bob').withRole('admin').build();
expect(onlyIssuerVoids.allows(admin, issuedByAda, { now })).toBe(false); });});When it answers false through Access, the reason of the ForbiddenError is the policy name,
invoice.only-issuer-voids.
anActor() answers an ActorBuilder, a Builder of @ddd-ts-kit/testing, so it nests in
other builders (anInvoice().issuedBy(anActor().named('ada'))) and is immutable like them. AllowAll and
DenyAll are stubs for specs that are not about rights; they are not held to the authorizer
contract, which they contradict by design.
The shortcuts this design removes
Section titled “The shortcuts this design removes”The most common one first. Instead of a route that decides who may do what:
// contexts/billing/presentation/http/void-invoice.route.ts: what this package removesasync ({ params, context }) => { const user = context.principal as { id: string; roles: string[] }; if (!user.roles.includes('controller')) { return { status: 403 }; // a job or a consumer never passes here } const invoice = await invoices.findById(params.invoiceId); if (invoice.issuedBy !== user.id) { return { status: 403 }; // a rule nobody can find, test or audit } invoice.void(new Date()); // and a handler bypassed await invoices.save(invoice); return { status: 204 };};write each decision where it belongs: the action on the use case, the element in a named policy, and the route only builds the command.
import { definePermissions, definePolicy, requires } from '@ddd-ts-kit/auth';import { AuthorizedCommand, type Actor } from '@ddd-ts-kit/kernel';
declare class Invoice { readonly issuedBy: string;}
export const billingPermissions = definePermissions('billing', { 'invoice.void': { description: 'Void an issued invoice' },});
export class VoidInvoice extends AuthorizedCommand { constructor( actor: Actor, readonly invoiceId: string, ) { super(actor); }}
// The action: checked by the bus for every caller, a route, a job or a consumer alike.export const voidInvoiceRequirement = requires(VoidInvoice, billingPermissions['invoice.void'], { resource: (command) => ({ type: 'invoice', id: command.invoiceId }),});
// The element: a named rule with a spec of its own, asked by the handler once the invoice is loaded.export const onlyIssuerVoids = definePolicy<Invoice>({ name: 'invoice.only-issuer-voids', permission: billingPermissions['invoice.void'], appliesTo: (subject): subject is Invoice => subject instanceof Invoice, allows: (actor, invoice) => actor.id === invoice.issuedBy,});The route of step 6 is then a mapping from the request to VoidInvoice, and the
roles live in the composition root. The table lists the other shortcuts and the rule that
catches each one.
| Shortcut | Why it is not needed any more | Caught by |
|---|---|---|
a role test in a controller or a route (if (!user.roles.includes('admin'))) |
the route only builds the command; the bus middleware decides | authz/in-application |
if (user.isAdmin) or an ownership test in an aggregate or a domain service |
rights are permissions and policies in application/authorization/; the domain holds invariants |
authz/in-application, layer/domain-purity |
| a use case reachable because nobody thought about it | .requires(...) or .public(), or the application is not created (authorization: 'required') and the message is refused (UndeclaredUseCaseError) |
authz/declared |
| a handler reading "the current user" from a global or a request scope | the actor is the first argument of AuthorizedCommand and AuthorizedQuery; requires does not compile without it |
authz/actor-explicit |
| a repository or an adapter deciding who may see a row by role | infrastructure only applies the scope it is handed and the tenant of the ExecutionScope |
authz/in-application |
invoices.filter((invoice) => canRead(user, invoice)) after loading |
Access.scope gives an AccessScope the read port turns into a WHERE clause |
authz/in-application |
| the raw token passed down to a handler or stored on the user object | Authenticator answers an Actor, plain data without the credential |
authz/actor-explicit |
if chains over ownership and status inside a handler |
a named policy with a spec of its own, asked through Access.authorize |
authz/in-application |
new Date() in a rule about time |
policies and conditions receive now from the Clock port |
purity/no-system-clock |
a string of the model ('admin', 'editor') scattered in the code |
roles and relations appear only in the composition root and in adapters; the code names permissions | authz/in-application |
| a refusal answered with a message quoting the element | Decision.reason is a stable machine code, checked by the contract suite |
the contract suite |
| a one-off bypass "for the demo" | a deviation is recorded as a decision or it is a defect | governance/recorded-deviation |