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
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:
| Call | Direction | '*' |
|---|---|---|
assignRole scope | grant | refused |
revokeRole scope | lookup | accepted |
updateAssignmentScope fromScope | lookup | accepted |
updateAssignmentScope toScope | grant | refused |
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.
assignRolesusesadapter.assignRoleManywhen present, otherwise one call per row.moveRoleScopesdelegates toupdateAssignmentScopeper 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, role, scope):
memory and file skip an existing pair, Redis uses SADD, Drizzle uses
onConflictDoNothing, and Prisma reads first and swallows a racing writer's
P2002. The HTTP adapter behaves however your backend does. On Prisma the
read cannot make the write atomic, so the unique index is what actually
decides a race - it has to be the NULLS NOT DISTINCT one schema.prisma
tells you to migrate to, since the plain index Prisma generates does not
collapse NULLs and unscoped duplicates would pile up unchecked.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].
| Guard | Applies to | Message |
|---|---|---|
| Non-empty string | id, subjectId, roleId | <name> must be a non-empty string (got <typeof>) |
| 1024-character cap | the same | <name> exceeds 1024-char cap (got length <n>) |
| Optional non-empty string | scope, fromScope, toScope | as above, skipped when undefined |
| Plain object | attrs | attributes must be a plain object (got <typeof>) |
| At most 256 own keys | attrs | attributes must have <=256 keys (got <n>) |
| Nesting depth at most 16 | attrs | attributes nesting depth <d> exceeds cap (16) |
| Schema validity | policy, 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.
subjectId is reported by its typeof, never by its content, so a
hostile value cannot ride into your logs through the error path. The one place
a value is interpolated - the snapshot schemaVersion - truncates strings at
64 characters and reports arrays and objects by shape only.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 }
mode | Behaviour |
|---|---|
'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
| Task | Do this instead |
|---|---|
| Data migration | Write to the adapter's storage directly, then call engine.cache.invalidate() once |
| Bulk import of thousands of rows | Same, or use import() which invalidates once rather than per row |
| Read-only export for a report | Call the adapter directly and skip the cache machinery |
import() ends with cache.invalidatePolicies() followed by cache.invalidateRoles() with no roleId. The no-argument form clears the whole subjectCache, not just the subjects holding a changed role. One import therefore re-resolves every subject on its next check, and with an invalidator attached that flush broadcasts to every instance. Import during a maintenance window, not under load.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
importvalidates 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.revokeRolewithout a scope removes every scope. That is not a bug; passingundefinedis not the same as passing an unscoped grant.assignRoleis idempotent on every shipped adapter. A repeat grant is a no-op, not an error.setAttributesmerges, and the merge is not transactional. Concurrent writers to the same subject can lose data.- There is no
listSubjectRoleson the admin facet. Useengine.getEffectiveRoles(subjectId, scope?)for the resolved role list, or the adapter's owngetSubjectRolesfor 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
okdoes not mean this call wrote it. Readvalue.changed, and treatundefinedas "the driver could not say".