# @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. nested `include`/`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.

```bash
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=enforce` with `AUTHZ_INTERNAL_AUTH_MODE=off` means no context → every query throws `UNSCOPED_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:

```ts
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

```ts
// 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** throws `UNMANAGED_MODEL` — fail-closed. Genuinely-unmanaged tables (e.g. OIDC adapter rows) go in `allowUnmanaged` (below).

## 2. Enforce on every query (Prisma extension)

```ts
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:

```ts
import { authzAls } from '@generazioneai/authz';
await authzAls.run(ctx, async () => client.post.findMany());   // scoped
```

System work bypasses scoping explicitly:

```ts
import { runUnscoped } from '@generazioneai/authz/enforce';
await runUnscoped('nightly-rollup', async () => client.post.count());
```

> **Gotcha:** the `authzAls.run` / `runUnscoped` callback must be `async` (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)

```ts
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):

```ts
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 role
```

## 4. Verify + hydrate downstream (NestJS + NATS)

```ts
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:

```ts
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
