Flip tenant registry source of truth from document-backed (type_id='tenant' rows) to the
Better Auth auth_tenant table (organization plugin). Greenfield only.
0001_core.sql— addstatus/domain/notescolumns toauth_tenant+ domain index.db/schema.ts— mirror new columns onauthTenant.auth/config.ts—organization()schemaadditionalFieldsfor status/domain/notes so BA-native flows round-trip them.TenantService— keep public API identical; swap storagedocuments→auth_tenant. Timestamps in ms (auth-table convention).countDocumentsForTenantstill queriesdocuments.tenant_id(content ownership unchanged).middleware/tenant.ts—loadTenantState()readsauth_tenantinstead oftype_id='tenant'docs. Plugin-active gate unchanged (still doc-backed plugin registry).tenant-service.sqlite.test.ts— assert againstauth_tenantrows, dropregisterTenantType.
- Membership gate —
auth_tenant_memberlookup inresolveTenantSlug/switcher so a user can only switch to tenants they belong to. (Security win; needs per-request user context.) - Remove dead
tenantdocument type +q_tenant_*generated columns fromdocument-types-seed.ts.
Shipped. Tenant registry source of truth flipped documents → auth_tenant.
0001_core.sql:auth_tenantgainsstatus/domain/notes+idx_auth_tenant_domain. Bundle regen'd, app copy synced byte-identical (R9).db/schema.ts:authTenantmirrors columns.auth/config.ts:organization()schemaadditionalFieldsfor the 3 fields.TenantService: internals →auth_tenant(raw SQL), public API unchanged → admin routes untouched. ms timestamps.countDocumentsForTenantstill readsdocuments.tenant_id.middleware/tenant.ts:loadTenantState()readsauth_tenant.tenant-service.sqlite.test.ts: rewritten to assertauth_tenant.
Verification: tsc clean · 23 plugin unit tests pass · spec 70 (6 tests) pass · local D1 reset + reseeded.
Dead but harmless (cleanup follow-up): tenant document type + q_tenant_status/q_tenant_domain
generated columns still registered in document-types-seed.ts — no longer read.
auth_tenant_member now gates tenant access for authed requests.
TenantService:listMemberSlugs/isMember(default always allowed) /addMember(idempotent,INSERT OR IGNORE).resolveTenantSlug(state, req, { memberSlugs, enforceMembership }): when enforcing, every non-'default' candidate (header/cookie/domain/subdomain) must be inmemberSlugs, else falls through → 'default'. No leak.tenantMiddleware: enforces only whenc.get('user')present + plugin active (runs after session mw at app.ts:445). Anonymous/public requests stay ungated → public multi-tenant API/content serving unchanged.- Switcher POST
/admin/tenants/switch: 403 if not a member. - Create route auto-enrolls the creator as
owner(so they can immediately switch in — and so spec 70 stays green). - Sidebar switcher dropdown filtered to member tenants.
Verification: tsc clean · 32 unit tests (resolver gate + membership CRUD) · spec 71 (5 tests) + spec 70 (6 tests) green.
- Remove dead
tenantdocument type +q_tenant_*generated columns fromdocument-types-seed.ts.
- Role/verb/grant definitions stay global (rbac_role/verb docs under 'default') — one platform role catalog.
- Role assignment is per-tenant:
defaulttenant → globalrbac_user_rolesdoc (unchanged, back-compat).- tenant T ≠ default →
auth_tenant_member.roleholds the rbac role name the user has in T.
can(userId, resource, verb, tenantId)resolves grants from the per-tenant role when T≠default, else global.- Super-admin (G2) = explicit
auth_user.is_super_adminflag (default 0). Bypasses membership gate + uses global roles in every tenant. NOT derived from the 'admin' role (admin's*:managewildcard would make every admin a super-admin and re-open the gate). Opt-in only.
auth_user.is_super_admin INTEGER NOT NULL DEFAULT 0(migration + schema + BA user additionalField input:false).app.ts: carryisSuperAdminonc.get('user')from BA session.user (no extra query).tenant.ts:enforceMembership = userPresent && pluginActive && !isSuperAdmin.- switcher POST: bypass membership if super-admin.
- Tests: e2e — super-admin switches into a non-member tenant (extends spec 71).
Two-layer authorization (clean separation, no RBAC-core signature churn):
- Global role gates route access (
requireRole/requireRbac) — can you enter the admin doc routes / platform sections at all. Unchanged. - Per-tenant role gates the document operation (document ACL
baseGrants[role]) — what you can do with content in the active tenant.
Implementation:
auth_tenant_member.roleholds a role name the document ACL understands (admin/editor/author/viewer). Tenant creator auto-enrolls asadmin(wasowner).TenantService.listMemberRoles/getMemberRole.tenant.ts: resolves the user's role in the active tenant ontoc.set('tenantRole')(global role for 'default' / super-admins).getDocumentRequestContext(the single ACL coupling point) feedstenantRoleas the role principal — so the same user is admin in one tenant, viewer in another.
Verification: tsc clean · 32 MT unit tests (incl. per-tenant role CRUD) · e2e spec 72
(viewer-in-tenant denied create / allowed read / global admin create in default) · specs 70+71 green.
/admin/tenants/<slug>/members — add a user by email with a per-tenant role, change role inline,
remove. Lockout guards refuse demoting/removing the last admin.
TenantService:listMembers(joined w/ auth_user),addMemberByEmail,setMemberRole,removeMember,adminCount;VALID_MEMBER_ROLES= admin/editor/author/viewer.- Routes: GET/POST
/:slug/members, POST/:slug/members/:userId/role,/:slug/members/:userId/delete. tenant-members.template.ts+ a "Members" action on each tenant row.
Verification: tsc clean · 33 MT unit tests (incl. lockout guards) · e2e spec 73 (add/role/remove +
error) · specs 70/71/72 green.
Invite an email to a tenant with a per-tenant role; the invitee joins by opening the accept link
while signed in with the invited email. On the members page (/admin/tenants/<slug>/members).
TenantService:createInvitation(pending row, id = accept token, 7-day TTL),listInvitations,revokeInvitation,acceptInvitation(fail-closed: pending + non-expired + signed-in email must match the invited email — never token-only).- Routes: POST
/:slug/invitations, POST/:slug/invitations/:id/revoke, and GET/invitations/accept?token=…(registered before/:slugroutes;invitationsis a reserved slug). - Invitations section on the members template (invite form + pending list w/ accept link + revoke).
Verification: tsc clean · 34 MT unit tests (full lifecycle + email-mismatch/expiry/duplicate/revoke
guards) · e2e spec 74 (invite→accept→member, revoke) · specs 70–73 green.
Invitation accept link emailed via the app EmailService (best-effort, guarded by
hasEmailService(); link still shown in UI). Merged to v3 (PR #878).
Centralized-first, safe-by-default approach.
- Primitive (centralized decision):
settings.global?: booleanon document types +GLOBAL_TENANT = '__global__'+effectiveTenantForType(requestTenant, settings)indocument-request-context.ts. Defaults to the request tenant → a type is isolated unless it opts intoglobal: true, so no existing type can regress into a cross-tenant leak. - Wired (vertical slice):
admin-documentscreate + list — global types write to / read from the shared pool from any tenant. - Tests: helper unit +
global-tenant-scope.sqlite.test.ts(global visible from every tenant; normal type stays isolated — the security property).
Both R4-sanctioned document-model routes are now fully global-aware:
admin-documents: create, list, get-by-id, versions, update, publish, unpublish, delete, reindex. By-id/root-id ops useresolveDocScope— look up the type without a tenant filter, compute the effective tenant, then re-verify the row lives in that effective scope (the isolation guard reproduces the oldAND tenant_id = ?semantics for normal types → no leak).api-documents: list + by-id/by-root public reads. The ACL override lookup also uses the effective tenant (denyIfNotAllowed/aclAllowsReadtake an override); the role principal stays per-tenant.
Proven end-to-end: e2e spec 75 (global type created in tenant A, visible from tenant B; normal type isolated). 36 MT unit + 25 e2e green.
- Legacy
admin-content/api-content-crud/admin-media/media-documentsraw-SQL paths are NOT global-aware. These are the legacy content/media routes already slated for decommissioning (CLAUDE.md §"content/media table DROP") — global types are a document-model feature served by the document routes above, so this is intentional, not a silent gap. - A way to mark a type global from config/UI (today: only via type registration
settings.global).
Payload's model: one shared role catalog; global-vs-tenant is the assignment location
(global = rbac_user_roles/is_super_admin, per-tenant = auth_tenant_member). No schema change.
- User-centric (
/admin/tenants/users/:userId): list a user's tenants + per-tenant role, add the user to a tenant (picker + role), change role, remove. Linked from the user edit page (/admin/users/:id/edit) when the plugin is active, and from each tenant member row ("All tenants"). - Role-centric (
/admin/tenants/roles/:roleName, read-only): per-tenant assignments of a role. Linked per-role from the RBAC roles tab. TenantService.listUserMemberships/listAssignmentsByRole; reserved slugsusers/roles.
Verification: 37 MT unit (incl. the two new queries) + e2e spec 76 (user edit link → add/change/remove + role-usage page). Full suite 70–76 = 29 e2e green.
- Optionally make
requireRbacportal-section gates tenant-aware if any section should be per-tenant. - The per-role "tenants" link on the RBAC roles tab is authored but not e2e-covered (hidden-tab DOM assertion was flaky); the role-usage page is reached directly + from user pages.