admin API
engine.admin.* - runtime CRUD for policies, roles, role assignments, and subject attributes. Automatically invalidates caches.
What admin does
engine.admin exposes CRUD operations for policies, roles, and subject attributes. All mutations automatically invalidate the relevant caches.
Use this when you want to manage authorization data without bypassing the engine. For pure data access (no cache invalidation), call the adapter directly.
Policy management
// List all policies
const policies = await engine.admin.listPolicies()
// Get a specific policy
const policy = await engine.admin.getPolicy('ip-restriction')
// Save (create or update) a policy
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: [] },
},
],
})
// Delete a policy
await engine.admin.deletePolicy('office-hours')
savePolicy is upsert semantics in every shipped adapter - saving an existing ID updates it.
Role management
// List all roles
const roles = await engine.admin.listRoles()
// Get a specific role
const role = await engine.admin.getRole('editor')
// Save (create or update) a role
await engine.admin.saveRole({
id: 'moderator',
name: 'Moderator',
permissions: [
{ action: 'read', resource: 'post' },
{ action: 'update', resource: 'post' },
{ action: 'delete', resource: 'comment' },
],
inherits: ['viewer'],
})
// Delete a role
await engine.admin.deleteRole('moderator')
For typed builders, use defineRole().build() and pass the result to saveRole() instead of writing raw Permission arrays.
Role assignments
// Assign a role to a user
await engine.admin.assignRole('user-1', 'editor')
// Assign a scoped role (multi-tenant)
await engine.admin.assignRole('user-1', 'admin', 'org-1')
// Revoke a role
await engine.admin.revokeRole('user-1', 'editor')
// Revoke a scoped role (only the matching scope)
await engine.admin.revokeRole('user-1', 'admin', 'org-1')
// Revoke all scopes of a role
await engine.admin.revokeRole('user-1', 'admin')
Idempotency caveat
Assignment idempotency depends on the adapter:
| Adapter | assignRole idempotent? |
|---|---|
| Memory | yes (in-memory check) |
| Redis | yes (set semantics) |
| Drizzle | yes (onConflictDoNothing) |
| Prisma | no - throws on duplicate against the unique constraint |
| HTTP | depends on backend |
For the Prisma adapter, wrap in try/catch or check existence first:
const existing = await engine.admin.listSubjectRoles?.('user-1') // adapter-dependent
if (!existing.includes('editor')) {
await engine.admin.assignRole('user-1', 'editor')
}
Subject attributes
// Set attributes (built-in adapters merge with existing)
await engine.admin.setAttributes('user-1', {
department: 'engineering',
level: 'senior',
region: 'us-east',
})
// Read attributes
const attrs = await engine.admin.getAttributes('user-1')
// { department: 'engineering', level: 'senior', region: 'us-east' }
Merge vs replace
The exact merge/replace behavior depends on the adapter implementation:
- Memory - shallow-merges new attributes into existing
- Prisma - read-merge-write (race risk under concurrent writes)
- Drizzle - same as Prisma
- Redis - same as above
- HTTP - depends on backend (the built-in HTTP adapter sends PATCH which most servers interpret as merge)
To remove an attribute, set it to null. The merge will replace the existing value with null.
Concurrent write race
For high-contention attribute writes (multiple services updating the same subject), wrap in a transaction:
// Prisma
await prisma.$transaction(async (tx) => {
const existing = await tx.accessSubjectAttr.findUnique({ where: { subjectId: 'user-1' } })
const merged = { ...(existing?.data as Attributes), ...newAttrs }
await tx.accessSubjectAttr.upsert({
where: { subjectId: 'user-1' },
create: { subjectId: 'user-1', data: merged },
update: { data: merged },
})
})
The built-in adapters don't wrap reads + merges in transactions - concurrent writes may lose data. For Redis, use WATCH/MULTI/EXEC or a Lua script.
Cache invalidation summary
| Admin call | Invalidates |
|---|---|
savePolicy / deletePolicy | Policy cache + RBAC cache |
saveRole / deleteRole | Role cache + RBAC cache + all subjects |
assignRole / revokeRole | Specific subject |
setAttributes | Specific subject |
See caching for full details.
When NOT to use admin
The admin API is the right surface for:
- Building admin UIs (your team adding/removing roles)
- Internal scripts that mutate authorization state
- Webhooks that sync from external IdPs (SCIM, etc.)
Don't use it for:
- Data migrations - write directly to the adapter database for speed, then call
engine.cache.invalidate() - Bulk imports - same reason; admin's per-item cache invalidation is overhead
- Read-only data export - call the adapter directly to skip cache machinery
Securing the admin surface
engine.admin is not authenticated at the programmatic level - anyone with a reference to engine can call it. The shipped HTTP admin routers, on the other hand, refuse to construct without an authorize callback:
import { adminRouter } from '@gentleduck/iam/server/express'
app.use(
'/api/access-admin',
requireAuth(), // your auth middleware
adminRouter(engine, {
authorize: (req) => req.user?.role === 'platform-admin',
})(() => express.Router()),
)
Mounting an admin router unauthenticated is no longer possible - the factory throws at boot when authorize is missing. Same secure-by-default contract on the Hono bindAdminRouter, Next.js createAdminHandlers, and NestJS createAdminOperations factories.
Snapshot export / import
engine.admin.export() returns a schema-versioned ISnapshot of policies + roles (subject assignments are intentionally excluded - they're user data, not config). engine.admin.import(snapshot, { mode }) applies one back.
// Export from staging
const snapshot = await stagingEngine.admin.export()
writeFileSync('iam-snapshot.json', JSON.stringify(snapshot, null, 2))
// Import to prod
const snapshot = JSON.parse(readFileSync('iam-snapshot.json', 'utf8'))
const result = await prodEngine.admin.import(snapshot, { mode: 'replace' })
// -> { policiesAdded, policiesDeleted, rolesAdded, rolesDeleted }
mode: 'merge'(default) - upserts every entry; existing rows not in the snapshot are untouched.mode: 'replace'- deletes every existing policy / role not present in the snapshot, then upserts. Use for full sync from a source of truth.- Unknown
schemaVersionthrows before any write - snapshots never half-apply.
Use for environment promotion (staging -> prod), GitOps-style policy review, disaster recovery, or git-tracked policy bundles.