-
Notifications
You must be signed in to change notification settings - Fork 0
FR Recipe NestJS Native Prisma
Recette 10 — Application NestJS from-scratch : auth native (argon2) + persistence Prisma + autorisation
Chemin par défaut / recommandé. On part d'une app NestJS vide et on branche le socle Kengela pour protéger une route de bout en bout : login (hash argon2 timing-safe) → session → décision Zero Trust (RBAC + policies) sur chaque requête. Tous les symboles ci-dessous sont réels et vérifiés dans les sources (
@kengela/contracts,@kengela/adapter-authn-native,@kengela/adapter-persistence-prisma,@kengela/authz-core,@kengela/adapter-expr-cel,@kengela/nestjs).
Kengela est un socle de ports (interfaces pures, @kengela/contracts) que des adapters
implémentent et que l'app compose. Ce scénario branche les ports suivants :
Port (@kengela/contracts) |
Adapter branché | Paquet | Qui l'écrit |
|---|---|---|---|
PasswordHasher |
Argon2PasswordHasher |
@kengela/adapter-authn-native |
fourni Kengela |
CredentialAuthenticator |
NativeCredentialAuthenticator |
@kengela/adapter-authn-native |
fourni Kengela |
CredentialStore |
PrismaCredentialStore (défaut) ou le tien
|
@kengela/adapter-persistence-prisma |
fourni / toi |
SessionStore |
PrismaSessionStore |
@kengela/adapter-persistence-prisma |
fourni Kengela |
AuthorizationRepository |
PrismaAuthorizationRepository |
@kengela/adapter-persistence-prisma |
fourni Kengela |
PolicyStore |
PrismaPolicyStore |
@kengela/adapter-persistence-prisma |
fourni Kengela |
ExpressionEnginePort (CEL) |
CelExpressionEngine |
@kengela/adapter-expr-cel |
fourni Kengela |
RelationResolver |
PrincipalRelationResolver (défaut) ou le tien
|
@kengela/authz-core |
fourni / toi |
PolicyDecisionPoint (PDP) |
RbacDecisionPoint ou LayeredDecisionPoint
|
@kengela/authz-core |
fourni Kengela |
| Guard + décorateurs NestJS |
KengelaAuthzGuard, @RequirePermission, … |
@kengela/nestjs |
fourni Kengela |
Deux ports pour lesquels un adapter par défaut GÉNÉRIQUE est désormais livré — utilisé tel quel dans cette recette, à remplacer par le tien seulement si ta forme diffère :
CredentialStore—PrismaCredentialStore(@kengela/adapter-persistence-prisma) résout un credential sur le modèle génériqueAccount(providerId='credential')+User(mêmes conventions queTranslogCredentialStore). Constructeur :new PrismaCredentialStore(prisma, { providerId? }). Si TON schéma diffère, écris le tien ;@kengela/connector-translogen donne un exemple réel.RelationResolver—PrincipalRelationResolver(@kengela/authz-core) calcule la relation org à partir des champs déjà portés par lePrincipal(orgUnitId/agencyId/coverageUnits) confrontés à laResourceRef(attributes.ownerId/unitId…), deny-by-default. Constructeur pur, sans I/O :new PrincipalRelationResolver({ ownerAttributeKeys?, unitAttributeKeys? }). Un organigramme calculé HORS jeton (unités traversées en base) reste du ressort d'un resolver app.
Choix du PDP :
-
RbacDecisionPoint— RBAC pur (grants × relation org). Le plus simple pour démarrer. -
LayeredDecisionPoint— RBAC (plancher) + policies déclaratives ABAC (conditions CEL) + step-up. Requiert en plus unPolicyStoreet unExpressionEnginePort. C'est celui qui débloque ledenyconditionnel et lestep_up. On l'utilise dans cette recette.
Fournis par Kengela (registre @kengela/*) :
pnpm add @kengela/contracts \
@kengela/adapter-authn-native \
@kengela/adapter-persistence-prisma \
@kengela/authz-core \
@kengela/adapter-expr-cel \
@kengela/nestjsÀ installer (dépendances tierces réelles) :
# @node-rs/argon2 est la dépendance native de l'adapter authn (hash/verify argon2id)
pnpm add @node-rs/argon2
# NestJS + Prisma + reflect-metadata (l'index @kengela/nestjs importe déjà 'reflect-metadata')
pnpm add @nestjs/common @nestjs/core reflect-metadata @prisma/client
pnpm add -D prismaEn npm : remplace
pnpm addparnpm i. La lib est ESM/TypeScript ; garde"type": "module"et unmoduleResolutionmoderne (NodeNext/Bundler).
Les modèles ci-dessous sont déduits des delegates réels de PrismaLike et
CredentialPrismaLike (adapter-persistence-prisma/src/prisma-like.ts). Le vrai PrismaClient
généré est structurellement compatible : ses lignes réelles sont des sur-ensembles des lignes
NARROW (GrantRow, RoleRow, SessionRow, PolicyRow, PolicyRuleRow, AccountRow,
CredentialUserRow) → il « passe » là où PrismaLike / CredentialPrismaLike est attendu, sans
import de @prisma/client dans l'adapter.
// prisma/schema.prisma
generator client { provider = "prisma-client-js" }
datasource db { provider = "postgresql"; url = env("DATABASE_URL") }
/// Lu par PrismaAuthorizationRepository.loadGrantsForUser (grant.findMany where { userId, tenantId })
/// ET exposé comme relation Role.grants (role.findFirst include { grants: true }).
/// -> Grant porte À LA FOIS `userId?` ET `roleId?` : les deux colonnes sont IMPOSÉES par les
/// signatures des delegates (cf. §7). Chaque ligne est rattachée à l'un OU l'autre :
/// un grant direct d'utilisateur (userId) ou un grant de rôle (roleId).
model Grant {
id String @id @default(cuid())
userId String?
tenantId String
roleId String?
role Role? @relation(fields: [roleId], references: [id])
permission String // "plane.resource.action" (PermissionString)
scope String // 'own'|'unit'|'subtree'|'tenant'|'global' (narrowing fail-closed en mapping)
source String // 'MANUAL'|'IDP'|'DELEGATION'
expiresAt DateTime?
@@index([userId, tenantId])
}
/// Lu par PrismaAuthorizationRepository.loadRole (role.findFirst where { key, tenantId } include grants)
model Role {
id String @id @default(cuid())
key String
tenantId String
grants Grant[]
@@unique([key, tenantId])
}
/// Lu/écrit par PrismaSessionStore. `ctx` = colonne JSON opaque (AuthContext sérialisé).
model Session {
token String @id @unique
userId String
tenantId String
createdAt DateTime
expiresAt DateTime
ctx Json // SessionRow.ctx: unknown -> Json côté base
@@index([userId])
}
/// Lu par PrismaPolicyStore (policy.findMany where { tenantId } include { rules: true })
model Policy {
id String @id @default(cuid())
resource String // "*" ou type de ressource
action String // "*" ou action
tenantId String
rules PolicyRule[]
}
model PolicyRule {
id String @id @default(cuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id])
effect String // 'allow'|'deny'|'step_up'
scope String? // Scope | null
when String? // condition CEL | null
obligations Json? // Obligation[] sérialisées (narrowing fail-closed en mapping)
reason String?
}
/// Identité par mot de passe, lue par PrismaCredentialStore (account.findFirst/findMany).
/// providerId='credential', accountId=email, password=hash argon2id (null autorisé).
model Account {
id String @id @default(cuid())
userId String
tenantId String
providerId String // 'credential'
accountId String // email
password String? // hash argon2id ; null si l'identité n'a pas (encore) de mot de passe
@@unique([tenantId, providerId, accountId])
@@index([providerId, accountId])
}
/// État du compte, lu par PrismaCredentialStore (user.findFirst/findMany) et joint au compte.
model User {
id String @id @default(cuid())
tenantId String
isActive Boolean @default(true)
deletedAt DateTime?
mfaEnabled Boolean @default(false)
roles String[] @default([]) // colonne liste (CredentialUserRow.roles)
}Colonnes d'union en
String. Côté base,scope/source/effectrestent desstring; le narrowing vers les unions littérales des contrats est fait fail-closed dans lemapping.tsde l'adapter (toute valeur inconnue fait tomber le grant/la règle, jamais unallowfantôme).Ton schéma diffère ? Si ton identité par mot de passe ne suit pas la convention
Account/Userci-dessus (ex. un seul modèleUseravecpasswordHash), garde les autres modèles et écris ton propreCredentialStore— voir la varianteAppCredentialStoreen §7.
Un seul module câble tout via useFactory. Points réels à respecter :
-
NativeCredentialAuthenticators'instancie via sa fabrique statique asynchroneNativeCredentialAuthenticator.create(store, hasher)(elle pré-calcule le hash leurre anti-énumération). Le constructeur direct existe aussi :new NativeCredentialAuthenticator(store, hasher, dummyHash). - Les stores Prisma prennent le client (
PrismaLike/CredentialPrismaLike) en 1er argument de constructeur. - Le PDP
LayeredDecisionPointprend un objet de deps{ grants, relations, policies, expr, log?, clock? }. - Le jeton d'injection du PDP côté NestJS est le symbole
KENGELA_PDP(@kengela/nestjs,tokens.ts).
// src/kengela/kengela.tokens.ts
export const CREDENTIAL_AUTHENTICATOR = Symbol('CREDENTIAL_AUTHENTICATOR');
export const SESSION_STORE = Symbol('SESSION_STORE');
export const PASSWORD_HASHER = Symbol('PASSWORD_HASHER');
export const CREDENTIAL_STORE = Symbol('CREDENTIAL_STORE');// src/kengela/kengela.module.ts
import { Module } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import type {
CredentialAuthenticator,
CredentialStore,
PasswordHasher,
SessionStore,
} from '@kengela/contracts';
import { Argon2PasswordHasher, NativeCredentialAuthenticator } from '@kengela/adapter-authn-native';
import {
PrismaSessionStore,
PrismaAuthorizationRepository,
PrismaPolicyStore,
PrismaCredentialStore,
} from '@kengela/adapter-persistence-prisma';
import type { PrismaLike, CredentialPrismaLike } from '@kengela/adapter-persistence-prisma';
import { LayeredDecisionPoint, PrincipalRelationResolver } from '@kengela/authz-core';
import { CelExpressionEngine } from '@kengela/adapter-expr-cel';
import { KENGELA_PDP } from '@kengela/nestjs';
import {
CREDENTIAL_AUTHENTICATOR,
SESSION_STORE,
PASSWORD_HASHER,
CREDENTIAL_STORE,
} from './kengela.tokens.js';
// -- 4.a Le vrai PrismaClient (sur-ensemble structurel de PrismaLike) --------
const prisma = new PrismaClient();
@Module({
providers: [
{ provide: PASSWORD_HASHER, useClass: Argon2PasswordHasher },
// 4.b CredentialStore — adapter par défaut Account(providerId='credential') + User.
// providerId par défaut = 'credential' ; surcharge via { providerId } si besoin.
{
provide: CREDENTIAL_STORE,
useFactory: (): CredentialStore =>
new PrismaCredentialStore(prisma as unknown as CredentialPrismaLike),
},
// 4.c Fabrique statique ASYNC (pré-calcule le hash leurre timing-safe).
{
provide: CREDENTIAL_AUTHENTICATOR,
inject: [CREDENTIAL_STORE, PASSWORD_HASHER],
useFactory: (
store: CredentialStore,
hasher: PasswordHasher,
): Promise<CredentialAuthenticator> => NativeCredentialAuthenticator.create(store, hasher),
},
{
provide: SESSION_STORE,
useFactory: (): SessionStore => new PrismaSessionStore(prisma as unknown as PrismaLike),
},
// 4.d PDP en couches : RBAC + policies (CEL) + step-up.
// RelationResolver par défaut, pur : relation déduite du Principal, deny-by-default.
{
provide: KENGELA_PDP,
useFactory: () =>
new LayeredDecisionPoint({
grants: new PrismaAuthorizationRepository(prisma as unknown as PrismaLike),
relations: new PrincipalRelationResolver(),
policies: new PrismaPolicyStore(prisma as unknown as PrismaLike),
expr: new CelExpressionEngine(),
// log, clock : optionnels (DecisionLogSink, Clock)
}),
},
],
exports: [
CREDENTIAL_AUTHENTICATOR,
SESSION_STORE,
PASSWORD_HASHER,
CREDENTIAL_STORE,
KENGELA_PDP,
],
})
export class KengelaModule {}
prisma as unknown as PrismaLike/as unknown as CredentialPrismaLike: compatibilité structurelle VOULUE. LePrismaClientgénéré satisfait ces surfaces NARROW (mêmes signatures de delegates +$transactionoptionnel) ; le double cast est là UNIQUEMENT parce que le client généré est nominalement distinct et beaucoup plus large que la surface attendue — pas pour masquer une incompatibilité. Aucune surprise à l'exécution tant que le schéma respecte les colonnes NARROW (§3). C'est le contrat documenté en tête deprisma-like.ts.RBAC pur ? Remplace le provider
KENGELA_PDPparnew RbacDecisionPoint({ grants, relations })(pas depolicies/expr).Attributs de ressource non standards ?
PrincipalRelationResolverlit par défautattributes.ownerId(owner) etattributes.unitId/orgUnitId/agencyId(unité). Pour d'autres noms :new PrincipalRelationResolver({ ownerAttributeKeys: ['createdBy'], unitAttributeKeys: ['stationId'] }).
On enregistre KengelaAuthzGuard en APP_GUARD : deny-by-default — toute route sans
@RequirePermission ni @PublicRoute est refusée (ForbiddenException('route_not_annotated')).
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { KengelaAuthzGuard } from '@kengela/nestjs';
import { KengelaModule } from './kengela/kengela.module.js';
import { AuthController } from './auth.controller.js';
import { InvoicesController } from './invoices.controller.js';
@Module({
imports: [KengelaModule],
controllers: [AuthController, InvoicesController],
providers: [{ provide: APP_GUARD, useClass: KengelaAuthzGuard }],
})
export class AppModule {}Décorateurs réels (@kengela/nestjs, decorators.ts + current-principal.decorator.ts) :
// src/invoices.controller.ts
import { Controller, Get } from '@nestjs/common';
import { RequirePermission, PublicRoute, CurrentPrincipal } from '@kengela/nestjs';
import type { Principal } from '@kengela/contracts';
@Controller('invoices')
export class InvoicesController {
// permission évaluée = `resourceType.action` = "data.invoice.read"
@Get()
@RequirePermission('data.invoice', 'read')
list(@CurrentPrincipal() principal: Principal) {
return { tenant: principal.tenantId, user: principal.userId };
}
}// route ouverte : le guard laisse passer sans décision
@PublicRoute()
@Get('health')
health() { return { ok: true }; }Précédence fail-closed (lue dans
authz.guard.ts) : l'annotation du handler prime TOUJOURS sur celle de la classe. Un@PublicRoute()de classe ne peut jamais neutraliser un@RequirePermissionde handler.Le guard n'évalue que le niveau TYPE de ressource (
{ type, tenantId }, tenant tiré duPrincipal). Les conditions ABAC sur les attributs d'une ressource précise (ex. « même agence »,resource.attributes.ownerId) se vérifient au niveau service en appelant directementpdp.check(request)avec la ressource chargée.
Le guard lit le Principal sur req.user. Un middleware/guard d'authn amont doit le poser :
// src/session.middleware.ts (extrait) — résout la session en Principal et pose req.user
const handle = await sessionStore.get(token); // SessionHandle | null
if (handle !== null) {
const record = await credentialStore.findByEmail(/* ... */);
req.user = /* Principal reconstruit depuis handle + record.roles */;
}// src/auth.controller.ts
import { Controller, Post, Body, Inject, UnauthorizedException } from '@nestjs/common';
import { PublicRoute } from '@kengela/nestjs';
import type { CredentialAuthenticator, SessionStore, AuthContext } from '@kengela/contracts';
import { CREDENTIAL_AUTHENTICATOR, SESSION_STORE } from './kengela/kengela.tokens.js';
@Controller('auth')
export class AuthController {
constructor(
@Inject(CREDENTIAL_AUTHENTICATOR) private readonly authn: CredentialAuthenticator,
@Inject(SESSION_STORE) private readonly sessions: SessionStore,
) {}
@Post('login')
@PublicRoute()
async login(@Body() body: { email: string; password: string; tenantId: string }) {
const ctx: AuthContext = { authTime: Date.now() }; // authTime requis
const outcome = await this.authn.authenticate({
email: body.email,
password: body.password,
tenantId: body.tenantId,
ctx,
});
switch (outcome.kind) {
case 'authenticated': {
// Création de session (token opaque 32o hex, TTL en ms).
const handle = await this.sessions.create({
userId: outcome.principal.userId,
tenantId: outcome.principal.tenantId,
ctx,
ttlMs: 1000 * 60 * 60 * 8, // 8 h
});
return { token: handle.token, expiresAt: handle.expiresAt };
}
case 'mfa_required':
return { next: 'mfa', userId: outcome.userId };
case 'tenant_choice':
return { next: 'choose_tenant', candidates: outcome.candidates };
case 'captcha_required':
return { next: 'captcha' };
case 'invalid_credentials':
default:
throw new UnauthorizedException('invalid_credentials');
}
}
}Sous le capot, NativeCredentialAuthenticator.authenticate effectue toujours un
hasher.verify (contre le hash leurre si l'email est inconnu) → temps de réponse constant,
anti-énumération de comptes. Le hash argon2id vient de Argon2PasswordHasher
(OWASP : m=19456 KiB, t=2, p=1) ; needsRehash() permet la migration transparente d'un
ancien hash au prochain login réussi.
GET /invoices avec Authorization valide → le middleware pose req.user (Principal) →
KengelaAuthzGuard.canActivate construit :
const request: AccessRequest = {
principal, // req.user
action: 'read',
resource: { type: 'data.invoice', tenantId: principal.tenantId },
};LayeredDecisionPoint.check : recharge les grants (anti-staleness : un droit révoqué cesse
d'agir immédiatement, on ne fait pas confiance au Principal.roles caché), résout la relation
org, applique le plancher RBAC puis les policies. Si un grant actif couvre
data.invoice.read à la bonne portée et qu'aucune règle ne s'y oppose →
{ effect: 'allow', reason: 'rbac_grant' } → 200.
-
Aucun grant couvrant la permission →
{ effect: 'deny', reason: 'no_grant' }→ForbiddenException('no_grant')→ 403. -
Cross-tenant :
principal.tenantId !== resource.tenantId→ la relation est forcée à'none'(défense en profondeur) → aucun grant tenant ne couvre →deny. -
Policy
denyexplicite matchée (deny-wins) →{ effect: 'deny', reason: <policy.reason> }. -
Condition CEL inévaluable (variable absente / expression invalide) → fail-closed
{ effect: 'deny', reason: 'condition_error' }.
Une policy sur (data.invoice, read) avec une règle effect: 'step_up' portant l'obligation
require_passkey (ex. quand env.riskScore est élevé) fait renvoyer au PDP
{ effect: 'step_up', reason: 'step_up_required', obligations: [{ type: 'require_passkey' }] }.
Le guard lève alors :
throw new StepUpRequiredException(decision.obligations ?? [], decision.reason);
// -> HTTP 403 { statusCode: 403, error: 'step_up_required', reason, obligations }Le client lit error: 'step_up_required' + obligations, déclenche le facteur d'authn exigé
(passkey / re-auth / MFA), rejoue la requête avec un Principal dont le ctx/mfaLevel
satisfait désormais la condition → allow. C'est le lien intime authz → authn : l'accès
est conditionnel à une force d'authentification.
Policy d'exemple (une ligne Policy + une ligne PolicyRule step-up en base) :
has(env.riskScore) && …est la forme tolérante :riskScoreest optionnel, et accéder à un champ absent en CEL lève (donc denycondition_error). Le gardehas()court-circuite l'absence sans erreur — voir recette 14 §5.
Code déjà fourni par Kengela (import direct, zéro réécriture) :
-
Argon2PasswordHasher,NativeCredentialAuthenticator(+ fabrique.create) —@kengela/adapter-authn-native -
PrismaSessionStore,PrismaAuthorizationRepository,PrismaPolicyStore,PrismaCredentialStore(+PrismaMfaSecretStore,PrismaMfaChallengeStoresi MFA) —@kengela/adapter-persistence-prisma -
RbacDecisionPoint,LayeredDecisionPoint,PrincipalRelationResolver(+activeGrants,grantCovers,tenantScopedRelation) —@kengela/authz-core -
CelExpressionEngine—@kengela/adapter-expr-cel -
KengelaAuthzGuard,RequirePermission,PublicRoute,CurrentPrincipal,StepUpRequiredException,KENGELA_PDP—@kengela/nestjs
Ce que tu écris toi-même :
-
Le schéma Prisma (§3) + la génération du
PrismaClient. -
CredentialStore— SEULEMENT si ton schéma diffère du défaut :PrismaCredentialStorecouvre le modèle génériqueAccount(providerId='credential')+User. Sinon calqueTranslogCredentialStore(@kengela/connector-translog), jointure fail-closed. Variante minimale pour un modèleUsermono-table (email/passwordHash) :import type { CredentialStore } from '@kengela/contracts'; import { PrismaClient } from '@prisma/client'; class AppCredentialStore implements CredentialStore { constructor(private readonly db: PrismaClient) {} async findByEmail(email: string, tenantId: string) { const u = await this.db.user.findUnique({ where: { email_tenantId: { email, tenantId } } }); if (u === null) return null; return { userId: u.id, tenantId: u.tenantId, passwordHash: u.passwordHash, isActive: u.isActive, mfaEnabled: u.mfaEnabled, roles: u.roleId !== null ? [u.roleId] : [], }; } async findByEmailAcrossTenants(email: string) { const rows = await this.db.user.findMany({ where: { email } }); return rows.map((u) => ({ userId: u.id, tenantId: u.tenantId, passwordHash: u.passwordHash, isActive: u.isActive, mfaEnabled: u.mfaEnabled, roles: u.roleId !== null ? [u.roleId] : [], })); } }
-
RelationResolver—PrincipalRelationResolver(@kengela/authz-core) suffit tant que la relation se déduit duPrincipal; écris le tien pour brancher un organigramme calculé en base (self/unit/subtree/tenant/none). Variante minimale :import type { RelationResolver, Principal, ResourceRef, OrgRelation } from '@kengela/contracts'; class AppRelationResolver implements RelationResolver { async resolveRelation(principal: Principal, resource: ResourceRef): Promise<OrgRelation> { if (principal.tenantId !== resource.tenantId) return 'none'; if (resource.attributes?.ownerId === principal.userId) return 'self'; return 'tenant'; } }
-
La composition root (§4) + le middleware d'authn qui résout la session en
Principalet le pose surreq.user. -
Optionnel :
DecisionLogSink(journal des décisions),Clock(tests déterministes),ContextProvider(enrichissement geo/device/risque pour le conditional access).
-
Grantà double rattachement — IMPOSÉ, pas optionnel. Le delegategrantsert deux lectures dansprisma-like.ts:GrantDelegate.findMany({ where: { userId, tenantId } })(utilisé parPrismaAuthorizationRepository.loadGrantsForUser) exige la colonneuserId; et la relationRole.grants, chargée parRoleDelegate.findFirst({ where: { key, tenantId }, include: { grants: true } }), exige une clé étrangèreroleIdsurGrant. Le modèle §3 porte doncuserId?etroleId?, chaque ligne rattachée à l'un OU l'autre (grant direct d'utilisateur vs grant de rôle). Les typesGrantRow/RoleRown'exposent quepermission/scope/source/expiresAt(les colonnes de rattachement ne sont lues que par leswhere/include), mais les deux colonnes restent requises par ces signatures. -
Cast
PrismaClient → PrismaLike/CredentialPrismaLike— compatibilité structurelle voulue. Le double castas unknown as …est le mode d'emploi documenté (tête deprisma-like.ts) : le client généré est un sur-ensemble des surfaces NARROW, nominalement distinct, d'où le cast. Pas de risque runtime tant que le schéma §3 est respecté.
Les 5 fichiers ci-dessous forment une app NestJS protégée de bout en bout avec le chemin par
défaut de cette recette (PrismaCredentialStore + PrincipalRelationResolver + LayeredDecisionPoint).
Ajoute le schéma Prisma du §3, prisma generate, puis colle tel quel.
// src/kengela/kengela.tokens.ts
export const CREDENTIAL_AUTHENTICATOR = Symbol('CREDENTIAL_AUTHENTICATOR');
export const SESSION_STORE = Symbol('SESSION_STORE');
export const PASSWORD_HASHER = Symbol('PASSWORD_HASHER');
export const CREDENTIAL_STORE = Symbol('CREDENTIAL_STORE');
// ---------------------------------------------------------------------------
// src/kengela/kengela.module.ts
import { Module } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import type {
CredentialAuthenticator,
CredentialStore,
PasswordHasher,
SessionStore,
} from '@kengela/contracts';
import { Argon2PasswordHasher, NativeCredentialAuthenticator } from '@kengela/adapter-authn-native';
import {
PrismaSessionStore,
PrismaAuthorizationRepository,
PrismaPolicyStore,
PrismaCredentialStore,
} from '@kengela/adapter-persistence-prisma';
import type { PrismaLike, CredentialPrismaLike } from '@kengela/adapter-persistence-prisma';
import { LayeredDecisionPoint, PrincipalRelationResolver } from '@kengela/authz-core';
import { CelExpressionEngine } from '@kengela/adapter-expr-cel';
import { KENGELA_PDP } from '@kengela/nestjs';
import {
CREDENTIAL_AUTHENTICATOR,
SESSION_STORE,
PASSWORD_HASHER,
CREDENTIAL_STORE,
} from './kengela.tokens.js';
const prisma = new PrismaClient();
@Module({
providers: [
{ provide: PASSWORD_HASHER, useClass: Argon2PasswordHasher },
{
provide: CREDENTIAL_STORE,
useFactory: (): CredentialStore =>
new PrismaCredentialStore(prisma as unknown as CredentialPrismaLike),
},
{
provide: CREDENTIAL_AUTHENTICATOR,
inject: [CREDENTIAL_STORE, PASSWORD_HASHER],
useFactory: (
store: CredentialStore,
hasher: PasswordHasher,
): Promise<CredentialAuthenticator> => NativeCredentialAuthenticator.create(store, hasher),
},
{
provide: SESSION_STORE,
useFactory: (): SessionStore => new PrismaSessionStore(prisma as unknown as PrismaLike),
},
{
provide: KENGELA_PDP,
useFactory: () =>
new LayeredDecisionPoint({
grants: new PrismaAuthorizationRepository(prisma as unknown as PrismaLike),
relations: new PrincipalRelationResolver(),
policies: new PrismaPolicyStore(prisma as unknown as PrismaLike),
expr: new CelExpressionEngine(),
}),
},
],
exports: [
CREDENTIAL_AUTHENTICATOR,
SESSION_STORE,
PASSWORD_HASHER,
CREDENTIAL_STORE,
KENGELA_PDP,
],
})
export class KengelaModule {}
// ---------------------------------------------------------------------------
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { KengelaAuthzGuard } from '@kengela/nestjs';
import { KengelaModule } from './kengela/kengela.module.js';
import { AuthController } from './auth.controller.js';
import { InvoicesController } from './invoices.controller.js';
@Module({
imports: [KengelaModule],
controllers: [AuthController, InvoicesController],
providers: [{ provide: APP_GUARD, useClass: KengelaAuthzGuard }],
})
export class AppModule {}
// ---------------------------------------------------------------------------
// src/invoices.controller.ts
import { Controller, Get } from '@nestjs/common';
import { RequirePermission, CurrentPrincipal } from '@kengela/nestjs';
import type { Principal } from '@kengela/contracts';
@Controller('invoices')
export class InvoicesController {
@Get()
@RequirePermission('data.invoice', 'read')
list(@CurrentPrincipal() principal: Principal) {
return { tenant: principal.tenantId, user: principal.userId };
}
}
// ---------------------------------------------------------------------------
// src/auth.controller.ts
import { Controller, Post, Body, Inject, UnauthorizedException } from '@nestjs/common';
import { PublicRoute } from '@kengela/nestjs';
import type { CredentialAuthenticator, SessionStore, AuthContext } from '@kengela/contracts';
import { CREDENTIAL_AUTHENTICATOR, SESSION_STORE } from './kengela/kengela.tokens.js';
@Controller('auth')
export class AuthController {
constructor(
@Inject(CREDENTIAL_AUTHENTICATOR) private readonly authn: CredentialAuthenticator,
@Inject(SESSION_STORE) private readonly sessions: SessionStore,
) {}
@Post('login')
@PublicRoute()
async login(@Body() body: { email: string; password: string; tenantId: string }) {
const ctx: AuthContext = { authTime: Date.now() };
const outcome = await this.authn.authenticate({
email: body.email,
password: body.password,
tenantId: body.tenantId,
ctx,
});
switch (outcome.kind) {
case 'authenticated': {
const handle = await this.sessions.create({
userId: outcome.principal.userId,
tenantId: outcome.principal.tenantId,
ctx,
ttlMs: 1000 * 60 * 60 * 8,
});
return { token: handle.token, expiresAt: handle.expiresAt };
}
case 'mfa_required':
return { next: 'mfa', userId: outcome.userId };
case 'tenant_choice':
return { next: 'choose_tenant', candidates: outcome.candidates };
case 'captcha_required':
return { next: 'captcha' };
case 'invalid_credentials':
default:
throw new UnauthorizedException('invalid_credentials');
}
}
}🇬🇧 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