Skip to content

Commit da5e686

Browse files
authored
fix(auth): tenancy-mode-aware create-user bind + ADR-0093 integration tests (#2887)
Follow-up to #2884. Multi-org runtime verification caught a real bug: the endpoint create-user bind resolved its target org via resolveDefaultOrgId, which prefers slug='default' regardless of org count — so in multi-org it would have mis-bound every new user into the bootstrap org (violating ADR-0093 D3). The bind now consults the tenancy service (single → default org; multi → no bind), falling back to the old resolution only when tenancy isn't wired. Adds dogfood integration tests driving the real better-auth pipeline: sign-up reconciler membership, D6 backfill, invite-only policy, and the D2 yield rule. Exports reconcile-membership + tenancy-service as the ADR-0093 host API.
1 parent 9f03fdd commit da5e686

8 files changed

Lines changed: 240 additions & 1 deletion

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(auth): create-user membership bind is tenancy-mode-aware; export the ADR-0093 host API
6+
7+
Multi-org runtime verification (real `@objectstack/organizations` linked into a
8+
live stack) caught a gap in the #2884 endpoint bind: it resolved its target org
9+
via `resolveDefaultOrgId` (slug='default' first), so in a multi-org deployment —
10+
where the bootstrap default org coexists with real tenant orgs — `/admin/create-user`
11+
would have bound the new user into the default org, violating ADR-0093 D3
12+
("the framework never guesses in multi mode"). The bind now consults the
13+
`tenancy` service (`getTenancy` on the endpoint deps): single mode → default org,
14+
multi mode → no bind. Verified live: multi-org create-user and sign-up both leave
15+
the new user member-less (invites / host hooks own membership there); single-org
16+
behavior unchanged.
17+
18+
Also exports `reconcile-membership` and `tenancy-service` from the package index
19+
as the public host API, and adds dogfood integration tests driving the REAL
20+
better-auth pipeline: sign-up membership via the reconciler hook alone, backfill
21+
bind + idempotency, invite-only refusal, and the yield-to-host-membership rule.

packages/dogfood/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"@objectstack/example-crm": "workspace:*",
1313
"@objectstack/example-showcase": "workspace:*",
1414
"@objectstack/objectql": "workspace:*",
15+
"@objectstack/plugin-auth": "workspace:*",
1516
"@objectstack/plugin-security": "workspace:*",
1617
"@objectstack/spec": "workspace:*",
1718
"@objectstack/verify": "workspace:*"
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0093 — membership lifecycle, end-to-end against a real stack.
5+
*
6+
* The original bug (#2882): user-creation paths outside better-auth's org
7+
* flows (`/admin/create-user`, sign-up) never wrote a `sys_member` row, so in
8+
* single-org mode those users didn't belong to the Default Organization.
9+
* ADR-0093 D2 gives the invariant ONE owner — a reconciler composed into
10+
* better-auth's `user.create.after` hook.
11+
*
12+
* Harness note: `bootStack` deliberately disables the default-org bootstrap
13+
* (`autoDefaultOrganization: false` — the ADR-0057 "single-tenant, no org row"
14+
* posture) and does not enable the better-auth admin plugin. So these tests
15+
* mint the single-org Default Organization themselves (system context, exactly
16+
* what the bootstrap would do) and drive the invariant through the REAL
17+
* better-auth sign-up pipeline — the path with no endpoint-side bind, where a
18+
* membership can only come from the `user.create.after` reconciler. The
19+
* endpoint-side create-user bind has its own unit coverage
20+
* (plugin-auth/src/admin-user-endpoints.test.ts).
21+
*/
22+
23+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
24+
import showcaseStack from '@objectstack/example-showcase';
25+
import { bootStack, type VerifyStack } from '@objectstack/verify';
26+
import { backfillMemberships, reconcileMembership } from '@objectstack/plugin-auth';
27+
28+
const SYSTEM_CTX = { isSystem: true };
29+
30+
async function findRows(ql: any, object: string, where: any, limit = 50): Promise<any[]> {
31+
const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX });
32+
return Array.isArray(rows) ? rows : (rows?.records ?? []);
33+
}
34+
35+
describe('ADR-0093: membership lifecycle (single-org, real stack)', () => {
36+
let stack: VerifyStack;
37+
let ql: any;
38+
let defaultOrgId: string;
39+
40+
beforeAll(async () => {
41+
stack = await bootStack(showcaseStack, {}); // single-org (no OS_MULTI_ORG_ENABLED)
42+
await stack.signIn();
43+
ql = await stack.kernel.getServiceAsync<any>('objectql');
44+
// Mint the Default Organization the single-org bootstrap would create
45+
// (the harness disables that bootstrap — see header). System context is
46+
// the legitimate writer for better-auth-managed tables (ADR-0092).
47+
const org = await ql.insert(
48+
'sys_organization',
49+
{ name: 'Default Organization', slug: 'default' },
50+
{ context: SYSTEM_CTX },
51+
);
52+
defaultOrgId = String(org.id);
53+
}, 120_000);
54+
55+
afterAll(async () => { await stack?.stop?.(); });
56+
57+
it('sign-up produces a membership through the user.create.after reconciler alone (D2, e2e)', async () => {
58+
// No endpoint bind exists on the sign-up path — a membership here can ONLY
59+
// come from the reconciler hook consulting tenancy.defaultOrgId().
60+
const token = await stack.signUp('reconciled.member@example.com', 'SignUp!Pass123', 'Reconciled Member');
61+
expect(token).toBeTruthy();
62+
const users = await findRows(ql, 'sys_user', { email: 'reconciled.member@example.com' }, 1);
63+
expect(users.length).toBe(1);
64+
const userId: string = users[0].id;
65+
66+
// better-auth defers user.create.after past the signup transaction —
67+
// poll briefly before asserting.
68+
let members: any[] = [];
69+
for (let i = 0; i < 40 && members.length === 0; i++) {
70+
members = await findRows(ql, 'sys_member', { user_id: userId }, 5);
71+
if (members.length === 0) await new Promise((r) => setTimeout(r, 250));
72+
}
73+
expect(members.length).toBe(1);
74+
expect(members[0].organization_id).toBe(defaultOrgId);
75+
expect(members[0].role).toBe('member');
76+
}, 30_000);
77+
78+
it('backfill binds a pre-existing member-less user and is idempotent (D6)', async () => {
79+
// A user created OUTSIDE the better-auth pipeline (system-context insert —
80+
// e.g. rows that predate the reconciler).
81+
const orphan = await ql.insert(
82+
'sys_user',
83+
{ name: 'Orphan User', email: 'orphan.user@example.com' },
84+
{ context: SYSTEM_CTX },
85+
);
86+
expect(orphan?.id).toBeTruthy();
87+
expect((await findRows(ql, 'sys_member', { user_id: orphan.id })).length).toBe(0);
88+
89+
const resolveTargetOrg = async () => defaultOrgId;
90+
const first = await backfillMemberships(ql, { policy: 'auto', resolveTargetOrg });
91+
expect(first.bound).toBeGreaterThanOrEqual(1); // orphan (+ any other member-less user, e.g. the dev admin)
92+
const members = await findRows(ql, 'sys_member', { user_id: orphan.id });
93+
expect(members.length).toBe(1);
94+
expect(members[0].organization_id).toBe(defaultOrgId);
95+
expect(members[0].role).toBe('member');
96+
97+
// Idempotent: a second run binds nothing new.
98+
const second = await backfillMemberships(ql, { policy: 'auto', resolveTargetOrg });
99+
expect(second.bound).toBe(0);
100+
expect((await findRows(ql, 'sys_member', { user_id: orphan.id })).length).toBe(1);
101+
}, 30_000);
102+
103+
it('invite-only policy never auto-binds, on the real engine (D1)', async () => {
104+
const loner = await ql.insert(
105+
'sys_user',
106+
{ name: 'Invite Only', email: 'invite.only@example.com' },
107+
{ context: SYSTEM_CTX },
108+
);
109+
110+
const res = await reconcileMembership(ql, loner.id, {
111+
policy: 'invite-only',
112+
resolveTargetOrg: async () => defaultOrgId,
113+
});
114+
expect(res.outcome).toBe('policy-skip');
115+
expect((await findRows(ql, 'sys_member', { user_id: loner.id })).length).toBe(0);
116+
117+
// Backfill refuses under invite-only too.
118+
const bf = await backfillMemberships(ql, {
119+
policy: 'invite-only',
120+
resolveTargetOrg: async () => defaultOrgId,
121+
});
122+
expect(bf.reason).toBe('policy');
123+
expect((await findRows(ql, 'sys_member', { user_id: loner.id })).length).toBe(0);
124+
}, 30_000);
125+
126+
it('reconciler yields to an existing membership instead of double-binding (D2 yield rule)', async () => {
127+
// Simulate a host hook having bound the user to some OTHER org first —
128+
// the reconciler must respect it and never add a second membership.
129+
const hosted = await ql.insert(
130+
'sys_user',
131+
{ name: 'Host Bound', email: 'host.bound@example.com' },
132+
{ context: SYSTEM_CTX },
133+
);
134+
const otherOrg = await ql.insert(
135+
'sys_organization',
136+
{ name: 'Host Org', slug: 'host-org' },
137+
{ context: SYSTEM_CTX },
138+
);
139+
await ql.insert(
140+
'sys_member',
141+
{ organization_id: String(otherOrg.id), user_id: hosted.id, role: 'owner' },
142+
{ context: SYSTEM_CTX },
143+
);
144+
145+
const res = await reconcileMembership(ql, hosted.id, {
146+
policy: 'auto',
147+
resolveTargetOrg: async () => defaultOrgId,
148+
});
149+
expect(res.outcome).toBe('yielded');
150+
const members = await findRows(ql, 'sys_member', { user_id: hosted.id });
151+
expect(members.length).toBe(1);
152+
expect(members[0].organization_id).toBe(String(otherOrg.id)); // host's bind won
153+
}, 30_000);
154+
});

packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,41 @@ describe('runAdminCreateUser', () => {
397397
expect(m.warn).toHaveBeenCalled();
398398
});
399399

400+
it('multi-org via tenancy: does NOT bind even when a slug=default org exists (ADR-0093 D3 regression)', async () => {
401+
// A multi-org deployment carries the bootstrap default org NEXT TO real
402+
// tenant orgs. Without the tenancy service the raw resolver would prefer
403+
// slug='default' and mis-bind the new user into it; the tenancy service
404+
// reports multi mode (defaultOrgId → null) and the bind must no-op.
405+
const m = makeDepsWithOrgs({
406+
orgs: [{ id: 'org_default', slug: 'default' }, { id: 'org_tenant_b' }],
407+
});
408+
m.deps.getTenancy = () => ({ defaultOrgId: async () => null });
409+
const res = await runAdminCreateUser(
410+
m.deps,
411+
makeRequest({ email: 'a@b.co', generatePassword: true }),
412+
ACTOR,
413+
);
414+
expect(res.status).toBe(200);
415+
const data = res.body.data as any;
416+
expect(data.organizationId).toBeUndefined();
417+
expect(data.membershipCreated).toBe(false);
418+
expect(m.engineInsert.mock.calls.some((c) => c[0] === 'sys_member')).toBe(false);
419+
});
420+
421+
it('single-org via tenancy: binds to the org the tenancy service resolves', async () => {
422+
const m = makeDepsWithOrgs({ orgs: [{ id: 'org_default', slug: 'default' }] });
423+
m.deps.getTenancy = () => ({ defaultOrgId: async () => 'org_default' });
424+
const res = await runAdminCreateUser(
425+
m.deps,
426+
makeRequest({ email: 'a@b.co', generatePassword: true }),
427+
ACTOR,
428+
);
429+
expect(res.status).toBe(200);
430+
const data = res.body.data as any;
431+
expect(data.organizationId).toBe('org_default');
432+
expect(data.membershipCreated).toBe(true);
433+
});
434+
400435
it('no-ops the bind (no throw) when the engine has no find surface', async () => {
401436
// Default makeDeps engine exposes only update/insert — the bind must be a
402437
// clean no-op, leaving exactly the audit insert.

packages/plugins/plugin-auth/src/admin-user-endpoints.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ export interface AdminUserEndpointDeps {
8282
noteMustChangePasswordIssued(): void;
8383
/** Is the better-auth phoneNumber plugin wired (#2766 V1.5)? */
8484
phoneNumberEnabled?(): boolean;
85+
/**
86+
* ADR-0093 D3 — accessor for the `tenancy` service. When present, the
87+
* create-user membership bind resolves its target org through
88+
* `tenancy.defaultOrgId()` (single → default org; MULTI → null, never
89+
* guess). Without it the bind falls back to `resolveDefaultOrgId`, which
90+
* prefers the `slug='default'` org — correct single-org, but in multi-org
91+
* (where a bootstrap default org coexists with real tenants) it would
92+
* mis-bind new users into the default org. Wire this everywhere tenancy
93+
* is registered.
94+
*/
95+
getTenancy?(): { defaultOrgId(): Promise<string | null> } | undefined;
8596
logger?: { warn(msg: string): void };
8697
}
8798

@@ -271,9 +282,15 @@ async function bindUserToSoleOrganization(
271282
userId: string,
272283
): Promise<{ organizationId: string | null; membershipCreated: boolean }> {
273284
const engine = deps.getDataEngine();
285+
// ADR-0093 D3 — mode-aware target resolution. The tenancy service returns
286+
// null in multi mode (the framework never guesses a tenant), which also
287+
// keeps this endpoint bind from grabbing the bootstrap `slug='default'` org
288+
// in a multi-org deployment. Fallback (no tenancy wired: lean embeddings,
289+
// legacy mocks) keeps the single-org resolution.
290+
const tenancy = deps.getTenancy?.();
274291
const result = await reconcileMembership(engine, userId, {
275292
policy: 'auto',
276-
resolveTargetOrg: () => resolveDefaultOrgId(engine),
293+
resolveTargetOrg: () => (tenancy ? tenancy.defaultOrgId() : resolveDefaultOrgId(engine)),
277294
logger: deps.logger
278295
? { warn: (msg, meta) => deps.logger?.warn(`${msg} ${meta ? JSON.stringify(meta) : ''}`.trim()) }
279296
: undefined,

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,6 +1379,10 @@ export class AuthPlugin implements Plugin {
13791379
assertPasswordComplexity: (pw: string) => this.authManager!.checkPasswordComplexity(pw),
13801380
noteMustChangePasswordIssued: () => this.authManager!.noteMustChangePasswordIssued(),
13811381
phoneNumberEnabled: () => this.authManager!.isPhoneNumberEnabled(),
1382+
// ADR-0093 D3 — mode-aware create-user bind: multi-org resolves NO
1383+
// target org (never grab the bootstrap default org in a multi-tenant
1384+
// deployment); single-org resolves the default org.
1385+
getTenancy: () => this.tenancy ?? undefined,
13821386
logger: ctx.logger,
13831387
});
13841388
const gateAdmin = async (c: any): Promise<{ id: string; email?: string } | Response> => {

packages/plugins/plugin-auth/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,8 @@ export * from './register-sso-provider.js';
2222
export * from './send-verification-email.js';
2323
export * from './objectql-adapter.js';
2424
export * from './auth-schema-config.js';
25+
// ADR-0093 — membership reconciler + tenancy service (public host API: hosts
26+
// compose the reconciler into their own hooks; embeddings query tenancy mode).
27+
export * from './reconcile-membership.js';
28+
export * from './tenancy-service.js';
2529
export type { AuthConfig, AuthProviderConfig, AuthPluginConfig } from '@objectstack/spec/system';

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)