Skip to main content

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:

AdapterassignRole idempotent?
Memoryyes (in-memory check)
Redisyes (set semantics)
Drizzleyes (onConflictDoNothing)
Prismano - throws on duplicate against the unique constraint
HTTPdepends 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 callInvalidates
savePolicy / deletePolicyPolicy cache + RBAC cache
saveRole / deleteRoleRole cache + RBAC cache + all subjects
assignRole / revokeRoleSpecific subject
setAttributesSpecific 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 schemaVersion throws before any write - snapshots never half-apply.

Use for environment promotion (staging -> prod), GitOps-style policy review, disaster recovery, or git-tracked policy bundles.