diff --git a/.changeset/21791-membership-decided-at-creation.md b/.changeset/21791-membership-decided-at-creation.md new file mode 100644 index 00000000000..ed308049d92 --- /dev/null +++ b/.changeset/21791-membership-decided-at-creation.md @@ -0,0 +1,28 @@ +--- +'@objectstack/plugin-auth': minor +'@objectstack/organizations': patch +'@objectstack/types': patch +--- + +Membership under the `auto` policy is settled when the user is created, per ADR-0093 D7. + +Clause-②: yes (widening) + +- **At creation.** A user created under `auto` is bound to the default organization at creation, and the first session of that creating request carries it. Membership is not decided again when the user signs in later. +- **One-time backfill.** The ADR-0093 D6 backfill of pre-existing users runs once per deployment, and once per process even if its record cannot be written. Its verdict is recorded in the `sys_migration` ledger with id `adr-0093-membership-backfill`. A pass on a deployment with no organization at all records nothing, and the backfill runs again once the default organization is created. If the ledger is missing or cannot be read, the pass does not run and logs a warning. If the record cannot be written, that is logged as an error. `OS_SKIP_MEMBERSHIP_BACKFILL=1` still disables the pass. +- **Default organization owner.** The platform admin is bound as owner of the default organization once, by the bootstrap that first decides it, in both the single-org and the walled organizations wiring. The decision is recorded in the same ledger with id `adr-0093-default-org-owner-bind` and held for the rest of the process even if the record cannot be written. After that, a missing default organization is recreated without binding anyone. To recover, an administrator re-adds members, including themselves, through member management. On a kernel without the ledger, the owner is bound only when the bootstrap creates the default organization. If the ledger exists but cannot be read, that call binds nobody and the next trigger decides. +- **Full scan.** The backfill reads the user and membership tables page by page with no row cap. A scan that cannot read either table in full binds nobody and records nothing. With organizations present but no default target, as in multi-organization deployments, the refusal is recorded. +- **Upgrade.** The first boot of an upgraded deployment runs the backfill once. +- **Unchanged.** `invite-only` binds nobody. Multi-organization deployments get no automatic binding. Users created through sign-up, admin create-user, import or SSO are bound under `auto` as before. +- **Narrowed.** A `sys_user` row inserted straight through the data engine never passes through user creation. Once the backfill is recorded, a later `app:seeded` pass leaves it unbound. That includes users written by a seed that finishes after its inline budget. Code that inserts users this way must write their membership itself; the showcase approval-demo personas now do. +- **`keysetWalk` (`@objectstack/types`).** The walk now decides that a page did not advance only when it gets back the same cursor key or the same page again. It no longer compares keys in JavaScript string order, which disagrees with database collations and could report a healthy walk as truncated. +- **New public surface of `@objectstack/plugin-auth` (additive).** + - `createEnsureDefaultOrganizationOnce` and `EnsureDefaultOrganizationOnceOptions` are the gated bootstrap both wirings call. + - `ObjectQLAdapterFactoryOptions` adds `onRecordCreated`, passed as the new optional second argument of `createObjectQLAdapterFactory`. + - `EnsureDefaultOrganizationOptions` gains `bindOnlyOnCreate` and `bindOwner`. + - `EnsureDefaultOrganizationResult.reason` gains `'owner_bind_decided'`. + - `BackfillMembershipsResult.reason` gains `'scan-incomplete'`. + - Code that switches exhaustively over those reasons sees one more member. +- **`backfillMemberships` (exported) changed behaviour.** Its `limit` option used to cap the rows scanned (default 5000); it is now the page size of a full scan with no cap. The function now needs a reader that can page by `id`; a reader that cannot gets `scan-incomplete` and binds nobody, where it used to bind. A direct call is not gated by the one-time ledger and decides membership again on every call; call it through the one-time pass instead. +- **Policy switch.** Once a pass under `invite-only` is recorded, switching the policy to `auto` later does not backfill the users who existed then; they get membership through invitation or member management. +- **Deprecated, not removed.** The ungated `ensureDefaultOrganization`, both plugin-auth's helper and the `@objectstack/organizations` wrapper, is `@deprecated` in favour of `createEnsureDefaultOrganizationOnce`. diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index f0da07d96e7..ab081f68f9c 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **67 of the 231 sites are spelled that way**. A +forwarding shim cannot, and **67 of the 232 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **231** | +| 175 write call sites | quoted in the merged changeset | **232** | | 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **34** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 231** decidable, **77** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 112 decidably elevated, 0 decidably not, 102 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 232** decidable, **78** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 113 decidably elevated, 0 decidably not, 102 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -207,11 +207,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -112 of 231 (48%) as decidably elevated, with 102 more whose elevation is a +113 of 232 (49%) as decidably elevated, with 102 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `9 / 231`, and say what it is**: the sites whose options argument was +⇒ **Cite `9 / 232`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **34 further sites** have an options argument this @@ -223,23 +223,23 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **231** | +| write call sites on the application surface | **232** | | …whose object name is statically decidable | 154 | -| …whose object name is chosen at run time | 77 | +| …whose object name is chosen at run time | 78 | | …against an object with tenancy ENABLED | 153 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 147 | +| threading a tenant context | 148 | | PROVABLY carrying none (options read, no context key) | **17** | | …of those, against a decidably tenancy-enabled object | **9** | | options argument UNREADABLE — may or may not carry one | 67 | | …of those, against a decidably tenancy-enabled object | 34 | -| threading a decidably ELEVATED (`isSystem`) context | 112 | +| threading a decidably ELEVATED (`isSystem`) context | 113 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 102 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 183 | +| receiver carried a readable engine type | 184 | | receiver erased, placed by the object NAME | 28 | | receiver erased, placed by an `object: string` PARAMETER | 15 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | @@ -247,7 +247,7 @@ cannot read, and they are neither in nor out. | object name spelled inline | 103 | | object name spelled through a `const` | 51 | | object name is an `object: string` parameter | 17 | -| object name is some other run-time expression | 60 | +| object name is some other run-time expression | 61 | ### Subtractions the census could NOT defend — enforced @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-02 at `b668cf134`. +Measured on 2026-10-05 at `34782539c`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 602 | -| engine-shaped types recognised | 67 | +| tracked non-test sources scanned | 605 | +| engine-shaped types recognised | 68 | | declared objects in the registry | 116 | -| same-named calls subtracted as non-engine | 152 | +| same-named calls subtracted as non-engine | 155 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 9aea141c009..5bfd9bfd4ab 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 231 | +| Write call sites | 232 | | Object name statically decidable | 154 | -| Object name chosen at run time | 77 | +| Object name chosen at run time | 78 | | Against a tenancy-enabled object | 153 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 147 | +| Threading a tenant context | 148 | | Provably carrying none | 17 | | …and decidably tenancy-enabled | 9 | | Options argument unreadable | 67 | | …and decidably tenancy-enabled | 34 | -| Threading a decidably elevated context | 112 | +| Threading a decidably elevated context | 113 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 102 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-02 at `b668cf134`. +Measured on 2026-10-05 at `34782539c`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 602 | -| engine-shaped types recognised | 67 | +| tracked non-test sources scanned | 605 | +| engine-shaped types recognised | 68 | | declared objects in the registry | 116 | -| same-named calls subtracted as non-engine | 152 | +| same-named calls subtracted as non-engine | 155 | ## Every site @@ -135,6 +135,7 @@ Measured on 2026-10-02 at `b668cf134`. | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `SystemObjectName.USER` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `insert` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/member-role-canonical.ts` | `update` | `MEMBER_OBJECT` | undecidable | elevated | 1 | +| `packages/plugins/plugin-auth/src/membership-backfill-ledger.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/membership-ended-session.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 2 | | `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `delete` | `m` | undecidable | options unreadable | 1 | | `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `insert` | `m` | undecidable | options unreadable | 1 | diff --git a/examples/app-showcase/src/security/seed-approval-demo.ts b/examples/app-showcase/src/security/seed-approval-demo.ts index 99ed7939e74..a0d0b09a7e7 100644 --- a/examples/app-showcase/src/security/seed-approval-demo.ts +++ b/examples/app-showcase/src/security/seed-approval-demo.ts @@ -235,6 +235,7 @@ async function ensureCredentialAccount( async function ensureDemoUser( ctx: ApprovalDemoContext, user: { id: string; name: string; email: string; phone_number?: string }, + organizationId: string | null, ): Promise { const existing = await findOne(ctx, 'sys_user', { email: user.email }); if (existing?.id) return String(existing.id); @@ -246,6 +247,12 @@ async function ensureDemoUser( // never provisioned and its surfaces render empty. await ctx.ql.insert('sys_user', { ...user }, { context: SYS }); ctx.logger?.info?.('[showcase] approval-demo persona provisioned', { email: user.email }); + // This raw insert never crosses the platform's user-creation seam, so the + // membership a created user gets there (ADR-0093 D7: decided at creation) + // is written here, once, by the code that creates the persona. A persona + // that already exists is left as it is — its membership was decided when + // it was created. + if (organizationId) await ensureDemoMembership(ctx, user.id, organizationId); return user.id; } catch (err) { // Non-fatal, and the whole persona is lost when it happens: with no row @@ -260,6 +267,24 @@ async function ensureDemoUser( } } +/** Bind a freshly created persona to the admin's organization as a plain member. */ +async function ensureDemoMembership(ctx: ApprovalDemoContext, userId: string, organizationId: string): Promise { + const existing = await findOne(ctx, 'sys_member', { user_id: userId, organization_id: organizationId }); + if (existing) return; + try { + await ctx.ql.insert( + 'sys_member', + { id: `mem_showcase_${userId}`, organization_id: organizationId, user_id: userId, role: 'member' }, + { context: SYS }, + ); + } catch (err) { + ctx.logger?.warn?.('[showcase] approval-demo persona membership failed (persona has no organization)', { + userId, + error: err instanceof Error ? err.message : String(err), + }); + } +} + /** * Launch a signoff flow on a record through the real automation engine, unless * a pending request already exists for it. @@ -350,11 +375,11 @@ export function registerShowcaseApprovalDemo(ctx: ApprovalDemoContext): void { await assignPositions(ctx, adminId, ADMIN_APPROVAL_POSITIONS, organizationId, 'admin'); // Mei holds no approval position, which makes her a clean *submitter* — a // requester who is never also one of her own approvers. - const submitterId = (await ensureDemoUser(ctx, PHONE_DEMO_USER)) ?? null; + const submitterId = (await ensureDemoUser(ctx, PHONE_DEMO_USER, organizationId)) ?? null; // The auditor persona backs the `finance` group of the per-group demo. It // deliberately holds ONLY `auditor`, so the two groups have distinct // holders and the request stays open until each group has answered. - const auditorId = await ensureDemoUser(ctx, AUDITOR_DEMO_USER); + const auditorId = await ensureDemoUser(ctx, AUDITOR_DEMO_USER, organizationId); if (auditorId) await assignPositions(ctx, auditorId, ['auditor'], organizationId, 'auditor'); // [#9308 fixture 1] Make both personas SIGN-INABLE. Provisioning them as diff --git a/packages/plugins/organizations/src/ensure-default-organization.ts b/packages/plugins/organizations/src/ensure-default-organization.ts index 4f69b681234..a3a21122f3a 100644 --- a/packages/plugins/organizations/src/ensure-default-organization.ts +++ b/packages/plugins/organizations/src/ensure-default-organization.ts @@ -1,16 +1,14 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * ensureDefaultOrganization — multi-org flavour of the default-org bootstrap. + * ensureDefaultOrganization — the walled-posture default-org bootstrap with the + * per-org seed-ownership handoff (`claimOrgSeedOwnership`) injected. * - * The helper itself moved to `@objectstack/plugin-auth` (cloud ADR-0081 D1: the - * open member-management basics own it — single-org mode runs it too, from - * AuthPlugin). This wrapper keeps the multi-org semantics this plugin always - * had by injecting the per-org seed-ownership handoff step - * (`claimOrgSeedOwnership`), which belongs to the org seed pipeline here, - * not to the basics. - * - * See the plugin-auth helper for the full strategy documentation. + * The helper itself lives in `@objectstack/plugin-auth` (cloud ADR-0081 D1). + * `OrganizationsPlugin` no longer calls this wrapper: it calls plugin-auth's + * `createEnsureDefaultOrganizationOnce` with the same handoff injected, which + * decides the owner bind once (ADR-0093 D7). This wrapper is kept for + * existing importers only. */ import { @@ -32,6 +30,12 @@ export type { EnsureDefaultOrganizationResult }; * Ensure the platform admin has a Default Organization to operate in, * then hand the org's seeded rows to them. Idempotent (stable slug * `default` + the admin's existing memberships short-circuit). + * + * @deprecated Use `createEnsureDefaultOrganizationOnce({ claimSeedOwnership: + * claimOrgSeedOwnership })` from `@objectstack/plugin-auth`. Called directly, + * this re-binds an owner whose membership was removed and re-runs the seed + * handoff on every call; the gated factory decides the bind once + * (ADR-0093 D7). */ export async function ensureDefaultOrganization( ql: any, diff --git a/packages/plugins/organizations/src/organizations-plugin.ts b/packages/plugins/organizations/src/organizations-plugin.ts index 02c022f8787..597275e19bf 100644 --- a/packages/plugins/organizations/src/organizations-plugin.ts +++ b/packages/plugins/organizations/src/organizations-plugin.ts @@ -3,8 +3,8 @@ import { Plugin, PluginContext } from '@objectstack/core'; import { claimOrphanOrgRows } from './claim-orphan-org-rows.js'; import type { OrgScopingEngine } from './org-scoping-engine.js'; -import { isDefaultOrganizationBootstrapTrigger } from '@objectstack/plugin-auth'; -import { ensureDefaultOrganization } from './ensure-default-organization.js'; +import { createEnsureDefaultOrganizationOnce, isDefaultOrganizationBootstrapTrigger } from '@objectstack/plugin-auth'; +import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js'; import { assertWalledMembershipPolicyDeclared } from './membership-policy-gate.js'; import { organizationsObjects, @@ -468,9 +468,18 @@ export class OrganizationsPlugin implements Plugin { // ── Default-org bootstrap on kernel:ready + on admin grant ──────── if (this.opts.ensureDefaultOrganization) { + // ADR-0093 D7 — the SAME once-gate the single-org AuthPlugin uses: the + // owner bind (and the seed-ownership handoff that follows it) is decided + // once, recorded in the `sys_migration` ledger and latched in-process; + // after that a missing default organization is recreated without + // binding anyone, so a removed owner's membership stays removed. + const ensureOnce = createEnsureDefaultOrganizationOnce({ + logger: ctx.logger, + claimSeedOwnership: claimOrgSeedOwnership, + }); const runEnsure = async () => { try { - const res = await ensureDefaultOrganization(ql, { logger: ctx.logger }); + const res = await ensureOnce(ql); if (res.defaultOrgCreated) { ctx.logger.info( `[org-scoping] created Default Organization ${res.defaultOrgId} for platform admin`, diff --git a/packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.ts b/packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.ts index a0d55d0183a..e0c465f914f 100644 --- a/packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.ts +++ b/packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.ts @@ -50,6 +50,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { resetPlatformAdminEmailMemo } from '@objectstack/core'; import { OrganizationsPlugin } from './organizations-plugin.js'; +import { assertEngineFindOnePredicate } from '@objectstack/objectql'; // ⛔ No entitlement grant: the open package has no licence gate to satisfy // (ADR-0132 boundary 3). @@ -373,3 +374,50 @@ describe('walled + invite-only: the multi-org default-org bootstrap never joins expect(inserts).toEqual([]); }); }); + +// ADR-0093 D7 — the walled wiring calls the SAME once-gate as the single-org +// AuthPlugin: the operator's owner bind is decided once, recorded in the +// `sys_migration` ledger, and a removed owner membership is never re-bound by a +// later bootstrap trigger. +describe('walled: the default-org owner bind is decided once (ADR-0093 D7)', () => { + it('after the recorded bind, removing the owner membership sticks across later triggers', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; + resetPlatformAdminEmailMemo(); + const store: Record = { + sys_permission_set: seededPermissionSets(), + sys_user_permission_set: [], + sys_user: [{ id: 'usr_ops', email: OWNER_EMAIL, email_verified: true, created_at: '2026-03-03T00:00:00.000Z' }], + sys_organization: [], + sys_member: [], + sys_migration: [], + }; + const { ql, inserts, middlewares } = makeEngine(store); + // The deployment ledger: registered, read by primary key. + ql.getObject = (name: string) => (name in store ? { name } : undefined); + ql.findOne = vi.fn(async (object: string, query: any) => { + assertEngineFindOnePredicate(object, query); + return (store[object] ?? []).find((r) => r.id === query?.where?.id) ?? null; + }); + const { ctx, trigger } = makeCtx(ql); + const plugin = new OrganizationsPlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + await trigger('kernel:bootstrapped'); + await trigger('kernel:ready'); + const memberInserts = () => inserts.filter((i) => i.object === 'sys_member'); + + // CONTROL: the first bootstrap binds the operator and records the decision. + expect(memberInserts()).toHaveLength(1); + expect(memberInserts()[0]!.data).toMatchObject({ user_id: 'usr_ops', role: 'owner' }); + expect(store.sys_migration!.map((r) => r.id)).toEqual(['adr-0093-default-org-owner-bind']); + + // The owner's membership is removed; every later trigger binds nobody. + store.sys_member!.length = 0; + const bootstrapMw = middlewares[middlewares.length - 1]; + await bootstrapMw({ object: 'sys_user', operation: 'update', data: { id: 'usr_ops', email_verified: true } }, async () => {}); + await trigger('kernel:ready'); + expect(memberInserts()).toHaveLength(1); + expect(store.sys_member).toHaveLength(0); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index a6696db0375..f95f8054f48 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -4058,7 +4058,16 @@ export class AuthManager { // The factory is created by better-auth's createAdapterFactory and // automatically applies modelName/fields transformations declared in // the betterAuth config above. - return createObjectQLAdapterFactory(this.config.dataEngine); + return createObjectQLAdapterFactory(this.config.dataEngine, { + // ADR-0093 D7 — the adapter is the one place that sees the created + // user's id INSIDE the creating endpoint call; staging it here lets the + // session seam recognise that user without reading the row back. + onRecordCreated: async (objectName, row) => { + if (objectName !== 'sys_user') return; + const { tryGetCurrentAuthEndpointContext } = await import('@better-auth/core/context'); + this.stageUserCreatedInRequest(tryGetCurrentAuthEndpointContext(), row?.id); + }, + }); } // Fallback warning if no dataEngine is provided @@ -4313,7 +4322,9 @@ export class AuthManager { * signup, admin create-user, bulk import, SSO JIT); * - `session.create.before`, which settles the membership before resolving * the session's active organization so a user's FIRST session is not - * minted tenant-less (#8247 rule 2 / #8245). + * minted tenant-less (#8247 rule 2 / #8245) — and ONLY for a session + * minted in the request that created the user (ADR-0093 D7: membership is + * decided at creation; see {@link isCreatedInThisRequest}). * * Assembling the deps at each call site instead would let the two drift on * the axis that matters most: the POLICY. `getMembershipPolicy()` reads a @@ -4355,6 +4366,46 @@ export class AuthManager { } } + /** + * [ADR-0093 D7] The users created in each in-flight better-auth request, + * keyed by that request's endpoint context — the object better-auth hands + * every database hook of one endpoint call and that `objectql-adapter`'s + * `onRecordCreated` reads from the same call. Values are user ids. + * + * Why the session seam needs it: `user.create.after` is deferred past the + * creation's transaction, so the session a sign-up mints is created BEFORE + * the reconciler has bound anyone. The session seam therefore settles the + * membership itself — but only for the user this same request created. Any + * other member-less user signing in is not mid-creation, and membership is + * decided at creation, never re-decided at sign-in. + * + * The id is captured from the adapter's own `create` result, so recognising + * the user needs no read of the store (a read that, on a driver writing + * through another connection, could miss the row). + * + * A `WeakMap` because the scope IS the request: the entry dies with the + * endpoint context, so nothing staged here can outlive the call that staged + * it and be read by a later sign-in. + */ + private usersCreatedInRequest = new WeakMap>(); + + /** Record that the current request created the user `userId`. */ + private stageUserCreatedInRequest(ctx: unknown, userId: unknown): void { + if (!ctx || typeof ctx !== 'object' || typeof userId !== 'string' || !userId) return; + let staged = this.usersCreatedInRequest.get(ctx); + if (!staged) { + staged = new Set(); + this.usersCreatedInRequest.set(ctx, staged); + } + staged.add(userId); + } + + /** Is `userId` a user the current request created? In-memory only. */ + private isCreatedInThisRequest(ctx: unknown, userId: string): boolean { + if (!ctx || typeof ctx !== 'object') return false; + return this.usersCreatedInRequest.get(ctx)?.has(userId) === true; + } + // ── [#11739] Audience posture — admission gate + self-registration grant ── /** @@ -7355,7 +7406,7 @@ export class AuthManager { return row?.organization_id; }; - const defaultActiveOrg = async (session: any) => { + const defaultActiveOrg = async (session: any, ctx: any) => { try { if (!session || session.activeOrganizationId) return; const userId = session.userId; @@ -7397,13 +7448,24 @@ export class AuthManager { // sessions with no active organization, which is the LEGAL state the // #8247 ruling declares — this removes a race, never a policy. // + // ⛔ AND IT RUNS ONLY FOR A USER WHOSE CREATION IS IN THIS REQUEST. + // ADR-0093 D7: membership is decided at creation. A member-less user + // signing in later is not a race to close — whatever left them without + // a membership (the policy, or an administrator) has already decided, + // and a sign-in must not decide again. The creation seam + // (the adapter's `create`, via `onRecordCreated`) stages the new + // user's id in the request's endpoint context; only a session minted + // in that same request for that same user settles here + // (`isCreatedInThisRequest`). + // // Cost is paid only where there is something to fix: a caller who - // already holds a membership never reaches this branch, and a - // deployment that binds nobody stops at the reconciler's own policy / - // target-org check without touching the store. The re-read is gated on - // an outcome that means a membership now EXISTS, so the common - // no-bind login costs no extra query at all. - if (!orgId) { + // already holds a membership never reaches this branch, every other + // sign-in stops at an in-memory lookup, and a deployment that + // binds nobody stops at the reconciler's own policy / target-org check + // without touching the store. The re-read is gated on an outcome that + // means a membership now EXISTS, so the common no-bind login costs no + // extra query at all. + if (!orgId && this.isCreatedInThisRequest(ctx, userId)) { const outcome = await this.settleMembership(userId); if (outcome === 'bound' || outcome === 'yielded') { orgId = await selectActiveOrg(reader, userId); @@ -7428,7 +7490,7 @@ export class AuthManager { // The host hook fully handled it → keep its result shape. if (draft?.activeOrganizationId) return { data: draft }; } - return (await defaultActiveOrg(draft)) ?? (draft === session ? undefined : { data: draft }); + return (await defaultActiveOrg(draft, ctx)) ?? (draft === session ? undefined : { data: draft }); } : hostSessionBefore; diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 74c49bacfe8..a3fec8e5535 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AuthPlugin } from './auth-plugin'; import { AuthManager } from './auth-manager'; import type { PluginContext } from '@objectstack/core'; +import { assertEngineFindOnePredicate } from '@objectstack/objectql'; describe('AuthPlugin', () => { let mockContext: PluginContext; @@ -1470,6 +1471,8 @@ describe('AuthPlugin', () => { sys_user: [{ id: 'admin' }], sys_member: [], sys_organization: [], + // The deployment ledger the one-time pass records itself in. + sys_migration: [], }; const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => { @@ -1479,9 +1482,14 @@ describe('AuthPlugin', () => { return { tables, registerMiddleware: vi.fn(), + getObject: vi.fn((name: string) => (name in tables ? { name } : undefined)), find: vi.fn(async (object: string, q: any) => (tables[object] ?? []).filter((r) => matches(r, q?.where)).slice(0, q?.limit ?? 100), ), + findOne: vi.fn(async (object: string, q: any) => { + assertEngineFindOnePredicate(object, q); + return (tables[object] ?? []).find((r) => matches(r, q?.where)) ?? null; + }), insert: vi.fn(async (object: string, data: any) => { (tables[object] ??= []).push(data); return data; @@ -1524,20 +1532,177 @@ describe('AuthPlugin', () => { expect(mockContext.hook).toHaveBeenCalledWith('app:seeded', expect.any(Function)); }); - it('binds a user seeded after kernel:ready when app:seeded fires', async () => { + it('app:seeded runs the one-time pass when kernel:ready had no target organization yet', async () => { + // No platform admin at kernel:ready ⇒ no default organization ⇒ the + // pass has no target, decides nothing and records nothing. + ql.tables.sys_user_permission_set = []; await boot(); - // Boot: default org created, admin bound as owner, first backfill pass - // sees no member-less users. await hookCapture.trigger('kernel:ready'); - expect(ql.tables.sys_member.map((m: any) => m.user_id)).toEqual(['admin']); + expect(ql.tables.sys_organization).toHaveLength(0); + expect(ql.tables.sys_migration).toHaveLength(0); - // A background seed inserts a member-less user AFTER kernel:ready. + // A background seed inserts a member-less user, and the organization + // appears before the seed settles. ql.tables.sys_user.push({ id: 'seeded_u2' }); + ql.tables.sys_organization.push({ id: 'org_default', slug: 'default' }); await hookCapture.trigger('app:seeded'); const seededMember = ql.tables.sys_member.find((m: any) => m.user_id === 'seeded_u2'); expect(seededMember).toMatchObject({ user_id: 'seeded_u2', role: 'member' }); + expect(ql.tables.sys_migration.map((r: any) => r.id)).toEqual(['adr-0093-membership-backfill']); + }); + + it('a restart after memberships are removed re-binds nobody — the admin included', async () => { + await boot(); + await hookCapture.trigger('kernel:ready'); + ql.tables.sys_user.push({ id: 'member_u4' }); + ql.tables.sys_member.push({ id: 'm_u4', organization_id: 'x', user_id: 'member_u4', role: 'member' }); + expect(ql.tables.sys_migration.map((r: any) => r.id).sort()).toEqual([ + 'adr-0093-default-org-owner-bind', + 'adr-0093-membership-backfill', + ]); + + // Both memberships are removed, then the process restarts. + ql.tables.sys_member.length = 0; + hookCapture = createHookCapture(); + mockContext.hook = hookCapture.hookFn; + await boot(); + await hookCapture.trigger('kernel:ready'); + await hookCapture.trigger('app:seeded'); + + expect(ql.tables.sys_member).toHaveLength(0); + expect(ql.tables.sys_migration).toHaveLength(2); + }); + + const fireBootstrapWrite = async (opCtx: any) => { + for (const [mw] of ql.registerMiddleware.mock.calls) { + await mw(opCtx, async () => {}); + } + }; + + it('the default-org-created trigger runs the one-time pass', async () => { + // No platform admin at kernel:ready: no organization, the pass has no + // target and records nothing. + ql.tables.sys_user_permission_set = []; + ql.tables.sys_user.push({ id: 'early_u5' }); + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_organization).toHaveLength(0); + expect(ql.tables.sys_migration).toHaveLength(0); + + // The admin's grant lands: the bootstrap creates the organization, and + // that moment runs the pass — no restart, no app:seeded. + ql.tables.sys_user_permission_set.push({ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', organization_id: null }); + await fireBootstrapWrite({ object: 'sys_user_permission_set', operation: 'insert' }); + await vi.waitFor(() => { + expect(ql.tables.sys_member.find((m: any) => m.user_id === 'early_u5')).toMatchObject({ role: 'member' }); + }); + expect(ql.tables.sys_migration.map((r: any) => r.id).sort()).toEqual([ + 'adr-0093-default-org-owner-bind', + 'adr-0093-membership-backfill', + ]); + }); + + it('unreadable ledger + an existing default organization + an unbound admin: the admin is NOT bound', async () => { + ql.tables.sys_organization.push({ id: 'org_existing', slug: 'default' }); + const realFindOne = ql.findOne.getMockImplementation(); + ql.findOne.mockImplementation(async (object: string, q: any) => { + if (object === 'sys_migration') throw new Error('ledger read failed'); + return realFindOne(object, q); + }); + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_member.find((m: any) => m.user_id === 'admin')).toBeUndefined(); + expect(ql.tables.sys_migration).toHaveLength(0); + }); + + it('fresh install, ledger UNREADABLE: the org is created, nobody is bound, and the next readable trigger decides', async () => { + const realFindOne = ql.findOne.getMockImplementation(); + let ledgerDown = true; + ql.findOne.mockImplementation(async (object: string, q: any) => { + if (object === 'sys_migration' && ledgerDown) throw new Error('ledger read failed'); + return realFindOne(object, q); + }); + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_organization).toHaveLength(1); + expect(ql.tables.sys_member).toHaveLength(0); + expect(ql.tables.sys_migration).toHaveLength(0); + + // The ledger answers again: the bind is decided now, once, and recorded. + ledgerDown = false; + await fireBootstrapWrite({ object: 'sys_user', operation: 'update', data: { email_verified: true } }); + expect(ql.tables.sys_member).toEqual([expect.objectContaining({ user_id: 'admin', role: 'owner' })]); + expect(ql.tables.sys_migration.map((r: any) => r.id)).toContain('adr-0093-default-org-owner-bind'); + }); + + it('fresh install, NO ledger on the kernel: the org is created and its owner bound on that call', async () => { + ql.getObject.mockImplementation((name: string) => (name === 'sys_migration' ? undefined : { name })); + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_organization).toHaveLength(1); + expect(ql.tables.sys_member).toEqual([expect.objectContaining({ user_id: 'admin', role: 'owner' })]); + + // Removed, then a later trigger in a new process: the org exists, so + // the no-ledger rule binds nobody. + ql.tables.sys_member.length = 0; + hookCapture = createHookCapture(); + mockContext.hook = hookCapture.hookFn; + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_member).toHaveLength(0); + }); + + it('a failed ledger write is latched in-process: a second trigger re-binds nobody', async () => { + const realInsert = ql.insert.getMockImplementation(); + ql.insert.mockImplementation(async (object: string, data: any) => { + if (object === 'sys_migration') throw new Error('ledger read-only'); + return realInsert(object, data); + }); + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_member.map((m: any) => m.user_id)).toEqual(['admin']); + expect(ql.tables.sys_migration).toHaveLength(0); + expect(mockContext.logger.error).toHaveBeenCalled(); + + // Memberships are removed, a member-less user appears, and every + // trigger fires again in the same process. + ql.tables.sys_member.length = 0; + ql.tables.sys_user.push({ id: 'later_u6' }); + await fireBootstrapWrite({ object: 'sys_user', operation: 'insert' }); + await hookCapture.trigger('app:seeded'); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_member).toHaveLength(0); + }); + + it('recorded owner bind + a deleted default organization: the organization is recreated, nobody is bound', async () => { + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_migration.map((r: any) => r.id)).toContain('adr-0093-default-org-owner-bind'); + ql.tables.sys_organization.length = 0; + ql.tables.sys_member.length = 0; + await fireBootstrapWrite({ object: 'sys_user', operation: 'insert' }); + expect(ql.tables.sys_organization).toHaveLength(1); + expect(ql.tables.sys_member).toHaveLength(0); + }); + + it('once the pass is recorded, a later trigger binds nobody', async () => { + await boot(); + // Boot: default org created, admin bound as owner, the one-time pass + // runs and records itself. + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_member.map((m: any) => m.user_id)).toEqual(['admin']); + expect(ql.tables.sys_migration.map((r: any) => r.id)).toContain('adr-0093-membership-backfill'); + + // A member-less user now exists — the shape a membership removal leaves. + ql.tables.sys_user.push({ id: 'removed_u3' }); + + await hookCapture.trigger('app:seeded'); + await hookCapture.trigger('kernel:ready'); + + expect(ql.tables.sys_member.find((m: any) => m.user_id === 'removed_u3')).toBeUndefined(); + expect(ql.tables.sys_migration.map((r: any) => r.id)).toContain('adr-0093-membership-backfill'); }); it('OS_SKIP_MEMBERSHIP_BACKFILL=1 disables the app:seeded re-run', async () => { diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index fac7d5a0ed6..f36179871dd 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -41,20 +41,18 @@ import { DEFAULT_AUTH_BASE_PATH, type AuthManagerOptions, } from './auth-manager.js'; -import { - ensureDefaultOrganization, - isDefaultOrganizationBootstrapTrigger, -} from './ensure-default-organization.js'; +import { isDefaultOrganizationBootstrapTrigger } from './ensure-default-organization.js'; import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js'; import { runAttributedToUser } from './auth-actor-attribution.js'; import type { AuthEventAuditSurface } from './auth-session-audit.js'; import { createTenancyService, type TenancyService } from './tenancy-service.js'; import { - backfillMemberships, isMembershipPolicy, MEMBERSHIP_POLICIES, type MembershipPolicy, } from './reconcile-membership.js'; +import { runOneTimeMembershipBackfill } from './membership-backfill-ledger.js'; +import { createEnsureDefaultOrganizationOnce } from './default-org-bootstrap-once.js'; import { registerIdentityWriteGuard, registerManagedUpdateWhitelist, @@ -1142,16 +1140,32 @@ export class AuthPlugin implements Plugin { // seed-ownership step injected. One crisp owner per posture, and the open // package never bootstraps an organization for a deployment whose // multi-organization runtime it does not provide. + // The one-time ADR-0093 D6 backfill (below) is handed to the bootstrap so + // the moment the default organization first EXISTS is a moment it runs: + // until then the pass has no target and records nothing, and deferring it + // to the next boot would let it judge users created during this uptime, + // whose membership was already decided at their creation. + let runBackfillOnDefaultOrg: ((source: string) => Promise) | undefined; if (this.options.autoDefaultOrganization !== false && !postureEnforcesWall(resolveTenancyPosture())) { + // ADR-0093 D7 — the platform admin's owner bind is decided ONCE + // (`default-org-bootstrap-once.ts`, the gate the walled wiring in + // `@objectstack/organizations` calls too): recorded in the + // `sys_migration` ledger and latched in-process, after which this hook + // still recreates a missing default organization but binds nobody. + const ensureOnce = createEnsureDefaultOrganizationOnce({ logger: ctx.logger }); const runEnsure = async () => { try { const ql = ctx.getService('objectql'); if (!ql) return; - const res = await ensureDefaultOrganization(ql, { logger: ctx.logger }); + const res = await ensureOnce(ql); if (res.defaultOrgCreated) { ctx.logger.info( `[auth] created Default Organization ${res.defaultOrgId} for the platform admin (single-org)`, ); + // Not awaited: this can run inside the `sys_user` insert that + // created the platform admin, and the pass is serialized on its + // own chain, which never rejects. + void runBackfillOnDefaultOrg?.('default-org-created'); } } catch (e) { ctx.logger.warn?.('[auth] ensureDefaultOrganization failed', { @@ -1184,7 +1198,8 @@ export class AuthPlugin implements Plugin { } // ADR-0093 D6 — backfill memberships for pre-existing member-less users - // (historical create-user / import rows from before the reconciler existed). + // (historical create-user / import rows from before the reconciler existed), + // ONCE per deployment (`membership-backfill-ledger.ts`, ADR-0093 D7). // Registered AFTER the default-org bootstrap hook so a target org exists by // the time it runs. `backfillMemberships` self-guards: it no-ops under // `invite-only` policy and in multi-org (tenancy.defaultOrgId() → null), @@ -1196,8 +1211,14 @@ export class AuthPlugin implements Plugin { // `app:seeded` from multiple app bundles) don't race the same scan and // trip the (organization_id, user_id) unique index into warn noise. let backfillChain: Promise = Promise.resolve(); + // Latched once the one-time pass has decided in this process — read + // from the ledger, or carried out now. A record that failed to land + // ('ran-unrecorded') does not unlatch it: a second trigger in this + // process must not run the pass again (ADR-0093 D7). + let backfillDecided = false; const runBackfill = (source: string): Promise => { backfillChain = backfillChain.then(async () => { + if (backfillDecided) return; try { // #5152 — the policy this pass runs under is a SETTING, so bind the // namespace before reading it. This hook is registered in `init()` @@ -1218,12 +1239,24 @@ export class AuthPlugin implements Plugin { // guard is a precondition, not a degraded mode. const manager = this.authManager; if (!ql || !tenancy || !manager) return; - const res = await backfillMemberships(ql, { - policy: manager.getMembershipPolicy(), - resolveTargetOrg: () => tenancy.defaultOrgId(), - logger: ctx.logger, - }); - if (res.bound > 0) { + // ADR-0093 D7 — membership is decided at creation, so this pass is + // ONE-TIME per deployment: it runs until it reaches a verdict, the + // verdict is recorded in the `sys_migration` ledger, and every later + // trigger reads the record and binds nobody. + const outcome = await runOneTimeMembershipBackfill( + ql, + { + policy: manager.getMembershipPolicy(), + resolveTargetOrg: () => tenancy.defaultOrgId(), + logger: ctx.logger, + }, + ctx.logger, + ); + if (outcome.status === 'ran' || outcome.status === 'ran-unrecorded' || outcome.status === 'already-run') { + backfillDecided = true; + } + const res = outcome.backfill; + if (res && res.bound > 0) { ctx.logger.info( `[auth] membership backfill (${source}) bound ${res.bound} member-less user(s) to the default organization (ADR-0093 D6)`, res, @@ -1238,12 +1271,14 @@ export class AuthPlugin implements Plugin { }); return backfillChain; }; + runBackfillOnDefaultOrg = runBackfill; ctx.hook('kernel:ready', () => runBackfill('kernel:ready')); // #2996: app seeds insert `sys_user` via raw engine.insert, bypassing // better-auth's `user.create.after` reconciler. A seed that overruns // OS_INLINE_SEED_BUDGET_MS finishes in the background AFTER kernel:ready, - // so its users miss the one-shot backfill above. Re-run the (idempotent) - // backfill when the app plugin signals seed settle. + // so its users would miss a kernel:ready pass that had no target yet. + // The trigger stays; the ledger makes it a no-op once the one-time pass + // has been recorded. ctx.hook('app:seeded', () => runBackfill('app:seeded')); } diff --git a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts new file mode 100644 index 00000000000..a2d30eeee50 --- /dev/null +++ b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The default-organization bootstrap with its owner bind decided ONCE + * (ADR-0093 D7: membership is decided at creation, never re-decided). + * + * `ensureDefaultOrganization` binds the platform admin as owner whenever the + * admin holds no membership, and its wirings run it on every `kernel:ready` + * and on every `sys_user` write that can move the platform-admin answer. On + * its own that re-binds an admin whose membership was removed. This wrapper + * is the ONE gate both wirings call — the single-org `AuthPlugin` and the + * walled `@objectstack/organizations` plugin — so the two postures cannot + * disagree about it: + * + * - the decision is recorded in the `sys_migration` ledger + * ({@link DEFAULT_ORG_OWNER_BIND_MIGRATION_ID}) the first time the + * bootstrap binds the admin, or finds the admin already holding a + * membership; + * - once recorded — or once acted on in this process, whether or not the + * record landed — the bootstrap still (re)creates a missing default + * organization but binds nobody and hands over no seed ownership; + * - with no ledger on the kernel it binds only on the call that CREATES the + * default organization, so a fresh install still gets its owner and an + * existing organization is never re-bound; + * - with a ledger that failed to answer it binds nobody on that call (it may + * still create the organization) and leaves the decision to the next + * trigger. + */ + +import { + ensureDefaultOrganization, + type EnsureDefaultOrganizationOptions, + type EnsureDefaultOrganizationResult, +} from './ensure-default-organization.js'; +import { + buildLedgerDecisionRecord, + DEFAULT_ORG_OWNER_BIND_MIGRATION_ID, + readLedgerDecision, + recordLedgerDecision, + type LedgerLogger, +} from './membership-backfill-ledger.js'; + +export interface EnsureDefaultOrganizationOnceOptions { + logger?: LedgerLogger & EnsureDefaultOrganizationOptions['logger']; + /** Injected seed-ownership handoff (the walled wiring passes its own). */ + claimSeedOwnership?: EnsureDefaultOrganizationOptions['claimSeedOwnership']; + /** The bootstrap to gate — the plugin-auth helper unless a test injects one. */ + ensure?: typeof ensureDefaultOrganization; +} + +/** + * Build the gated bootstrap. The returned function carries the in-process + * latch, so a wiring creates ONE and calls it from every trigger. + */ +export function createEnsureDefaultOrganizationOnce( + options: EnsureDefaultOrganizationOnceOptions = {}, +): (ql: any) => Promise { + const ensure = options.ensure ?? ensureDefaultOrganization; + // Latched once the owner bind is decided in this process — read from the + // ledger, or acted on now. A record that failed to land does not unlatch + // it: the next trigger in this process must not bind again. + let decided = false; + + return async (ql: any): Promise => { + const base: EnsureDefaultOrganizationOptions = { + logger: options.logger, + ...(options.claimSeedOwnership ? { claimSeedOwnership: options.claimSeedOwnership } : {}), + }; + if (decided) return ensure(ql, { ...base, bindOwner: false }); + + const reading = await readLedgerDecision(ql, DEFAULT_ORG_OWNER_BIND_MIGRATION_ID); + if (reading === 'recorded') { + decided = true; + return ensure(ql, { ...base, bindOwner: false }); + } + + // `unavailable` (this kernel has no ledger) binds only on the call that + // CREATES the default organization, so a fresh install without a ledger + // still gets its owner. `unreadable` is a ledger that exists but failed to + // answer: the decision may well be recorded, so this call binds nobody — + // it can still (re)create the organization — and the next trigger, with a + // readable ledger, decides. + const res = + reading === 'unreadable' + ? await ensure(ql, { ...base, bindOwner: false }) + : await ensure(ql, { ...base, bindOnlyOnCreate: reading === 'unavailable' }); + // Decided: the admin was bound now, or already held a membership. + // `no_admin` and the failed writes leave it open for the next trigger. + const actedOn = res.memberCreated || res.reason === 'admin_already_in_org'; + if (!actedOn) return res; + decided = true; + if (reading === 'absent') { + await recordLedgerDecision( + ql, + buildLedgerDecisionRecord( + DEFAULT_ORG_OWNER_BIND_MIGRATION_ID, + { + outcome: res.memberCreated ? 'bound' : 'admin-already-member', + ...(res.defaultOrgId ? { organizationId: res.defaultOrgId } : {}), + }, + new Date().toISOString(), + ), + 'default organization owner bind', + options.logger, + ); + } + return res; + }; +} diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts index affaa356c02..cea8ce4d719 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -150,6 +150,20 @@ export interface EnsureDefaultOrganizationOptions { userId: string, options: { logger?: BootstrapLogger }, ) => Promise>; + /** + * ADR-0093 D7 — bind the admin ONLY on the call that creates the default + * organization. The caller sets it when it cannot tell from its own record + * whether the owner bind was already decided (no readable deployment + * ledger): binding to an organization that already exists could then put + * back a membership that was removed. Default `false`. + */ + bindOnlyOnCreate?: boolean; + /** + * ADR-0093 D7 — `false` once the owner bind has been decided: the default + * organization is still (re)created when it is missing, but nobody is bound + * to it and no seed ownership is handed over. Default `true`. + */ + bindOwner?: boolean; } const SYSTEM_CTX = { isSystem: true }; @@ -284,7 +298,7 @@ export interface EnsureDefaultOrganizationResult { /** Whether a sys_member row was inserted binding the admin to the default org. */ memberCreated: boolean; /** Human-readable reason when the helper short-circuited. */ - reason?: 'no_admin' | 'admin_already_in_org' | 'org_insert_failed' | 'member_insert_failed'; + reason?: 'no_admin' | 'admin_already_in_org' | 'org_insert_failed' | 'member_insert_failed' | 'owner_bind_decided'; /** Count of the default org's seeded rows re-owned to the platform admin. */ ownershipClaimed?: number; } @@ -293,6 +307,12 @@ export interface EnsureDefaultOrganizationResult { * Ensure the platform admin has a Default Organization to operate in. * Safe to call multiple times — idempotent on stable slug `default` * and on the presence of any existing `sys_member` row for the admin. + * + * @deprecated Call it through {@link createEnsureDefaultOrganizationOnce} + * (`default-org-bootstrap-once.ts`). Called directly, this helper binds the + * platform admin as owner whenever they hold no membership, so a bootstrap + * trigger re-binds an admin whose membership was removed — ADR-0093 D7 + * decides membership once. Kept as the primitive the gated factory wraps. */ export async function ensureDefaultOrganization( ql: any, @@ -369,6 +389,11 @@ export async function ensureDefaultOrganization( const existingDefault = await tryFind(ql, 'sys_organization', { slug: 'default' }, 1); if (existingDefault.length > 0 && existingDefault[0].id) { defaultOrgId = String(existingDefault[0].id); + if (options.bindOnlyOnCreate) { + // The organization predates this call, so whether its owner was bound + // was decided earlier — never re-decided here (ADR-0093 D7). + return { defaultOrgCreated: false, defaultOrgId, memberCreated: false, reason: 'owner_bind_decided' }; + } } else { const newOrgId = genId('org'); const orgRow = await tryInsert(ql, 'sys_organization', { @@ -403,6 +428,11 @@ export async function ensureDefaultOrganization( defaultOrgCreated = true; } + if (options.bindOwner === false) { + // The owner bind was decided earlier (ADR-0093 D7) — never re-decided. + return { defaultOrgCreated, defaultOrgId, memberCreated: false, reason: 'owner_bind_decided' }; + } + // 5. Bind the admin as owner. const memRow = await tryInsert(ql, 'sys_member', { id: genId('mem'), diff --git a/packages/plugins/plugin-auth/src/first-session-membership-ordering.test.ts b/packages/plugins/plugin-auth/src/first-session-membership-ordering.test.ts index 233ea63da7a..0e80a84e407 100644 --- a/packages/plugins/plugin-auth/src/first-session-membership-ordering.test.ts +++ b/packages/plugins/plugin-auth/src/first-session-membership-ordering.test.ts @@ -33,6 +33,16 @@ * store (`insert` never called), not merely on the returned draft. A user whom * policy says must not be bound still mints a session with no active * organization, which is the legal state the #8247 ruling declares. + * + * ## Only the session of the CREATING request settles (ADR-0093 D7) + * + * Membership is decided at creation. The settle at the session seam closes the + * race for the request that creates the user and for nothing else: a + * member-less user who signs in later — for instance one whose membership was + * removed — must not be bound again. The cases below therefore drive the real + * sign-up order (the user row created and the session minted in one request + * context) for every settle they expect, and the last block pins the + * sign-ins that must NOT settle. */ import { describe, it, expect, vi } from 'vitest'; @@ -41,6 +51,7 @@ import { loginEventFor } from './auth-session-audit.js'; import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/objectql'; const USER = 'u_first_session'; +const USER_EMAIL = 'first.session@example.com'; const DEFAULT_ORG = 'org_default'; interface MemberRow { @@ -60,6 +71,9 @@ interface MemberRow { */ function makeEngine(seed: MemberRow[] = []) { const rows: MemberRow[] = [...seed]; + // The created user's row — what the session seam reads to recognise the + // user the current request is creating. + const users = [{ id: USER, email: USER_EMAIL }, { id: 'u_other', email: 'other@example.com' }]; const match = (r: MemberRow, where: Record = {}) => (where.user_id === undefined || r.user_id === where.user_id) && (where.role === undefined || r.role === where.role) && @@ -78,7 +92,11 @@ function makeEngine(seed: MemberRow[] = []) { ), // eslint-disable-next-line @typescript-eslint/no-explicit-any findOne: vi.fn(async (model: string, q: any) => - { assertEngineFindOnePredicate(model, q); return model === 'sys_member' ? (rows.find((r) => match(r, q?.where)) ?? null) : null; }, + { + assertEngineFindOnePredicate(model, q); + if (model === 'sys_user') return users.find((u) => u.id === q?.where?.id) ?? null; + return model === 'sys_member' ? (rows.find((r) => match(r, q?.where)) ?? null) : null; + }, ), }; } @@ -104,7 +122,25 @@ function hooksFor(opts: ManagerOpts): any { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (manager as any).composeDatabaseHooks(opts.databaseHooks); + const hooks = (manager as any).composeDatabaseHooks(opts.databaseHooks); + // What the adapter's `create` does for a `sys_user` row inside the creating + // request (`onRecordCreated`; the real wiring is pinned in + // `user-created-in-request.test.ts`). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hooks.__stageCreated = (request: unknown, userId: string) => (manager as any).stageUserCreatedInRequest(request, userId); + return hooks; +} + +/** + * Drive the sign-up order better-auth runs: the user row is created and the + * session minted inside ONE endpoint context (the request), and + * `user.create.after` is deferred past both. Returns the session hook's result. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function firstSessionOfCreation(hooks: any, session: Record = { userId: USER }): Promise { + const request = {}; + hooks.__stageCreated(request, USER); + return hooks.session.create.before(session, request); } describe("[#8247 rule 2] a user's FIRST session settles the membership before resolving its active org", () => { @@ -120,7 +156,7 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re it('the FIRST session carries the active organization — the reconciler is run, then re-read', async () => { const engine = makeEngine(); const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); - const result = await hooks.session.create.before({ userId: USER }); + const result = await firstSessionOfCreation(hooks); expect(result?.data?.activeOrganizationId).toBe(DEFAULT_ORG); // …and it is a real membership, not a value invented for the draft. The // difference matters: a stamped-but-unbacked org would satisfy the session @@ -137,7 +173,7 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re // where no RLS reader could ever see it again. const engine = makeEngine(); const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); - const result = await hooks.session.create.before({ userId: USER, id: 'sess_1' }); + const result = await firstSessionOfCreation(hooks, { userId: USER, id: 'sess_1' }); const event = loginEventFor(result?.data); expect(event?.organizationId).toBe(DEFAULT_ORG); expect(event?.userId).toBe(USER); @@ -170,7 +206,7 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re it('`invite-only`: nothing is bound, and the session is legitimately org-less', async () => { const engine = makeEngine(); const hooks = hooksFor({ engine, membershipPolicy: 'invite-only', defaultOrgId: DEFAULT_ORG }); - const result = await hooks.session.create.before({ userId: USER }); + const result = await firstSessionOfCreation(hooks); expect(result?.data?.activeOrganizationId).toBeUndefined(); // Asserted on the STORE, not just on the draft: a policy that says "no // auto-bind" must not be satisfied by binding and then declining to stamp. @@ -181,7 +217,7 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re it('no unambiguous target organization (multi-org): nothing is bound, session org-less', async () => { const engine = makeEngine(); const hooks = hooksFor({ engine, defaultOrgId: null }); - const result = await hooks.session.create.before({ userId: USER }); + const result = await firstSessionOfCreation(hooks); expect(result?.data?.activeOrganizationId).toBeUndefined(); expect(engine.insert).not.toHaveBeenCalled(); expect(engine.rows).toHaveLength(0); @@ -190,10 +226,11 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re it('a no-bind sign-in costs NO extra membership read — the re-read is gated on the outcome', async () => { const engine = makeEngine(); const hooks = hooksFor({ engine, membershipPolicy: 'invite-only', defaultOrgId: DEFAULT_ORG }); - await hooks.session.create.before({ userId: USER }); + await firstSessionOfCreation(hooks); // The two owner-preferred lookups of the ORIGINAL selection, and nothing - // more: `invite-only` is refused by the reconciler before it touches the - // store, and the re-read never runs because nothing was bound. + // more: recognising the user being created reads nothing, `invite-only` + // is refused by the reconciler before it touches the store, and the + // re-read never runs because nothing was bound. expect(engine.findOne).toHaveBeenCalledTimes(2); expect(engine.find).not.toHaveBeenCalled(); }); @@ -223,7 +260,7 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re defaultOrgId: DEFAULT_ORG, databaseHooks: { session: { create: { before: hostHook } } }, }); - const result = await hooks.session.create.before({ userId: USER }); + const result = await firstSessionOfCreation(hooks); expect(hostHook).toHaveBeenCalledTimes(1); expect(result?.data?.activeOrganizationId).toBe('org_host'); // The host owned it, so the settle never ran. @@ -239,7 +276,7 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const hooks = hooksFor({ engine: engine as any, defaultOrgId: DEFAULT_ORG }); - const result = await hooks.session.create.before({ userId: USER }); + const result = await firstSessionOfCreation(hooks); expect(result?.data?.activeOrganizationId).toBeUndefined(); }); @@ -262,7 +299,77 @@ describe("[#8247 rule 2] a user's FIRST session settles the membership before re const engine = makeEngine(); const hooks = hooksFor({ engine, membershipPolicy: 'invite-only', defaultOrgId: DEFAULT_ORG }); await hooks.user.create.after({ id: USER }); - await hooks.session.create.before({ userId: USER }); + await firstSessionOfCreation(hooks); + expect(engine.insert).not.toHaveBeenCalled(); + }); + }); + describe('ADR-0093 D7 — membership is decided at creation, never re-decided at sign-in', () => { + it('a member-less user signing in WITHOUT being created in that request is not bound', async () => { + // The removed-member shape: the user exists, holds no membership, and + // signs in. Nothing in this request created them. + const engine = makeEngine(); + const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); + const result = await hooks.session.create.before({ userId: USER }, {}); + expect(result?.data?.activeOrganizationId).toBeUndefined(); + expect(engine.insert).not.toHaveBeenCalled(); + expect(engine.rows).toHaveLength(0); + }); + + it('CONTROL: the same user, created in the same request, IS bound', async () => { + // Same engine shape, same policy, same target — only the creation + // differs, so the case above measures the gate and not a broken fixture. + const engine = makeEngine(); + const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); + const result = await firstSessionOfCreation(hooks); + expect(result?.data?.activeOrganizationId).toBe(DEFAULT_ORG); + expect(engine.rows).toHaveLength(1); + }); + + it('a sign-in that created nobody costs no read beyond the original selection', async () => { + const engine = makeEngine(); + const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); + await hooks.session.create.before({ userId: USER }, {}); + expect(engine.findOne).toHaveBeenCalledTimes(2); + expect(engine.find).not.toHaveBeenCalled(); + }); + + it('a creation staged in ANOTHER request does not reach this sign-in', async () => { + const engine = makeEngine(); + const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); + hooks.__stageCreated({}, USER); + const result = await hooks.session.create.before({ userId: USER }, {}); + expect(result?.data?.activeOrganizationId).toBeUndefined(); + expect(engine.insert).not.toHaveBeenCalled(); + }); + + it('a request creating one user does not bind a DIFFERENT member-less user', async () => { + const engine = makeEngine(); + const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); + const result = await firstSessionOfCreation(hooks, { userId: 'u_other' }); + expect(result?.data?.activeOrganizationId).toBeUndefined(); + expect(engine.insert).not.toHaveBeenCalled(); + }); + + it('email verification required: the creation seam binds with no session, and the later sign-in finds it', async () => { + // A deployment that requires verification mints no session at sign-up; + // the membership is still decided at creation, by `user.create.after`. + const engine = makeEngine(); + const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); + await hooks.user.create.after({ id: USER }); + expect(engine.rows).toEqual([expect.objectContaining({ organization_id: DEFAULT_ORG, user_id: USER })]); + // The first sign-in after verification is a different request: it + // decides nothing, and resolves the membership creation wrote. + const result = await hooks.session.create.before({ userId: USER }, {}); + expect(result?.data?.activeOrganizationId).toBe(DEFAULT_ORG); + expect(engine.insert).toHaveBeenCalledTimes(1); + }); + + it('a session minted with no request context settles nothing', async () => { + const engine = makeEngine(); + const hooks = hooksFor({ engine, defaultOrgId: DEFAULT_ORG }); + hooks.__stageCreated(undefined, USER); + const result = await hooks.session.create.before({ userId: USER }, undefined); + expect(result?.data?.activeOrganizationId).toBeUndefined(); expect(engine.insert).not.toHaveBeenCalled(); }); }); diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index e1612f1b725..21cbc0f50e6 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -11,6 +11,9 @@ export * from './auth-plugin.js'; export * from './auth-manager.js'; export * from './ensure-default-organization.js'; +// ADR-0093 D7 — the default-org bootstrap with its owner bind decided once; +// the walled `@objectstack/organizations` wiring calls the same gate. +export * from './default-org-bootstrap-once.js'; // #17440 — the read-only preflight that guards the retirement of // `sys_account.issuer`, plus the re-point guard that answers the one case the // retired column still discriminated. Exported because a host that upgrades diff --git a/packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts b/packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts new file mode 100644 index 00000000000..08bd9795065 --- /dev/null +++ b/packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ADR-0093 D6 backfill is ONE-TIME per deployment (ADR-0093 D7: + * membership is decided at creation). These cases pin the ledger gate around + * it: which verdicts are remembered, that a remembered verdict binds nobody — + * the removed-member case — and the two failure directions. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { assertEngineFindOnePredicate } from '@objectstack/objectql'; +import { + MEMBERSHIP_BACKFILL_MIGRATION_ID, + runOneTimeMembershipBackfill, +} from './membership-backfill-ledger.js'; + +const ORG = 'org_default'; + +interface Tables { + sys_organization: Array<{ id: string }>; + sys_user: Array<{ id: string }>; + sys_member: Array<{ id?: string; organization_id: string; user_id: string; role?: string }>; + sys_migration: Array>; +} + +function makeEngine(seed: Partial = {}, opts: { ledger?: boolean } = {}) { + const tables: Tables = { + sys_organization: [...(seed.sys_organization ?? [])], + sys_user: [...(seed.sys_user ?? [])], + sys_member: [...(seed.sys_member ?? [])], + sys_migration: [...(seed.sys_migration ?? [])], + }; + const engine = { + tables, + getObject: vi.fn((name: string) => + name === 'sys_migration' && opts.ledger === false ? undefined : { name }, + ), + // Honours the keyset walk's seek (`id > cursor`), order and page size. + find: vi.fn(async (object: string, q: any) => { + const gt = q?.where?.id?.$gt; + const rows = [...((tables as any)[object] ?? [])] + .filter((r: any) => gt === undefined || String(r.id) > String(gt)) + .sort((a: any, b: any) => (String(a.id) < String(b.id) ? -1 : 1)); + return rows.slice(0, q?.limit ?? rows.length); + }), + findOne: vi.fn(async (object: string, q: any) => { + assertEngineFindOnePredicate(object, q); + return ((tables as any)[object] ?? []).find((r: any) => r.id === q?.where?.id) ?? null; + }), + insert: vi.fn(async (object: string, data: any) => { + ((tables as any)[object] ??= []).push({ ...data }); + return data; + }), + }; + return engine; +} + +const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }); + +const auto = { policy: 'auto' as const, resolveTargetOrg: async () => ORG }; + +describe('one-time membership backfill (ADR-0093 D6 + D7)', () => { + it('first pass: binds pre-existing member-less users and records the verdict', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_legacy' }] }); + const res = await runOneTimeMembershipBackfill(engine, auto, logger()); + expect(res.status).toBe('ran'); + expect(engine.tables.sys_member).toEqual([ + expect.objectContaining({ organization_id: ORG, user_id: 'u_legacy', role: 'member' }), + ]); + expect(engine.tables.sys_migration).toHaveLength(1); + expect(engine.tables.sys_migration[0]).toMatchObject({ + id: MEMBERSHIP_BACKFILL_MIGRATION_ID, + verified_at: null, + blocking: 0, + }); + expect(JSON.parse(String(engine.tables.sys_migration[0].details))).toMatchObject({ policy: 'auto', bound: 1 }); + }); + + it('a recorded verdict binds nobody', async () => { + const engine = makeEngine({ + sys_user: [{ id: 'u_removed' }], + sys_migration: [{ id: MEMBERSHIP_BACKFILL_MIGRATION_ID, blocking: 0 }], + }); + const res = await runOneTimeMembershipBackfill(engine, auto, logger()); + expect(res.status).toBe('already-run'); + expect(engine.tables.sys_member).toHaveLength(0); + expect(engine.insert).not.toHaveBeenCalled(); + // Not even scanned: the user table is never read. + expect(engine.find).not.toHaveBeenCalled(); + }); + + it('two consecutive passes: the second is a no-op whatever the membership state', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }); + expect((await runOneTimeMembershipBackfill(engine, auto, logger())).status).toBe('ran'); + engine.tables.sys_member.length = 0; // an administrator removes the membership + expect((await runOneTimeMembershipBackfill(engine, auto, logger())).status).toBe('already-run'); + expect(engine.tables.sys_member).toHaveLength(0); + }); + + it('`invite-only` is a decided verdict: nothing bound, the verdict recorded', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }); + const res = await runOneTimeMembershipBackfill( + engine, + { policy: 'invite-only', resolveTargetOrg: async () => ORG }, + logger(), + ); + expect(res.status).toBe('ran'); + expect(res.backfill?.reason).toBe('policy'); + expect(engine.tables.sys_member).toHaveLength(0); + expect(engine.tables.sys_migration).toHaveLength(1); + // A later switch to `auto` does not sweep in the users created under it. + expect((await runOneTimeMembershipBackfill(engine, auto, logger())).status).toBe('already-run'); + expect(engine.tables.sys_member).toHaveLength(0); + }); + + it('no target organization while organizations exist (multi-org): the refusal is recorded', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }], sys_organization: [{ id: 'org_a' }, { id: 'org_b' }] }); + const res = await runOneTimeMembershipBackfill( + engine, + { policy: 'auto', resolveTargetOrg: async () => null }, + logger(), + ); + expect(res.status).toBe('ran'); + expect(res.backfill?.reason).toBe('no-target-org'); + expect(engine.tables.sys_member).toHaveLength(0); + expect(engine.tables.sys_migration).toHaveLength(1); + // A later pass that now has a target binds nobody. + expect((await runOneTimeMembershipBackfill(engine, auto, logger())).status).toBe('already-run'); + expect(engine.tables.sys_member).toHaveLength(0); + }); + + it('scans past one page: every member-less user is bound, members are not', async () => { + const users = Array.from({ length: 7 }, (_, i) => ({ id: `u_${i}` })); + const engine = makeEngine({ + sys_user: users, + sys_member: [{ id: 'm_1', organization_id: ORG, user_id: 'u_5', role: 'member' }], + }); + const res = await runOneTimeMembershipBackfill(engine, { ...auto, limit: 2 }, logger()); + expect(res.status).toBe('ran'); + expect(res.backfill).toMatchObject({ scanned: 7, bound: 6, skipped: 1 }); + expect(engine.tables.sys_member.map((m) => m.user_id).sort()).toEqual(users.map((u) => u.id).sort()); + }); + + it('a scan that cannot read a table in full binds nobody and records nothing', async () => { + const engine = makeEngine({ sys_user: Array.from({ length: 5 }, (_, i) => ({ id: `u_${i}` })) }); + const realFind = engine.find.getMockImplementation()!; + engine.find.mockImplementation(async (object: string, q: any) => { + if (object === 'sys_user' && q?.where?.id?.$gt !== undefined) throw new Error('page 2 failed'); + return realFind(object, q); + }); + const res = await runOneTimeMembershipBackfill(engine, { ...auto, limit: 2 }, logger()); + expect(res.status).toBe('undecided'); + expect(res.backfill?.reason).toBe('scan-incomplete'); + expect(engine.tables.sys_member).toHaveLength(0); + expect(engine.tables.sys_migration).toHaveLength(0); + }); + + it('no target organization and no organization at all (fresh install): undecided, nothing recorded', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }); + const res = await runOneTimeMembershipBackfill( + engine, + { policy: 'auto', resolveTargetOrg: async () => null }, + logger(), + ); + expect(res.status).toBe('undecided'); + expect(engine.tables.sys_member).toHaveLength(0); + expect(engine.tables.sys_migration).toHaveLength(0); + }); + + it('no ledger on the kernel: the pass does not run, and says so', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }, { ledger: false }); + const log = logger(); + const res = await runOneTimeMembershipBackfill(engine, auto, log); + expect(res.status).toBe('ledger-unavailable'); + expect(engine.tables.sys_member).toHaveLength(0); + expect(log.warn).toHaveBeenCalledTimes(1); + }); + + it('an unreadable ledger: the pass does not run', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }); + engine.findOne.mockRejectedValueOnce(new Error('db down')); + const log = logger(); + const res = await runOneTimeMembershipBackfill(engine, auto, log); + expect(res.status).toBe('ledger-unreadable'); + expect(engine.tables.sys_member).toHaveLength(0); + expect(log.warn).toHaveBeenCalledTimes(1); + }); + + it('a record that fails to land is reported at error', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }); + const realInsert = engine.insert.getMockImplementation()!; + engine.insert.mockImplementation(async (object: string, data: any) => { + if (object === 'sys_migration') throw new Error('read-only'); + return realInsert(object, data); + }); + const log = logger(); + const res = await runOneTimeMembershipBackfill(engine, auto, log); + expect(res.status).toBe('ran-unrecorded'); + expect(log.error).toHaveBeenCalledTimes(1); + expect(String(log.error.mock.calls[0]![0])).toContain('will decide it AGAIN'); + }); + + it('a record written first by a concurrent pass counts as recorded', async () => { + const engine = makeEngine({ sys_user: [{ id: 'u_a' }] }); + const realInsert = engine.insert.getMockImplementation()!; + engine.insert.mockImplementation(async (object: string, data: any) => { + if (object === 'sys_migration') { + engine.tables.sys_migration.push({ id: MEMBERSHIP_BACKFILL_MIGRATION_ID }); + throw new Error('duplicate key'); + } + return realInsert(object, data); + }); + const log = logger(); + const res = await runOneTimeMembershipBackfill(engine, auto, log); + expect(res.status).toBe('ran'); + expect(log.error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts b/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts new file mode 100644 index 00000000000..ebca910c4ab --- /dev/null +++ b/packages/plugins/plugin-auth/src/membership-backfill-ledger.ts @@ -0,0 +1,313 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ADR-0093 D6 membership backfill runs ONCE per deployment, and the + * deployment remembers that it ran. + * + * ## Why it must be one-time + * + * ADR-0093 D7 rules that membership is decided at CREATION: under the `auto` + * policy a user is bound to the default organization when the user is created, + * and "User leaves their only org — allowed … the invariant governs creation, + * not the full lifecycle". The backfill exists for the users that predate the + * reconciler — rows created before there was anything to bind them at + * creation. It is not a lifecycle reconciler. + * + * A scan that binds every member-less user cannot tell a user who predates + * the policy from a user whose membership was decided at creation and has + * since changed, so the scan may only ever run before any such decision + * exists. The pass therefore runs until it has reached a verdict once, + * records that verdict in the deployment ledger, and every later pass reads + * the record and does nothing. + * + * ## Why the record lives in `sys_migration` + * + * `sys_migration` is the deployment-level ledger that already answers "has + * THIS deployment run this data migration" — one row per migration, written + * against the `DataMigrationFlag` row contract in `@objectstack/spec/system`, + * read-only over the API, provisioned by `PlatformObjectsPlugin` on every + * served kernel. The seed/API tenancy repair in `metadata-protocol` writes its + * receipt there from a boot hook for the same reasons; this module follows its + * shape. The field reading: + * + * - `verified_at: null` — the pass has no self-check; a timestamp would be a + * certificate nothing earned, and null is what `isDataMigrationFlagVerified` + * reads as "not verified", so no other id's gate can be reached by it; + * - `blocking: 0` — no consumer is held closed by this row; + * - `last_run_at` / `applied_at` / `details` — what the pass did. + * + * Every reader of the ledger reads one row by its id, so a new id cannot reach + * another migration's gate. + * + * ## Which verdicts are recorded + * + * Recorded — the deployment has DECIDED, and a later pass must not decide + * again: + * - the pass ran (policy `auto`, a default organization to bind to); + * - the policy was `invite-only`: users created under it were deliberately + * left unbound, so a later switch to `auto` must not sweep them in; + * - no unambiguous target organization while organizations DO exist + * (multi-org, where the backfill is refused by design). + * + * Not recorded — nothing was decided yet, so a later pass still gets its turn: + * - no target organization and no organization at all (a fresh install + * before its default organization exists); + * - a scan that could not read the user or membership table in full; + * - an off-vocabulary policy value, or no usable engine. + * + * The same ledger also records the one-time default-organization owner bind + * ({@link DEFAULT_ORG_OWNER_BIND_MIGRATION_ID}), through the same helpers. + * + * ## Failure directions + * + * Without a readable ledger the pass does NOT run: a pass that cannot be + * recorded would run again on the next boot and judge users whose + * membership was already decided. That is a functional absence (pre-existing + * member-less users stay unbound until an administrator adds them) and is + * reported at `warn`. A pass that ran but whose record failed to land is the + * opposite case — nothing looks wrong, and the next boot silently repeats the + * bulk bind — so it is reported at `error`, with the remedy. + */ + +import { DATA_MIGRATION_FLAG_OBJECT, type DataMigrationFlag } from '@objectstack/spec/system'; +import { + backfillMemberships, + type BackfillMembershipsResult, + type ReconcileMembershipDeps, +} from './reconcile-membership.js'; + +/** + * Ledger row id of the one-time membership backfill. + * + * Spelled here, not in `@objectstack/spec/system`: the writer and the only + * reader are both this module, so publishing the id would declare a + * cross-package coordination point that does not exist. + */ +export const MEMBERSHIP_BACKFILL_MIGRATION_ID = 'adr-0093-membership-backfill'; + +/** + * Ledger row id of the one-time default-organization owner bind: the platform + * admin is bound as owner of the default organization once, when the + * bootstrap first decides it, and never again automatically (ADR-0093 D7). + */ +export const DEFAULT_ORG_OWNER_BIND_MIGRATION_ID = 'adr-0093-default-org-owner-bind'; + +/** The engine surface the ledger needs, duck-typed. */ +export interface MembershipBackfillLedger { + getObject(name: string): unknown; + findOne(object: string, options: Record): Promise | null>; + insert(object: string, data: Record, options?: Record): Promise; +} + +export type OneTimeMembershipBackfillStatus = + /** The pass ran (or reached a recordable verdict) and its record landed. */ + | 'ran' + /** The pass ran but its record did not land — the next boot repeats it. */ + | 'ran-unrecorded' + /** The pass reached no verdict worth recording (see the module doc). */ + | 'undecided' + /** The ledger already records a verdict — nothing was scanned or bound. */ + | 'already-run' + /** No ledger on this kernel — the pass did not run. */ + | 'ledger-unavailable' + /** The ledger could not be read — the pass did not run. */ + | 'ledger-unreadable'; + +export interface OneTimeMembershipBackfillResult { + status: OneTimeMembershipBackfillStatus; + /** The backfill's own summary, present whenever it was consulted. */ + backfill?: BackfillMembershipsResult; +} + +export interface LedgerLogger { + info?: (msg: string, meta?: any) => void; + warn: (msg: string, meta?: any) => void; + error?: (msg: string, meta?: any) => void; +} + +/** What the ledger says about one decision id. */ +export type LedgerDecisionReading = 'recorded' | 'absent' | 'unavailable' | 'unreadable'; + +const SYSTEM_CTX = { isSystem: true }; + +function resolveLedger(engine: unknown): MembershipBackfillLedger | undefined { + const candidate = engine as any; + for (const method of ['getObject', 'findOne', 'insert']) { + if (typeof candidate?.[method] !== 'function') return undefined; + } + return candidate as MembershipBackfillLedger; +} + +/** + * Read whether the deployment ledger records decision `id`. Never throws: + * `unavailable` (no ledger on this kernel) and `unreadable` (the read failed) + * are answers the caller must handle, never "absent". + */ +export async function readLedgerDecision(engine: unknown, id: string): Promise { + const ledger = resolveLedger(engine); + try { + if (!ledger || !ledger.getObject(DATA_MIGRATION_FLAG_OBJECT)) return 'unavailable'; + const row = await ledger.findOne(DATA_MIGRATION_FLAG_OBJECT, { where: { id }, context: SYSTEM_CTX }); + return row?.id === id ? 'recorded' : 'absent'; + } catch { + return 'unreadable'; + } +} + +/** The ledger row for one recorded decision — pure. */ +export function buildLedgerDecisionRecord(id: string, details: Record, now: string): DataMigrationFlag { + return { + id, + last_run_at: now, + applied_at: now, + verified_at: null, + blocking: 0, + details: JSON.stringify(details), + }; +} + +/** The ledger row for one decided backfill pass — pure. */ +export function buildMembershipBackfillRecord( + result: BackfillMembershipsResult, + policy: unknown, + now: string, +): DataMigrationFlag { + return buildLedgerDecisionRecord( + MEMBERSHIP_BACKFILL_MIGRATION_ID, + { + policy: typeof policy === 'string' ? policy : String(policy), + scanned: result.scanned, + bound: result.bound, + skipped: result.skipped, + ...(result.reason ? { reason: result.reason } : {}), + }, + now, + ); +} + +/** + * Insert one decision row. THROWS on failure — the caller decides what a lost + * record means. Named in `DURABILITY_CRITICAL_CALLEES` + * (`scripts/check-durability-degradation-log-level.mjs`): a lost record leaves + * every log line clean while the next boot decides again. + */ +async function persistLedgerDecisionRow(ledger: MembershipBackfillLedger, flag: DataMigrationFlag): Promise { + const now = flag.last_run_at; + await ledger.insert( + DATA_MIGRATION_FLAG_OBJECT, + { ...flag, created_at: now, updated_at: now }, + { context: SYSTEM_CTX }, + ); +} + +/** + * Record decision `flag` in the ledger. Never throws. A concurrent writer that + * landed the same id first counts as recorded; any other failure is reported + * at `error` (nothing else looks wrong, and the next boot decides again). + */ +export async function recordLedgerDecision( + engine: unknown, + flag: DataMigrationFlag, + what: string, + logger?: LedgerLogger, +): Promise { + const ledger = resolveLedger(engine); + if (!ledger) return false; + try { + await persistLedgerDecisionRow(ledger, flag); + logger?.info?.( + `[auth] ${what} recorded in ${DATA_MIGRATION_FLAG_OBJECT} (id '${flag.id}') — later boots will not decide it again`, + { id: flag.id, details: flag.details }, + ); + return true; + } catch (e: unknown) { + if ((await readLedgerDecision(engine, flag.id)) === 'recorded') return true; + // Inline, at `error`: the decision was acted on and every line reads + // clean, while the record that stops the next boot from deciding again is + // absent. + const detail = e instanceof Error ? e.message : String(e); + const message = + `[auth] ${what} was carried out, but recording it in ${DATA_MIGRATION_FLAG_OBJECT} failed (${detail}). ` + + `The next boot will decide it AGAIN, re-deciding membership for users whose membership was already ` + + `decided. Fix: make ${DATA_MIGRATION_FLAG_OBJECT} writable on this deployment (it is provisioned by ` + + `PlatformObjectsPlugin) and verify with SELECT * FROM ${DATA_MIGRATION_FLAG_OBJECT} WHERE id = '${flag.id}'.`; + if (logger?.error) logger.error(message, { error: detail }); + else logger?.warn?.(message, { error: detail }); + return false; + } +} + +/** Whether any organization exists — `undefined` when that cannot be read. */ +async function anyOrganizationExists(engine: any): Promise { + try { + if (typeof engine?.count === 'function') { + const n = await engine.count('sys_organization', { where: {}, context: SYSTEM_CTX }); + if (typeof n === 'number') return n > 0; + } + if (typeof engine?.find !== 'function') return undefined; + const rows = await engine.find( + 'sys_organization', + { where: {}, orderBy: [{ field: 'id', order: 'asc' }], limit: 1 }, + { context: SYSTEM_CTX }, + ); + return Array.isArray(rows) ? rows.length > 0 : undefined; + } catch { + return undefined; + } +} + +/** + * Whether a backfill summary is a verdict the deployment should remember. + * + * `no-target-org` is deferred ONLY while the deployment holds no organization + * at all (a fresh install before its default organization exists). With + * organizations present and still no unambiguous target — multi-org, where + * the backfill is refused by design — the refusal IS the decision, and it is + * recorded so a later pass cannot reverse it. + */ +async function isRecordableVerdict(engine: unknown, result: BackfillMembershipsResult): Promise { + if (result.reason === undefined || result.reason === 'policy') return true; + if (result.reason === 'no-target-org') return (await anyOrganizationExists(engine)) === true; + return false; +} + +/** + * Run the ADR-0093 D6 backfill unless this deployment has already decided it, + * and record the verdict when it decides now. Never throws: it runs inside + * boot hooks, which bookkeeping must not break. + */ +export async function runOneTimeMembershipBackfill( + engine: unknown, + deps: ReconcileMembershipDeps & { limit?: number }, + logger?: LedgerLogger, +): Promise { + const reading = await readLedgerDecision(engine, MEMBERSHIP_BACKFILL_MIGRATION_ID); + if (reading === 'unavailable') { + logger?.warn?.( + `[auth] membership backfill did NOT run: the deployment ledger (${DATA_MIGRATION_FLAG_OBJECT}) is not ` + + `available on this kernel, so a pass could not be recorded and would repeat on every boot, ` + + `re-deciding membership for users whose membership was already decided. Pre-existing users without ` + + `a membership stay unbound until an administrator adds them. Compose PlatformObjectsPlugin (it ` + + `provisions the ledger) to let the one-time backfill run.`, + ); + return { status: 'ledger-unavailable' }; + } + if (reading === 'unreadable') { + logger?.warn?.( + `[auth] membership backfill did NOT run: reading its record in ${DATA_MIGRATION_FLAG_OBJECT} failed, ` + + `and running without knowing whether it already ran could re-decide membership for users whose ` + + `membership was already decided. It is retried on the next boot.`, + ); + return { status: 'ledger-unreadable' }; + } + if (reading === 'recorded') return { status: 'already-run' }; + + const backfill = await backfillMemberships(engine, deps); + if (!(await isRecordableVerdict(engine, backfill))) { + return { status: 'undecided', backfill }; + } + + const flag = buildMembershipBackfillRecord(backfill, deps.policy, new Date().toISOString()); + const recorded = await recordLedgerDecision(engine, flag, 'membership backfill', logger); + return { status: recorded ? 'ran' : 'ran-unrecorded', backfill }; +} diff --git a/packages/plugins/plugin-auth/src/membership-policy-setting.test.ts b/packages/plugins/plugin-auth/src/membership-policy-setting.test.ts index f710ec20969..e49884d3d2a 100644 --- a/packages/plugins/plugin-auth/src/membership-policy-setting.test.ts +++ b/packages/plugins/plugin-auth/src/membership-policy-setting.test.ts @@ -86,6 +86,10 @@ function makeEngine(seed: { users?: Array<{ id: string }>; members?: Array<{ use return []; }), findOne: vi.fn(async (object: string, query?: EngineFindOneQueryInput) => { assertEngineFindOnePredicate(object, query); return null; }), + // The deployment ledger the one-time backfill records itself in is + // present; it holds no record yet (`findOne` answers null), so the pass + // runs on this boot. + getObject: vi.fn((name: string) => ({ name })), }; } @@ -163,7 +167,9 @@ describe('auth.membership_policy — the setting', () => { } = {}) => { settingsStore.values = opts.settings ?? {}; const engine = opts.engine ?? makeEngine(); - const defaultOrgId = vi.fn(async () => opts.defaultOrgId ?? 'org_default'); + // `null` is a real answer (no unambiguous target), so only an omitted + // option falls back to the default organization. + const defaultOrgId = vi.fn(async () => ('defaultOrgId' in opts ? (opts.defaultOrgId ?? null) : 'org_default')); (mockContext.getService as any).mockImplementation((name: string) => { if (name === 'manifest') return { register: vi.fn() }; if (name === 'settings') return makeSettings(); @@ -324,7 +330,10 @@ describe('auth.membership_policy — the setting', () => { // read sites that happen to agree today. This one fails the moment either // path goes back to reading a captured copy, whatever value it holds. const engine = makeEngine({ users: [{ id: 'usr_legacy_1' }] }); - const { manager } = await boot({ engine }); + // No target organization and no organization at all: the kernel:ready pass + // stays undecided, so the `app:seeded` pass below runs (and reads the + // policy) instead of being latched off as already decided. + const { manager } = await boot({ engine, defaultOrgId: null }); const spy = vi.spyOn(manager, 'getMembershipPolicy'); await signUpHook(manager)({ id: 'usr_new' }); diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.ts b/packages/plugins/plugin-auth/src/objectql-adapter.ts index 3535477b3a0..61785b0bc40 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -736,7 +736,22 @@ export const withSystemReadContext = withSystemContext; * @param dataEngine - ObjectQL data engine instance * @returns better-auth AdapterFactory */ -export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) { +export interface ObjectQLAdapterFactoryOptions { + /** + * Called after every successful `create`, with the protocol object name and + * the row the engine returned, before `create` resolves — so it runs inside + * the better-auth endpoint call that wrote the row, ahead of any later write + * of that call. Awaited; a throw is swallowed (bookkeeping must never fail a + * write that landed). The AuthManager uses it to know which user the current + * request created without reading the row back (ADR-0093 D7). + */ + onRecordCreated?: (objectName: string, row: Record) => void | Promise; +} + +export function createObjectQLAdapterFactory( + rawDataEngine: IDataEngine, + factoryOptions: ObjectQLAdapterFactoryOptions = {}, +) { const dataEngine = withSystemContext(rawDataEngine); // [#8009] The OIDC `clientSecret` seam needs the engine's PRIVILEGED secret // dereference, which `withSystemContext` deliberately does not carry (it @@ -867,6 +882,13 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) { // why the seam is here and what adoption does to the role. const adopted = await adoptExistingMembership(dataEngine as any, objectName, row); const result = adopted ?? (await dataEngine.insert(objectName, row)); + if (factoryOptions.onRecordCreated && result && typeof result === 'object') { + try { + await factoryOptions.onRecordCreated(objectName, result as Record); + } catch { + // Bookkeeping only — the row landed and the write stands. + } + } const norm = normaliseLegacyDates(model, result); return (bridged ? remapKeys(norm, snakeToCamel) : norm) as T; }, diff --git a/packages/plugins/plugin-auth/src/reconcile-membership.test.ts b/packages/plugins/plugin-auth/src/reconcile-membership.test.ts index 696fce5bed3..137b1f3af81 100644 --- a/packages/plugins/plugin-auth/src/reconcile-membership.test.ts +++ b/packages/plugins/plugin-auth/src/reconcile-membership.test.ts @@ -128,7 +128,9 @@ describe('backfillMemberships', () => { it('binds only the member-less users (single/auto)', async () => { const engine = makeEngine({ users: [{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }], - members: [{ organization_id: 'org_default', user_id: 'u2' }], + // A stored membership carries its primary key; the backfill's keyset + // walk seeks on it, and a row without one cannot be paged past. + members: [{ id: 'm2', organization_id: 'org_default', user_id: 'u2' } as any], }); const res = await backfillMemberships(engine, { policy: 'auto', diff --git a/packages/plugins/plugin-auth/src/reconcile-membership.ts b/packages/plugins/plugin-auth/src/reconcile-membership.ts index 3b75122e621..3395f3bb638 100644 --- a/packages/plugins/plugin-auth/src/reconcile-membership.ts +++ b/packages/plugins/plugin-auth/src/reconcile-membership.ts @@ -47,6 +47,7 @@ * value is rejected loudly, never coerced to `auto`. */ +import { keysetWalk } from '@objectstack/types'; import { authSystemWriteContext } from './auth-actor-attribution.js'; /** @@ -269,7 +270,7 @@ export interface BackfillMembershipsResult { scanned: number; bound: number; skipped: number; - reason?: 'policy' | 'invalid-policy' | 'no-target-org' | 'engine-unavailable'; + reason?: 'policy' | 'invalid-policy' | 'no-target-org' | 'engine-unavailable' | 'scan-incomplete'; /** * Present with `reason: 'invalid-policy'` — the refusal message, naming the * offending value. Carried on the result, not only logged, so the diagnosis @@ -278,14 +279,51 @@ export interface BackfillMembershipsResult { error?: string; } +/** Rows per page of the backfill's keyset walks. */ +const BACKFILL_PAGE_SIZE = 500; + +/** + * Walk every row of `object` in id order (keyset pagination, no row cap). + * Resolves `undefined` when the walk could not see the whole table — a read + * failed, or the reader could not seek — because the backfill's verdict is + * recorded once (ADR-0093 D7) and a partial scan must not become permanent. + */ +async function walkAll(engine: any, object: string, pageSize: number): Promise { + const walk = keysetWalk>( + async (q) => { + const page = await engine.find(object, { ...q }, { context: SYSTEM_CTX }); + return Array.isArray(page) ? page : []; + }, + { pageSize }, + ); + const rows: any[] = []; + try { + for await (const page of walk.pages()) rows.push(...page); + } catch { + return undefined; + } + return walk.truncated ? undefined : rows; +} + /** - * One-shot backfill for pre-existing member-less users (ADR-0093 D6). Run on - * `kernel:ready` in single mode with `membershipPolicy: 'auto'` only — multi-org - * backfill is refused by design (there is no correct guess, and a wrong org in a - * tenant-isolated deployment is a data-exposure bug, not a convenience). + * One-time backfill for pre-existing member-less users (ADR-0093 D6; one-time + * per ADR-0093 D7, see `membership-backfill-ledger.ts`). Single mode with + * `membershipPolicy: 'auto'` only — multi-org backfill is refused by design + * (there is no correct guess, and a wrong org in a tenant-isolated deployment + * is a data-exposure bug, not a convenience). * - * Bounded, idempotent, failure-isolated per user. Ordered after the default-org - * bootstrap so a target org exists. + * Reads both tables WHOLE through keyset pages — no row cap, because the + * verdict is recorded once and a capped scan would leave every user past the + * cap unbound for good. A scan that cannot complete binds nobody and answers + * `scan-incomplete`. Idempotent, failure-isolated per user. Ordered after the + * default-org bootstrap so a target org exists. `limit` is the page size. + * + * Calling this directly is NOT gated: every call decides membership again for + * every member-less user, including one whose membership was removed. Under the + * one-time rule, call it only through `runOneTimeMembershipBackfill`, which + * records the verdict and runs the pass once per deployment. The reader must be + * able to page by `id` (keyset); a reader that cannot answers `scan-incomplete` + * and binds nobody. */ export async function backfillMemberships( engine: any, @@ -309,9 +347,17 @@ export async function backfillMemberships( return { ...summary, reason: 'no-target-org' }; } - const limit = deps.limit ?? 5000; - const users = await findRows(engine, 'sys_user', {}, limit); - const members = await findRows(engine, 'sys_member', {}, limit); + const pageSize = deps.limit ?? BACKFILL_PAGE_SIZE; + const members = await walkAll(engine, 'sys_member', pageSize); + const users = members ? await walkAll(engine, 'sys_user', pageSize) : undefined; + if (!members || !users) { + deps.logger?.warn?.( + '[membership] backfill did not run: the user or membership table could not be read in full, ' + + 'so no user was bound and the pass is retried on the next boot', + { organizationId }, + ); + return { ...summary, reason: 'scan-incomplete' }; + } const membered = new Set(members.map((m: any) => String(m?.user_id ?? '')).filter(Boolean)); for (const user of users) { diff --git a/packages/plugins/plugin-auth/src/user-created-in-request.test.ts b/packages/plugins/plugin-auth/src/user-created-in-request.test.ts new file mode 100644 index 00000000000..1d65db095e8 --- /dev/null +++ b/packages/plugins/plugin-auth/src/user-created-in-request.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0093 D7 — the session seam recognises the user its request CREATED from + * the adapter's own `create` result, with no read of the store. These cases + * pin the real wiring: the AuthManager's adapter factory, a `sys_user` create + * inside a better-auth endpoint context, and the session hook of that context. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { runWithEndpointContext } from '@better-auth/core/context'; +import { AuthManager } from './auth-manager.js'; +import { createObjectQLAdapterFactory } from './objectql-adapter.js'; +import { assertEngineFindOnePredicate } from '@objectstack/objectql'; + +const DEFAULT_ORG = 'org_default'; + +function makeEngine() { + const tables: Record = { sys_user: [], sys_member: [] }; + let seq = 0; + const match = (r: any, where: any = {}) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake engine: unsupported combinator ${k}`); + return r[k] === v; + }); + return { + tables, + insert: vi.fn(async (object: string, data: any) => { + const row = { ...data, id: data.id ?? `${object}_${++seq}` }; + (tables[object] ??= []).push(row); + return row; + }), + find: vi.fn(async (object: string, q: any) => + (tables[object] ?? []).filter((r) => match(r, q?.where)).slice(0, q?.limit ?? 100), + ), + findOne: vi.fn(async (object: string, q: any) => { + assertEngineFindOnePredicate(object, q); + return (tables[object] ?? []).find((r) => match(r, q?.where)) ?? null; + }), + }; +} + +async function setup() { + const engine = makeEngine(); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + dataEngine: engine, + getTenancy: () => ({ defaultOrgId: async () => DEFAULT_ORG }), + } as any); + const factory = await (manager as any).createDatabaseConfig(); + const adapter: any = factory({} as any); + const hooks: any = (manager as any).composeDatabaseHooks(undefined); + const createUser = (request: object) => + runWithEndpointContext(request as any, () => + adapter.create({ + model: 'user', + data: { email: 'created.here@example.com', name: 'Created Here', emailVerified: false }, + }), + ); + return { engine, hooks, createUser }; +} + +describe('the creating request is recognised from the adapter create result (ADR-0093 D7)', () => { + it('a user created through the adapter in this request is settled by its session', async () => { + const { engine, hooks, createUser } = await setup(); + const request = {}; + const created = await createUser(request); + const reads = engine.findOne.mock.calls.length; + const result = await hooks.session.create.before({ userId: created.id }, request); + expect(result?.data?.activeOrganizationId).toBe(DEFAULT_ORG); + expect(engine.tables.sys_member).toEqual([ + expect.objectContaining({ organization_id: DEFAULT_ORG, user_id: created.id }), + ]); + // Recognising the user read nothing from `sys_user`. + const userReads = engine.findOne.mock.calls.slice(reads).filter((c) => c[0] === 'sys_user'); + expect(userReads).toHaveLength(0); + }); + + it('a request that created one user does not settle a DIFFERENT pre-existing member-less user', async () => { + const { engine, hooks, createUser } = await setup(); + engine.tables.sys_user.push({ id: 'usr_preexisting', email: 'pre@example.com' }); + const request = {}; + await createUser(request); + const result = await hooks.session.create.before({ userId: 'usr_preexisting' }, request); + expect(result?.data?.activeOrganizationId).toBeUndefined(); + expect(engine.tables.sys_member).toHaveLength(0); + }); + + it('CONTROL: the same user signing in from another request is not settled', async () => { + const { engine, hooks, createUser } = await setup(); + const created = await createUser({}); + const result = await hooks.session.create.before({ userId: created.id }, {}); + expect(result?.data?.activeOrganizationId).toBeUndefined(); + expect(engine.tables.sys_member).toHaveLength(0); + }); +}); + +describe('objectql-adapter onRecordCreated', () => { + it('reports the protocol object name and the stored row', async () => { + const engine = makeEngine(); + const onRecordCreated = vi.fn(); + const adapter: any = (createObjectQLAdapterFactory(engine as any, { onRecordCreated }) as any)({} as any); + const row = await adapter.create({ model: 'user', data: { email: 'a@example.com', name: 'A', emailVerified: false } }); + expect(onRecordCreated).toHaveBeenCalledTimes(1); + expect(onRecordCreated.mock.calls[0]![0]).toBe('sys_user'); + expect(onRecordCreated.mock.calls[0]![1]).toMatchObject({ id: row.id }); + }); + + it('a throwing callback does not fail a write that landed', async () => { + const engine = makeEngine(); + const onRecordCreated = vi.fn(async () => { + throw new Error('bookkeeping down'); + }); + const adapter: any = (createObjectQLAdapterFactory(engine as any, { onRecordCreated }) as any)({} as any); + await expect( + adapter.create({ model: 'user', data: { email: 'b@example.com', name: 'B', emailVerified: false } }), + ).resolves.toMatchObject({ email: 'b@example.com' }); + expect(engine.tables.sys_user).toHaveLength(1); + }); +}); diff --git a/packages/qa/dogfood/test/membership-decided-at-creation.dogfood.test.ts b/packages/qa/dogfood/test/membership-decided-at-creation.dogfood.test.ts new file mode 100644 index 00000000000..d04d4b0bdfa --- /dev/null +++ b/packages/qa/dogfood/test/membership-decided-at-creation.dogfood.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0093 D7 on a real boot: under the `auto` membership policy a user is + * bound to the default organization when the user is CREATED, and a user + * whose membership was removed is never bound again automatically — not by + * signing in again. + * + * Driven through the real HTTP surface: sign-up, better-auth's own + * remove-member endpoint, and sign-in. The positive half (creation binds, and + * the creating request's first session carries the organization) is asserted + * on the same run, so the negative half measures the decision and not a + * deployment that binds nobody. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +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 ?? []); +} + +/** The creation-time membership may land after the sign-up response. */ +async function waitForMembership(ql: any, userId: string): Promise { + for (let i = 0; i < 60; i++) { + const rows = await findRows(ql, 'sys_member', { user_id: userId }, 5); + if (rows.length > 0) return rows[0]; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`no sys_member row appeared for ${userId}`); +} + +describe('membership under the auto policy is decided at user creation (ADR-0093 D7)', () => { + let stack: VerifyStack; + let ql: any; + let adminToken: string; + let orgId: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + adminToken = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + // The single-org default organization — reuse the bootstrap's when the + // boot made one, otherwise stand it up the way the bootstrap would. + const [existing] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); + orgId = existing + ? String(existing.id) + : String((await ql.insert('sys_organization', { name: 'Default Organization', slug: 'default' }, { context: SYSTEM_CTX })).id); + + // The admin must own the organization for remove-member to be authorized. + const [adminUser] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); + const adminUserId = String(adminUser.id); + const adminMembers = await findRows(ql, 'sys_member', { user_id: adminUserId, organization_id: orgId }, 5); + if (adminMembers.length > 0) { + await ql.update('sys_member', { id: adminMembers[0].id, role: 'owner' }, { context: SYSTEM_CTX }); + } else { + await ql.insert('sys_member', { user_id: adminUserId, organization_id: orgId, role: 'owner' }, { context: SYSTEM_CTX }); + } + }, 240_000); + + afterAll(async () => { await stack?.stop?.(); }); + + it('membership is decided once, at creation', async () => { + const email = 'member.decided.at.creation@example.com'; + const password = 'Member!Pass123'; + + // ── creation binds ─────────────────────────────────────────────────── + await stack.signUp(email, password, 'Decided At Creation'); + const [user] = await findRows(ql, 'sys_user', { email }, 1); + const userId = String(user.id); + const member = await waitForMembership(ql, userId); + expect(String(member.organization_id)).toBe(orgId); + expect(member.role).toBe('member'); + + // The creating request's first session carries the organization: the + // creation-time settle still runs ahead of the deferred creation hook. + const firstSessions = await findRows(ql, 'sys_session', { user_id: userId }, 5); + expect(firstSessions.length).toBeGreaterThan(0); + expect(String(firstSessions[0].active_organization_id)).toBe(orgId); + + // ── an administrator removes the membership ────────────────────────── + const removed = await stack.apiAs(adminToken, 'POST', '/auth/organization/remove-member', { + memberIdOrEmail: String(member.id), + organizationId: orgId, + }); + expect(removed.status, await removed.clone().text()).toBe(200); + expect(await findRows(ql, 'sys_member', { user_id: userId }, 5)).toHaveLength(0); + + // ── signing in again does not put them back ────────────────────────── + const token = await stack.signIn(email, password); + const session = await stack.apiAs(token, 'GET', '/auth/get-session'); + expect(session.status).toBe(200); + const body = (await session.json()) as any; + expect(body?.user?.id).toBe(userId); + expect(body?.session?.activeOrganizationId ?? null).toBeNull(); + + // Allow any deferred hook of the sign-in to finish before reading the + // store, so an absence is not merely "not yet". + await new Promise((r) => setTimeout(r, 1_000)); + expect(await findRows(ql, 'sys_member', { user_id: userId }, 5)).toHaveLength(0); + }, 180_000); +}); diff --git a/packages/qa/dogfood/test/showcase-demo-personas-membership.dogfood.test.ts b/packages/qa/dogfood/test/showcase-demo-personas-membership.dogfood.test.ts new file mode 100644 index 00000000000..a97dd70287c --- /dev/null +++ b/packages/qa/dogfood/test/showcase-demo-personas-membership.dogfood.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The showcase approval-demo personas are raw `sys_user` inserts (the seed in + * `examples/app-showcase/src/security/seed-approval-demo.ts`), so they never + * cross the platform's user-creation seam. Under ADR-0093 D7 membership is + * decided at creation, and the one-time backfill does not pick up users + * inserted after it ran — so the code that creates them writes their + * membership. This boot gives the admin a real organization (`orgContext`) + * and runs the showcase hook, the shape a `pnpm dev:showcase` operator gets. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack, { onEnable } from '@objectstack/example-showcase'; +import { ADMIN_EMAIL, PHONE_DEMO_USER, AUDITOR_DEMO_USER } from '@objectstack/example-showcase/security-personas'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { showcaseAppDefaultSecurity } from './showcase-security.js'; + +const SYS = { isSystem: true } as const; +const showcaseBundleWithHook = { ...(showcaseStack as Record), onEnable }; +const rowsOf = (r: unknown): Array> => + Array.isArray(r) ? (r as Array>) : ((r as { records?: unknown[] })?.records as Array>) ?? []; + +describe('showcase demo personas are created into the admin\'s organization (ADR-0093 D7)', () => { + let stack: VerifyStack; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + + const membersOf = async (userId: string) => + rowsOf(await ql.find('sys_member', { where: { user_id: userId }, limit: 5, context: SYS })); + + /** The persona seed runs on `kernel:bootstrapped`; give it a moment to land. */ + const waitForUser = async (email: string) => { + for (let i = 0; i < 60; i++) { + const row = rowsOf(await ql.find('sys_user', { where: { email }, limit: 1, context: SYS }))[0]; + if (row?.id) return row; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`persona ${email} was never provisioned`); + }; + + beforeAll(async () => { + stack = await bootStack(showcaseBundleWithHook, { security: showcaseAppDefaultSecurity(), orgContext: true }); + await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('each persona holds exactly one plain membership, in the admin\'s organization', async () => { + const admin = await waitForUser(ADMIN_EMAIL); + const adminOrgs = (await membersOf(String(admin.id))).map((m) => String(m.organization_id)); + expect(adminOrgs.length, 'PREMISE: the admin is bound to an organization (orgContext)').toBeGreaterThan(0); + for (const persona of [PHONE_DEMO_USER, AUDITOR_DEMO_USER]) { + await waitForUser(persona.email); + const members = await membersOf(persona.id); + expect(members.length, `${persona.email} has an organization`).toBe(1); + expect(adminOrgs, `${persona.email} is a member of the admin's organization`).toContain(String(members[0]!.organization_id)); + expect(members[0]!.role, `${persona.email} is a plain member`).toBe('member'); + } + }); +}); diff --git a/packages/types/src/keyset-walk.test.ts b/packages/types/src/keyset-walk.test.ts index 008281ec544..3afd137a7ee 100644 --- a/packages/types/src/keyset-walk.test.ts +++ b/packages/types/src/keyset-walk.test.ts @@ -179,6 +179,28 @@ describe('keysetWalk', () => { expect(calls).toBeLessThan(5); }); + it('a driver collation that orders keys differently from JS strings is NOT truncation', async () => { + // A case-insensitive collation (the PG/MySQL default shape) sorts 'b02' + // before 'C03' before 'd04', while JS string order puts 'C03' first. The + // reader seeks by ITS collation; the walk must only ask whether the + // cursor came back the same, never whether it is greater in JS order. + const keys = ['a01', 'b02', 'C03', 'd04', 'E05']; + const collate = (x: string, y: string) => x.toLowerCase().localeCompare(y.toLowerCase()); + const walk = keysetWalk( + async (q) => { + const gt = (q.where as any)?.id?.$gt as string | undefined; + return keys + .filter((k) => gt === undefined || collate(k, gt) > 0) + .sort(collate) + .slice(0, q.limit) + .map((id) => ({ id, status: 'open' })); + }, + { pageSize: 2 }, + ); + expect(await collect(walk)).toEqual(keys); + expect(walk.truncated).toBe(false); + }); + it('stops instead of spinning when a row has no key', async () => { const store = makeStore([{ id: 'r01', status: 'open' }, { id: undefined as any, status: 'open' }]); const walk = keysetWalk( diff --git a/packages/types/src/keyset-walk.ts b/packages/types/src/keyset-walk.ts index cac22cee8c3..8a76d9b8a9c 100644 --- a/packages/types/src/keyset-walk.ts +++ b/packages/types/src/keyset-walk.ts @@ -120,6 +120,7 @@ export function keysetWalk>( async function* pages(): AsyncGenerator { let cursor: unknown = undefined; + let previousFirst: unknown = undefined; for (;;) { const want = options.max == null ? pageSize : Math.min(pageSize, options.max - scanned); if (want <= 0) { @@ -163,10 +164,21 @@ export function keysetWalk>( // this page again, forever. Production drivers execute the predicate; // a test double or a future reader that quietly drops it would otherwise // hang rather than fail, and a hang is the one failure nobody can read. - if (cursor !== undefined && !(String(last) > String(cursor))) { + // + // Judged by EQUALITY, never by order: the driver orders the key by its + // own collation (PG/MySQL collations do not sort strings the way JS + // compares them), so "did not advance" is "came back as the same key", + // or the same page again — never "is not greater in JS string order", + // which would report a perfectly healthy walk as truncated. + const first = emit[0]?.[key]; + if ( + cursor !== undefined && + (String(last) === String(cursor) || (previousFirst !== undefined && String(first) === String(previousFirst))) + ) { truncated = true; return; } + previousFirst = first; cursor = last; // A short page means the source is exhausted. diff --git a/scripts/check-durability-degradation-log-level.mjs b/scripts/check-durability-degradation-log-level.mjs index f6a74d76a5e..e105dceffc1 100644 --- a/scripts/check-durability-degradation-log-level.mjs +++ b/scripts/check-durability-degradation-log-level.mjs @@ -400,6 +400,10 @@ const DURABILITY_CRITICAL_CALLEES = new Map([ 'persistSeedTenancyReceiptRow', "The `sys_migration` receipt for the #8686 seed/API tenancy repair was never written — the repair itself SUCCEEDED, so the rows already carry the organization, the `__global__` counter is already gone, and every log line reads clean, while the only durable record that a boot rewrote stored data does not exist. Not retried: the repair is idempotent, so the next boot finds no split and writes no receipt either, and the one `logger.info` line that named it is gone as soon as the container is replaced. That leaves \"was my data rewritten, and when\" unanswerable from the deployment — which is the defect #9451 exists to remove, reproduced by its own fix.", ], + [ + 'persistLedgerDecisionRow', + "A one-time membership decision (the ADR-0093 D6 backfill, or the default organization's owner bind) was carried out but its `sys_migration` record was never written. Every log line reads clean and the memberships it wrote stand, while the record that makes the decision ONE-TIME is absent, so the next boot decides it again — re-deciding membership for users whose membership was already decided at creation (ADR-0093 D7).", + ], [ 'runWideningAlters', "The widening ALTER never ran — the MySQL column keeps its legacy zero-precision type (`TIMESTAMP` for a `Field.datetime`, `TIME` for a `Field.time`) while the object stays registered and served, so every subsequent write silently drops the milliseconds the canonical storage form promises are always present: a `TIMESTAMP` truncates them, and a `TIME(0)` ROUNDS a fractional literal, changing the wall clock it was asked to store. Reads come back looking clean because the value that was stored is the value that is returned, and nothing else reports the column is still un-widened (#9609).", diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 19c69f678a8..153c16e5f13 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2681,6 +2681,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/auth-plugin.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/auth-settings-refusal-isolation.test.ts", "verb": "findOne", @@ -2761,6 +2766,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/membership-backfill-ledger.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/membership-policy-setting.test.ts", "verb": "findOne", @@ -2876,6 +2886,11 @@ "verb": "delete", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/user-created-in-request.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/plugins/plugin-email/src/attachment-reclaim.test.ts", "verb": "delete", diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index 8b79efa73f8..1f5656b05ff 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -328,6 +328,7 @@ const WRITE_SHAPED_CALLEES = new Map([ ['deleteMetaItemFromLoader', 'gate-vocabulary'], ['persistPackageCommitRow', 'gate-vocabulary'], ['persistSeedTenancyReceiptRow', 'gate-vocabulary'], + ['persistLedgerDecisionRow', 'gate-vocabulary'], ['recordLog', 'gate-vocabulary'], ['runWideningAlters', 'gate-vocabulary'], ['applyConfigPatch', 'gate-vocabulary'],