Pairing with duck-auth
duck-iam answers "what may they do?"; duck-auth answers "who are they?". Wire the two with a tiny projectToSubject function.
The split
- duck-auth owns identities, sessions, credentials, MFA, providers, and the runtime that proves who the caller is.
- duck-iam owns roles, policies, conditions, and the engine that decides what they may do.
Keeping the two libraries separate means you can swap either one without touching the other. The bridge is a single function in your app code.
projectToSubject
The function takes an auth-side Identity plus Session and returns
an iam-side Subject:
import type { AuthIdentity, AuthSession } from '@gentleduck/auth/core'
import type { AccessControl } from '@gentleduck/iam'
import type { access } from '~/lib/access' // your defineIam instance
type Profile = {
displayName: string
externalGroups?: string[]
signupSource?: 'web' | 'cli' | 'api'
}
export function projectToSubject(
identity: Identity<Profile>,
session: Session,
): AccessControl.ISubject<typeof access> {
return {
id: identity.id,
tenantId: identity.tenantId,
roles: [
// 1. Static role from the identity profile (assigned at signup or via admin).
...(identity.profile.role ? [identity.profile.role] : []),
// 2. Dynamic roles from external groups (e.g. SAML attribute push).
...(identity.profile.externalGroups?.map((g) => `group:${g}`) ?? []),
],
attributes: {
email: identity.email,
emailVerified: identity.emailVerified,
aal: session.aal,
amr: session.amr,
kind: session.kind, // 'user' | 'apikey' | 'm2m'
displayName: identity.profile.displayName,
signupSource: identity.profile.signupSource,
},
}
}
Use it in handlers
import { AuthError } from '@gentleduck/auth/core'
import { auth } from '~/lib/auth'
import { iam } from '~/lib/iam'
import { projectToSubject } from '~/lib/projectToSubject'
app.post('/api/posts/:id/delete', async (req, res, next) => {
try {
const ctx = await auth.resolveSession(req)
if (!ctx) throw new AuthError('AUTH/UNAUTHENTICATED')
const subject = projectToSubject(ctx.identity, ctx.session)
const post = await getPost(req.params.id)
const decision = iam.can({
subject,
action: 'delete',
resource: { type: 'post', attributes: post },
})
if (!decision) return res.status(403).end()
await deletePost(req.params.id)
res.status(204).end()
} catch (err) {
next(err)
}
})
Conditions that reference auth state
iam policies can read every attribute you put on the Subject. A
common pattern: gate sensitive actions on the session's AAL or
freshness:
definePolicy('admin-actions', {
combine: 'deny-overrides',
rules: [
rule({
effect: 'allow',
target: { actions: 'delete', resources: 'post' },
condition: when()
.equals('subject.attributes.aal', 'aal2')
.equals('subject.roles', 'admin', { mode: 'includes' }),
}),
],
})
If subject.attributes.aal !== 'aal2', the rule's condition fails and
the policy denies. Your handler catches the deny and your client
prompts for MFA.
Step-up driven by iam
You can let iam decide when to step-up. Add a custom requiresStepUp
attribute to policies and check it client-side:
const decision = iam.explain({
subject,
action: 'delete',
resource: { type: 'post', attributes: post },
})
if (decision.matchedConditions.includes('aal2-required') && subject.attributes.aal !== 'aal2') {
throw new AuthError('AUTH/STEP_UP_REQUIRED')
}
if (!decision.allow) {
return res.status(403).end()
}
The trace from engine.explain() shows which condition gated the
decision - pick that out, surface it as a step-up signal, and the
client retries after MFA.
Multi-tenancy
Both libraries are multi-tenant. The convention is to thread the same
tenantId through both:
const identity = await auth.identities.findById(id, { tenantId: 'org-123' })
const subject = projectToSubject(identity, session)
// subject.tenantId === 'org-123'
iam.can({ subject, action, resource, scope: 'org-123' })
// engine looks up policies + role assignments scoped to 'org-123'
A single identity can be admin in org-1 and viewer in org-2 -
iam's scoped role assignments handle that natively. The auth side
doesn't need to know.
Caching subjects
Building the Subject is cheap (it's a plain object), but for
high-traffic routes that hit iam.can() multiple times per request,
cache the projection per request:
const subjectCache = new WeakMap<Identity, AccessControl.ISubject<typeof access>>()
export function projectToSubject(identity: Identity, session: Session) {
const cached = subjectCache.get(identity)
if (cached) return cached
const subject = { /* ... */ }
subjectCache.set(identity, subject)
return subject
}
For app-wide caching, store the subject under
req._authSubject and reuse it across middlewares.
When you don't need iam
For very simple apps (one role per user, no per-resource conditions, no multi-tenancy), iam may be overkill. Drive permissions directly off the auth-side identity:
if (identity.profile.role !== 'admin') return res.status(403).end()
if (session.aal !== 'aal2') throw new AuthError('AUTH/AAL_INSUFFICIENT')
Switch to iam when you need conditions ("editors can only delete their own posts"), multi-tenancy, or audit traces.