diff --git a/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md index 628858cb8d..38e1dd727c 100644 --- a/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md +++ b/docs/adr/0093-tenancy-mode-and-membership-lifecycle.md @@ -61,6 +61,13 @@ Decision: - **D8** — Non-goals: single-org membership still does **not** gate data access; RLS strip semantics, the dual frontend feature flags, and better-auth's ownership of member CRUD are all unchanged. +- **D9** *(recording, not a new ruling)* — The `session.create.before` hook + resolves a session's `activeOrganizationId` from the caller's `sys_member` + row (owner-preferred, else oldest), only when the draft lacks one, + best-effort, opt-out via `autoActiveOrganization: false`. Shipped behaviour, + previously cited in code as a pre-repo "ADR-0081 D1" whose number now + collides with this repo's ADR-0081; anchored here because it reads exactly + the invariant D1 states and D2 owns. ## Context @@ -84,10 +91,10 @@ so paths that create users *outside* better-auth's org flows (`/admin/create-use `/admin/import-users`, plain email signup) produce **member-less users**. In single-org mode a member-less user is degraded in concrete ways: -- the ADR-0081-D1 `session.create.before` hook resolves `activeOrganizationId` - from the user's `sys_member` row — no row ⇒ **null active org for every - session**, so better-auth org endpoints can't resolve an org for them and - `{current_org_id}` navigation tokens fall back; +- the `session.create.before` hook resolves `activeOrganizationId` from the + user's `sys_member` row (recorded as D9) — no row ⇒ **null active org for + every session**, so better-auth org endpoints can't resolve an org for them + and `{current_org_id}` navigation tokens fall back; - the Setup app's Members list omits them, right next to an Invite flow that *does* create membership — the operator-visible inconsistency reported against PR #2882. @@ -386,6 +393,56 @@ admin surfaces. actor-active-org inheritance, etc. remain the province of the flows that have real context (JIT, invites, host hooks). +### D9 — The session's active organization is resolved from membership, here + +> **This decision is a RECORDING, not a new ruling.** The behaviour below has +> shipped since before this ADR, and nothing about it changes. What changes is +> that it now has an anchor. The code carried it as **"ADR-0081 D1"**, a label +> inherited from a decision record that predates this repo's ADR series (the +> same pre-repo record the *Relates to* line names for the default-org +> bootstrap). That number now collides with this repo's +> [ADR-0081](./0081-trusted-react-page-tier.md) — the trusted `kind:'react'` +> page tier — so a reader following the citation landed in a document about +> React pages with no signal they were in the wrong record. The decision is +> restated here because this is the record that owns the fact it depends on: +> `sys_member` and the membership lifecycle (D1/D2). + +On session create, plugin-auth stamps the session's `activeOrganizationId` from +the caller's membership. Mechanically (`AuthManager.composeDatabaseHooks` → +`defaultActiveOrg`): + +- **Seam.** better-auth's `session.create.before` database hook. A + host-supplied `session.create.before` chains **first** and keeps precedence, + exactly as D2's reconciler yields to host hooks on `user.create.after`. +- **Selection.** Owner-preferred, else the oldest `sys_member` row for the + user — one selection helper, so every call site resolves identically. The + read runs through the system context, because the caller has no organization + yet at this point. +- **Only when absent.** A draft session that already carries an + `activeOrganizationId` is never overwritten. +- **Best-effort.** Failures are swallowed; login never fails on this + bookkeeping. +- **Opt-out.** `auth.autoActiveOrganization: false` restores raw better-auth + behaviour (sessions start org-less). Default `true`. +- **Ordering (#8245 / #8247).** The hook settles membership through D2's + reconciler *before* resolving the organization, so a user's first session is + not minted tenant-less by a race with better-auth's deferred + `user.create.after`. This is an ordering change only — same reconciler, same + policy, same target-org resolution — so it never widens who gets bound. + +**Why it lives in this record.** The stamp is a *read* of the invariant D1 +states and D2 owns: it can only resolve an organization when a `sys_member` row +exists, and which row exists is entirely decided by D1's policy and D2's +reconciler. Recording it anywhere else would separate the read from the write +that determines it. + +**What it does NOT decide.** When no `sys_member` row exists the hook declines, +and the resulting session is authenticated with no active organization. That is +a legal, declared state, and its semantics belong to +[ADR-0123](./0123-no-active-organization-session-semantics.md) — not here. D9 +records how the organization is *found*; ADR-0123 governs what happens when +there is none. + ## Rollout | Phase | Contents | Risk | diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 03d824942b..c8b090952c 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -3669,7 +3669,7 @@ describe('AuthManager', () => { }); }); - // ADR-0081 D1 — default active-org stamp on session create. + // ADR-0093 D9 — default active-org stamp on session create. describe('composeDatabaseHooks – session.create.before active-org default', () => { const OWNER_ROW = { id: 'm1', organization_id: 'org_owner', user_id: 'u1', role: 'owner' }; const MEMBER_ROW = { id: 'm2', organization_id: 'org_member', user_id: 'u1', role: 'member' }; diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index cfa1b0b67c..935ee5db4d 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -519,7 +519,7 @@ export interface AuthManagerOptions extends Partial { appName?: string; /** - * ADR-0081 D1 — default active-org on session create. When enabled + * ADR-0093 D9 — default active-org on session create. When enabled * (default), a `session.create.before` hook stamps `activeOrganizationId` * from the caller's `sys_member` row (owner-preferred) whenever the draft * lacks one. A host-supplied `session.create.before` (see @@ -4417,7 +4417,7 @@ export class AuthManager { } : stamp; - // ADR-0081 D1 — default active-org on session create. Without it, a user + // ADR-0093 D9 — default active-org on session create. Without it, a user // with memberships logs in with `activeOrganizationId = null`: better-auth // org endpoints can't resolve an active org (single-org invite dead-end) // and `{current_org_id}` nav tokens fall back to list views. Resolve the diff --git a/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.ts b/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.ts index bf0e4617cd..7446dc0643 100644 --- a/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.ts +++ b/packages/plugins/plugin-auth/src/impersonation-bearer-rotation.ts @@ -240,7 +240,7 @@ export async function rotateCallerBearerOnImpersonation(ctx: any): Promise // admin had selected, which is the only piece of session state they would // notice losing on the way back. Everything else is re-derived for a session // minted now — `ipAddress`/`userAgent` from this request, and - // `activeOrganizationId` itself by ADR-0081 D1's `session.create.before` + // `activeOrganizationId` itself by ADR-0093 D9's `session.create.before` // stamp when the admin had not switched away from their default. // // Spreading the whole old row here instead would be handing the producer a diff --git a/packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts b/packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts index 03d920680d..f4da0871a9 100644 --- a/packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts +++ b/packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts @@ -42,7 +42,7 @@ export interface SeamPrincipal { email?: string; /** * better-auth `session.activeOrganizationId` — the ONE wire field a real - * login carries the caller's active organization on (ADR-0081 D1 stamps it + * login carries the caller's active organization on (ADR-0093 D9 stamps it * from the user's `sys_member` row on session create). `null` reproduces a * membership-less / platform-scoped session. */ diff --git a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts index 86217acea5..fe80c87314 100644 --- a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts @@ -25,7 +25,7 @@ // This file is that measurement, taken through the real login path rather than // inferred from reading the resolver. Every step below is the product's own: // better-auth `sign-up` / `sign-in` mint the session, `session.create.before` -// (ADR-0081 D1) is the hook that would have stamped an active organization and +// (ADR-0093 D9) is the hook that would have stamped an active organization and // declines to because the user holds no `sys_member` row, `resolveAuthzContext` // turns that session into the execution context, and the REST route is the one // the Setup sharing pages call. Nothing here is simulated, and the @@ -166,8 +166,8 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r }); it('PRECONDITION: the exposed persona holds no membership, and its SESSION carries no active organization', async () => { - // This is the card's unmeasured half. `session.create.before` (ADR-0081 - // D1) stamps `activeOrganizationId` from the caller's `sys_member` row; + // This is the card's unmeasured half. `session.create.before` (ADR-0093 + // D9) stamps `activeOrganizationId` from the caller's `sys_member` row; // with no such row it declines, and nothing downstream re-derives one. const members = await ql.find('sys_member', { where: { user_id: orgLessUserId }, context: SYS }); expect(Array.isArray(members) ? members : members?.records ?? []).toHaveLength(0);