Skip to content

TypeScript

Kerberos.js ships hand-maintained types. By default every position is open — kind and action are string, attr is Record<string, unknown> — 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<AppSchema>(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<AppSchema> = {
  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 (resource: narrows each entry's action) and role policies (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<Action, Effect>
await kerberos.checkResources(args, true); // results[].actions is Record<Action, boolean>

planResources 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:

TypeResolves to
ResourceKindOf<S>Union of declared resource kinds.
ActionOf<S, K>Actions for kind K; every action across all kinds when K is omitted.
ResourceAttrOf<S, K>Attribute bag of kind K.
PrincipalRoleOf<S> / PrincipalAttrOf<S>Declared principal roles / attributes.
RequestPrincipal<S>, RequestResource<S, K>, BaseRequest<S, K>Request shapes.
PolicyEvalRequest<S, K>The { P, R, V, C } envelope a condition/variable/output callback receives.
CheckResourcesArgs<S>, CheckResourcesResponse<S, E>checkResources arguments and response.
PlanResourcesArgs<S, K>, PlanResourcesResponse<S>planResources arguments and response.
AnySchemaThe permissive default used when no schema is supplied.

An example wrapper:

typescript
import type { ActionOf, RequestPrincipal, ResourceKindOf } from '@alexify/kerberos';

async function assertAllowed<K extends ResourceKindOf<AppSchema>>(
  principal: RequestPrincipal<AppSchema>,
  kind: K,
  id: string,
  action: ActionOf<AppSchema, K>,
): Promise<void> {
  if (!(await kerberos.isAllowed({ principal, resource: { kind, id }, action }))) {
    throw new Error(`${principal.id} may not ${action} ${kind}:${id}`);
  }
}

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.

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.

Released under the MIT License.