Skip to main content

Orgs (multi-tenancy)

WIP

Typed 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

EventPayload
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 }