From 5868ab4a0ec63c69ebda4f60038b4e9ab6f05709 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 16:01:12 +0000 Subject: [PATCH] =?UTF-8?q?fix(auth,verify)!:=20organization/create=20?= =?UTF-8?q?=E5=88=A4=E5=AE=9E=E9=99=85=E7=94=9F=E6=95=88=E7=9A=84=20postur?= =?UTF-8?q?e=EF=BC=8C=E9=99=8D=E7=BA=A7=E9=83=A8=E7=BD=B2=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E4=BA=A7=E5=87=BA=E6=97=A0=E5=A2=99=E7=9A=84=E7=BB=84?= =?UTF-8?q?=E7=BB=87=20(#5261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `beforeCreateOrganization` 此前判操作者**请求的** posture (`postureEnforcesWall(resolveTenancyPosture())`),与 `/auth/config` 的 `features.multiOrgEnabled`(判**生效** posture)在 ADR-0093 D5 降级态下分叉: console 藏起「创建组织」按钮,API 却照建,且建出来的每个组织都是没有任何引擎 强制的租户边界 —— 声明了但没强制,ADR-0049 最讨厌的那一类。 闸门改判 `tenancy?.posture ?? resolveTenancyPosture()`,与 `/auth/config` 共用同一次求值(`effectiveTenancyPosture()`),两站点从此同解、永不分叉。 `packages/spec` 的 `PUBLIC_AUTH_FEATURES` 本就声明 `multiOrgEnabled` 把守 `sys_organization.actions.create_organization` 且「reflects ACTUAL … not just the requested posture」——出线的一直是路由。 BREAKING:没有安装企业包 `@objectstack/organizations` 的部署将完全无法创建组织, 任何 env 组合都不行。有意为之,搭 v17 主版本车;`serve.ts` 本就在降级态默认拒绝 启动(要 `OS_ALLOW_DEGRADED_TENANCY=1`)。 #3624 的 dogfood 此前靠「boot 后翻 env、闸门 live 读」开路,本次收缩把这个绕法 关死。**未删测试**:`@objectstack/verify` 的 `BootOptions.multiTenant` 新增 `'posture-only'`,注册内置的 `org-scoping` 服务替身,让 tenancy 服务解析出真实、 非降级的 `isolated` posture —— 模拟真实的有墙部署,而不是让闸门放行。该选项 明确声明不做任何租户隔离,跨租户证明仍只认 `multiTenant: true` + 真实企业包。 对抗性验证:对未修复源码跑 org-create-posture-gate.test.ts,25 例中 7 例红 (`expected 200 to be 403`);把 dogfood 换回旧的 env-flip 绕法,得到 403 "Creating additional organizations is disabled on this deployment." Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t --- .../org-create-effective-posture-gate.md | 55 ++++++ .../plugins/plugin-auth/src/auth-manager.ts | 83 ++++++--- .../src/org-create-posture-gate.test.ts | 170 +++++++++++++++--- .../org-create-default-team.dogfood.test.ts | 76 ++++++-- .../verify/src/harness.posture-only.test.ts | 143 +++++++++++++++ packages/verify/src/harness.ts | 68 ++++++- 6 files changed, 532 insertions(+), 63 deletions(-) create mode 100644 .changeset/org-create-effective-posture-gate.md create mode 100644 packages/verify/src/harness.posture-only.test.ts diff --git a/.changeset/org-create-effective-posture-gate.md b/.changeset/org-create-effective-posture-gate.md new file mode 100644 index 0000000000..6900ec29fa --- /dev/null +++ b/.changeset/org-create-effective-posture-gate.md @@ -0,0 +1,55 @@ +--- +"@objectstack/plugin-auth": major +"@objectstack/verify": minor +--- + +BREAKING(auth): `organization/create` 改判**实际生效的** tenancy posture —— 没有组织墙的部署不再能创建组织 (#5261) + +`POST /api/v1/auth/organization/create` 的闸门此前判的是操作者**请求的** posture +(`postureEnforcesWall(resolveTenancyPosture())`,一次纯 env 读)。现在判 `tenancy` 服务给出的 +**生效** posture —— `tenancy?.posture ?? resolveTenancyPosture()`,与 `/auth/config` 的 +`features.multiOrgEnabled` 是**同一次求值**。 + +## 为什么 + +两个站点此前只在一种形状下分叉,而那种形状恰恰是最不该放行的一种 —— ADR-0093 D5 **降级态**: +请求了 `isolated`/`group`,但企业包 `@objectstack/organizations` 缺席,于是 `tenancy.posture` +解析为 `single` 且 `degraded=true`。此时: + +- 闸门读「请求」→ **放行**; +- `/auth/config` 读「生效」→ `multiOrgEnabled=false`,console 把「创建组织」入口**藏起来**。 + +结果是 UI 没有按钮而 API 打得通,并且建出来的每一个组织都是**没有任何引擎强制的租户边界** —— +声明了但没强制,ADR-0049 最讨厌的那一类,只不过发生在部署层。改判生效 posture 之后两者同解、 +永不分叉:**没有墙,就没有组织**,无论这个部署是从未要过墙,还是要了没拿到。 + +## 破坏性影响(有意为之) + +**没有安装企业包 `@objectstack/organizations` 的部署将完全无法创建组织**,任何 env 组合都不行 —— +`OS_TENANCY_POSTURE=isolated`、`OS_MULTI_ORG_ENABLED=true`、两个一起设,都不再能把闸门说通。 +这是一次实打实的能力收缩,不是 knob 纠正,所以搭 v17 主版本车。 + +| 部署形状 | 改前 | 改后 | +|---|---|---| +| 有企业包,posture `isolated` / `group`(墙真的立着) | 200 | **200**(不变) | +| **请求了墙但企业包缺席(D5 降级态)** | 200 | **403** ⚠️ | +| `single` / 两个 knob 都不设 | 403 | 403(不变) | +| 未注册 `tenancy` 服务的精简嵌入(回落 env 解析) | 按 env | 按 env(不变) | + +`serve.ts` 本来就在降级态**默认拒绝启动**(要 `OS_ALLOW_DEGRADED_TENANCY=1` 才走),所以这条收缩 +命中的是一个已经需要显式选择才能到达的形状:从此那里的 org-create 路由也一并拒绝,而不是半通不通。 +cloud 控制面与任何装了企业包的部署不受影响。 + +**迁移**:需要多组织能力的部署安装并声明 `@objectstack/organizations`(ADR-0081 D2)。仅靠 env +声明一个墙、而没有实现它的运行时,不再被当作多组织部署对待。 + +## `@objectstack/verify`(minor,新增) + +`BootOptions.multiTenant` 增加 `'posture-only'` 取值:注册一个内置的 `org-scoping` 服务替身, +让 `tenancy` 服务解析出真实、**非降级**的 `isolated` posture,从而打开受 posture 把守的路由 —— +供那些「组织墙是**前置条件**而非被测对象」的 fixture 使用(#3624 的 `org-create-default-team` +dogfood 就是为它而建:那条回归此前靠「boot 后翻 env、闸门 live 读」开路,本次收缩把这个绕法关死了)。 + +⛔ 它**不做任何租户隔离**:不 stamp `organization_id`,不 scope 任何查询 —— 它让部署的 +**posture** 为真,不是让**墙**为真。跨租户隔离的唯一诚实证明仍然是 `multiTenant: true` + +真实的企业包,这也是那些 gate 在本仓继续 skip 而不是假装通过的原因。 diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 721317ac5a..c8c7c3dcdf 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -18,7 +18,7 @@ import { BUILTIN_IDENTITY_PLATFORM_ADMIN, MEMBERSHIP_ROLE_DELEGATED_ADMIN, } from '@objectstack/spec'; -import { postureEnforcesWall } from '@objectstack/spec/security'; +import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js'; import { runWithAuthActorScope, setAuthActorResolver } from './auth-actor-attribution.js'; @@ -1899,13 +1899,19 @@ export class AuthManager { // organization there would mint a boundary nothing keeps (ADR-0049 at // the deployment layer). // - // [#5233] The judge is the REQUESTED tenancy posture - // (`multiOrgPostureRequested()`), never the `OS_MULTI_ORG_ENABLED` - // boolean ADR-0105 D1 demoted — that one reads `false` on a - // deployment configured with only the authoritative + // [#5233] The judge is the tenancy POSTURE, never the + // `OS_MULTI_ORG_ENABLED` boolean ADR-0105 D1 demoted — that one reads + // `false` on a deployment configured with only the authoritative // `OS_TENANCY_POSTURE=isolated`, so the whole organization wall // mounted and every org-less user's guided "create your workspace" // path still 403'd. Same defect shape as cloud#1020. + // + // [#5261] And the judge is the EFFECTIVE posture + // (`multiOrgPostureEffective()` — the `tenancy` service's answer), + // not the requested one, so this gate and `/auth/config`'s + // `features.multiOrgEnabled` are literally the same derivation. On the + // ADR-0093 D5 degraded shape they used to disagree: the console hid + // the button while the route minted organizations no engine walls. beforeCreateOrganization: async ({ organization }: any = {}) => { // [ADR-0120 D3] `'__global__'` is the platform's name for the // NULL-organization bucket: the autonumber sequence table keys @@ -1922,7 +1928,7 @@ export class AuthManager { '(ADR-0120 D3) and cannot be used as an organization id or slug.', }); } - if (!this.multiOrgPostureRequested()) { + if (!this.multiOrgPostureEffective()) { const { APIError } = await import('better-auth/api'); throw new APIError('FORBIDDEN', { message: @@ -3195,8 +3201,16 @@ export class AuthManager { } /** - * [ADR-0105 D1 / #5233] Does this deployment ASK for a multi-organization - * posture? The `beforeCreateOrganization` gate's judge. + * The tenancy posture ACTUALLY IN FORCE on this deployment — the one fact + * both `organization/create`'s gate and `/auth/config`'s + * `features.multiOrgEnabled` are derived from, so the two can never disagree. + * + * The `tenancy` service is authoritative because it is the only thing that + * knows whether a requested wall is actually STANDING: it probes the + * enterprise `org-scoping` runtime and resolves an unenforceable request down + * to `single` + `degraded` (ADR-0093 D5 / ADR-0105 D1). Only a lean embedding + * that never registered the service falls back to `resolveTenancyPosture()`, + * the operator's REQUEST read from env. * * ⛔ Never `resolveMultiOrgEnabled()`. ADR-0105 D1 DEMOTED that boolean to a * back-compat INPUT of `resolveTenancyPosture()`, so it reads `false` on a @@ -3207,19 +3221,43 @@ export class AuthManager { * every org-less user's guided "create your workspace" path dead-ended while * `/auth/config` advertised the capability as present. * - * REQUESTED, not effective — the same fact `serve.ts`'s ADR-0093 D5 boot - * guard keys on (`resolveTenancyPosture() !== 'single'`), and the same fact - * the old boolean expressed, so this corrects the KNOB and nothing else. - * Whether a requested wall is actually ENFORCED is the `tenancy` service's - * separate answer (`degraded`), which `/auth/config` reports and this gate - * deliberately does not consult; #5261 carries that question. + * [#5261] EFFECTIVE, not requested — this is a deliberate, BREAKING capability + * contraction over what #5233 shipped. The gate used to judge + * `postureEnforcesWall(resolveTenancyPosture())`, the operator's request, which + * came apart from `/auth/config` on exactly one deployment shape: ADR-0093 D5 + * degradation (a wall was asked for, the enterprise `@objectstack/organizations` + * runtime is absent, so nothing isolates anything). There the console hid the + * "Create organization" action while the API happily minted organizations whose + * boundary NO engine enforces — a declared-but-unenforced security property, + * ADR-0049's canonical failure, at the deployment layer. Reading the effective + * posture retires that shape: a deployment without an organization wall cannot + * mint an organization, whether it never asked for a wall or asked and did not + * get one. * - * Read live on every call — never cached. The posture is process-level - * config, and freezing it at plugin-build time would make the gate unable to - * see anything a later boot phase (or a test) established. + * Consequence, accepted knowingly: a deployment running WITHOUT the enterprise + * runtime can no longer create organizations at all, no matter which env knobs + * it sets. `serve.ts` already refuses to boot that shape unless the operator + * passes `OS_ALLOW_DEGRADED_TENANCY=1`; from here on, the org-create route is + * refused there too rather than half-working. + * + * Read live on every call — never cached. The posture is process-level config + * and the service's own probe is lazy, so freezing either at plugin-build time + * would make the gate unable to see a provider that registers later in the + * same boot (the recorded-verdict defect AGENTS.md's startup-registry rule + * names). + */ + private multiOrgPostureEffective(): boolean { + return postureEnforcesWall(this.effectiveTenancyPosture()); + } + + /** + * The effective posture itself. Single derivation, two readers — the gate + * above and `getPublicConfig()`'s `features.multiOrgEnabled` / + * `features.tenancyPosture` — because #5233 was only hard to see thanks to + * those two sites answering from different facts. */ - private multiOrgPostureRequested(): boolean { - return postureEnforcesWall(resolveTenancyPosture()); + private effectiveTenancyPosture(): TenancyPosture { + return this.config.getTenancy?.()?.posture ?? resolveTenancyPosture(); } getPublicConfig() { @@ -3295,8 +3333,13 @@ export class AuthManager { // contract that broke the org-create gate, one site over. It reads the // posture now, so an unwired-tenancy embedding advertises the capability // its gate actually allows. + // + // [#5261] `effectiveTenancyPosture()` is now literally the SAME call the + // `organization/create` gate makes, so this flag cannot advertise a + // capability the route refuses (or hide one it allows) on any deployment + // shape — including ADR-0093 D5 degradation, where the two used to split. const tenancy = this.config.getTenancy?.(); - const tenancyPosture = tenancy?.posture ?? resolveTenancyPosture(); + const tenancyPosture = this.effectiveTenancyPosture(); const multiOrgEnabled = postureEnforcesWall(tenancyPosture); const degradedTenancy = tenancy?.degraded ?? false; diff --git a/packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts b/packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts index d4e7c14a39..aaab024bed 100644 --- a/packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts +++ b/packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts @@ -28,12 +28,28 @@ // of defect pointed the other way, so every scenario asserts BOTH and the // table at the bottom asserts they cannot disagree. // -// The gate judges the REQUESTED posture, which is what the old boolean also -// meant and what `serve.ts`'s ADR-0093 D5 boot guard keys on — this corrects -// the KNOB and nothing else. Whether a requested wall is actually ENFORCED is -// the `tenancy` service's separate answer; the deployment where those two come -// apart (degraded) is pinned at the bottom as CURRENT behaviour, unchanged by -// this fix, with the follow-up that owns it. +// #5261 — and the posture the gate judges is the EFFECTIVE one. +// +// #5233 corrected the KNOB and left the gate judging the operator's REQUEST, +// which is what the demoted boolean also meant. That left exactly one shape +// where the gate and the flag answered from different facts: ADR-0093 D5 +// degradation — a wall was requested, the enterprise `@objectstack/organizations` +// runtime is absent, so the `tenancy` service resolves an effective posture of +// `single` + `degraded`. The console hid the "Create organization" action while +// the route happily minted organizations whose boundary NO engine enforces: +// declared-but-unenforced, ADR-0049's canonical failure, at the deployment layer. +// +// The maintainer settled it (2026-08-04) as option B: the gate reads the +// EFFECTIVE posture, the same derivation `/auth/config` reads, so the two sites +// are one fact and cannot diverge on ANY deployment shape. The assertions this +// file used to carry as "pinned as CURRENT behaviour (#5261)" are flipped here +// deliberately, which is precisely what pinning them was for. +// +// This is a BREAKING capability contraction, shipped with v17: a deployment +// running without the enterprise runtime can no longer create organizations at +// all, whatever its env says. `serve.ts` already refuses to boot that shape +// without `OS_ALLOW_DEGRADED_TENANCY=1`; the org-create route now refuses too +// rather than half-working. // // Real better-auth pipeline throughout (the #3585 EdDSA / #4785 session-of-record // precedent: patch the real thing, never stub our own code). @@ -161,10 +177,11 @@ const enforcedTenancy = (requested: TenancyPosture): TenancyService => /** * ADR-0093 D5 degradation: a wall was REQUESTED and the enterprise * `@objectstack/organizations` runtime is absent, so `posture` resolves to - * `single` and `degraded` is true. + * `single` and `degraded` is true. Both walled postures degrade — the wall's + * code is open, ACTIVATING a multi-organization posture is the entitlement. */ -const degradedTenancy = (): TenancyService => - createTenancyService({ requested: 'isolated', probeIsolation: () => false }); +const degradedTenancy = (requested: TenancyPosture = 'isolated'): TenancyService => + createTenancyService({ requested, probeIsolation: () => false }); interface Scenario { /** `OS_TENANCY_POSTURE`, or `undefined` to leave it unset. */ @@ -204,6 +221,7 @@ const runGuidedWorkspaceCreation = async (scenario: Scenario, slug = 'acme') => body, multiOrgEnabled: features.multiOrgEnabled as boolean, tenancyPosture: features.tenancyPosture as TenancyPosture, + degradedTenancy: features.degradedTenancy as boolean, orgRows: (engine.tables.get('sys_organization') ?? []) as any[], }; }; @@ -224,7 +242,11 @@ afterEach(() => { }); // ─────────────────────────────────────────────────────────────────────────── -describe('#5233 — the org-create gate reads OS_TENANCY_POSTURE, not the demoted boolean', () => { +describe('#5233 — no tenancy service wired: the gate reads OS_TENANCY_POSTURE, not the demoted boolean', () => { + // A lean embedding that never registered the `tenancy` service. There is + // nothing that knows whether a wall is standing, so both the gate and + // `/auth/config` fall back to the operator's env-resolved REQUEST — the path + // #5261 leaves exactly as #5233 shipped it, and these cases are its proof. it('posture-only deployment (OS_TENANCY_POSTURE=isolated, legacy boolean UNSET) creates the workspace', async () => { // THE regression. Configured exactly as the docs say, and exactly as // cloud#1012's real `objectstack serve` probe was: the authoritative knob @@ -312,27 +334,69 @@ describe('#5233 — with a `tenancy` service wired, as a real kernel boot has', expect(run.multiOrgEnabled).toBe(true); }); - it('DEGRADED: the gate still allows while the flag hides — pinned as CURRENT behaviour (#5261)', async () => { - // ADR-0093 D5: a wall was REQUESTED and cannot be enforced (no enterprise - // `@objectstack/organizations`), so the tenancy service reports an - // effective posture of `single` + `degraded`. The gate judges the REQUEST, - // so it allows; the flag reports ACTUAL capability, so it hides. + it('DEGRADED (isolated requested, no enterprise runtime): the gate REFUSES — #5261', async () => { + // THE #5261 regression, and the whole point of this change. // - // That divergence PREDATES this fix — `resolveMultiOrgEnabled()` was an env - // read too, and answered `true` here just the same — so #5233 does not - // silently change it: tightening the gate to the effective posture would - // take org creation away from every deployment running without the - // enterprise package, which is a capability decision for the maintainer, - // not a knob correction. Filed as #5261. This assertion exists so that - // whichever way it is settled, it is settled DELIBERATELY. + // ADR-0093 D5: a wall was REQUESTED and cannot be enforced (no enterprise + // `@objectstack/organizations`), so the tenancy service reports an effective + // posture of `single` + `degraded`. Before #5261 the gate judged the REQUEST + // and answered 200 here, while `/auth/config` reported ACTUAL capability and + // hid the button — so the console showed no way to create an organization + // and the API minted them anyway, each one a tenant boundary that nothing + // isolates. Now both read the effective posture: no wall, no organization. const run = await runGuidedWorkspaceCreation( { posture: 'isolated', tenancy: degradedTenancy() }, 'degraded', ); - expect(run.status).toBe(200); + expect(run.status).toBe(403); + expect(JSON.stringify(run.body)).toContain( + 'Creating additional organizations is disabled on this deployment.', + ); + // Nothing was minted — the assertion that separates "refused" from + // "returned an error after committing the row" (#3624's failure shape). + expect(run.orgRows).toHaveLength(0); + expect(run.multiOrgEnabled).toBe(false); + expect(run.tenancyPosture).toBe('single'); + // …and the deployment is branded degraded, so an operator reading + // /auth/config can tell "no wall was asked for" from "the wall fell down". + expect(run.degradedTenancy).toBe(true); + }); + + it('DEGRADED (group requested) refuses too — the entitlement gates BOTH walled postures', async () => { + // `group` walls organizations as much as `isolated` does (ADR-0105 D1); the + // wall's code is open but ACTIVATING a multi-organization posture is the + // entitlement, so a `group` request without the enterprise runtime degrades + // to `single` exactly like `isolated`. Asserted separately because reading + // the gate as an `isolated`-only concern is how `group` briefly became a + // free multi-org back door around it. + const run = await runGuidedWorkspaceCreation( + { posture: 'group', tenancy: degradedTenancy('group') }, + 'degraded-group', + ); + + expect(run.status).toBe(403); + expect(run.orgRows).toHaveLength(0); expect(run.multiOrgEnabled).toBe(false); expect(run.tenancyPosture).toBe('single'); + expect(run.degradedTenancy).toBe(true); + }); + + it('the env knobs cannot talk the gate past a degraded tenancy service', async () => { + // The escape hatch #5261 deliberately closes. `resolveTenancyPosture()` is + // still the fallback for a lean embedding with no tenancy service, so the + // temptation is to let a loud enough env config win. It must not: the + // service is the only thing that knows whether the wall is STANDING, and a + // deployment that could out-shout it would be back to minting unenforced + // boundaries. Both knobs are set as loudly as possible here. + const run = await runGuidedWorkspaceCreation( + { posture: 'isolated', legacy: 'true', tenancy: degradedTenancy() }, + 'degraded-loud-env', + ); + + expect(run.status).toBe(403); + expect(run.orgRows).toHaveLength(0); + expect(run.multiOrgEnabled).toBe(false); }); it('the verdict is taken LIVE per request, never frozen at plugin-build time', async () => { @@ -358,16 +422,52 @@ describe('#5233 — with a `tenancy` service wired, as a real kernel boot has', expect(allowed.status).toBe(200); expect((manager.getPublicConfig() as any).features.multiOrgEnabled).toBe(true); }); + + it('sees the enterprise runtime that registers AFTER plugin-auth — the probe is live too', async () => { + // [#5261] Same recorded-verdict hazard, one layer down. The `tenancy` + // service's `probeIsolation` is lazy BECAUSE `org-scoping` registers after + // plugin-auth: the wall genuinely comes up mid-boot. Now that the gate reads + // the service's effective posture, caching either the service handle's + // answer or our own derivation would freeze the deployment in its + // pre-org-scoping state and refuse org creation forever on a fully walled + // deployment — #5233's 403, resurrected by a different mistake. + process.env.OS_TENANCY_POSTURE = 'isolated'; + delete process.env.OS_MULTI_ORG_ENABLED; + + let orgScopingRegistered = false; + const tenancy = createTenancyService({ + requested: 'isolated', + probeIsolation: () => orgScopingRegistered, + }); + + const engine = createMemoryEngine(); + const manager = makeManager(engine, { getTenancy: () => tenancy }); + const cookie = cookieFrom(await signUp(manager, 'late-wall@example.com')); + + const refused = await createOrganization(manager, cookie, 'before-wall'); + expect(refused.status).toBe(403); + expect((manager.getPublicConfig() as any).features.multiOrgEnabled).toBe(false); + + // The enterprise runtime registers its `org-scoping` service. + orgScopingRegistered = true; + + const allowed = await createOrganization(manager, cookie, 'after-wall'); + expect(allowed.status).toBe(200); + expect((manager.getPublicConfig() as any).features.multiOrgEnabled).toBe(true); + }); }); // ─────────────────────────────────────────────────────────────────────────── -describe('#5233 — /auth/config and the gate agree on every deployment shape', () => { - // For every configuration where the requested wall is the wall in force, +describe('#5233 / #5261 — /auth/config and the gate agree on EVERY deployment shape', () => { // `features.multiOrgEnabled` must predict the HTTP answer exactly. A future // change that fixes one site and forgets the other fails here, which is the // only reason #5233 was ever hard to see — the flag said yes, the route said - // no, and nothing compared them. (The one shape where the two legitimately - // report different facts — degraded — is asserted above, not here.) + // no, and nothing compared them. + // + // [#5261] The table now covers EVERY shape, with no exceptions carved out. + // It used to exclude the degraded rows, because there the two sites reported + // different facts by design; making the gate read the effective posture is + // what let the exception go, and the exception's absence is itself the proof. const scenarios: Array<{ name: string; scenario: Scenario; allowed: boolean }> = [ { name: 'posture=isolated (legacy unset)', scenario: { posture: 'isolated' }, allowed: true }, { name: 'posture=group (legacy unset)', scenario: { posture: 'group' }, allowed: true }, @@ -395,6 +495,22 @@ describe('#5233 — /auth/config and the gate agree on every deployment shape', scenario: { posture: 'single', tenancy: enforcedTenancy('single') }, allowed: false, }, + // [#5261] The rows the table could not previously hold. + { + name: 'tenancy service: isolated requested + DEGRADED (no enterprise runtime)', + scenario: { posture: 'isolated', tenancy: degradedTenancy('isolated') }, + allowed: false, + }, + { + name: 'tenancy service: group requested + DEGRADED (no enterprise runtime)', + scenario: { posture: 'group', tenancy: degradedTenancy('group') }, + allowed: false, + }, + { + name: 'tenancy service: DEGRADED with both env knobs shouting yes', + scenario: { posture: 'isolated', legacy: 'true', tenancy: degradedTenancy('isolated') }, + allowed: false, + }, ]; it.each(scenarios)('$name', async ({ name, scenario, allowed }) => { diff --git a/packages/qa/dogfood/test/org-create-default-team.dogfood.test.ts b/packages/qa/dogfood/test/org-create-default-team.dogfood.test.ts index 0778b6b492..9b32b41954 100644 --- a/packages/qa/dogfood/test/org-create-default-team.dogfood.test.ts +++ b/packages/qa/dogfood/test/org-create-default-team.dogfood.test.ts @@ -24,32 +24,80 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; import { bootStack, type VerifyStack } from '@objectstack/verify'; +/** + * ── How this fixture opens the route (#5261) ───────────────────────────────── + * + * `beforeCreateOrganization` denies `organization/create` outright unless an + * organization wall is in force, so a single-tenant boot 403s long before + * better-auth reaches the team insert this file is about. + * + * It used to open the route by flipping `OS_MULTI_ORG_ENABLED=true` AFTER boot + * and leaning on the gate reading env live per request. That was a BYPASS, not a + * deployment: the stack stayed single-tenant while the gate alone was told + * otherwise. #5261 made the gate read the tenancy service's EFFECTIVE posture + * precisely so that no env combination can talk a wall-less deployment into + * minting organizations, which closes that trick — deliberately. + * + * So the fixture now simulates the DEPLOYMENT instead of fooling the gate: + * `multiTenant: 'posture-only'` registers the harness's stand-in for the + * enterprise `org-scoping` runtime, and the `tenancy` service resolves a real, + * non-degraded `isolated` posture the whole stack agrees on. That is more honest + * than the flag flip — it is the shape a deployment with the wall up actually + * has — but it is a stand-in, and its limits are exactly stated in + * `BootOptions.multiTenant`: it activates the POSTURE, never the WALL. Nothing + * here asserts isolation; the cross-tenant proofs still skip in this workspace + * (see `enterprise-organizations.ts`) rather than pretend. + * + * #3624's regression is in the team INSERT, which is identical under any + * posture that lets the route run. + */ describe('#3624: org create provisions its default team', () => { let stack: VerifyStack; let token: string; - let priorMultiOrg: string | undefined; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); + stack = await bootStack(showcaseStack, { multiTenant: 'posture-only' }); token = await stack.signIn(); - // `beforeCreateOrganization` denies the route outright unless multi-org is - // on, so a single-tenant boot 403s before better-auth ever reaches the team - // insert. The guard calls `resolveMultiOrgEnabled()` live per request, so - // flipping the flag AFTER boot opens the route while leaving the stack - // itself single-tenant — no OrgScopingPlugin, no enterprise - // `@objectstack/organizations` dependency (which this workspace does not - // ship, and which is why the multi-org RLS dogfood test skips here). - // The regression is in the team INSERT, which is identical either way. - priorMultiOrg = process.env.OS_MULTI_ORG_ENABLED; - process.env.OS_MULTI_ORG_ENABLED = 'true'; }, 120_000); afterAll(async () => { - if (priorMultiOrg === undefined) delete process.env.OS_MULTI_ORG_ENABLED; - else process.env.OS_MULTI_ORG_ENABLED = priorMultiOrg; await stack?.stop?.(); }); + it('boots a genuinely walled posture — the route is open because the deployment is, not because a flag was flipped', async () => { + // [#5261] Guard the guard. If the stand-in ever stops activating the + // posture, the create below would 403 and this file would report "#3624 + // regressed" — sending the next reader after a team-insert bug that isn't + // there. Naming the precondition separately keeps that misdiagnosis off the + // table, and pins the fact the fixture actually depends on. + const tenancy = await stack.kernel.getServiceAsync<{ + posture: string; + requestedPosture: string; + isolationActive: boolean; + degraded: boolean; + }>('tenancy'); + + expect(tenancy.requestedPosture).toBe('isolated'); + expect(tenancy.posture).toBe('isolated'); + expect(tenancy.isolationActive).toBe(true); + // NOT degraded — that is the whole difference from the old flag flip, and + // the state #5261 refuses to create organizations in. + expect(tenancy.degraded).toBe(false); + + // And the capability the console renders its button from agrees, because + // since #5261 it is the same derivation the gate uses. + const cfg = await stack.api('/auth/config'); + expect(cfg.status).toBe(200); + const body = (await cfg.json()) as { data?: { features?: Record } }; + const features = body.data?.features; + // Read the key out explicitly before asserting on it: `?? {}` over a moved + // envelope turns "the flag is missing" into "the flag is false", which is + // how a green assertion can cover a route that stopped reporting at all. + expect(features, `no features in /auth/config: ${JSON.stringify(body)}`).toBeDefined(); + expect(features!.multiOrgEnabled).toBe(true); + expect(features!.degradedTenancy).toBe(false); + }); + it('creates the org AND its default team without a 500', async () => { const res = await stack.apiAs(token, 'POST', '/auth/organization/create', { name: 'Regression Org 3624', diff --git a/packages/verify/src/harness.posture-only.test.ts b/packages/verify/src/harness.posture-only.test.ts new file mode 100644 index 0000000000..0d4fbba30c --- /dev/null +++ b/packages/verify/src/harness.posture-only.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#5261] `bootStack({ multiTenant: 'posture-only' })` — the stand-in that +// activates the tenancy POSTURE without the cloud-private enterprise runtime. +// +// Why it exists: since #5261 the `organization/create` gate reads the EFFECTIVE +// tenancy posture (the `tenancy` service's answer), not the operator's env +// request, so a deployment with no organization wall can no longer be talked +// into minting organizations by any env combination. That deliberately closed +// the trick `org-create-default-team.dogfood.test.ts` (#3624's e2e half) used to +// open the route with — flipping `OS_MULTI_ORG_ENABLED` after boot — and the +// enterprise `@objectstack/organizations` package is not installable in this +// workspace, so the fixture needed an honest way to be a walled deployment +// rather than a single-tenant stack lying to one gate. +// +// ⚠️ Deliberately NO `vi.mock('@objectstack/organizations')` here — unlike +// `harness.posture.test.ts`, which fakes the module to exercise the REAL +// `multiTenant: true` path. The whole claim under test is that this option needs +// no enterprise package at all, and a mocked one standing by would make that +// claim untestable: green whether or not the option actually stands alone. + +import { describe, it, expect, afterEach } from 'vitest'; +import { bootStack } from './harness'; + +const app = { + manifest: { + id: 'com.example.posture-only', + namespace: 'postureonly', + version: '0.0.1', + type: 'app', + name: 'Posture-Only Fixture', + }, + objects: [], +}; + +interface TenancyShape { + posture: string; + requestedPosture: string; + isolationActive: boolean; + degraded: boolean; +} + +afterEach(() => { + delete process.env.OS_TENANCY_POSTURE; +}); + +// Booting the full in-process stack runs well past vitest's 5s default. +const BOOT_TIMEOUT = 120_000; + +describe("bootStack multiTenant: 'posture-only' (#5261)", () => { + it( + 'resolves a real, NON-DEGRADED isolated posture with no enterprise package installed', + async () => { + const stack = await bootStack(app, { multiTenant: 'posture-only' }); + try { + const tenancy = await stack.kernel.getServiceAsync('tenancy'); + expect(tenancy.requestedPosture).toBe('isolated'); + expect(tenancy.posture).toBe('isolated'); + expect(tenancy.isolationActive).toBe(true); + // The load-bearing assertion. `degraded` is exactly "a wall was asked + // for and could not be stood up" — the ADR-0093 D5 state #5261 refuses + // to create organizations in. If the stand-in ever stopped registering + // `org-scoping`, the posture would fall back to `single` + degraded and + // every fixture booting this way would 403 with no explanation. + expect(tenancy.degraded).toBe(false); + } finally { + await stack.stop(); + } + }, + BOOT_TIMEOUT, + ); + + it( + 'registers the `org-scoping` service — the one fact the open core probes', + async () => { + const stack = await bootStack(app, { multiTenant: 'posture-only' }); + try { + // Named explicitly because it is a CONTRACT with the enterprise package, + // not an implementation detail: `TenancyService.probeIsolation`, + // SecurityPlugin's RLS-strip decision and `requiresService` nav gating + // all key on this exact service name. A stand-in registering it under + // any other name would satisfy none of them. + const orgScoping = await stack.kernel.getServiceAsync('org-scoping'); + expect(orgScoping).toBeTruthy(); + } finally { + await stack.stop(); + } + }, + BOOT_TIMEOUT, + ); + + it( + 'restores OS_TENANCY_POSTURE on stop(), like the real multiTenant path', + async () => { + expect(process.env.OS_TENANCY_POSTURE).toBeUndefined(); + const stack = await bootStack(app, { multiTenant: 'posture-only' }); + expect(process.env.OS_TENANCY_POSTURE).toBe('isolated'); + await stack.stop(); + // A leaked posture would silently change the NEXT boot in this worker — + // the failure mode that makes env-driven fixtures order-dependent. + expect(process.env.OS_TENANCY_POSTURE).toBeUndefined(); + }, + BOOT_TIMEOUT, + ); + + it( + 'never overrides an explicit caller-provided OS_TENANCY_POSTURE', + async () => { + process.env.OS_TENANCY_POSTURE = 'group'; + const stack = await bootStack(app, { multiTenant: 'posture-only' }); + try { + const tenancy = await stack.kernel.getServiceAsync('tenancy'); + // The stand-in declares `supportedPostures: ['group', 'isolated']` + // (ADR-0105 D12), so `group` is entitled and stands up as itself. + expect(tenancy.requestedPosture).toBe('group'); + expect(tenancy.posture).toBe('group'); + expect(tenancy.degraded).toBe(false); + } finally { + await stack.stop(); + } + expect(process.env.OS_TENANCY_POSTURE).toBe('group'); + }, + BOOT_TIMEOUT, + ); + + it( + 'a default boot stays single — the stand-in is opt-in and nothing else changed', + async () => { + // The other half of the contract: `multiTenant` unset must not drift into + // a walled posture just because the option now has a second spelling. + const stack = await bootStack(app, {}); + try { + const tenancy = await stack.kernel.getServiceAsync('tenancy'); + expect(tenancy.posture).toBe('single'); + expect(tenancy.isolationActive).toBe(false); + expect(tenancy.degraded).toBe(false); + } finally { + await stack.stop(); + } + }, + BOOT_TIMEOUT, + ); +}); diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 9b02153511..0249d446e0 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -40,6 +40,36 @@ interface InjectableApp { request(input: string, init?: RequestInit): Promise; } +/** + * [#5261] Stand-in for the cloud-private `@objectstack/organizations` runtime, + * mounted by `bootStack({ multiTenant: 'posture-only' })`. + * + * It registers the `org-scoping` service and nothing else. That single fact is + * what the open core reads to decide whether a REQUESTED organization wall can + * actually stand (`TenancyService.probeIsolation` → `posture` / `degraded`, + * ADR-0093 D5), so registering it turns a degraded deployment into a + * non-degraded `isolated` one from every consumer's point of view. + * + * ⛔ It stamps no `organization_id` and scopes no query — it is the deployment's + * POSTURE, not its WALL. See `BootOptions.multiTenant` for what that permits and + * what it must never be used to claim. + * + * `supportedPostures` is declared (ADR-0105 D12) so the stand-in entitles the + * same set a runtime predating that seam does, rather than accidentally + * exercising the narrowed-entitlement path. + */ +class SimulatedOrgScopingPlugin { + readonly name = 'com.objectstack.verify.simulated-org-scoping'; + readonly version = '1.0.0'; + readonly type = 'standard'; + readonly providesServices = ['org-scoping']; + readonly supportedPostures = ['group', 'isolated'] as const; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async init(ctx: any): Promise { + ctx.registerService('org-scoping', this); + } +} + const API_PREFIX = '/api/v1'; const DEFAULT_ADMIN_EMAIL = 'admin@objectos.ai'; const DEFAULT_ADMIN_PASSWORD = 'admin123'; @@ -102,8 +132,34 @@ export interface BootOptions { * Also REQUESTS the `isolated` tenancy posture (ADR-0105 D1) for the boot, * unless the caller already set `OS_TENANCY_POSTURE` — mounting the plugin * entitles a walled posture but no longer activates one by itself. + * + * ## `'posture-only'` — a stand-in, for proving org LIFECYCLE without isolation + * + * `multiTenant: 'posture-only'` boots the same shape but registers a built-in + * stand-in for the `org-scoping` service instead of requiring the cloud-private + * enterprise package. The `tenancy` service then resolves a real, NON-DEGRADED + * `isolated` posture, which is what posture-gated seams key on — above all + * `POST /auth/organization/create`, which since #5261 refuses whenever the + * EFFECTIVE posture has no organization wall. + * + * ⛔ **It performs no tenant isolation whatsoever.** Nothing stamps + * `organization_id`, nothing scopes a query. It makes the deployment's POSTURE + * true, not its WALL. A fixture that asserts one tenant cannot read another's + * rows and boots this way would assert nothing and pass — the constant-false + * capability probe of #4700 wearing the opposite mask. Cross-tenant isolation + * has exactly one honest proof in this repo: `multiTenant: true` with the real + * `@objectstack/organizations` installed, which is why those gates SKIP here + * (see `test/enterprise-organizations.ts`) instead of pretending. + * + * Use it only where the organization wall is the PRECONDITION of the thing + * under test rather than the thing itself — `org-create-default-team` + * (#3624: better-auth's default-team insert must not 500) is the case it was + * built for. Before #5261 that fixture opened the route by flipping + * `OS_MULTI_ORG_ENABLED` after boot and leaning on the gate's live env read; + * the gate now reads the tenancy service, so the honest way to open it is to + * simulate the deployment that legitimately has it open. */ - multiTenant?: boolean; + multiTenant?: boolean | 'posture-only'; /** * Root directory of the **host app** being verified — the one whose * `node_modules` carries the optional packages it declares (currently the @@ -286,7 +342,15 @@ export async function bootStack( // ordering for `OS_MULTI_ORG_ENABLED`. `multiTenant` is an explicit opt-in, // so a missing package is a hard, actionable error — not a silent // single-org downgrade that would flip the fixture's RLS posture. - if (opts.multiTenant) { + if (opts.multiTenant === 'posture-only') { + // See BootOptions.multiTenant: activates the POSTURE, never the WALL. + // Registered in the enterprise plugin's own slot so every downstream probe + // (SecurityPlugin's strip decision, the `tenancy` service's `isolationActive`, + // `requiresService: 'org-scoping'` nav gating) sees one consistent answer — + // a stack where half the layers believe the wall is up would be a worse lie + // than either honest posture. + await kernel.use(new SimulatedOrgScopingPlugin()); + } else if (opts.multiTenant) { // #4700: this used a bare `import()`, which Node ESM resolves against the // IMPORTER's realpath — `packages/verify`, inside the framework workspace. // `@objectstack/organizations` is cloud-private and only ever lives in the