@generazioneai/authz
v0.3.8
Published
Agnostic runtime authorization for NestJS + Prisma: CASL scoping, signed inter-service JWTs, and snapshot-based permission propagation over NATS.
Readme
@generazioneai/authz
Agnostic, fail-closed runtime authorization for NestJS + Prisma.
A small, composable engine for multi-tenant authorization across a microservice mesh:
- Query-level scoping — a Prisma Client extension ANDs the caller's CASL ability into every
where(incl. nestedinclude/select), so a row a user can't see is impossible to read/update/delete/connect — even from a bug. - Snapshot-based permissions — one service builds a user's permission snapshot (roles → CASL rules) once, caches it in Redis under an opaque id; every other service rehydrates it by id. No N+1 permission lookups.
- Signed inter-service calls — short-lived EdDSA JWTs authenticate every NATS message (replay-protected, body-hash-bound, cmd-bound).
- Default-deny route guards —
@Public()/@Authenticated()/@Authorize(action, subject).
Agnostic: the engine knows nothing about your entities, tables, identity model, or service names. Your domain lives in resource manifests you write in each service; the SDK only provides the mechanism.
npm i @generazioneai/authz @casl/ability @casl/prisma
# per-subpath extras: ioredis (snapshot/nats), jose (dep), @nestjs/* (nest), @prisma/client (peer)Pre-1.0: minor versions (
0.x) may break.
Modes
Two independent env switches, both default off (adding the SDK to a running service changes nothing):
| var | read by | off | shadow | enforce |
|---|---|---|---|---|
| AUTHZ_ENFORCE_MODE | the Prisma extension | passthrough | measure + log | scope queries, fail-closed |
| AUTHZ_INTERNAL_AUTH_MODE | the inter-service interceptor | strip token | verify + log | verify + reject |
Enable them together. The Prisma extension reads the ability from
AsyncLocalStorage, which is populated by the inter-service interceptor.AUTHZ_ENFORCE_MODE=enforcewithAUTHZ_INTERNAL_AUTH_MODE=offmeans no context → every query throwsUNSCOPED_QUERY. (Reference services fail-fast at boot on this combo.)
Concepts
| Piece | What it is |
|---|---|
| Resource manifest | defineResource({ subject, prismaModel, tenancy, scopes, actions, autoquery }) — how a model is scoped. Lives in your service. |
| AuthzContext | Per-request payload in AsyncLocalStorage: { userId, tenantId?, ability, …claims }. Open shape — attach any domain claims your $ctx paths reference. |
| Scope template | A Prisma where with { $ctx: 'path' } placeholders, substituted from the context (e.g. { ownerId: { $ctx: 'userId' } }). |
| Snapshot | Serialized CASL rules + raw grants + claims, cached in Redis under an opaque snapId. |
The context is yours
The SDK reads only userId, tenantId, ability, and a few engine internals. Everything else is your vocabulary, attached as claims and resolved by $ctx paths in your manifests:
import type { AuthzContext } from '@generazioneai/authz';
interface AppContext extends AuthzContext { // purely for your own type-safety
orgId?: string;
teamIds?: string[];
}
// a manifest scope can then reference { $ctx: 'orgId' } or { $ctx: 'teamIds' }1. Declare resources
// post.resource.ts (in your service)
import { defineResource } from '@generazioneai/authz';
export const PostResource = defineResource({
subject: 'Post',
prismaModel: 'post', // must match the Prisma model (camelCase)
service: 'blog-api', // free-form metadata
tenancy: { kind: 'single', field: 'orgId' }, // stamped on create
actions: ['create', 'read', 'update', 'delete'],
scopes: {
TENANT: { orgId: { $ctx: 'orgId' } }, // members of an org
OWN: { authorId: { $ctx: 'userId' } }, // the author
// GLOBAL needs no template (unrestricted within the grant)
},
autoquery: { filterable: {}, sortable: [], includable: [], pagination: { default: 50, max: 200 } },
});
// registry.ts
import { ResourceRegistry } from '@generazioneai/authz';
export const registry = new ResourceRegistry();
[PostResource /*, ... */].forEach((m) => registry.register(m));In
enforce, a query on a model with no manifest throwsUNMANAGED_MODEL— fail-closed. Genuinely-unmanaged tables (e.g. OIDC adapter rows) go inallowUnmanaged(below).
2. Enforce on every query (Prisma extension)
import { Prisma, PrismaClient } from '@prisma/client';
import { createAuthzPrismaExtension } from '@generazioneai/authz/enforce';
let client: any;
client = new PrismaClient().$extends(
createAuthzPrismaExtension(registry, {
mode: process.env.AUTHZ_ENFORCE_MODE, // default 'off'
dmmf: Prisma.dmmf.datamodel.models, // required to scope nested include/select
allowUnmanaged: ['OidcEntity'], // models intentionally unscoped (optional)
getClient: () => client, // scoped client → verify connect/set targets
}),
);What it does in enforce (all fail-closed):
| operation | behaviour |
|---|---|
| read / update / delete | ANDs accessibleBy(ability, action)[subject] into where |
| field-level read | strips scalar fields the caller may not read from the returned row(s) + included relations, based on permittedFieldsOf(ability, 'read', subject). A read rule without fields grants every scalar (no-op); a rule with fields narrows it (needs dmmf). |
| nested include / select (to-many) | ANDs the child's read scope into the nested where (needs dmmf) — throws if it can't be resolved |
| create | stamps the tenant field (caller-supplied value can't override it), recursively for nested creates |
| nested connect / set / connectOrCreate | pre-verifies the target is in scope via a scoped read (needs getClient), else LINK_DENIED |
| raw ops ($queryRaw, aggregateRaw, …) | thrown — can't be scoped; wrap in runUnscoped if intended |
| unmanaged model | thrown unless in allowUnmanaged |
The extension reads the ability from AsyncLocalStorage. Wrap each request:
import { authzAls } from '@generazioneai/authz';
await authzAls.run(ctx, async () => client.post.findMany()); // scopedSystem work bypasses scoping explicitly:
import { runUnscoped } from '@generazioneai/authz/enforce';
await runUnscoped('nightly-rollup', async () => client.post.count());Gotcha: the
authzAls.run/runUnscopedcallback must beasync(or return a promise). A sync callback returning a lazy Prisma promise loses the context before the query runs.
3. Build a snapshot (the service that owns roles)
import { buildRulesFromGrants, computePermHash, computeSnapId, RedisSnapshotStore } from '@generazioneai/authz/snapshot';
const grants = /* read the user's roles → [{ action, subject, scope, fields? }] */;
const ctx = { userId, tenantId, orgId, /* …claims */ };
const rules = buildRulesFromGrants(grants, registry, ctx);
const snapId = computeSnapId(userId, tenantId, Date.now());
await new RedisSnapshotStore(redis).put({
schemaVersion: 1, snapId, userId, tenantId,
permHash: computePermHash(rules), builtAt: Date.now(),
rules, grants,
claims: { orgId /* …whatever your $ctx paths need downstream */ },
});Revocation: evict snapshots when roles/permissions change, so the change is immediate (not bounded by the 60s TTL):
const store = new RedisSnapshotStore(redis);
await store.revokeUser(userId); // on a user's role assignment change
await store.revokeRole('system', roleId); // cascade to all members of a role4. Verify + hydrate downstream (NestJS + NATS)
import { InternalAuthInterceptor } from '@generazioneai/authz/nest';
import { createInternalJwks, RedisReplayCache } from '@generazioneai/authz/nats';
import { RedisSnapshotStore, hydrateAbility, buildRulesFromGrants } from '@generazioneai/authz/snapshot';
app.useGlobalInterceptors(new InternalAuthInterceptor({
jwks: createInternalJwks({ url: process.env.AUTHZ_JWKS_URL }),
replay: new RedisReplayCache(redis),
serviceName: 'blog-api',
reflector: app.get(Reflector),
mode: process.env.AUTHZ_INTERNAL_AUTH_MODE, // default 'off'
snapshotStore: new RedisSnapshotStore(redis),
hydrate: hydrateAbility,
buildRules: buildRulesFromGrants, registry, // re-substitute scopes with THIS service's registry
}));It verifies the internal JWT, fetches the snapshot by its snap claim, replays the claims
onto the context, rebuilds the ability with this service's registry (so it scopes subjects
the builder doesn't own), and runs the handler inside authzAls.
The signing side (the gateway) uses NatsScopedClientProxy + InternalTokenSigner and serves
its public keys at a JWKS endpoint (createInternalJwks fetches it). Keys load via
loadSigningKey / generateSigningKeyPair (@generazioneai/authz/nats).
5. Route-level default-deny (optional)
Three intention-revealing decorators on one axis — how much identity a route demands:
import { GlobalAuthzGuard, Authorize, Authenticated, Public } from '@generazioneai/authz/nest';
@Public() // no auth at all (login, webhooks, health)
@Get('health') health() {}
@Authenticated() // any logged-in user — no permission check
@Get('me') me() {}
@Authorize('read', 'Post') // logged-in AND has the permission (data scoped by the ability)
@Get() findAll() {}| decorator | login | permission |
|---|---|---|
| @Public() | ❌ | ❌ |
| @Authenticated() | ✅ | ❌ |
| @Authorize(action, subject) | ✅ | ✅ |
Register GlobalAuthzGuard as an APP_GUARD with an AbilityResolver (resolves the request's
ability), plus your own authn guard first (it enforces login; have it honour PUBLIC_KEY so
@Public opts out of both). In enforce, a route with none of the three is denied.
Orthogonal concerns (e.g. tenant binding) compose as separate app-provided decorators.
Subpath exports
| import | contents | extra peer deps |
|---|---|---|
| @generazioneai/authz | defineResource, ResourceRegistry, AuthzContext, authzAls | @casl/* |
| @generazioneai/authz/enforce | Prisma extension, runUnscoped, scoped repo | @casl/prisma |
| @generazioneai/authz/snapshot | grants→rules, perm-hash, envelope, Redis store + revocation | @casl/prisma, ioredis |
| @generazioneai/authz/nats | EdDSA sign/verify, JWKS, replay cache, key loader | jose, ioredis |
| @generazioneai/authz/nest | guards, decorators, interceptors, signing proxy | @nestjs/* |
Rollout
Off by default. Flip shadow (measures would-deny, never blocks) on both switches → then
enforce. Provision the gateway signing key + the downstream JWKS URL before enabling
inter-service auth.
License
MIT
