Orgs (multi-tenancy)
WIPTyped organization memberships per identity. Pairs with duck-iam's scoped roles for B2B SaaS apps.
When to use
For B2B SaaS where one identity belongs to multiple organizations
with potentially different roles per org. The classic case: a
freelance designer is admin of their own company workspace and
viewer of three client workspaces.
duck-auth doesn't enforce per-org permissions on its own - that's
duck-iam's job. The orgs facet
owns the membership data; iam reads it to scope role assignments.
Setup
The orgs facet is optional. Wire stores.orgs to enable:
import { AuthMemoryAdapter } from '@gentleduck/auth/adapters/memory'
type OrgMeta = { plan: 'team' | 'enterprise' }
const adapter = new AuthMemoryAdapter<Profile, OrgMeta>()
export const auth = new AuthEngine<Profile, never, OrgMeta>({
// ...
stores: {
identities: adapter.identities,
sessions: adapter.sessions,
credentials: adapter.credentials,
orgs: adapter.orgs, // <- enables the facet
},
})
// auth.orgs is now non-null and typed by OrgMeta.
If stores.orgs is omitted, auth.orgs is null. Single-tenant apps
skip this facet entirely.
CRUD
// Create an org
const org = await auth.orgs.create({
name: 'Contoso',
tenantId: 'tenant-1',
meta: { plan: 'enterprise' },
})
// Add a member
await auth.orgs.addMember({
orgId: org.id,
identityId: 'identity-1',
role: 'admin',
})
// List an identity's memberships
const memberships = await auth.orgs.listForIdentity('identity-1')
// -> [{ orgId, identityId, role, joinedAt }, ...]
// Update a member's role
await auth.orgs.updateMemberRole({ orgId, identityId, role: 'viewer' })
// Remove a member
await auth.orgs.removeMember({ orgId, identityId })
The membership row has the shape { orgId, identityId, role, joinedAt, metadata? }. The role is opaque to duck-auth; it's interpreted by
your iam policies.
Invitation flow
For "invite by email" workflows:
// 1. Owner invites a new member
const invite = await auth.orgs.createInvitation({
orgId: org.id,
inviterIdentityId: ctx.identity.id,
email: 'newperson@example.com',
role: 'editor',
expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days
})
// -> { token, expiresAt }
// -> channel sends an email containing the link
// 2. Invitee clicks the link, signs in if necessary, then:
await auth.orgs.acceptInvitation({
token,
identityId: ctx.identity.id,
})
// -> adds the membership row, marks invite consumed
The invitation token is hashed at rest and single-use. If the invitee doesn't have an identity yet, redirect them through sign-up first; once they have an identity, retry the accept.
Membership-based session resolution
To enforce "session must belong to a member of this org," pass
expectedTenantId to resolveSession:
const ctx = await auth.resolveSession(req, {
expectedTenantId: org.tenantId,
})
if (!ctx) return res.status(401).end()
const memberships = await auth.orgs.listForIdentity(ctx.identity.id)
const orgMembership = memberships.find((m) => m.orgId === org.id)
if (!orgMembership) return res.status(403).end()
// The session is for an identity that is a member of this org.
Or, more concisely, project into a subject for duck-iam:
const subject = projectToSubject(ctx.identity, ctx.session, { memberships })
const allowed = iam.can({ subject, action, resource, scope: `org:${org.id}` })
Soft-delete
Like identities, orgs support soft-delete with a configurable grace window:
await auth.orgs.softDelete(orgId)
// Org hidden from listings; memberships preserved for the grace period.
await auth.orgs.restore(orgId)
await auth.orgs.erase(orgId)
// Hard-delete; all memberships removed; emit org.erased.
Events emitted
| Event | Payload |
|---|---|
org.created | { orgId, name, tenantId } |
org.updated | { orgId, changes } |
org.deleted | { orgId, soft: boolean } |
org.member-added | { orgId, identityId, role } |
org.member-removed | { orgId, identityId } |
org.member-role-changed | { orgId, identityId, fromRole, toRole } |
org.invitation-created | { orgId, inviterIdentityId, email, role } |
org.invitation-accepted | { orgId, identityId, role } |
org.invitation-revoked | { orgId, email } |