Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/tenancy-aware-endpoint-bind.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/dogfood/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
Expand Down
154 changes: 154 additions & 0 deletions packages/dogfood/test/membership-reconciler.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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<any[]> {
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<any>('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);
});
35 changes: 35 additions & 0 deletions packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 18 additions & 1 deletion packages/plugins/plugin-auth/src/admin-user-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> } | undefined;
logger?: { warn(msg: string): void };
}

Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> => {
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/plugin-auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.