Skip to main content

Admin API

engine.admin - runtime CRUD for policies, roles, assignments and attributes, with input validation, cache invalidation and snapshot import/export

engine.admin is a lazily-built facet that wraps the adapter with three things the adapter does not do on its own: argument validation, policy/role schema validation, and cache invalidation. Reads pass through; writes validate, persist, then invalidate. This page documents every method, every error message it can produce, and the two snapshot operations.

What an admin write does

Loading diagram...

Both guards at B and D run before the adapter is touched, so a rejected write leaves storage untouched - the engine test does not touch the adapter when validation fails pins that. The validator at C is a dynamic import of roughly 12 KB gzipped, loaded on the first write and memoised, so a read-only service never pays for it; engine.preload({ validator: true }) front-loads it at boot instead. Step F is described in full on caching and invalidation; H matters in both modes, since both evaluate through the table.

One more step follows H when hooks.onMutation is wired: the write emits its mutation event. It fires after the adapter write resolves and after invalidation, in that order, so a write that threw emits nothing and a consumer reacting to an event never reads a cache still holding the old answer. With the hook unset the sink is left off entirely and no events are allocated.

Reads (listPolicies, getPolicy, listRoles, getRole, getAttributes, export) skip everything from C onward - they validate their arguments and delegate.

API reference

Policies

listPolicies(): Promise<AccessControl.IPolicy<TAction, TResource, TRole>[]>
getPolicy(id: string): Promise<AccessControl.IPolicy<TAction, TResource, TRole> | null>
savePolicy(policy: AccessControl.IPolicy<TAction, TResource, TRole>, opts?: { actor?: string }): Promise<void>
deletePolicy(id: string, opts?: { actor?: string }): Promise<void>
await engine.admin.savePolicy({
  id: 'office-hours',
  name: 'Office hours only',
  algorithm: 'deny-overrides',
  rules: [
    {
      id: 'deny-outside-hours',
      effect: 'deny',
      priority: 100,
      actions: ['*'],
      resources: ['*'],
      conditions: {
        any: [
          { field: 'environment.hour', operator: 'lt', value: 9 },
          { field: 'environment.hour', operator: 'gt', value: 17 },
        ],
      },
    },
    { id: 'allow-all', effect: 'allow', priority: 1, actions: ['*'], resources: ['*'], conditions: { all: [] } },
  ],
})

await engine.admin.deletePolicy('office-hours')

savePolicy is upsert in every shipped adapter - saving an existing ID replaces it. Both writes call invalidatePolicies().

Prefer definePolicy() over hand-written objects: the builder validates as you go and produces exactly this shape. See building policies.

Roles

listRoles(): Promise<AccessControl.IRole<TAction, TResource, TRole, TScope>[]>
getRole(id: string): Promise<AccessControl.IRole<TAction, TResource, TRole, TScope> | null>
saveRole(role: AccessControl.IRole<TAction, TResource, TRole, TScope>, opts?: { actor?: string }): Promise<void>
deleteRole(id: string, opts?: { actor?: string }): Promise<void>
import { defineRole } from '@gentleduck/iam'

await engine.admin.saveRole(
  defineRole('moderator')
    .inherits('viewer')
    .grant('update', 'post')
    .grant('delete', 'comment')
    .build(),
)

saveRole invalidates with role.id, deleteRole with the ID you passed. Both therefore evict only the subject-cache entries that hold that role - directly or through inheritance - not the whole subject cache.

Assignments

assignRole(subjectId: string, roleId: TRole, scope?: TScope, opts?: IamAdapter.IAssignOptions): Promise<void>
revokeRole(subjectId: string, roleId: TRole, scope?: TScope, opts?: IamAdapter.IRevokeOptions): Promise<void>
updateAssignmentScope(
  subjectId: string,
  roleId: TRole,
  fromScope: TScope | undefined,
  toScope: TScope | undefined,
  actor?: string,
): Promise<void>
await engine.admin.assignRole('user-1', 'editor')             // global
await engine.admin.assignRole('user-1', 'admin', 'org-1')     // scoped
await engine.admin.revokeRole('user-1', 'admin', 'org-1')     // that scope only
await engine.admin.revokeRole('user-1', 'admin')              // every scope

Omitting scope on revokeRole removes the assignment across all scopes, in every shipped adapter. That asymmetry with assignRole (where omitting scope creates one unscoped grant) is deliberate and contractual.

updateAssignmentScope moves a grant between scopes. When the adapter implements the optional updateAssignmentScope, it is one write; otherwise, or when nothing matched fromScope, the engine falls back to revokeRole(fromScope) followed by assignRole(toScope). Either way the subject's cache entry is invalidated. The actor argument reaches the store's provenance column where one exists (created_by on the drizzle schemas) and reaches onMutation either way.

All three invalidate exactly one subject.

'*' is refused as an assignment scope

'*' is a global wildcard on the lookup side, so granting it would write a row that covers every scope at once. The engine refuses it on the grant direction and accepts it on the lookup direction:

CallDirection'*'
assignRole scopegrantrefused
revokeRole scopelookupaccepted
updateAssignmentScope fromScopelookupaccepted
updateAssignmentScope toScopegrantrefused

A revoke addresses a row that already exists, and an operator holding '*' rows written before the guard has to be able to delete them. A move reads one end and writes the other, so '*' may be moved off, never to. The adapters carry the same guard; the engine runs it a layer earlier so a batch fails before it writes anything.

Batch assignments

assignRoles(rows: readonly IAssignRow<TRole, TScope>[]): Promise<Batch.Result<IAssignRow, Batch.Change>>
revokeRoles(rows: readonly IRevokeRow<TRole, TScope>[]): Promise<Batch.Result<IRevokeRow, Batch.Change>>
moveRoleScopes(rows: readonly IMoveRow<TRole, TScope>[]): Promise<Batch.Result<IMoveRow>>
invalidateSubjects(subjectIds: readonly string[]): void

A row is a plain triple plus its own per-call options:

interface IAssignRow<TRole, TScope> { subjectId: string; roleId: TRole; scope?: TScope; opts?: IAssignOptions }
interface IMoveRow<TRole, TScope>   { subjectId: string; roleId: TRole; fromScope?: TScope; toScope?: TScope; actor?: string }
const result = await engine.admin.assignRoles([
  { subjectId: 'user-1', roleId: 'editor', scope: 'org-1' },
  { subjectId: 'user-2', roleId: 'viewer', scope: 'org-1', opts: { expiresAt: new Date('2026-01-01') } },
])
// result.applied === 2
// result.outcomes[0] === { row: {...}, ok: true, value: { changed: true } }

Batch.Result is { outcomes, applied }, with one outcome per input row in input order, so matching by index stays exact. An outcome carries the row it describes rather than a key derived from it - a (subject, role, scope) triple of free-form strings has no unambiguous encoding into one key, and an earlier space-joined version collided whenever an id contained a space.

Every row comes back ok: the grant is in place afterwards whether or not this call is what put it there. outcome.value.changed distinguishes the two where the driver could say - true when this row accounts for a write the statement made, false when the row was already in that state or an earlier row of the same batch already accounts for the write, so a write is credited to exactly one row. It is absent, not guessed, when the driver could not report it: MySQL has no RETURNING, and the per-row fallback's single-row methods return void.

Three behaviours worth knowing:

  • Every row is validated before any is written. A malformed row aborts the whole batch rather than half-applying it - a caller who fixes that row and retries would otherwise double-apply every row that had already landed.
  • A batch that throws part-way still settles. The engine invalidates every requested subject, not just the ones known to have landed: the set-based adapters cannot say how far they got, and dropping a cache entry that did not need it costs one reload where keeping a stale one costs a wrong answer. Events are emitted only for rows known to have landed.
  • One statement where the adapter offers one. assignRoles uses adapter.assignRoleMany when present, otherwise one call per row. moveRoleScopes delegates to updateAssignmentScope per row.

invalidateSubjects(ids) drops several subject cache entries in one call, collapsing duplicate ids. Use it after writing to the store outside engine.admin.

Subject attributes

setAttributes(subjectId: string, attrs: IamPrimitives.Attributes, opts?: { actor?: string }): Promise<void>
getAttributes(subjectId: string): Promise<IamPrimitives.Attributes>
await engine.admin.setAttributes('user-1', { department: 'engineering', level: 'senior' })
const attrs = await engine.admin.getAttributes('user-1')

Shipped adapters shallow-merge rather than replace, so a partial patch keeps untouched keys. Set a key to null to blank it. The merge is a read-then-write in the SQL and Redis adapters and is not transactional - concurrent writers to the same subject can lose an update. Wrap it yourself when that matters:

await prisma.$transaction(async (tx) => {
  const existing = await tx.accessSubjectAttr.findUnique({ where: { subjectId: 'user-1' } })
  const merged = { ...(existing?.data as Record<string, unknown>), ...newAttrs }
  await tx.accessSubjectAttr.upsert({
    where: { subjectId: 'user-1' },
    create: { subjectId: 'user-1', data: merged },
    update: { data: merged },
  })
})

For Redis, use WATCH/MULTI/EXEC or a Lua script.

Argument validation

Every admin method validates before doing anything. All messages are prefixed [@gentleduck/iam:engine].

GuardApplies toMessage
Non-empty stringid, subjectId, roleId<name> must be a non-empty string (got <typeof>)
1024-character capthe same<name> exceeds 1024-char cap (got length <n>)
Optional non-empty stringscope, fromScope, toScopeas above, skipped when undefined
Plain objectattrsattributes must be a plain object (got <typeof>)
At most 256 own keysattrsattributes must have <=256 keys (got <n>)
Nesting depth at most 16attrsattributes nesting depth <d> exceeds cap (16)
Schema validitypolicy, role<policy|role> rejected by validator - <CODE> at "<path>"; ...

The 1024-character cap exists because these values become URL segments on the HTTP adapter, key components on Redis, and column values on SQL. The 256-key and depth-16 attribute caps bound what a hostile caller can push into a JSON column and what the dot-path resolver has to walk.

export

export(): Promise<IamEngineTypes.ISnapshot<TAction, TResource, TRole, TScope>>

interface ISnapshot {
  readonly schemaVersion: 1
  readonly exportedAt: string
  readonly policies: readonly AccessControl.IPolicy[]
  readonly roles: readonly AccessControl.IRole[]
}

A configuration snapshot: policies and roles, and deliberately nothing else. Subject assignments and attributes are user data, they vary per environment, and most adapters cannot enumerate subjects cheaply. exportedAt is an ISO-8601 timestamp.

import

import(
  snapshot: IamEngineTypes.ISnapshot<TAction, TResource, TRole, TScope>,
  options?: { mode?: 'merge' | 'replace' },
  opts?: { actor?: string },
): Promise<IamEngineTypes.IImportResult>

interface IImportResult {
  readonly policiesAdded: number
  readonly policiesDeleted: number
  readonly rolesAdded: number
  readonly rolesDeleted: number
}
const snapshot = await stagingEngine.admin.export()
writeFileSync('iam-snapshot.json', JSON.stringify(snapshot, null, 2))

const result = await prodEngine.admin.import(JSON.parse(readFileSync('iam-snapshot.json', 'utf8')), {
  mode: 'replace',
})
// { policiesAdded: 12, policiesDeleted: 3, rolesAdded: 7, rolesDeleted: 0 }
modeBehaviour
'merge' (default)Upserts every entry. Existing IDs are overwritten; IDs absent from the snapshot are left alone. policiesDeleted and rolesDeleted are 0.
'replace'First deletes every existing policy and role whose ID is not in the snapshot, then upserts. Use for a full sync from a source of truth.

The whole snapshot is validated before the adapter is touched: schemaVersion, then that policies and roles are arrays, then every policy and every role through the validator. Interleaving meant an invalid row halfway through left the store half-applied - and in 'replace' mode the deletions had already landed, so deny policies could be gone with nothing written back in their place.

schemaVersion must be exactly 1. Anything else throws before a single write:

[@gentleduck/iam:engine] unsupported snapshot schemaVersion string 'v2'; expected 1

The counts are snapshot sizes and delete counts, not diffs: policiesAdded is snapshot.policies.length regardless of how many were already identical.

import invalidates once at the end - invalidatePolicies() followed by invalidateRoles() with no role ID, which clears the whole subject cache - rather than paying per-row invalidation across a bulk write.

Use it for environment promotion, GitOps-style policy review, disaster recovery, or a git-tracked policy bundle.

Securing the admin surface

engine.admin is not authenticated. Anything holding a reference to the engine can call it, which is correct for a library API and dangerous the moment it is reachable over HTTP.

The shipped admin routers refuse to be constructed without an authorize callback:

import { iamAdminRouter } from '@gentleduck/iam/server/express'

app.use(
  '/api/access-admin',
  requireAuth(),
  iamAdminRouter(engine, {
    authorize: (req) => req.user?.role === 'platform-admin',
  })(() => express.Router()),
)

Omitting it throws at boot:

[@gentleduck/iam] iamAdminRouter requires an `authorize` callback.
Mounting admin endpoints unauthenticated is never safe.

The same secure-by-default contract holds for iamBindAdminRouter (Hono), createIamAdminHandlers (Next.js) and createIamAdminOperations (NestJS). All four also apply a default Sec-Fetch-Site CSRF check on mutating routes; pass csrfCheck: false to opt out, or your own predicate to replace it.

When not to use admin

TaskDo this instead
Data migrationWrite to the adapter's storage directly, then call engine.cache.invalidate() once
Bulk import of thousands of rowsSame, or use import() which invalidates once rather than per row
Read-only export for a reportCall the adapter directly and skip the cache machinery

Admin is the right surface for admin UIs, internal scripts, and IdP or SCIM sync webhooks - anywhere a write must be reflected in the engine's caches immediately.

Gotchas

  • import validates everything before it writes anything, so a malformed snapshot cannot half-apply. It is still not transactional across the adapter: an adapter failure part-way through a 'replace' leaves the deletions applied. Its mutation events are buffered and emitted only after every write lands, so an import that throws does not report a history of the rows that happened to go first.
  • revokeRole without a scope removes every scope. That is not a bug; passing undefined is not the same as passing an unscoped grant.
  • assignRole is idempotent on every shipped adapter. A repeat grant is a no-op, not an error.
  • setAttributes merges, and the merge is not transactional. Concurrent writers to the same subject can lose data.
  • There is no listSubjectRoles on the admin facet. Use engine.getEffectiveRoles(subjectId, scope?) for the resolved role list, or the adapter's own getSubjectRoles for the raw assignments.
  • '*' cannot be assigned as a scope, but can be revoked. The two directions ask different questions.
  • A batch row that comes back ok does not mean this call wrote it. Read value.changed, and treat undefined as "the driver could not say".

See also