diff --git a/.changeset/tenancy-aware-endpoint-bind.md b/.changeset/tenancy-aware-endpoint-bind.md new file mode 100644 index 000000000..da2dbec02 --- /dev/null +++ b/.changeset/tenancy-aware-endpoint-bind.md @@ -0,0 +1,21 @@ +--- +"@objectstack/plugin-auth": patch +--- + +fix(auth): create-user membership bind is tenancy-mode-aware; export the ADR-0093 host API + +Multi-org runtime verification (real `@objectstack/organizations` linked into a +live stack) caught a gap in the #2884 endpoint bind: it resolved its target org +via `resolveDefaultOrgId` (slug='default' first), so in a multi-org deployment — +where the bootstrap default org coexists with real tenant orgs — `/admin/create-user` +would have bound the new user into the default org, violating ADR-0093 D3 +("the framework never guesses in multi mode"). The bind now consults the +`tenancy` service (`getTenancy` on the endpoint deps): single mode → default org, +multi mode → no bind. Verified live: multi-org create-user and sign-up both leave +the new user member-less (invites / host hooks own membership there); single-org +behavior unchanged. + +Also exports `reconcile-membership` and `tenancy-service` from the package index +as the public host API, and adds dogfood integration tests driving the REAL +better-auth pipeline: sign-up membership via the reconciler hook alone, backfill +bind + idempotency, invite-only refusal, and the yield-to-host-membership rule. diff --git a/packages/dogfood/package.json b/packages/dogfood/package.json index 6cb4fe587..e97da66f0 100644 --- a/packages/dogfood/package.json +++ b/packages/dogfood/package.json @@ -12,6 +12,7 @@ "@objectstack/example-crm": "workspace:*", "@objectstack/example-showcase": "workspace:*", "@objectstack/objectql": "workspace:*", + "@objectstack/plugin-auth": "workspace:*", "@objectstack/plugin-security": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/verify": "workspace:*" diff --git a/packages/dogfood/test/membership-reconciler.dogfood.test.ts b/packages/dogfood/test/membership-reconciler.dogfood.test.ts new file mode 100644 index 000000000..df2c56c29 --- /dev/null +++ b/packages/dogfood/test/membership-reconciler.dogfood.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0093 — membership lifecycle, end-to-end against a real stack. + * + * The original bug (#2882): user-creation paths outside better-auth's org + * flows (`/admin/create-user`, sign-up) never wrote a `sys_member` row, so in + * single-org mode those users didn't belong to the Default Organization. + * ADR-0093 D2 gives the invariant ONE owner — a reconciler composed into + * better-auth's `user.create.after` hook. + * + * Harness note: `bootStack` deliberately disables the default-org bootstrap + * (`autoDefaultOrganization: false` — the ADR-0057 "single-tenant, no org row" + * posture) and does not enable the better-auth admin plugin. So these tests + * mint the single-org Default Organization themselves (system context, exactly + * what the bootstrap would do) and drive the invariant through the REAL + * better-auth sign-up pipeline — the path with no endpoint-side bind, where a + * membership can only come from the `user.create.after` reconciler. The + * endpoint-side create-user bind has its own unit coverage + * (plugin-auth/src/admin-user-endpoints.test.ts). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { backfillMemberships, reconcileMembership } from '@objectstack/plugin-auth'; + +const SYSTEM_CTX = { isSystem: true }; + +async function findRows(ql: any, object: string, where: any, limit = 50): Promise { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); +} + +describe('ADR-0093: membership lifecycle (single-org, real stack)', () => { + let stack: VerifyStack; + let ql: any; + let defaultOrgId: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, {}); // single-org (no OS_MULTI_ORG_ENABLED) + await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + // Mint the Default Organization the single-org bootstrap would create + // (the harness disables that bootstrap — see header). System context is + // the legitimate writer for better-auth-managed tables (ADR-0092). + const org = await ql.insert( + 'sys_organization', + { name: 'Default Organization', slug: 'default' }, + { context: SYSTEM_CTX }, + ); + defaultOrgId = String(org.id); + }, 120_000); + + afterAll(async () => { await stack?.stop?.(); }); + + it('sign-up produces a membership through the user.create.after reconciler alone (D2, e2e)', async () => { + // No endpoint bind exists on the sign-up path — a membership here can ONLY + // come from the reconciler hook consulting tenancy.defaultOrgId(). + const token = await stack.signUp('reconciled.member@example.com', 'SignUp!Pass123', 'Reconciled Member'); + expect(token).toBeTruthy(); + const users = await findRows(ql, 'sys_user', { email: 'reconciled.member@example.com' }, 1); + expect(users.length).toBe(1); + const userId: string = users[0].id; + + // better-auth defers user.create.after past the signup transaction — + // poll briefly before asserting. + let members: any[] = []; + for (let i = 0; i < 40 && members.length === 0; i++) { + members = await findRows(ql, 'sys_member', { user_id: userId }, 5); + if (members.length === 0) await new Promise((r) => setTimeout(r, 250)); + } + expect(members.length).toBe(1); + expect(members[0].organization_id).toBe(defaultOrgId); + expect(members[0].role).toBe('member'); + }, 30_000); + + it('backfill binds a pre-existing member-less user and is idempotent (D6)', async () => { + // A user created OUTSIDE the better-auth pipeline (system-context insert — + // e.g. rows that predate the reconciler). + const orphan = await ql.insert( + 'sys_user', + { name: 'Orphan User', email: 'orphan.user@example.com' }, + { context: SYSTEM_CTX }, + ); + expect(orphan?.id).toBeTruthy(); + expect((await findRows(ql, 'sys_member', { user_id: orphan.id })).length).toBe(0); + + const resolveTargetOrg = async () => defaultOrgId; + const first = await backfillMemberships(ql, { policy: 'auto', resolveTargetOrg }); + expect(first.bound).toBeGreaterThanOrEqual(1); // orphan (+ any other member-less user, e.g. the dev admin) + const members = await findRows(ql, 'sys_member', { user_id: orphan.id }); + expect(members.length).toBe(1); + expect(members[0].organization_id).toBe(defaultOrgId); + expect(members[0].role).toBe('member'); + + // Idempotent: a second run binds nothing new. + const second = await backfillMemberships(ql, { policy: 'auto', resolveTargetOrg }); + expect(second.bound).toBe(0); + expect((await findRows(ql, 'sys_member', { user_id: orphan.id })).length).toBe(1); + }, 30_000); + + it('invite-only policy never auto-binds, on the real engine (D1)', async () => { + const loner = await ql.insert( + 'sys_user', + { name: 'Invite Only', email: 'invite.only@example.com' }, + { context: SYSTEM_CTX }, + ); + + const res = await reconcileMembership(ql, loner.id, { + policy: 'invite-only', + resolveTargetOrg: async () => defaultOrgId, + }); + expect(res.outcome).toBe('policy-skip'); + expect((await findRows(ql, 'sys_member', { user_id: loner.id })).length).toBe(0); + + // Backfill refuses under invite-only too. + const bf = await backfillMemberships(ql, { + policy: 'invite-only', + resolveTargetOrg: async () => defaultOrgId, + }); + expect(bf.reason).toBe('policy'); + expect((await findRows(ql, 'sys_member', { user_id: loner.id })).length).toBe(0); + }, 30_000); + + it('reconciler yields to an existing membership instead of double-binding (D2 yield rule)', async () => { + // Simulate a host hook having bound the user to some OTHER org first — + // the reconciler must respect it and never add a second membership. + const hosted = await ql.insert( + 'sys_user', + { name: 'Host Bound', email: 'host.bound@example.com' }, + { context: SYSTEM_CTX }, + ); + const otherOrg = await ql.insert( + 'sys_organization', + { name: 'Host Org', slug: 'host-org' }, + { context: SYSTEM_CTX }, + ); + await ql.insert( + 'sys_member', + { organization_id: String(otherOrg.id), user_id: hosted.id, role: 'owner' }, + { context: SYSTEM_CTX }, + ); + + const res = await reconcileMembership(ql, hosted.id, { + policy: 'auto', + resolveTargetOrg: async () => defaultOrgId, + }); + expect(res.outcome).toBe('yielded'); + const members = await findRows(ql, 'sys_member', { user_id: hosted.id }); + expect(members.length).toBe(1); + expect(members[0].organization_id).toBe(String(otherOrg.id)); // host's bind won + }, 30_000); +}); diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts index 549e66868..f836b8095 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -397,6 +397,41 @@ describe('runAdminCreateUser', () => { expect(m.warn).toHaveBeenCalled(); }); + it('multi-org via tenancy: does NOT bind even when a slug=default org exists (ADR-0093 D3 regression)', async () => { + // A multi-org deployment carries the bootstrap default org NEXT TO real + // tenant orgs. Without the tenancy service the raw resolver would prefer + // slug='default' and mis-bind the new user into it; the tenancy service + // reports multi mode (defaultOrgId → null) and the bind must no-op. + const m = makeDepsWithOrgs({ + orgs: [{ id: 'org_default', slug: 'default' }, { id: 'org_tenant_b' }], + }); + m.deps.getTenancy = () => ({ defaultOrgId: async () => null }); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ email: 'a@b.co', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(200); + const data = res.body.data as any; + expect(data.organizationId).toBeUndefined(); + expect(data.membershipCreated).toBe(false); + expect(m.engineInsert.mock.calls.some((c) => c[0] === 'sys_member')).toBe(false); + }); + + it('single-org via tenancy: binds to the org the tenancy service resolves', async () => { + const m = makeDepsWithOrgs({ orgs: [{ id: 'org_default', slug: 'default' }] }); + m.deps.getTenancy = () => ({ defaultOrgId: async () => 'org_default' }); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ email: 'a@b.co', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(200); + const data = res.body.data as any; + expect(data.organizationId).toBe('org_default'); + expect(data.membershipCreated).toBe(true); + }); + it('no-ops the bind (no throw) when the engine has no find surface', async () => { // Default makeDeps engine exposes only update/insert — the bind must be a // clean no-op, leaving exactly the audit insert. diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index 35b3913cc..419dae77b 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -82,6 +82,17 @@ export interface AdminUserEndpointDeps { noteMustChangePasswordIssued(): void; /** Is the better-auth phoneNumber plugin wired (#2766 V1.5)? */ phoneNumberEnabled?(): boolean; + /** + * ADR-0093 D3 — accessor for the `tenancy` service. When present, the + * create-user membership bind resolves its target org through + * `tenancy.defaultOrgId()` (single → default org; MULTI → null, never + * guess). Without it the bind falls back to `resolveDefaultOrgId`, which + * prefers the `slug='default'` org — correct single-org, but in multi-org + * (where a bootstrap default org coexists with real tenants) it would + * mis-bind new users into the default org. Wire this everywhere tenancy + * is registered. + */ + getTenancy?(): { defaultOrgId(): Promise } | undefined; logger?: { warn(msg: string): void }; } @@ -271,9 +282,15 @@ async function bindUserToSoleOrganization( userId: string, ): Promise<{ organizationId: string | null; membershipCreated: boolean }> { const engine = deps.getDataEngine(); + // ADR-0093 D3 — mode-aware target resolution. The tenancy service returns + // null in multi mode (the framework never guesses a tenant), which also + // keeps this endpoint bind from grabbing the bootstrap `slug='default'` org + // in a multi-org deployment. Fallback (no tenancy wired: lean embeddings, + // legacy mocks) keeps the single-org resolution. + const tenancy = deps.getTenancy?.(); const result = await reconcileMembership(engine, userId, { policy: 'auto', - resolveTargetOrg: () => resolveDefaultOrgId(engine), + resolveTargetOrg: () => (tenancy ? tenancy.defaultOrgId() : resolveDefaultOrgId(engine)), logger: deps.logger ? { warn: (msg, meta) => deps.logger?.warn(`${msg} ${meta ? JSON.stringify(meta) : ''}`.trim()) } : undefined, diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index c5f362ec8..ca9fcd57a 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1379,6 +1379,10 @@ export class AuthPlugin implements Plugin { assertPasswordComplexity: (pw: string) => this.authManager!.checkPasswordComplexity(pw), noteMustChangePasswordIssued: () => this.authManager!.noteMustChangePasswordIssued(), phoneNumberEnabled: () => this.authManager!.isPhoneNumberEnabled(), + // ADR-0093 D3 — mode-aware create-user bind: multi-org resolves NO + // target org (never grab the bootstrap default org in a multi-tenant + // deployment); single-org resolves the default org. + getTenancy: () => this.tenancy ?? undefined, logger: ctx.logger, }); const gateAdmin = async (c: any): Promise<{ id: string; email?: string } | Response> => { diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index edbdba0a8..2d6fe3612 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -22,4 +22,8 @@ export * from './register-sso-provider.js'; export * from './send-verification-email.js'; export * from './objectql-adapter.js'; export * from './auth-schema-config.js'; +// ADR-0093 — membership reconciler + tenancy service (public host API: hosts +// compose the reconciler into their own hooks; embeddings query tenancy mode). +export * from './reconcile-membership.js'; +export * from './tenancy-service.js'; export type { AuthConfig, AuthProviderConfig, AuthPluginConfig } from '@objectstack/spec/system'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 938df9e3a..e33b71c55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -742,6 +742,9 @@ importers: '@objectstack/objectql': specifier: workspace:* version: link:../objectql + '@objectstack/plugin-auth': + specifier: workspace:* + version: link:../plugins/plugin-auth '@objectstack/plugin-security': specifier: workspace:* version: link:../plugins/plugin-security