diff --git a/.changeset/walled-owner-email-elevation.md b/.changeset/walled-owner-email-elevation.md new file mode 100644 index 0000000000..23d4a5ea2e --- /dev/null +++ b/.changeset/walled-owner-email-elevation.md @@ -0,0 +1,55 @@ +--- +"@objectstack/plugin-security": minor +"@objectstack/plugin-auth": minor +"@objectstack/types": minor +"@objectstack/verify": patch +--- + +fix(security): walled postures elevate only the env-declared platform owner, never the first registrant (#11184, the framework leg of cloud#1509) + +**BREAKING** for walled deployments (`OS_TENANCY_POSTURE=group` or +`isolated`), shipped as `minor` under the repo's launch-window convention for +breaking changes. Single-org deployments are byte-for-byte unchanged. + +Measured defect (cloud#1509): on a walled multi-tenant SaaS with +`OS_TENANCY_POSTURE=isolated` and `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, the +FIRST self-registrant received the cross-tenant `admin_full_access` grant +(`platform_admin`, `isPlatformAdmin: true`) and — because the default-org +bootstrap binds "the platform admin" — was merged into the deployment's +Default Organization as its owner. Whoever curls the public sign-up endpoint +first owned the platform. + +Per the maintainer ruling of 2026-08-23 (verbatim: +「1509 选择 env 指定 owner 邮箱」): + +- **Walled postures: platform admin comes ONLY from the env-declared owner.** + `bootstrapPlatformAdmin` (plugin-security) no longer promotes the oldest + human user when the requested posture is walled; it promotes exactly the + account whose email matches the new `OS_PLATFORM_OWNER_EMAIL` variable + (case-insensitive, matched whenever that account registers — arrival order + is irrelevant). Self-registrants are never promoted and, since the shared + `ensureDefaultOrganization` helper binds only the platform admin, are never + auto-merged into the Default Organization either. +- **Fail-closed startup refusal.** A walled posture with no + `OS_PLATFORM_OWNER_EMAIL` declared refuses to boot from `AuthPlugin.init()` + with a message naming the variable — never a silent fallback to + first-registrant elevation. The elevation site itself also refuses + (`reason: 'walled_owner_email_undeclared'`, logged at `error`) as + defense-in-depth for compositions that reach the bootstrap without + plugin-auth (`os meta resync`, bare embeddings). +- **Single-org posture unchanged.** "First user is owner" stays as ruled + reasonable there; the new variable is never consulted under `single`. +- The requested posture (`resolveTenancyPosture()`) is deliberately the input, + so a walled-requested deployment running degraded + (`OS_ALLOW_DEGRADED_TENANCY=1`) still refuses first-registrant elevation. + +Operator action for walled deployments: set `OS_PLATFORM_OWNER_EMAIL` to the +operator account's email address before upgrading. Deployments that already +hold a human platform admin are untouched (the bootstrap remains a no-op once +any human holds the cross-tenant grant); the variable governs installs that +have not yet minted their admin. `@objectstack/types` gains the +`resolvePlatformOwnerEmail()` resolver and the `PLATFORM_OWNER_EMAIL_ENV` +constant; the verify harness declares the owner email (defaulting to its dev +admin) for walled fixtures. + + diff --git a/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts b/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts index 6fec9b75da..0739621f5c 100644 --- a/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts +++ b/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts @@ -146,6 +146,11 @@ afterAll(() => { const SERVE_ENV = { OS_AUTH_SECRET: 'org-host-resolution-e2e-secret', OS_TENANCY_POSTURE: 'isolated', + // [#11184] A walled posture refuses to boot unless the platform owner is + // env-declared; these fixtures' subject is the organizations-package + // resolution, so declare one (the refusal itself is pinned in + // plugin-auth's auth-plugin-walled-owner-boot-refusal.test.ts). + OS_PLATFORM_OWNER_EMAIL: 'operator@corp.example', }; describe('os serve — enterprise organizations resolution (cloud#1013)', () => { diff --git a/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts b/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts index 3722b6b715..f2ddc33331 100644 --- a/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts +++ b/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts @@ -134,6 +134,11 @@ afterAll(() => { const SERVE_ENV = { OS_AUTH_SECRET: 'org-mount-failure-e2e-secret', OS_TENANCY_POSTURE: 'isolated', + // [#11184] A walled posture refuses to boot unless the platform owner is + // env-declared; these fixtures' subject is the organizations-package + // resolution, so declare one (the refusal itself is pinned in + // plugin-auth's auth-plugin-walled-owner-boot-refusal.test.ts). + OS_PLATFORM_OWNER_EMAIL: 'operator@corp.example', }; const BANNER = 'Press Ctrl+C to stop'; diff --git a/packages/plugins/plugin-auth/src/auth-plugin-walled-owner-boot-refusal.test.ts b/packages/plugins/plugin-auth/src/auth-plugin-walled-owner-boot-refusal.test.ts new file mode 100644 index 0000000000..29e50d5f9c --- /dev/null +++ b/packages/plugins/plugin-auth/src/auth-plugin-walled-owner-boot-refusal.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * AuthPlugin init — the fail-closed clause of #11184 (framework leg of + * cloud#1509; maintainer ruling 2026-08-23, verbatim: + * 「1509 选择 env 指定 owner 邮箱」). + * + * A WALLED tenancy posture (`group` / `isolated`) with no + * `OS_PLATFORM_OWNER_EMAIL` declared must REFUSE STARTUP, naming the + * variable — never boot into a state that either can mint no platform admin + * or tempts a silent fallback to first-registrant elevation. The throw is in + * `init()`, where a failure aborts kernel boot (Phase 1 propagates). + * + * This refusal is a process-boot abort, not an HTTP answer — there is no + * ADR-0112 envelope to carry `code`/`status`. The machine-checkable pin is + * the message: it must name the variable (the remedy) and the posture that + * demanded it, the same shape the ADR-0093 D5 walled fail-fast pins. + * + * Both over-denial directions are pinned as positive controls: a walled boot + * WITH the owner declared initializes, and a `single` boot never consults the + * variable at all. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AuthPlugin } from './auth-plugin'; +import type { PluginContext } from '@objectstack/core'; + +const makeCtx = (): PluginContext => + ({ + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name === 'manifest') return { register: vi.fn() }; + return undefined; + }), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + }) as unknown as PluginContext; + +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; +const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; +const OLD_OWNER = process.env.OS_PLATFORM_OWNER_EMAIL; + +beforeEach(() => { + delete process.env.OS_TENANCY_POSTURE; + delete process.env.OS_MULTI_ORG_ENABLED; + delete process.env.OS_PLATFORM_OWNER_EMAIL; +}); +afterEach(() => { + if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; + if (OLD_LEGACY === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = OLD_LEGACY; + if (OLD_OWNER === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = OLD_OWNER; +}); + +const plugin = () => new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long' }); + +describe('#11184 — walled posture + undeclared owner email refuses startup', () => { + it("isolated: init rejects, and the message carries the variable (the remedy) and the posture", async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + const err = await plugin() + .init(makeCtx()) + .then(() => null) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + const msg = (err as Error).message; + expect(msg).toContain('OS_PLATFORM_OWNER_EMAIL'); + expect(msg).toContain("'isolated'"); + expect(msg).toContain('Refusing to boot'); + // Never silently reverting is the point — the message says so. + expect(msg).toContain('first-registrant elevation'); + }); + + it('group: the other walled posture refuses identically, naming itself', async () => { + process.env.OS_TENANCY_POSTURE = 'group'; + const err = await plugin() + .init(makeCtx()) + .then(() => null) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain('OS_PLATFORM_OWNER_EMAIL'); + expect((err as Error).message).toContain("'group'"); + }); + + it('a blank value is undeclared: whitespace does not satisfy the clause', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = ' '; + const err = await plugin() + .init(makeCtx()) + .then(() => null) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain('OS_PLATFORM_OWNER_EMAIL'); + }); + + it('the legacy boolean spelling of a walled posture (OS_MULTI_ORG_ENABLED=true) is covered too', async () => { + process.env.OS_MULTI_ORG_ENABLED = 'true'; + const err = await plugin() + .init(makeCtx()) + .then(() => null) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain('OS_PLATFORM_OWNER_EMAIL'); + }); +}); + +describe('#11184 — over-denial guards (positive controls)', () => { + it('walled + declared owner email initializes and registers auth + tenancy', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + const ctx = makeCtx(); + await plugin().init(ctx); + const registered = (ctx.registerService as ReturnType).mock.calls.map((c) => c[0]); + expect(registered).toContain('auth'); + expect(registered).toContain('tenancy'); + }); + + it('single posture boots with no owner email declared — first-user-is-owner stays as ruled', async () => { + const ctx = makeCtx(); + await plugin().init(ctx); + const registered = (ctx.registerService as ReturnType).mock.calls.map((c) => c[0]); + expect(registered).toContain('auth'); + expect(registered).toContain('tenancy'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 78b8085424..f2966de15b 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -1097,10 +1097,21 @@ describe('AuthPlugin', () => { it('multi-org: bootstrap is NOT wired (enterprise organizations package owns it)', async () => { process.env.OS_MULTI_ORG_ENABLED = 'true'; - await boot(); - await hookCapture.trigger('kernel:ready'); - expect(ql.tables.sys_organization).toHaveLength(0); - expect(ql.tables.sys_member).toHaveLength(0); + // [#11184] A walled posture now declares its platform owner or refuses + // to boot; this fixture's subject is the default-org wiring, so declare + // one (the boot-refusal itself is pinned in + // auth-plugin-walled-owner-boot-refusal.test.ts). + const oldOwner = process.env.OS_PLATFORM_OWNER_EMAIL; + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + try { + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_organization).toHaveLength(0); + expect(ql.tables.sys_member).toHaveLength(0); + } finally { + if (oldOwner === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = oldOwner; + } }); it('autoDefaultOrganization: false opts out', async () => { diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 56c783b059..d334fc9536 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -17,7 +17,7 @@ import { SystemOverviewDatasets, } from '@objectstack/platform-objects/apps'; import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages'; -import { resolveTenancyPosture } from '@objectstack/types'; +import { PLATFORM_OWNER_EMAIL_ENV, resolvePlatformOwnerEmail, resolveTenancyPosture } from '@objectstack/types'; import { postureEnforcesWall, type OrgScopingEntitlement } from '@objectstack/spec/security'; import type { IDataEngine, IEmailService, II18nService, IObjectQLEngine, ISmsService } from '@objectstack/spec/contracts'; import { @@ -492,8 +492,33 @@ export class AuthPlugin implements Plugin { // never probes. `getService` is a cheap registry lookup and org-scoping // registers AFTER plugin-auth, so the probe is deferred to first read // (start()/request time). + const requestedPosture = resolveTenancyPosture(); + // [#11184 / cloud#1509] Fail-closed clause of the 2026-08-23 ruling + // (「1509 选择 env 指定 owner 邮箱」): a WALLED posture must declare its + // platform owner. Under `group`/`isolated` the "first registrant becomes + // platform admin" bootstrap path is removed (plugin-security's + // `bootstrapPlatformAdmin` grants the cross-tenant `admin_full_access` + // only to the account matching the declared owner email), so a walled + // deployment with no owner declared would otherwise boot into a state + // with NO way to ever mint a platform admin — or, worse, tempt a silent + // fallback to first-registrant elevation, the exact hole cloud#1509 + // measured. Refuse startup instead, naming the variable — the same + // fail-fast direction as `resolveTenancyPosture`'s own throw and the + // ADR-0093 D5 degraded-tenancy guard. A throw here aborts kernel boot + // (Phase 1 `init()` failures propagate). The `single` posture never + // consults the variable: "first user is owner" stays as ruled. + if (postureEnforcesWall(requestedPosture) && !resolvePlatformOwnerEmail()) { + throw new Error( + `[auth] tenancy posture '${requestedPosture}' requires ${PLATFORM_OWNER_EMAIL_ENV} to be set. ` + + 'Under walled postures the first self-registrant is NOT promoted to platform admin; ' + + 'the platform-admin grant goes only to the account whose email matches the declared ' + + 'owner. Refusing to boot rather than silently reverting to first-registrant elevation. ' + + `Set ${PLATFORM_OWNER_EMAIL_ENV} to the operator's email address, or set ` + + "OS_TENANCY_POSTURE=single to run single-org.", + ); + } const tenancy: TenancyService = createTenancyService({ - requested: resolveTenancyPosture(), + requested: requestedPosture, probeIsolation: () => { try { return !!ctx.getService('org-scoping'); diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 93d91ab6b9..0f5261eaaf 100644 --- a/packages/plugins/plugin-security/package.json +++ b/packages/plugins/plugin-security/package.json @@ -22,7 +22,8 @@ "@objectstack/formula": "workspace:*", "@objectstack/metadata-core": "workspace:*", "@objectstack/platform-objects": "workspace:*", - "@objectstack/spec": "workspace:*" + "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*" }, "devDependencies": { "@objectstack/driver-sql": "workspace:*", diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts new file mode 100644 index 0000000000..2fa2547112 --- /dev/null +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts @@ -0,0 +1,235 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * bootstrapPlatformAdmin — posture-keyed elevation (#11184, the framework leg + * of cloud#1509; maintainer ruling 2026-08-23, verbatim: + * 「1509 选择 env 指定 owner 邮箱」). + * + * Measured defect: on a walled deployment (`OS_TENANCY_POSTURE=isolated` + + * invite-only) the FIRST self-registrant received the cross-tenant + * `admin_full_access` grant — and, because `ensureDefaultOrganization` binds + * "the platform admin", the operator's Default Organization too. + * + * Both directions are pinned here: + * (a) walled: ONLY the account matching the env-declared owner email + * (`OS_PLATFORM_OWNER_EMAIL`) elevates — a self-registrant never does, + * whatever the arrival order; undeclared owner ⇒ the elevation REFUSES + * (it never falls back to first-registrant), loudly, naming the variable; + * (b) single: "first user is owner" is ruled reasonable and UNCHANGED — the + * owner-email variable is never consulted there. + * + * The refusals here are bootstrap outcomes, not HTTP answers, so there is no + * ADR-0112 envelope to assert; the machine-checkable surface is the exact + * `reason` value plus the absence of any `sys_user_permission_set` write (the + * "service was never called" half). + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; + +/** In-memory ql over the three objects the promotion path touches. */ +function makeQl(seed: { users?: any[]; grants?: any[] } = {}) { + const tables = new Map([ + ['sys_permission_set', []], + ['sys_user', (seed.users ?? []).map((r) => ({ ...r }))], + ['sys_user_permission_set', (seed.grants ?? []).map((r) => ({ ...r }))], + ]); + const rowsOf = (object: string) => tables.get(object) ?? []; + return { + tables, + async find(object: string, q: any) { + const where = q?.where ?? {}; + return rowsOf(object).filter((r) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + }), + ); + }, + async insert(object: string, data: any) { + if (!tables.has(object)) tables.set(object, []); + tables.get(object)!.push({ ...data }); + return { id: data.id }; + }, + // Opens with the PRODUCER's own dispatch predicate, never a hand-mirrored + // guard (check:engine-double-contract) — a fixture drifting to a call + // shape ObjectQL.update would refuse fails loudly here. + async update(object: string, data: any, options?: any) { + const dispatch = assertEngineUpdateDispatch(data, options); + const rows = rowsOf(object); + const targets = + dispatch.kind === 'by-id' ? rows.filter((r) => r.id === dispatch.id) : []; + for (const r of targets) Object.assign(r, data); + return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; + }, + grants(): any[] { + return rowsOf('sys_user_permission_set'); + }, + }; +} + +const adminFullAccess = () => + ({ name: 'admin_full_access', label: 'Admin', objects: {}, systemPermissions: ['setup.access'] }) as any; + +const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }); + +const user = (id: string, email: string, createdAt: string) => ({ + id, + email, + created_at: createdAt, +}); + +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; +const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; +const OLD_OWNER = process.env.OS_PLATFORM_OWNER_EMAIL; + +beforeEach(() => { + delete process.env.OS_TENANCY_POSTURE; + delete process.env.OS_MULTI_ORG_ENABLED; + delete process.env.OS_PLATFORM_OWNER_EMAIL; +}); +afterEach(() => { + if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; + if (OLD_LEGACY === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = OLD_LEGACY; + if (OLD_OWNER === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = OLD_OWNER; +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('walled posture + declared owner — only the owner elevates', () => { + it('promotes the declared owner even when a self-registrant arrived FIRST', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + const ql = makeQl({ + users: [ + user('u_stranger', 'stranger@evil.example', '2026-08-23T01:00:00Z'), + user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z'), + ], + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); + expect(r.adminPromoted).toBe(true); + const grants = ql.grants(); + expect(grants).toHaveLength(1); + // The cross-tenant grant lands on the OWNER — never the first registrant. + expect(grants[0].user_id).toBe('u_owner'); + expect(grants[0].organization_id).toBeNull(); + }); + + it('matches the owner email case-insensitively (declared spelling ≠ stored spelling)', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = 'Operator@Corp.EXAMPLE'; + const ql = makeQl({ + users: [user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z')], + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); + expect(r.adminPromoted).toBe(true); + expect(ql.grants()[0]?.user_id).toBe('u_owner'); + }); + + it('owner not registered yet: refuses with the exact reason and writes NO grant', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + const log = logger(); + const ql = makeQl({ + users: [user('u_stranger', 'stranger@evil.example', '2026-08-23T01:00:00Z')], + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(r.adminPromoted).toBe(false); + expect(r.reason).toBe('walled_owner_not_registered'); + // The preservation half of the pin: the grant write never happened. + expect(ql.grants()).toHaveLength(0); + }); + + it("the `group` posture is walled too — a first registrant that isn't the owner never elevates", async () => { + process.env.OS_TENANCY_POSTURE = 'group'; + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + const ql = makeQl({ + users: [user('u_stranger', 'stranger@evil.example', '2026-08-23T01:00:00Z')], + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); + expect(r.adminPromoted).toBe(false); + expect(r.reason).toBe('walled_owner_not_registered'); + expect(ql.grants()).toHaveLength(0); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('walled posture + UNDECLARED owner — fail-closed, never first-registrant', () => { + it('refuses the elevation with the exact reason, logs at error naming the variable, writes NO grant', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + const log = logger(); + const ql = makeQl({ + users: [user('u_stranger', 'stranger@evil.example', '2026-08-23T01:00:00Z')], + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(r.adminPromoted).toBe(false); + expect(r.reason).toBe('walled_owner_email_undeclared'); + expect(ql.grants()).toHaveLength(0); + // Loud, at error, and the message NAMES the variable so the operator's + // remedy is in the line itself (the boot-refusal half lives in + // plugin-auth init and is pinned in that package). + expect(log.error).toHaveBeenCalledTimes(1); + expect(String(log.error.mock.calls[0][0])).toContain('OS_PLATFORM_OWNER_EMAIL'); + }); + + it('a blank OS_PLATFORM_OWNER_EMAIL is undeclared, not a declared empty owner', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = ' '; + const ql = makeQl({ + users: [user('u_stranger', 'stranger@evil.example', '2026-08-23T01:00:00Z')], + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); + expect(r.reason).toBe('walled_owner_email_undeclared'); + expect(ql.grants()).toHaveLength(0); + }); + + it('degrades to warn when the caller handed a logger without error (narrower legacy shape)', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + const warn = vi.fn(); + const ql = makeQl({ + users: [user('u_stranger', 'stranger@evil.example', '2026-08-23T01:00:00Z')], + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { + logger: { info: vi.fn(), warn } as any, + }); + expect(r.reason).toBe('walled_owner_email_undeclared'); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('OS_PLATFORM_OWNER_EMAIL'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('single posture — "first user is owner" is ruled reasonable and UNCHANGED', () => { + it('promotes the first human user with no owner email declared (the pre-#11184 shape)', async () => { + // Posture unset ⇒ `single`. + const ql = makeQl({ + users: [ + user('u_first', 'first@corp.example', '2026-08-23T01:00:00Z'), + user('u_second', 'second@corp.example', '2026-08-23T02:00:00Z'), + ], + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); + expect(r.adminPromoted).toBe(true); + expect(ql.grants()[0]?.user_id).toBe('u_first'); + }); + + it('never consults the owner-email variable: a declared owner does NOT redirect the single-org promotion', async () => { + // Over-denial guard for direction (b): setting the variable under `single` + // must not change who is promoted — the ruling scoped the owner-email + // bootstrap to walled postures only. + process.env.OS_TENANCY_POSTURE = 'single'; + process.env.OS_PLATFORM_OWNER_EMAIL = 'second@corp.example'; + const ql = makeQl({ + users: [ + user('u_first', 'first@corp.example', '2026-08-23T01:00:00Z'), + user('u_second', 'second@corp.example', '2026-08-23T02:00:00Z'), + ], + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); + expect(r.adminPromoted).toBe(true); + expect(ql.grants()[0]?.user_id).toBe('u_first'); + }); +}); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index becd6c749d..d7502d32b7 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -8,10 +8,16 @@ * 1. **Seed `sys_permission_set` rows** for each `defaultPermissionSets` * entry (admin_full_access / member_default / viewer_readonly). * - * 2. **Promote the first registered user to platform admin** by - * inserting a `sys_user_permission_set` row that points at - * `admin_full_access` with `organization_id = NULL` (= cross-tenant). - * If a platform admin already exists, this is a no-op forever. + * 2. **Promote the platform OWNER to platform admin** by inserting a + * `sys_user_permission_set` row that points at `admin_full_access` with + * `organization_id = NULL` (= cross-tenant). If a platform admin already + * exists, this is a no-op forever. WHO the owner is depends on the + * tenancy posture (#11184, maintainer ruling 2026-08-23): + * - `single`: the first registered human user (unchanged); + * - walled (`group`/`isolated`): ONLY the account matching the + * env-declared `OS_PLATFORM_OWNER_EMAIL` — never the first + * registrant, and never anyone at all while that var is undeclared + * (fail-closed; the boot-refusal half lives in plugin-auth `init()`). * * The "create a Default Organization for the freshly-promoted admin" * behavior moved to `@objectstack/organizations` (see @@ -45,8 +51,13 @@ * make, not one a boot should make on their behalf. */ -import type { PermissionSet } from '@objectstack/spec/security'; +import { postureEnforcesWall, type PermissionSet } from '@objectstack/spec/security'; import { SystemUserId } from '@objectstack/spec/system'; +import { + PLATFORM_OWNER_EMAIL_ENV, + resolvePlatformOwnerEmail, + resolveTenancyPosture, +} from '@objectstack/types'; import { claimSeedOwnership } from './claim-seed-ownership.js'; interface BootstrapOptions { @@ -54,6 +65,14 @@ interface BootstrapOptions { logger?: { info: (message: string, meta?: Record) => void; warn: (message: string, meta?: Record) => void; + /** + * [#11184] Optional because pre-existing callers hand in narrower shapes; + * the walled owner-email refusal degrades to `warn` when absent. The meta + * parameter is `any` on purpose: the kernel Logger types it `Error`, the + * siblings above type it `Record`, and this option must + * accept both. + */ + error?: (message: string, meta?: any) => void; }; /** * [#2705] Force re-materialization of the default permission-set rows from @@ -251,25 +270,104 @@ export async function bootstrapPlatformAdmin( return { seeded: seededCount, adminPromoted: false, reason: 'already_have_admin', ...resyncCounts }; } - const allUsers = await tryFind(ql, 'sys_user', {}, 50); + // [#11184 / cloud#1509] Elevation is POSTURE-KEYED (maintainer ruling + // 2026-08-23, verbatim: 「1509 选择 env 指定 owner 邮箱」): + // + // - `single`: first human user is promoted — ruled reasonable, unchanged. + // - walled (`group` / `isolated`): the first-registrant path is REMOVED. + // Platform admin is granted ONLY to the account matching the + // env-declared owner email (`OS_PLATFORM_OWNER_EMAIL`). On a walled + // deployment with self-registration reachable, whoever curls sign-up + // first would otherwise receive the cross-tenant `admin_full_access` + // grant AND (via `ensureDefaultOrganization`, which binds "the platform + // admin") the operator's Default Organization — measured on a real + // walled SaaS in cloud#1509. + // + // The REQUESTED posture (`resolveTenancyPosture()`, what the operator asked + // for) is deliberately the input here rather than the enforced one: a + // deployment that requested a wall must not fall back to first-registrant + // elevation even while running degraded (OS_ALLOW_DEGRADED_TENANCY=1) — + // fail toward the stricter reading, same direction ADR-0093 D5 fails. + // + // The startup half of the fail-closed clause (walled + undeclared owner ⇒ + // REFUSE BOOT, naming the variable) lives in plugin-auth's `init()`, which + // every standard walled composition runs and where a throw aborts the boot. + // This branch is the defense-in-depth backstop for paths that reach the + // bootstrap without that guard (`os meta resync`, embeddings without + // plugin-auth): it refuses the ELEVATION, loudly, and never silently + // reverts to promoting the first registrant. + const walled = postureEnforcesWall(resolveTenancyPosture()); + const declaredOwnerEmail = walled ? resolvePlatformOwnerEmail() : undefined; + if (walled && !declaredOwnerEmail) { + const message = + `[security] tenancy posture is walled but ${PLATFORM_OWNER_EMAIL_ENV} is not set — ` + + 'REFUSING platform-admin elevation. Under walled postures the first registrant is ' + + 'never promoted; platform admin is granted only to the account matching the declared ' + + `owner email. Set ${PLATFORM_OWNER_EMAIL_ENV} to the operator's email address.`; + if (logger?.error) logger.error(message); + else logger?.warn?.(message); + return { + seeded: seededCount, + adminPromoted: false, + reason: 'walled_owner_email_undeclared', + ...resyncCounts, + }; + } + // Exclude the non-loginable system service account. It is created during // seed loading — *before* the first human sign-up — so without this filter // it is the earliest user and steals the platform-admin promotion, leaving // the real admin login without `setup.access` / `studio.access` (Setup and // Studio then stay invisible even though login succeeds). - const humanUsers = allUsers.filter( - (u) => u.id !== SystemUserId.SYSTEM && u.role !== 'system', - ); - if (humanUsers.length === 0) { - logger?.info?.('[security] no human users yet — first sign-up will be promoted to platform admin'); - return { seeded: seededCount, adminPromoted: false, reason: 'no_users', ...resyncCounts }; + const isHumanUser = (u: any) => u && u.id !== SystemUserId.SYSTEM && u.role !== 'system'; + const oldestOf = (users: any[]) => + [...users].sort((a, b) => { + const ta = a.created_at ? new Date(a.created_at).getTime() : 0; + const tb = b.created_at ? new Date(b.created_at).getTime() : 0; + return ta - tb; + })[0]; + + let target: any; + if (walled) { + // Query BY EMAIL rather than scanning the first N users: on a walled + // deployment any number of self-registrants may exist before the owner + // registers, and the owner must be found regardless of arrival order. + // Email comparison is case-insensitive; better-auth stores sign-up emails + // lowercased, but imported/legacy rows may not be, so both the lowercased + // and the verbatim spellings are queried and matches are de-duplicated. + const wanted = declaredOwnerEmail!.toLowerCase(); + const spellings = [...new Set([wanted, declaredOwnerEmail!])]; + const byId = new Map(); + for (const spelling of spellings) { + for (const u of await tryFind(ql, 'sys_user', { email: spelling }, 5)) { + if (u?.id) byId.set(u.id, u); + } + } + const owners = [...byId.values()].filter( + (u) => isHumanUser(u) && String(u.email ?? '').trim().toLowerCase() === wanted, + ); + if (owners.length === 0) { + logger?.info?.( + `[security] walled posture — platform admin will be granted to the declared owner ` + + `(${PLATFORM_OWNER_EMAIL_ENV}) when that account registers; self-registrants are never promoted`, + ); + return { + seeded: seededCount, + adminPromoted: false, + reason: 'walled_owner_not_registered', + ...resyncCounts, + }; + } + target = oldestOf(owners); + } else { + const allUsers = await tryFind(ql, 'sys_user', {}, 50); + const humanUsers = allUsers.filter(isHumanUser); + if (humanUsers.length === 0) { + logger?.info?.('[security] no human users yet — first sign-up will be promoted to platform admin'); + return { seeded: seededCount, adminPromoted: false, reason: 'no_users', ...resyncCounts }; + } + target = oldestOf(humanUsers); } - const sorted = [...humanUsers].sort((a, b) => { - const ta = a.created_at ? new Date(a.created_at).getTime() : 0; - const tb = b.created_at ? new Date(b.created_at).getTime() : 0; - return ta - tb; - }); - const target = sorted[0]; const inserted = await tryInsert(ql, 'sys_user_permission_set', { id: genId('ups'), @@ -282,7 +380,11 @@ export async function bootstrapPlatformAdmin( logger?.warn?.(`[security] failed to grant admin_full_access to first user ${target.email ?? target.id}`); return { seeded: seededCount, adminPromoted: false, reason: 'insert_failed', ...resyncCounts }; } - logger?.info?.(`[security] first user promoted to platform admin: ${target.email ?? target.id}`); + logger?.info?.( + walled + ? `[security] declared platform owner (${PLATFORM_OWNER_EMAIL_ENV}) promoted to platform admin: ${target.email ?? target.id}` + : `[security] first user promoted to platform admin: ${target.email ?? target.id}`, + ); // Hand seeded business records (owner_id NULL / usr_system) to the freshly // promoted admin so owner-keyed UX works out of the box. Best-effort and diff --git a/packages/plugins/plugin-security/tsconfig.json b/packages/plugins/plugin-security/tsconfig.json index f6a1e8bad5..3c1094dd19 100644 --- a/packages/plugins/plugin-security/tsconfig.json +++ b/packages/plugins/plugin-security/tsconfig.json @@ -2,10 +2,32 @@ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src", + // [#11184] Widened from `./src` as a CONSEQUENCE of the `paths` rule + // below, exactly as `packages/rest/tsconfig.json` documents (#9960): + // redirecting `@objectstack/types` to its source puts + // `packages/types/src/**` into this program, and `rootDir` is enforced + // over every program file even under `--noEmit`. `../..` (= `packages/`) + // is the directory that contains every file in the program. Emit is + // unaffected: this package builds with tsup, and `typecheck` passes + // `--noEmit`. + "rootDir": "../..", "types": [ "node" - ] + ], + // [#11184] `@objectstack/types` is imported as a value by + // `src/bootstrap-platform-admin.ts`. Without this rule tsc resolves the + // specifier through the dependency's `exports` map — `dist/index.d.ts`, a + // BUILD ARTIFACT — so this package's typecheck would render a verdict + // about the last `pnpm build` rather than about the producer's source in + // the checkout (`check:type-source-resolution` refuses exactly that; its + // header states why the dangerous case is a typecheck that PASSES). ONE + // rule for the bare name only: this package imports no + // `@objectstack/types/*` subpath, and a `paths` target that matches + // nothing on disk would silently fall back to node resolution (see the + // rest package's block for the measured traps). + "paths": { + "@objectstack/types": ["../../types/src/index.ts"] + } }, "include": [ "src/**/*" diff --git a/packages/plugins/plugin-security/vitest.config.ts b/packages/plugins/plugin-security/vitest.config.ts index 53a043376f..2dd5591aa7 100644 --- a/packages/plugins/plugin-security/vitest.config.ts +++ b/packages/plugins/plugin-security/vitest.config.ts @@ -45,6 +45,15 @@ export default defineConfig({ find: /^@objectstack\/objectql$/, replacement: path.resolve(__dirname, '../../objectql/src/index.ts'), }, + // [#11184] `bootstrap-platform-admin.ts` imports `@objectstack/types` as + // a VALUE (`resolveTenancyPosture` / `resolvePlatformOwnerEmail`), so + // its suites must read the producer's source in this checkout rather + // than `dist/` — a stale dist would run GREEN against the dependency's + // old behaviour (`check:test-source-alias` refuses exactly that). + { + find: /^@objectstack\/types$/, + replacement: path.resolve(__dirname, '../../types/src/index.ts'), + }, ], }, }); diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index 0fa62aa597..95009ae324 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -161,6 +161,50 @@ export function resolveTenancyPosture(): TenancyPosture { return resolveMultiOrgEnabled() ? 'isolated' : 'single'; } +/** + * The env variable naming the deployment's PLATFORM OWNER account + * (#11184, the framework leg of cloud#1509). + * + * Exported as a constant so every message that refuses over it (the walled + * boot guard in plugin-auth, the elevation refusal in plugin-security's + * `bootstrapPlatformAdmin`) names exactly one spelling. + */ +export const PLATFORM_OWNER_EMAIL_ENV = 'OS_PLATFORM_OWNER_EMAIL'; + +/** + * [#11184 / cloud#1509] Resolve the env-declared platform OWNER email — + * `OS_PLATFORM_OWNER_EMAIL`. + * + * Under a WALLED tenancy posture (`group` / `isolated`) the "first registrant + * becomes owner/platform admin" bootstrap path is REMOVED (maintainer ruling + * 2026-08-23, verbatim: 「1509 选择 env 指定 owner 邮箱」): on a walled + * deployment with self-registration reachable, whoever curls the sign-up + * endpoint first would otherwise receive the cross-tenant `admin_full_access` + * grant — measured on a real walled SaaS in cloud#1509. Platform admin is + * granted ONLY to the account whose email matches this variable, and a walled + * posture with no value declared REFUSES STARTUP (fail-closed, same reasoning + * as {@link resolveTenancyPosture}'s throw and ADR-0093 D5) rather than + * silently reverting to first-registrant elevation. + * + * The `single` posture never consults this: "first user is owner" is ruled + * reasonable there and unchanged. + * + * Returns the operator's value trimmed, or `undefined` when unset/blank. + * Comparison against `sys_user.email` is the CONSUMER's job and must be + * case-insensitive (this resolver echoes what the operator typed so refusal + * messages can quote it verbatim). + * + * Reads `process.env` live on each call, through `globalThis` like the other + * resolvers here (this package targets non-Node runtimes too). + */ +export function resolvePlatformOwnerEmail(): string | undefined { + const raw = (globalThis as { process?: { env?: Record } }) + .process?.env?.[PLATFORM_OWNER_EMAIL_ENV]; + if (raw == null) return undefined; + const trimmed = String(raw).trim(); + return trimmed === '' ? undefined : trimmed; +} + /** * Escape hatch for the degraded-tenancy boot guard (ADR-0093 D5). * diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 800a929b95..7d3b072c98 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -357,7 +357,27 @@ export async function bootStack( const prevTenancyPosture = process.env.OS_TENANCY_POSTURE; const requestIsolatedPosture = !!opts.multiTenant && !prevTenancyPosture; if (requestIsolatedPosture) process.env.OS_TENANCY_POSTURE = 'isolated'; + // [#11184] A walled posture now REFUSES BOOT unless OS_PLATFORM_OWNER_EMAIL + // is declared (plugin-auth init), and plugin-security promotes ONLY the + // account matching it — never the first registrant. A correctly configured + // walled deployment declares its owner, so the harness does too: the + // declared owner is the dev admin the harness seeds and signs in as, which + // keeps every walled fixture's observable state exactly as before (the + // seeded admin is promoted; a fresh `signUp` stays a plain member). A + // caller-provided value wins, mirroring the posture knob above; restored on + // stop() alongside it. + const bootRunsWalled = + requestIsolatedPosture || prevTenancyPosture === 'isolated' || prevTenancyPosture === 'group'; + const prevPlatformOwnerEmail = process.env.OS_PLATFORM_OWNER_EMAIL; + const declareHarnessOwnerEmail = bootRunsWalled && !prevPlatformOwnerEmail; + if (declareHarnessOwnerEmail) { + process.env.OS_PLATFORM_OWNER_EMAIL = opts.admin?.email ?? DEFAULT_ADMIN_EMAIL; + } const restoreTenancyPosture = () => { + if (declareHarnessOwnerEmail) { + if (prevPlatformOwnerEmail === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = prevPlatformOwnerEmail; + } if (!requestIsolatedPosture) return; if (prevTenancyPosture === undefined) delete process.env.OS_TENANCY_POSTURE; else process.env.OS_TENANCY_POSTURE = prevTenancyPosture; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8491e01df7..fcab674a8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1745,6 +1745,9 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../../spec + '@objectstack/types': + specifier: workspace:* + version: link:../../types devDependencies: '@objectstack/driver-sql': specifier: workspace:* diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index b04c69167a..884aac311c 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1401,6 +1401,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts", "verb": "update",