From 649880cb3fbdde4437c328af49c2e00fdb12ff43 Mon Sep 17 00:00:00 2001 From: Alex Dolid Date: Mon, 24 Aug 2026 20:51:41 +0300 Subject: [PATCH 01/10] feat(types): typed authoring via an optional application schema (P0.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the DX gap against CASL, which was the strongest argument against adopting Kerberos.js in a TS-first codebase: `attr` was `Record`, kinds and actions were bare `string`, and condition callbacks were untyped. An application now declares its authorization domain once: type AppSchema = { principal: { roles: 'admin' | 'user'; attr: { department: string } }; resources: { document: { actions: 'view' | 'edit'; attr: { ownerId: string } }; invoice: { actions: 'view' | 'approve'; attr: { amount: number } }; }; }; const kerberos = new Kerberos(policies, derivedRoles); and the resource kind then drives everything else — `action`, `attr`, `roles`, and the `{ P, R, V, C }` envelope handed to conditions, variables and outputs. Policy documents are discriminated unions over `resource:`, so a rule naming another kind's action, an undeclared role, or a condition reading an attribute the kind does not have is a compile error instead of a silent EFFECT_DENY in production. Zero runtime change: this is entirely in the hand-maintained `.d.ts`, and every type parameter defaults to the new `AnySchema`, which reproduces the previous untyped surface verbatim — the pre-existing `types.test-d.ts` passes unmodified apart from the PlanKind line noted below. Also in this commit, all type-level: - `Effect` and `PlanKind` are declared as frozen const objects rather than TypeScript `enum`s. The runtime has always been a frozen plain object (verified), so the `enum` declaration mis-described it and, worse, made `effect: 'EFFECT_ALLOW'` in a plain JSON policy literal a type error — exactly the form stored/serialized policies carry. - `checkResources` gains overloads: the response's effects are typed `Effect`, or `boolean` when `effectAsBoolean` is passed, instead of the `Effect | boolean` union in both cases. - `{ $expr }` descriptors are accepted in `condition.match` and in `output`, which stored policies always used but the types rejected. - `errorName` added to the `checkResources` result meta (present at runtime since the wave-1 work, missing from the response type). - `BaseRule` gains the optional `name` it has always accepted. New: `test/typed-schema.test-d.ts` — 30+ assertions pinning the narrowing behaviour and the backward-compatible defaults, including `expectError` cases for wrong-action/wrong-kind/wrong-role/wrong-attr. Verified the harness is real by temporarily asserting an error on valid code and confirming tsd failed. It also pulls `relations.d.ts` into the same compilation and asserts a `RelationResolver` stays assignable to a schema-typed engine. Docs: new "TypeScript" section in README and /guide/typescript, exports table extended with the type-only surface. Co-Authored-By: Claude Fable 5 --- README.md | 108 +++++++- docs/.vitepress/config.mts | 1 + docs/api/exports.md | 16 +- docs/guide/typescript.md | 139 ++++++++++ index.d.ts | 536 ++++++++++++++++++++++++------------ test/typed-schema.test-d.ts | 240 ++++++++++++++++ test/types.test-d.ts | 6 +- 7 files changed, 866 insertions(+), 180 deletions(-) create mode 100644 docs/guide/typescript.md create mode 100644 test/typed-schema.test-d.ts diff --git a/README.md b/README.md index c4d8bd5..7d31265 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ await kerberos.isAllowed({ - [Scopes and Policy Versions](#scopes-and-policy-versions) - [API Reference](#api-reference) - [`new Kerberos(...)`](#new-kerberospolicies-derivedroles-options) · [`isAllowed`](#kerberosisallowedargs--promiseboolean) · [`checkResources`](#kerberoscheckresourcesargs-effectasboolean--false--promisecheckresourcesresponse) · [`planResources`](#kerberosplanresourcesargs--promiseplanresourcesresponse) · [Errors](#errors) · [Exports](#exports) +- [TypeScript](#typescript) + - [Declaring a schema](#declaring-a-schema) · [What it buys you](#what-it-buys-you) · [Schema helper types](#schema-helper-types) - [Configuration Options](#configuration-options) - [Options](#options) · [Pino logging](#using-pino-for-production-logging) · [Call ID generation](#call-id-generation) - [Outputs](#outputs) @@ -473,7 +475,7 @@ All error classes are exported from the main entry. Evaluation-phase errors foll | Export | Purpose | | ------ | ------- | | `Kerberos` | Main authorization engine. | -| `Effect` | `{ Allow: 'EFFECT_ALLOW', Deny: 'EFFECT_DENY' }`. | +| `Effect` | `{ Allow: 'EFFECT_ALLOW', Deny: 'EFFECT_DENY' }` — a frozen const object, [not an `enum`](#typescript). | | `ResourcePolicy`, `PrincipalPolicy`, `RolePolicy`, `DerivedRoles` | Policy classes (rarely constructed directly). | | `Conditions`, `Variables`, `Constants`, `Outputs` | DSL building blocks. | | `createSafeExprCodec`, `serializePolicy`, `deserializePolicy` | Safe AST codec for [dynamic/stored policies](#caching--storing-policies). | @@ -500,6 +502,110 @@ Subpath **`@alexify/kerberos/tests`** (dev/test only — not loaded by the main | `PrincipalMock`, `PrincipalsMock`, `ResourceMock`, `ResourcesMock` | Named fixtures for test suites. | | `*ZodSchemas`, `*JsonSchemas`, `*TypeBoxSchemas` | Schema builders for the test harness. | +## TypeScript + +Kerberos.js ships hand-maintained types. By default every position is open — `kind` and `action` are `string`, `attr` is `Record` — which is what you want for policies loaded from a store at runtime. + +When your resource kinds are known at compile time, declare them once and the whole surface narrows to them. + +### Declaring a schema + +```typescript +import { Kerberos, Effect, type KerberosPolicy } from '@alexify/kerberos'; + +type AppSchema = { + principal: { + roles: 'admin' | 'user'; + attr: { department: string; clearance: number }; + }; + resources: { + document: { actions: 'view' | 'edit' | 'delete'; attr: { ownerId: string; status: 'draft' | 'published' } }; + invoice: { actions: 'view' | 'approve'; attr: { amount: number } }; + }; +}; + +const kerberos = new Kerberos(policies, derivedRoles); +``` + +Both keys are optional — declare only `resources` if you do not want to enumerate roles. + +### What it buys you + +The resource kind drives everything else. `action`, `attr`, and the condition callbacks all narrow to the kind you named: + +```typescript +await kerberos.isAllowed({ + principal: { id: 'u1', roles: ['admin'], attr: { department: 'eng', clearance: 3 } }, + resource: { kind: 'document', id: 'd1', attr: { ownerId: 'u1', status: 'draft' } }, + action: 'edit', // ✅ autocompleted from `document`'s actions +}); + +await kerberos.isAllowed({ + principal: { id: 'u1', roles: ['admin'] }, + resource: { kind: 'document', id: 'd1' }, + action: 'approve', // ❌ 'approve' belongs to `invoice`, not `document` +}); +``` + +Policy documents are checked the same way — `resource:` discriminates the rules, so a typo in an action or a role is a compile error rather than a silent `EFFECT_DENY` at 3am: + +```typescript +const policy: KerberosPolicy = { + resourcePolicy: { + version: 'default', + resource: 'document', + rules: [ + { actions: ['view', 'edit'], effect: Effect.Allow, roles: ['admin'] }, + { + actions: ['edit'], + effect: Effect.Allow, + roles: ['user'], + // R.attr is { ownerId: string; status: 'draft' | 'published' } + condition: { match: ({ R, P }) => R.attr?.ownerId === P.id && R.attr?.status === 'draft' }, + }, + ], + }, +}; +``` + +`checkResources` keeps each batch entry typed independently, so a mixed batch still catches a wrong action per kind: + +```typescript +const { results } = await kerberos.checkResources({ + principal: { id: 'u1', roles: ['user'] }, + resources: [ + { resource: { kind: 'document', id: 'd1' }, actions: ['view', 'edit'] }, + { resource: { kind: 'invoice', id: 'i1' }, actions: ['approve'] }, + ], +}); +``` + +The second argument now selects the effect representation through overloads: `checkResources(args)` resolves `results[].actions` to `Effect`, and `checkResources(args, true)` to `boolean` — previously both were typed as the `Effect | boolean` union. + +### Schema helper types + +Exported so you can build your own typed wrappers (an Express middleware, a React hook) over the same schema: + +| Type | Resolves to | +| ---- | ----------- | +| `ResourceKindOf` | Union of declared resource kinds. | +| `ActionOf` | Actions for kind `K`; every action across all kinds when `K` is omitted. | +| `ResourceAttrOf` | Attribute bag of kind `K`. | +| `PrincipalRoleOf` / `PrincipalAttrOf` | Declared principal roles / attributes. | +| `RequestPrincipal`, `RequestResource`, `BaseRequest` | Request shapes. | +| `PolicyEvalRequest` | The `{ P, R, V, C }` envelope a condition/variable/output callback receives. | +| `CheckResourcesArgs`, `CheckResourcesResponse`, `PlanResourcesArgs`, `PlanResourcesResponse` | Method arguments and responses. | +| `AnySchema` | The permissive default used when no schema is supplied. | + +> [!NOTE] +> Typing is **compile-time only** — there is no runtime cost and no runtime enforcement. A schema constrains the policies and requests you write in TypeScript; it does not validate policies loaded from a cache at runtime. For that, use [schema validation](#schema-validation). + +`Effect` and `PlanKind` are const objects rather than TypeScript `enum`s, so the raw wire strings that a stored policy or a serialized plan actually carries stay assignable: + +```typescript +const rule = { actions: ['view'], effect: 'EFFECT_ALLOW', roles: ['user'] }; // ✅ no `Effect.Allow` needed +``` + ## Configuration Options The Kerberos constructor accepts an optional third parameter with configuration options: diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 1d9389d..038add7 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -134,6 +134,7 @@ export default withMermaid( text: 'Core features', items: [ { text: 'Configuration', link: '/guide/configuration' }, + { text: 'TypeScript', link: '/guide/typescript' }, { text: 'Outputs', link: '/guide/outputs' }, { text: 'Decision metadata', link: '/guide/decision-metadata' }, { text: 'Schema validation', link: '/guide/schema-validation' }, diff --git a/docs/api/exports.md b/docs/api/exports.md index 95ff751..d467e1e 100644 --- a/docs/api/exports.md +++ b/docs/api/exports.md @@ -7,17 +7,29 @@ The full public surface of the package, by entry point. | Export | Purpose | | ------ | ------- | | `Kerberos` | Main authorization engine. | -| `Effect` | `{ Allow: 'EFFECT_ALLOW', Deny: 'EFFECT_DENY' }`. | +| `Effect` | `{ Allow: 'EFFECT_ALLOW', Deny: 'EFFECT_DENY' }` — a frozen const object, [not an `enum`](/guide/typescript#effect-and-plankind-are-const-objects). | | `ResourcePolicy`, `PrincipalPolicy`, `RolePolicy`, `DerivedRoles` | Policy classes (rarely constructed directly). | | `Conditions`, `Variables`, `Constants`, `Outputs` | DSL building blocks. | | `createSafeExprCodec`, `serializePolicy`, `deserializePolicy` | Safe AST codec for [dynamic/stored policies](/guide/caching). | -| `PlanKind` | `{ AlwaysAllowed, AlwaysDenied, Conditional }` — [query plan](/guide/query-plans) filter kinds. | +| `PlanKind` | `{ AlwaysAllowed, AlwaysDenied, Conditional }` — [query plan](/guide/query-plans) filter kinds (const object, not an `enum`). | | `expandRelationOperands` | Materializes ReBAC `relation` operands of a [query plan](/guide/query-plans) into id filters. | | `KerberosValidationError`, `KerberosCacheError`, `KerberosCodecError`, `KerberosExprError`, `KerberosRelationsError` | Typed [error classes](/api/errors). | | `registerAjvKeywords`, `createAjvAdapter` | [Validation](/guide/schema-validation) helpers. | | `JsonSchemas`, `TypeBoxSchemas`, `ZodSchemas`, `KerberosJsonSchemas`, `ResourcePolicyJsonSchemas`, `PrincipalPolicyJsonSchemas`, `RolePolicyJsonSchemas`, … | Schema builders for the three backends. | | `ALL_ACTIONS`, `ALL_ROLES`, `ALL_RESOURCES`, `DEFAULT_VERSION`, `BASE_SCOPE` | Wildcard/default tokens (`'*'`, `'default'`, `''`). | +Type-only exports for [typed authoring](/guide/typescript) (erased at runtime): + +| Type | Purpose | +| ---- | ------- | +| `KerberosSchema`, `AnySchema`, `KerberosResourceContract` | Shape of an application authorization schema, and the permissive default. | +| `ResourceKindOf`, `ActionOf`, `ResourceAttrOf` | Projections of the declared resource kinds. | +| `PrincipalRoleOf`, `PrincipalAttrOf` | Projections of the declared principal. | +| `RequestPrincipal`, `RequestResource`, `BaseRequest`, `PolicyEvalRequest` | Request shapes, including the `{ P, R, V, C }` callback envelope. | +| `KerberosPolicy`, `ResourcePolicySchema`, `PrincipalPolicySchema`, `RolePolicySchema`, `DerivedRolesSchema` | Policy document shapes. | +| `CheckResourcesArgs`, `CheckResourcesEntry`, `CheckResourcesResult`, `CheckResourcesResponse` | `checkResources` arguments and response. | +| `PlanResourcesArgs`, `PlanResourcesResponse`, `PlanFilter`, `PlanExpressionOperand` | `planResources` arguments and response. | + ## `@alexify/kerberos/relations` Opt-in ReBAC — kept out of the main entry so non-ReBAC bundles do not grow: diff --git a/docs/guide/typescript.md b/docs/guide/typescript.md new file mode 100644 index 0000000..feead70 --- /dev/null +++ b/docs/guide/typescript.md @@ -0,0 +1,139 @@ +# TypeScript + +Kerberos.js ships hand-maintained types. By default every position is open — `kind` and `action` are `string`, `attr` is `Record` — which is what you want for policies loaded from a store at runtime. + +When your resource kinds are known at compile time, declare them once and the whole surface narrows to them. + +## Declaring a schema + +```typescript +import { Kerberos, Effect, type KerberosPolicy } from '@alexify/kerberos'; + +type AppSchema = { + principal: { + roles: 'admin' | 'user'; + attr: { department: string; clearance: number }; + }; + resources: { + document: { actions: 'view' | 'edit' | 'delete'; attr: { ownerId: string; status: 'draft' | 'published' } }; + invoice: { actions: 'view' | 'approve'; attr: { amount: number } }; + }; +}; + +const kerberos = new Kerberos(policies, derivedRoles); +``` + +Both keys are optional — declare only `resources` if you do not want to enumerate roles. + +## What it buys you + +The resource kind drives everything else. `action`, `attr`, and the condition callbacks all narrow to the kind you named: + +```typescript +await kerberos.isAllowed({ + principal: { id: 'u1', roles: ['admin'], attr: { department: 'eng', clearance: 3 } }, + resource: { kind: 'document', id: 'd1', attr: { ownerId: 'u1', status: 'draft' } }, + action: 'edit', // ✅ autocompleted from `document`'s actions +}); + +await kerberos.isAllowed({ + principal: { id: 'u1', roles: ['admin'] }, + resource: { kind: 'document', id: 'd1' }, + action: 'approve', // ❌ 'approve' belongs to `invoice`, not `document` +}); +``` + +Policy documents are checked the same way — `resource:` discriminates the rules, so a typo in an action or a role is a compile error rather than a silent `EFFECT_DENY` at 3am: + +```typescript +const policy: KerberosPolicy = { + resourcePolicy: { + version: 'default', + resource: 'document', + rules: [ + { actions: ['view', 'edit'], effect: Effect.Allow, roles: ['admin'] }, + { + actions: ['edit'], + effect: Effect.Allow, + roles: ['user'], + // R.attr is { ownerId: string; status: 'draft' | 'published' } + condition: { match: ({ R, P }) => R.attr?.ownerId === P.id && R.attr?.status === 'draft' }, + }, + ], + }, +}; +``` + +The same narrowing applies to [principal policies](/guide/policy-types#principalpolicy) (`resource:` narrows each entry's `action`) and [role policies](/guide/policy-types#rolepolicy) (`resource:` narrows `allowActions`, and `role` / `parentRoles` are checked against the declared roles). + +## Batches and plans + +`checkResources` keeps each batch entry typed independently, so a mixed batch still catches a wrong action per kind: + +```typescript +const { results } = await kerberos.checkResources({ + principal: { id: 'u1', roles: ['user'] }, + resources: [ + { resource: { kind: 'document', id: 'd1' }, actions: ['view', 'edit'] }, + { resource: { kind: 'invoice', id: 'i1' }, actions: ['approve'] }, + ], +}); +``` + +The second argument selects the effect representation through overloads: + +```typescript +await kerberos.checkResources(args); // results[].actions is Record +await kerberos.checkResources(args, true); // results[].actions is Record +``` + +[`planResources`](/guide/query-plans) narrows `action` / `actions` against the planned kind in the same way. + +## Schema helper types + +Exported so you can build your own typed wrappers (an Express middleware, a React hook) over the same schema: + +| Type | Resolves to | +| ---- | ----------- | +| `ResourceKindOf` | Union of declared resource kinds. | +| `ActionOf` | Actions for kind `K`; every action across all kinds when `K` is omitted. | +| `ResourceAttrOf` | Attribute bag of kind `K`. | +| `PrincipalRoleOf` / `PrincipalAttrOf` | Declared principal roles / attributes. | +| `RequestPrincipal`, `RequestResource`, `BaseRequest` | Request shapes. | +| `PolicyEvalRequest` | The `{ P, R, V, C }` envelope a condition/variable/output callback receives. | +| `CheckResourcesArgs`, `CheckResourcesResponse` | `checkResources` arguments and response. | +| `PlanResourcesArgs`, `PlanResourcesResponse` | `planResources` arguments and response. | +| `AnySchema` | The permissive default used when no schema is supplied. | + +An example wrapper: + +```typescript +import type { ActionOf, RequestPrincipal, ResourceKindOf } from '@alexify/kerberos'; + +async function assertAllowed>( + principal: RequestPrincipal, + kind: K, + id: string, + action: ActionOf, +): Promise { + if (!(await kerberos.isAllowed({ principal, resource: { kind, id }, action }))) { + throw new Error(`${principal.id} may not ${action} ${kind}:${id}`); + } +} +``` + +::: warning Compile-time only +Typing has **no runtime cost and no runtime enforcement**. A schema constrains the policies and requests you write in TypeScript; it does not validate policies loaded from a cache at runtime. For that, use [schema validation](/guide/schema-validation). +::: + +## `Effect` and `PlanKind` are const objects + +Neither is a TypeScript `enum`, so the raw wire strings that a stored policy or a serialized plan actually carries stay assignable: + +```typescript +const rule = { actions: ['view'], effect: 'EFFECT_ALLOW', roles: ['user'] }; // ✅ no `Effect.Allow` needed + +if (planResponse.filter.kind === 'KIND_ALWAYS_DENIED') return []; +``` + +`Effect.Allow` and `PlanKind.Conditional` keep working exactly as before — they are just typed as the literal strings they hold at runtime. diff --git a/index.d.ts b/index.d.ts index 3891d45..c7b2e3a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -49,37 +49,133 @@ export type ValidationOptions = { typebox?: TypeBoxLike; }; -export type RequestPrincipal = { +/* -------------------------------------------------------------------------- * + * Typed authoring + * + * Every public policy/request type below is generic over an optional + * application schema `S` naming the resource kinds, the actions each kind + * supports, their attribute bags, and the principal's roles/attributes. All + * parameters default to `AnySchema`, which reproduces the untyped + * (`string` / `Record`) surface verbatim — declaring a schema + * is purely opt-in and changes nothing at runtime. + * + * ```ts + * type AppSchema = { + * principal: { roles: 'admin' | 'user'; attr: { department: string } }; + * resources: { + * document: { actions: 'view' | 'edit'; attr: { ownerId: string } }; + * invoice: { actions: 'view' | 'approve'; attr: { amount: number } }; + * }; + * }; + * + * const kerberos = new Kerberos(policies, derivedRoles); + * await kerberos.isAllowed({ + * principal: { id: 'u1', roles: ['admin'], attr: { department: 'eng' } }, + * resource: { kind: 'document', id: 'd1', attr: { ownerId: 'u1' } }, + * action: 'view', // ← checked against `document`'s actions, not `invoice`'s + * }); + * ``` + * -------------------------------------------------------------------------- */ + +/** One resource kind's contract: the actions it supports and its attribute bag. */ +export type KerberosResourceContract = { + actions?: string; + attr?: Record; +}; + +/** An application's authorization domain — the type argument of {@link Kerberos}. */ +export type KerberosSchema = { + principal?: { roles?: string; attr?: Record }; + resources?: Record; +}; + +/** The permissive default: any resource kind, any action, any attribute. */ +export type AnySchema = { + principal: { roles: string; attr: Record }; + resources: Record }>; +}; + +type ResourcesOf = S extends { + resources: infer R extends Record; +} + ? R + : AnySchema['resources']; + +/** Resource kinds declared by the schema (`string` when untyped). */ +export type ResourceKindOf = keyof ResourcesOf & string; + +/** Actions valid for one resource kind (`string` when untyped). */ +export type ActionOf< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> = ResourcesOf[K & keyof ResourcesOf] extends { actions: infer A extends string } ? A : string; + +/** Attribute bag of one resource kind (`Record` when untyped). */ +export type ResourceAttrOf< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> = ResourcesOf[K & keyof ResourcesOf] extends { attr: infer A extends Record } + ? A + : Record; + +/** Roles the schema's principals may carry (`string` when untyped). */ +export type PrincipalRoleOf = S extends { + principal: { roles: infer R extends string }; +} + ? R + : string; + +/** Attribute bag of the schema's principals (`Record` when untyped). */ +export type PrincipalAttrOf = S extends { + principal: { attr: infer A extends Record }; +} + ? A + : Record; + +export type RequestPrincipal = { id: string; - roles: string[]; + roles: PrincipalRoleOf[]; policyVersion?: string; scope?: string; - attr?: Record; + attr?: PrincipalAttrOf; }; -export type RequestResource = { +export type RequestResource< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> = { id: string; - kind: string; + kind: K; policyVersion?: string; scope?: string; - attr?: Record; + attr?: ResourceAttrOf; }; -export type BaseRequest = { - principal: RequestPrincipal; - P: RequestPrincipal; - resource: RequestResource; - R: RequestResource; - actions: string[]; +export type BaseRequest< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> = { + principal: RequestPrincipal; + P: RequestPrincipal; + resource: RequestResource; + R: RequestResource; + actions: ActionOf[]; reqId?: string; callId?: string; includeMeta?: boolean; }; -export enum Effect { - Allow = 'EFFECT_ALLOW', - Deny = 'EFFECT_DENY', -} +/** + * Policy rule effects. Declared as a frozen const object (not a TypeScript + * `enum`) so that plain JSON policy literals — `effect: 'EFFECT_ALLOW'` — are + * assignable to the `Effect` type, which is what stored/serialized policies + * actually contain. `Effect.Allow` keeps working as before. + */ +export declare const Effect: { + readonly Allow: 'EFFECT_ALLOW'; + readonly Deny: 'EFFECT_DENY'; +}; +export type Effect = 'EFFECT_ALLOW' | 'EFFECT_DENY'; export class ZodSchemas { static buildScopeString(z: unknown): unknown; @@ -103,7 +199,10 @@ export class TypeBoxSchemas { } type ConstantsSchema = Record; -type RequestWithConstants = BaseRequest & Partial<{ C: ConstantsSchema; constants: ConstantsSchema }>; +type RequestWithConstants< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> = BaseRequest & Partial<{ C: ConstantsSchema; constants: ConstantsSchema }>; export class Constants { constructor(schema: ConstantsSchema, options?: ValidationOptions); get(): ConstantsSchema; @@ -121,11 +220,20 @@ export class ConstantsTypeBoxSchemas { static buildRequestWithConstants(typebox: TypeBoxLike): unknown; } -type VariablesSchema = Record unknown>; -type RequestWithVariables = BaseRequest & Partial<{ V: Record; variables: Record }>; -export class Variables { - constructor(schema: VariablesSchema, options?: ValidationOptions); - get(req: RequestWithConstants): Record; +type VariablesSchema< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> = Record) => unknown>; +type RequestWithVariables< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> = BaseRequest & Partial<{ V: Record; variables: Record }>; +export class Variables< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> { + constructor(schema: VariablesSchema, options?: ValidationOptions); + get(req: RequestWithConstants): Record; } export class VariablesZodSchemas { static buildShape(z: unknown): unknown; @@ -140,24 +248,40 @@ export class VariablesTypeBoxSchemas { static buildRequestWithVariables(typebox: TypeBoxLike): unknown; } -type ConditionSingleMatchExpression = (req: RequestWithConstants & RequestWithVariables) => boolean; -type ConditionMatch = - | ConditionSingleMatchExpression +/** The `{ P, R, V, C, ... }` envelope handed to condition/variable/output callbacks. */ +export type PolicyEvalRequest< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> = RequestWithConstants & RequestWithVariables; + +type ConditionSingleMatchExpression< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> = (req: PolicyEvalRequest) => boolean; +type ConditionMatch = ResourceKindOf> = + | ConditionSingleMatchExpression + | PolicyExprDescriptor | { - any: NonEmptyArray; + any: NonEmptyArray>; } | { - all: NonEmptyArray; + all: NonEmptyArray>; } | { - none: NonEmptyArray; + none: NonEmptyArray>; }; -export type ConditionsSchema = { - match: ConditionMatch; +export type ConditionsSchema< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> = { + match: ConditionMatch; }; -export class Conditions { - constructor(schema: ConditionsSchema, options?: ValidationOptions); - isFulfilled(req: RequestWithConstants & RequestWithVariables, condition?: ConditionMatch): boolean; +export class Conditions< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> { + constructor(schema: ConditionsSchema, options?: ValidationOptions); + isFulfilled(req: PolicyEvalRequest, condition?: ConditionMatch): boolean; } export class ConditionsZodSchemas { static buildShape(z: unknown): unknown; @@ -172,17 +296,24 @@ export class ConditionsTypeBoxSchemas { static buildFullRequest(typebox: TypeBoxLike): unknown; } -export type OutputsSchema = +export type OutputsSchema< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> = | { when: { - ruleActivated?: (req: RequestWithConstants & RequestWithVariables) => unknown; - conditionNotMet?: (req: RequestWithConstants & RequestWithVariables) => unknown; + ruleActivated?: ((req: PolicyEvalRequest) => unknown) | PolicyExprDescriptor; + conditionNotMet?: ((req: PolicyEvalRequest) => unknown) | PolicyExprDescriptor; }; } - | ((req: RequestWithConstants & RequestWithVariables) => unknown); -export class Outputs { - constructor(schema: OutputsSchema, options?: ValidationOptions); - build(req: RequestWithConstants & RequestWithVariables, isConditionFulfilled: boolean, src: string): { + | ((req: PolicyEvalRequest) => unknown) + | PolicyExprDescriptor; +export class Outputs< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> { + constructor(schema: OutputsSchema, options?: ValidationOptions); + build(req: PolicyEvalRequest, isConditionFulfilled: boolean, src: string): { src: string; val: unknown; }; @@ -214,34 +345,36 @@ export class MetadataTypeBoxSchemas { } /** Classic condition-backed definition: parentRoles and condition required. */ -type ConditionDerivedRolesDefinition = { +type ConditionDerivedRolesDefinition = { name: string; - parentRoles: NonEmptyArray; - condition: ConditionsSchema | Conditions; + parentRoles: NonEmptyArray | '*'>; + condition: ConditionsSchema | Conditions; }; /** * Relation-backed (ReBAC) definition: the role activates when the configured * `relations` resolver grants the named relation/permission on the request's * resource. `parentRoles` and `condition` become optional synchronous gates. */ -type RelationDerivedRolesDefinition = { +type RelationDerivedRolesDefinition = { name: string; relation: string; - parentRoles?: NonEmptyArray; - condition?: ConditionsSchema | Conditions; + parentRoles?: NonEmptyArray | '*'>; + condition?: ConditionsSchema | Conditions; }; -type DerivedRolesDefinition = ConditionDerivedRolesDefinition | RelationDerivedRolesDefinition; -export type DerivedRolesSchema = { +type DerivedRolesDefinition = + | ConditionDerivedRolesDefinition + | RelationDerivedRolesDefinition; +export type DerivedRolesSchema = { name: string; description?: string; - variables?: VariablesSchema | Variables; + variables?: VariablesSchema | Variables; constants?: ConstantsSchema | Constants; - definitions: NonEmptyArray; + definitions: NonEmptyArray>; }; -export class DerivedRoles { - constructor(schema: DerivedRolesSchema, options?: ValidationOptions); - get(req: BaseRequest): Set; - getRelationCandidates(req: BaseRequest): Array<{ name: string; relation: string }>; +export class DerivedRoles { + constructor(schema: DerivedRolesSchema, options?: ValidationOptions); + get(req: BaseRequest): Set; + getRelationCandidates(req: BaseRequest): Array<{ name: string; relation: string }>; } export class DerivedRolesZodSchemas { static buildShape(z: unknown): unknown; @@ -253,34 +386,48 @@ export class DerivedRolesTypeBoxSchemas { static buildShape(typebox: TypeBoxLike): unknown; } -type BaseRule = { - actions: NonEmptyArray; +type BaseRule = ResourceKindOf> = { + name?: string; + actions: NonEmptyArray | '*'>; effect: Effect; - condition?: ConditionsSchema | Conditions; - output?: OutputsSchema | Outputs; + condition?: ConditionsSchema | Conditions; + output?: OutputsSchema | Outputs; }; -type RuleWithRoles = BaseRule & { - roles: NonEmptyArray | readonly ['*']; -}; -type RuleWithDerivedRoles = BaseRule & { +type RuleWithRoles = ResourceKindOf> = + BaseRule & { + roles: NonEmptyArray | '*'>; + }; +type RuleWithDerivedRoles< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> = BaseRule & { derivedRoles: NonEmptyArray; }; -type Rule = RuleWithRoles | RuleWithDerivedRoles; -export type ResourcePolicySchema = { - version: string; - resource: string; - scope?: string; - rules: NonEmptyArray; - variables?: VariablesSchema | Variables; - constants?: ConstantsSchema | Constants; - importDerivedRoles?: NonEmptyArray | readonly string[]; -}; -export type ResourcePolicyRootSchema = { - resourcePolicy: ResourcePolicySchema; +type Rule = ResourceKindOf> = + | RuleWithRoles + | RuleWithDerivedRoles; +/** + * One resource policy. When the schema declares resource kinds this is a + * discriminated union over `resource:` — writing `resource: 'document'` + * narrows every rule's `actions` and every condition's `R.attr` to that kind. + */ +export type ResourcePolicySchema = { + [K in ResourceKindOf]: { + version: string; + resource: K; + scope?: string; + rules: NonEmptyArray>; + variables?: VariablesSchema | Variables; + constants?: ConstantsSchema | Constants; + importDerivedRoles?: NonEmptyArray | readonly string[]; + }; +}[ResourceKindOf]; +export type ResourcePolicyRootSchema = { + resourcePolicy: ResourcePolicySchema; }; -export class ResourcePolicy { - constructor(schema: ResourcePolicyRootSchema, options?: ValidationOptions); - check(req: BaseRequest, derivedRoles: Set, effectAsBoolean?: boolean): { +export class ResourcePolicy { + constructor(schema: ResourcePolicyRootSchema, options?: ValidationOptions); + check(req: BaseRequest, derivedRoles: Set, effectAsBoolean?: boolean): { effects: Map; outputs: Map; meta: { @@ -299,31 +446,37 @@ export class ResourcePolicyTypeBoxSchemas { static buildShape(typebox: TypeBoxLike): unknown; } -type PrincipalPolicyActionRuleSchema = { +type PrincipalPolicyActionRuleSchema< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> = { name?: string; - action: string; + action: ActionOf | '*'; effect: Effect; - condition?: ConditionsSchema | Conditions; - output?: OutputsSchema | Outputs; -}; -type PrincipalPolicyRuleSchema = { - resource: string; - actions: NonEmptyArray; + condition?: ConditionsSchema | Conditions; + output?: OutputsSchema | Outputs; }; -export type PrincipalPolicySchema = { +/** Discriminated over `resource:` — the kind narrows each entry's `action`. */ +type PrincipalPolicyRuleSchema = { + [K in ResourceKindOf]: { + resource: K | '*'; + actions: NonEmptyArray>; + }; +}[ResourceKindOf]; +export type PrincipalPolicySchema = { principal: string; version: string; scope?: string; - rules: NonEmptyArray; - variables?: VariablesSchema | Variables; + rules: NonEmptyArray>; + variables?: VariablesSchema | Variables; constants?: ConstantsSchema | Constants; }; -export type PrincipalPolicyRootSchema = { - principalPolicy: PrincipalPolicySchema; +export type PrincipalPolicyRootSchema = { + principalPolicy: PrincipalPolicySchema; }; -export class PrincipalPolicy { - constructor(schema: PrincipalPolicyRootSchema, options?: ValidationOptions); - check(req: BaseRequest, effectAsBoolean?: boolean): { +export class PrincipalPolicy { + constructor(schema: PrincipalPolicyRootSchema, options?: ValidationOptions); + check(req: BaseRequest, effectAsBoolean?: boolean): { effects: Map; outputs: Map; meta: { @@ -342,28 +495,31 @@ export class PrincipalPolicyTypeBoxSchemas { static buildShape(typebox: TypeBoxLike): unknown; } -type RolePolicyRuleSchema = { - name?: string; - resource: string; - allowActions: NonEmptyArray; - condition?: ConditionsSchema | Conditions; - output?: OutputsSchema | Outputs; -}; -export type RolePolicySchema = { - role: string; +/** Discriminated over `resource:` — the kind narrows `allowActions`. */ +type RolePolicyRuleSchema = { + [K in ResourceKindOf]: { + name?: string; + resource: K | '*'; + allowActions: NonEmptyArray | '*'>; + condition?: ConditionsSchema | Conditions; + output?: OutputsSchema | Outputs; + }; +}[ResourceKindOf]; +export type RolePolicySchema = { + role: PrincipalRoleOf; version: string; scope?: string; - parentRoles?: NonEmptyArray | readonly string[]; - rules: NonEmptyArray; - variables?: VariablesSchema | Variables; + parentRoles?: NonEmptyArray> | readonly PrincipalRoleOf[]; + rules: NonEmptyArray>; + variables?: VariablesSchema | Variables; constants?: ConstantsSchema | Constants; }; -export type RolePolicyRootSchema = { - rolePolicy: RolePolicySchema; +export type RolePolicyRootSchema = { + rolePolicy: RolePolicySchema; }; -export class RolePolicy { - constructor(schema: RolePolicyRootSchema, options?: ValidationOptions); - check(req: BaseRequest, effectAsBoolean?: boolean): { +export class RolePolicy { + constructor(schema: RolePolicyRootSchema, options?: ValidationOptions); + check(req: BaseRequest, effectAsBoolean?: boolean): { effects: Map; outputs: Map; meta: { @@ -381,14 +537,14 @@ export class RolePolicyTypeBoxSchemas { static buildShape(typebox: TypeBoxLike): unknown; } -export type KerberosPolicy = - | ResourcePolicy - | ResourcePolicyRootSchema - | PrincipalPolicy - | PrincipalPolicyRootSchema - | RolePolicy - | RolePolicyRootSchema; -export type KerberosDerivedRoles = DerivedRoles | DerivedRolesSchema; +export type KerberosPolicy = + | ResourcePolicy + | ResourcePolicyRootSchema + | PrincipalPolicy + | PrincipalPolicyRootSchema + | RolePolicy + | RolePolicyRootSchema; +export type KerberosDerivedRoles = DerivedRoles | DerivedRolesSchema; export type KerberosAuditLogEntry = { callId?: string; reqId?: string; @@ -667,13 +823,13 @@ export function deserializePolicy(json: unknown, codec: PolicyCodec): unknown; * is request-scoped and shared across all resources of a `checkResources` * batch — resolvers may use it to share subproblems. */ -export type KerberosRelationsResolver = { +export type KerberosRelationsResolver = { check( - args: { principal: RequestPrincipal; resource: RequestResource; relation: string }, + args: { principal: RequestPrincipal; resource: RequestResource; relation: string }, opts?: { memo?: Map | null; callId?: string | null }, ): boolean | Promise; list?( - args: { principal: RequestPrincipal; resource: RequestResource; relations: string[] }, + args: { principal: RequestPrincipal; resource: RequestResource; relations: string[] }, opts?: { memo?: Map | null; callId?: string | null }, ): Set | string[] | Promise | string[]>; }; @@ -698,7 +854,7 @@ export type KerberosCacheRetry = { onExhausted?: 'throw' | 'miss'; }; -export type KerberosOptions = ValidationOptions & { +export type KerberosOptions = ValidationOptions & { logger?: KerberosLogger | boolean; telemetry?: KerberosTelemetryOptions; cache?: CacheLike; @@ -708,7 +864,7 @@ export type KerberosOptions = ValidationOptions & { cacheKeyPrefix?: string; codec?: PolicyCodec; /** ReBAC resolver used for relation-backed derived roles. */ - relations?: KerberosRelationsResolver | null; + relations?: KerberosRelationsResolver | null; /** Optional bound on each `relations.check`/`relations.list` call; a hung resolver fails as `KerberosRelationsError` instead of hanging authorization. Off by default. */ relationsTimeoutMs?: number; /** @@ -755,12 +911,18 @@ export function createCacheReader( retry?: KerberosCacheRetry | null, ): { enabled: boolean; get(key: string): Promise }; -/** planResources filter outcome (Cerbos-compatible). */ -export enum PlanKind { - AlwaysAllowed = 'KIND_ALWAYS_ALLOWED', - AlwaysDenied = 'KIND_ALWAYS_DENIED', - Conditional = 'KIND_CONDITIONAL', -} +/** + * planResources filter outcome (Cerbos-compatible). Declared as a frozen const + * object rather than a TypeScript `enum` so that the raw wire strings + * (`'KIND_CONDITIONAL'`) — which is what a serialized plan actually carries — + * are assignable to the `PlanKind` type. `PlanKind.Conditional` still works. + */ +export declare const PlanKind: { + readonly AlwaysAllowed: 'KIND_ALWAYS_ALLOWED'; + readonly AlwaysDenied: 'KIND_ALWAYS_DENIED'; + readonly Conditional: 'KIND_CONDITIONAL'; +}; +export type PlanKind = 'KIND_ALWAYS_ALLOWED' | 'KIND_ALWAYS_DENIED' | 'KIND_CONDITIONAL'; /** * One operand of a planResources condition tree: a literal, a reference to an @@ -782,32 +944,38 @@ export type PlanFilter = { }; /** planResources plans over a resource KIND: no `id`, `attr` = KNOWN fields. */ -export type RequestPlanResource = { - kind: string; +export type RequestPlanResource< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> = { + kind: K; policyVersion?: string; scope?: string; - attr?: Record; + attr?: Partial>; }; -export type PlanResourcesArgs = { +export type PlanResourcesArgs< + S extends KerberosSchema = AnySchema, + K extends ResourceKindOf = ResourceKindOf, +> = { reqId?: string; - principal: RequestPrincipal; - resource: RequestPlanResource; + principal: RequestPrincipal; + resource: RequestPlanResource; /** Exactly one of `action` / `actions` must be provided. */ - action?: string; + action?: ActionOf; /** Multiple actions plan the conjunction (Cerbos AND semantics). */ - actions?: string[]; + actions?: ActionOf[]; includeMeta?: boolean; }; -export type PlanResourcesResponse = { +export type PlanResourcesResponse = { reqId?: string; kerberosCallId: string; /** Echo of the request form: `action` for single-action requests… */ - action?: string; + action?: ActionOf; /** …or `actions` for multi-action requests. */ - actions?: string[]; - resourceKind: string; + actions?: ActionOf[]; + resourceKind: ResourceKindOf; policyVersion: string; filter: PlanFilter; meta?: { @@ -828,13 +996,50 @@ export type PlanResourcesResponse = { * by `RelationResolver.lookupResources` from `@alexify/kerberos/relations`), * then re-normalizes the filter. Returns a new response object. */ -export function expandRelationOperands( - planResponse: PlanResourcesResponse, +export function expandRelationOperands( + planResponse: PlanResourcesResponse, lookup: (args: { name: string; relation: string }) => Promise> | Iterable, -): Promise; +): Promise>; + +/** One entry of a `checkResources` batch — the kind narrows its `actions`. */ +export type CheckResourcesEntry = { + [K in ResourceKindOf]: { resource: RequestResource; actions: ActionOf[] }; +}[ResourceKindOf]; + +export type CheckResourcesArgs = { + reqId?: string; + principal: RequestPrincipal; + resources: CheckResourcesEntry[]; + includeMeta?: boolean; +}; + +/** `E` is `Effect` by default and `boolean` when `effectAsBoolean` is set. */ +export type CheckResourcesResult = { + resource: Pick, 'id' | 'kind' | 'policyVersion' | 'scope'>; + actions: Record, E>; + outputs: unknown[]; + meta?: { + actions: Record; + effectiveDerivedRoles: string[]; + resolution?: KerberosResolutionTraceEntry[]; + }; +}; -export class Kerberos { - constructor(policies: KerberosPolicy[], derivedRoles: KerberosDerivedRoles[], options?: KerberosOptions); +export type CheckResourcesResponse = { + reqId?: string; + kerberosCallId: string; + results: CheckResourcesResult[]; +}; + +export class Kerberos { + constructor(policies: KerberosPolicy[], derivedRoles: KerberosDerivedRoles[], options?: KerberosOptions); static generateCallId(): string; static normalizeScope(scope?: string): string; static getScopeSearchChain(scope?: string): string[]; @@ -850,41 +1055,20 @@ export class Kerberos { static parseCheckResourcesArgs(args: unknown, options?: ValidationOptions & { schema?: unknown }): Record; /** Validates `planResources` arguments with the configured backend. */ static parsePlanResourcesArgs(args: unknown, options?: ValidationOptions & { schema?: unknown }): Record; - isAllowed(args: { + isAllowed>(args: { reqId?: string; - principal: RequestPrincipal; - resource: RequestResource; - action: string; + principal: RequestPrincipal; + resource: RequestResource; + action: ActionOf; includeMeta?: boolean; }): Promise; + checkResources(args: CheckResourcesArgs, effectAsBoolean: true): Promise>; + checkResources(args: CheckResourcesArgs, effectAsBoolean?: false): Promise>; checkResources( - args: { - reqId?: string; - principal: RequestPrincipal; - resources: { resource: RequestResource; actions: string[] }[]; - includeMeta?: boolean; - }, + args: CheckResourcesArgs, effectAsBoolean?: boolean, - ): Promise<{ - reqId?: string; - kerberosCallId: string; - results: { - resource: Pick; - actions: Record; - outputs: unknown[]; - meta?: { - actions: Record; - effectiveDerivedRoles: string[]; - resolution?: KerberosResolutionTraceEntry[]; - }; - }[]; - }>; - planResources(args: PlanResourcesArgs): Promise; + ): Promise>; + planResources>(args: PlanResourcesArgs): Promise>; } export class KerberosZodSchemas { static buildResourcePolicyInstance(z: unknown): unknown; diff --git a/test/typed-schema.test-d.ts b/test/typed-schema.test-d.ts new file mode 100644 index 0000000..f7a8b5c --- /dev/null +++ b/test/typed-schema.test-d.ts @@ -0,0 +1,240 @@ +import { expectAssignable, expectError, expectType } from 'tsd'; +import { + Effect, + Kerberos, + type ActionOf, + type CheckResourcesResponse, + type KerberosPolicy, + type PlanResourcesResponse, + type PrincipalRoleOf, + type RequestPrincipal, + type ResourceAttrOf, + type ResourceKindOf, +} from '../index.js'; +import { RelationResolver } from '../relations.js'; + +/** + * Typed authoring: an application declares its authorization domain once and + * every policy shape, request and response narrows to it. These assertions are + * the contract — they must keep holding as `index.d.ts` evolves. + */ +type AppSchema = { + principal: { roles: 'admin' | 'user'; attr: { department: string; clearance: number } }; + resources: { + document: { actions: 'view' | 'edit' | 'delete'; attr: { ownerId: string; status: 'draft' | 'published' } }; + invoice: { actions: 'view' | 'approve'; attr: { amount: number } }; + }; +}; + +// --------------------------------------------------------------------------- +// Schema projections +// --------------------------------------------------------------------------- + +expectType<'document' | 'invoice'>({} as ResourceKindOf); +expectType<'view' | 'edit' | 'delete'>({} as ActionOf); +expectType<'view' | 'approve'>({} as ActionOf); +// Unparameterized, `ActionOf` is the union across every kind. +expectType<'view' | 'edit' | 'delete' | 'approve'>({} as ActionOf); +expectType<'admin' | 'user'>({} as PrincipalRoleOf); +expectType<{ amount: number }>({} as ResourceAttrOf); + +// --------------------------------------------------------------------------- +// Requests: the resource kind narrows the action and the attribute bag +// --------------------------------------------------------------------------- + +declare const app: Kerberos; + +app.isAllowed({ + principal: { id: 'u1', roles: ['admin'], attr: { department: 'eng', clearance: 3 } }, + resource: { kind: 'document', id: 'd1', attr: { ownerId: 'u1', status: 'draft' } }, + action: 'edit', +}); + +// 'approve' belongs to `invoice`, not to `document`. +expectError( + app.isAllowed({ + principal: { id: 'u1', roles: ['admin'] }, + resource: { kind: 'document', id: 'd1' }, + action: 'approve', + }), +); + +// Undeclared resource kind. +expectError( + app.isAllowed({ + principal: { id: 'u1', roles: ['admin'] }, + resource: { kind: 'ledger', id: 'l1' }, + action: 'view', + }), +); + +// Undeclared role. +expectError( + app.isAllowed({ + principal: { id: 'u1', roles: ['superuser'] }, + resource: { kind: 'document', id: 'd1' }, + action: 'view', + }), +); + +// Attribute bag is checked against the kind: `status` has a literal union. +expectError( + app.isAllowed({ + principal: { id: 'u1', roles: ['admin'] }, + resource: { kind: 'document', id: 'd1', attr: { ownerId: 'u1', status: 'archived' } }, + action: 'view', + }), +); + +// Principal attributes are checked too (`clearance` is a number). +expectError( + app.isAllowed({ + principal: { id: 'u1', roles: ['admin'], attr: { department: 'eng', clearance: 'high' } }, + resource: { kind: 'document', id: 'd1' }, + action: 'view', + }), +); + +// --------------------------------------------------------------------------- +// checkResources: heterogeneous batches stay per-entry typed +// --------------------------------------------------------------------------- + +app.checkResources({ + principal: { id: 'u1', roles: ['user'] }, + resources: [ + { resource: { kind: 'document', id: 'd1' }, actions: ['view', 'edit'] }, + { resource: { kind: 'invoice', id: 'i1' }, actions: ['approve'] }, + ], +}); + +// `edit` is not an `invoice` action, even inside a mixed batch. +expectError( + app.checkResources({ + principal: { id: 'u1', roles: ['user'] }, + resources: [{ resource: { kind: 'invoice', id: 'i1' }, actions: ['edit'] }], + }), +); + +// `effectAsBoolean` selects the effect representation through overloads. +expectType>>( + app.checkResources({ principal: { id: 'u1', roles: ['user'] }, resources: [] }), +); +expectType>>( + app.checkResources({ principal: { id: 'u1', roles: ['user'] }, resources: [] }, true), +); +declare const asBoolean: boolean; +expectType>>( + app.checkResources({ principal: { id: 'u1', roles: ['user'] }, resources: [] }, asBoolean), +); + +// --------------------------------------------------------------------------- +// planResources +// --------------------------------------------------------------------------- + +expectType>>( + app.planResources({ + principal: { id: 'u1', roles: ['user'] }, + resource: { kind: 'invoice' }, + action: 'approve', + }), +); + +expectError( + app.planResources({ + principal: { id: 'u1', roles: ['user'] }, + resource: { kind: 'invoice' }, + action: 'delete', + }), +); + +// --------------------------------------------------------------------------- +// Policy authoring: `resource:` discriminates the rules +// --------------------------------------------------------------------------- + +const documentPolicy: KerberosPolicy = { + resourcePolicy: { + version: 'default', + resource: 'document', + rules: [ + { actions: ['view', 'edit'], effect: Effect.Allow, roles: ['admin'] }, + // Raw wire strings are assignable — this is what a stored policy carries. + { actions: ['*'], effect: 'EFFECT_DENY', roles: ['*'] }, + { + actions: ['edit'], + effect: Effect.Allow, + roles: ['user'], + // Condition callbacks see the narrowed `R.attr` / `P.attr`. + condition: { match: ({ R, P }) => R.attr?.ownerId === P.id && R.attr?.status === 'draft' }, + }, + // Serialized `$expr` conditions are accepted as well. + { actions: ['delete'], effect: Effect.Allow, roles: ['admin'], condition: { match: { $expr: 'P.id == R.id' } } }, + ], + }, +}; +void documentPolicy; + +// `approve` is not a `document` action. +expectError>({ + resourcePolicy: { + version: 'default', + resource: 'document', + rules: [{ actions: ['approve'], effect: Effect.Allow, roles: ['admin'] }], + }, +}); + +// A condition reading an attribute the kind does not declare. +expectError>({ + resourcePolicy: { + version: 'default', + resource: 'invoice', + rules: [ + { actions: ['view'], effect: Effect.Allow, roles: ['user'], condition: { match: ({ R }) => R.attr?.ownerId } }, + ], + }, +}); + +const invoiceRolePolicy: KerberosPolicy = { + rolePolicy: { + role: 'user', + version: 'default', + rules: [{ resource: 'invoice', allowActions: ['view'] }], + }, +}; +void invoiceRolePolicy; + +expectError>({ + rolePolicy: { + role: 'user', + version: 'default', + rules: [{ resource: 'invoice', allowActions: ['delete'] }], + }, +}); + +const principalPolicy: KerberosPolicy = { + principalPolicy: { + principal: 'u1', + version: 'default', + rules: [{ resource: 'document', actions: [{ action: 'delete', effect: Effect.Deny }] }], + }, +}; +void principalPolicy; + +new Kerberos([documentPolicy, invoiceRolePolicy, principalPolicy], []); + +// --------------------------------------------------------------------------- +// Backward compatibility: without a schema every position stays open +// --------------------------------------------------------------------------- + +// The built-in ReBAC resolver stays usable under a typed schema. +declare const resolver: RelationResolver; +new Kerberos([], [], { relations: resolver }); + +declare const untyped: Kerberos; +untyped.isAllowed({ + principal: { id: 'u1', roles: ['anything'] }, + resource: { kind: 'whatever', id: 'x', attr: { free: 'form' } }, + action: 'any-action', +}); +expectAssignable({ id: 'u1', roles: ['a', 'b'], attr: { any: 1 } }); +expectType({} as ResourceKindOf); +expectType({} as ActionOf); diff --git a/test/types.test-d.ts b/test/types.test-d.ts index 710d94d..4765559 100644 --- a/test/types.test-d.ts +++ b/test/types.test-d.ts @@ -116,7 +116,11 @@ declare const planResponse: PlanResourcesResponse; expectType(planResponse.filter); expectType(planResponse.filter.kind); expectAssignable(PlanKind.AlwaysAllowed); -expectType(PlanKind.Conditional); +expectType<'KIND_CONDITIONAL'>(PlanKind.Conditional); +// PlanKind/Effect are const objects, not `enum`s — the raw wire strings that a +// serialized plan or a JSON policy actually carries stay assignable. +expectAssignable('KIND_ALWAYS_DENIED'); +expectAssignable('EFFECT_ALLOW'); // The operand union accepts nested expressions, variables and literals. const conditionalOperand: PlanExpressionOperand = { expression: { From cecf78bc74375ce8018e9e902d55b7ec3a05d98f Mon Sep 17 00:00:00 2001 From: Alex Dolid Date: Mon, 24 Aug 2026 21:01:11 +0300 Subject: [PATCH 02/10] feat(docs): in-browser playground (P0.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adoption, not engine capability, is the binding constraint — and Kerberos.js is uniquely cheap to demo because the engine *is* a browser library. The playground is a static VitePress page with no backend: policies and requests are edited in the page, and `isAllowed` / `checkResources` / `planResources` run in the visitor's own tab. That is simultaneously the demo and the proof of the "runs in your browser" claim. Three seeded examples: RBAC+ABAC (derived roles, variables, conditions, outputs), a query plan (showing the Cerbos-compatible conditional filter with `P.id` partially evaluated into a literal), and scope-chain walking. Conditions in the examples are `{ "$expr": "..." }` strings rather than functions, so every example is also a valid *stored* policy and the page exercises the eval-free codec path a cache-backed deployment uses. Implementation note: the package is CommonJS and lives outside node_modules (no workspace self-link), so Vite pre-bundles it neither in dev nor through the default rollup commonjs `include` — a plain alias produced "module is not defined" at runtime. Instead of guessing at interop flags, a small Vite plugin serves the engine as a virtual module built by esbuild with exactly the options `scripts/size.js` already uses. The page therefore ships the same artifact `pnpm size` reports, and a broken browser/node runtime swap fails the docs build loudly rather than silently shipping `node:crypto`. Verified in a real browser against the dev server: the RBAC example activates the OWNER derived role, evaluates its `$expr` condition, emits the rule output and the full resolution trace in ~5 ms; the query-plan example returns KIND_CONDITIONAL with the expected or/eq tree. Production build checked too — the engine is a lazily-loaded chunk (30 KB gzipped, only fetched when the playground is opened), with zero Node builtins and the browser `randomUUID` shim in place. Co-Authored-By: Claude Fable 5 --- docs/.vitepress/config.mts | 52 +++ .../theme/components/Playground.vue | 303 ++++++++++++++++++ .../theme/components/playground-examples.ts | 157 +++++++++ docs/playground.md | 40 +++ 4 files changed, 552 insertions(+) create mode 100644 docs/.vitepress/theme/components/Playground.vue create mode 100644 docs/.vitepress/theme/components/playground-examples.ts create mode 100644 docs/playground.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 038add7..1636cd8 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,6 +1,50 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitepress'; import { withMermaid } from 'vitepress-plugin-mermaid'; +const packageRoot = fileURLToPath(new URL('../..', import.meta.url)); + +/** + * Supplies the playground with the engine as a browser bundle. + * + * The package is CommonJS and lives outside node_modules (there is no workspace + * self-link), so Vite pre-bundles neither in dev nor via the default rollup + * commonjs `include`. Rather than guess at interop settings, this builds the + * bundle with esbuild using exactly the options `scripts/size.js` uses — the + * `browser` condition applies the package.json runtime swap + * (`src/runtime/node.js` → `src/runtime/browser.js`), so the page ships the same + * artifact `pnpm size` reports, and a broken swap fails the docs build loudly + * because `node:crypto` cannot resolve for the browser platform. + */ +function kerberosBrowserBundle() { + const virtualId = 'virtual:kerberos-browser'; + const resolvedId = `\0${virtualId}`; + let cached: string | null = null; + + return { + name: 'kerberos-browser-bundle', + resolveId(id: string) { + return id === virtualId ? resolvedId : null; + }, + async load(id: string) { + if (id !== resolvedId) return null; + if (cached) return cached; + const esbuild = await import('esbuild'); + const result = await esbuild.build({ + entryPoints: [`${packageRoot}browser.js`], + bundle: true, + format: 'esm', + platform: 'browser', + conditions: ['browser'], + write: false, + logLevel: 'silent', + }); + cached = result.outputFiles[0].text; + return cached; + }, + }; +} + const ogTitle = 'Kerberos.js — embedded authorization engine for Node.js & the browser'; const ogDescription = 'Zero-dependency, in-process authorization engine for JavaScript. Cerbos-style RBAC + ABAC policies, ' + @@ -104,6 +148,7 @@ export default withMermaid( // ─── Top navigation ────────────────────────────────────────────── nav: [ { text: 'Guide', link: '/guide/why', activeMatch: '/guide/' }, + { text: 'Playground', link: '/playground', activeMatch: '/playground' }, { text: 'API', link: '/api/kerberos', activeMatch: '/api/' }, { text: 'Reference', link: '/reference/plan-operators', activeMatch: '/reference/' }, { @@ -196,5 +241,12 @@ export default withMermaid( next: 'Next page', }, }, + + vite: { + plugins: [kerberosBrowserBundle()], + optimizeDeps: { + include: ['jsep', '@jsep-plugin/object', '@jsep-plugin/ternary', '@jsep-plugin/new'], + }, + }, }), ); diff --git a/docs/.vitepress/theme/components/Playground.vue b/docs/.vitepress/theme/components/Playground.vue new file mode 100644 index 0000000..c801183 --- /dev/null +++ b/docs/.vitepress/theme/components/Playground.vue @@ -0,0 +1,303 @@ + + +