diff --git a/.changeset/adr-0105-group-posture-entitlement.md b/.changeset/adr-0105-group-posture-entitlement.md new file mode 100644 index 0000000000..82fdf3d401 --- /dev/null +++ b/.changeset/adr-0105-group-posture-entitlement.md @@ -0,0 +1,59 @@ +--- +'@objectstack/plugin-auth': minor +'@objectstack/plugin-security': minor +'@objectstack/spec': minor +'@objectstack/cli': patch +--- + +Multi-organization operation is an ENTITLEMENT again: the `group` posture no +longer activates without the enterprise runtime (ADR-0105 D12 correction). + +The first ADR-0105 wave read D12 as "the `group` wall ships open" and made the +posture self-activating — it never probed for `@objectstack/organizations`. That +turned `group` into a free multi-org path around the `isolated` gate (ADR-0081 +D2), and made the weaker isolation the free one, which is not a boundary anyone +would draw on purpose. + +The distinction that was missed: **open code is not free activation.** The wall's +implementation has always lived in the open packages — that is equally true of +`isolated`, whose Layer 0 wall sits in `plugin-security` and is gated on a +service the enterprise package registers. Cloud ADR-0016's 铁律 +(强制免费、治理收费) guarantees that a deployment RUNNING a multi-org shape is +safe; it is satisfied by REFUSING to run one unwalled, not by giving the posture +away. + +## Changes + +- **`tenancy-service`**: `group` probes `org-scoping` exactly like `isolated`. + Without it the posture resolves to `single` and reports `degraded`. +- **`os serve`**: the ADR-0093 D5 boot guard keys off the resolved POSTURE + instead of `OS_MULTI_ORG_ENABLED`. Previously `OS_TENANCY_POSTURE=group` skipped + both the enterprise package load AND the fail-fast, silently degrading to an + unwalled deployment — the exact ADR-0049 class that guard exists to close. A + `group` request without the runtime now refuses to boot unless + `OS_ALLOW_DEGRADED_TENANCY=1`. +- **New seam — the runtime declares what it entitles.** `org-scoping` may expose + `supportedPostures` (`OrgScopingEntitlement`, `@objectstack/spec/security`); + the open side honours it and fails closed on anything not listed. Whether + `group` and `isolated` are one commercial tier or two is packaging policy, and + packaging policy belongs to the commercial runtime rather than hard-coded in + open core. Omitting the field entitles every walled posture, so existing + runtimes are unaffected. +- **`organization_id` stamping returns to the enterprise runtime.** The previous + wave moved auto-stamping into the open engine; that removed the closed + package's only load-bearing runtime duty, so a five-line forged `org-scoping` + registration would have produced a fully working multi-org deployment. With + stamping back where it was, a forged registration yields NULL-org rows the wall + hides — a broken deployment, not an unlicensed working one. + + **Write-side VALIDATION stays open and is unchanged**, including the + bulk-insert coverage: rejecting a forged `organization_id` is a security + property, not a packaging one. Only filling an ABSENT value moved back. +- Default-organization bootstrap returns to `single`-only; every walled posture + keeps its existing owner (ADR-0081 D1). + +## Note for operators + +`OS_TENANCY_POSTURE=group` without `@objectstack/organizations` installed now +**refuses to boot** rather than running single-org. This only affects +deployments that adopted `group` between the two waves. diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 5a78ee27c9..8d234c9125 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -79,7 +79,8 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | `OS_AUTH_GOOGLE_ENABLED` | boolean | settings default | Settings env override for `auth.google_enabled`. Requires Google OAuth credentials from Settings or env. | | `GOOGLE_CLIENT_ID` | string | — | Deployment-level Google OAuth client id for the open-source Google login implementation. | | `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. | -| `OS_MULTI_ORG_ENABLED` | boolean | `false` | When `true`, expose organization creation/switching UI. When `false`, run in single-tenant mode. | +| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = `, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. | +| `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. | | `OS_OIDC_PROVIDER_ENABLED` | boolean | tracks MCP | When `true`, expose this instance as an OIDC identity provider. When unset it follows the MCP server surface (`OS_MCP_SERVER_ENABLED`, on by default) — the MCP human-client track is OAuth 2.1, so every MCP-enabled deployment is its own authorization server. | | `OS_COOKIE_DOMAIN` | string | — | Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). | | `OS_TRUSTED_ORIGINS` | csv | — | Comma-separated list of origins permitted for auth callbacks / CSRF. | diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index fa86be5fc1..bf2be31696 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -9,7 +9,7 @@ import { bundleRequire } from 'bundle-require'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; import { resolveDriverType, createStorageDriver, UnsupportedDriverError } from '../utils/storage-driver.js'; -import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types'; +import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types'; import { PLATFORM_CAPABILITY_TOKENS } from '@objectstack/spec/kernel'; import { missingProviderMessage } from '../utils/capability-preflight.js'; import { resolveObjectStackHome } from '@objectstack/runtime'; @@ -1588,7 +1588,14 @@ export default class Serve extends Command { // it, deployments are single-org: the open member-management // basics (plugin-auth's default-org bootstrap + better-auth // invitations) still work. - const multiTenant = resolveMultiOrgEnabled(); + // [ADR-0105 D1] Key off the resolved POSTURE, not the legacy boolean. + // Both walled postures (`group` and `isolated`) need this package: + // gating on `OS_MULTI_ORG_ENABLED` alone would let + // `OS_TENANCY_POSTURE=group` skip the load AND the fail-fast below, + // silently degrading to an unwalled single-org deployment — the exact + // ADR-0049 class this guard exists to close. + const tenancyPosture = resolveTenancyPosture(); + const multiTenant = tenancyPosture !== 'single'; if (multiTenant) { try { const organizationsPkg = '@objectstack/organizations'; @@ -1612,12 +1619,13 @@ export default class Serve extends Command { if (!resolveAllowDegradedTenancy()) { console.error( chalk.red( - '\n ✖ FATAL: OS_MULTI_ORG_ENABLED=true but @objectstack/organizations could not be loaded,\n' + - ' so tenant isolation is INACTIVE. Refusing to boot — a deployment that requested\n' + - ' multi-tenant isolation must not serve traffic without it (ADR-0093 D5).\n\n' + + `\n ✖ FATAL: tenancy posture '${tenancyPosture}' was requested but ` + + '@objectstack/organizations could not be loaded,\n' + + ' so the organization wall is INACTIVE. Refusing to boot — a deployment that requested\n' + + ' multi-organization isolation must not serve traffic without it (ADR-0093 D5).\n\n' + ' Fix one of:\n' + ' • install @objectstack/organizations (the enterprise multi-org runtime), or\n' + - ' • unset OS_MULTI_ORG_ENABLED to run single-org, or\n' + + " • set OS_TENANCY_POSTURE=single (or unset OS_MULTI_ORG_ENABLED) to run single-org, or\n" + ' • set OS_ALLOW_DEGRADED_TENANCY=1 to boot in an explicitly degraded single-org state.\n\n' + ` cause: ${cause}\n`, ), @@ -1629,9 +1637,9 @@ export default class Serve extends Command { // /auth/config and the Setup dashboard so it stays visible. console.warn( chalk.yellow( - ' ⚠ DEGRADED TENANCY (OS_ALLOW_DEGRADED_TENANCY=1): OS_MULTI_ORG_ENABLED=true but ' + - '@objectstack/organizations is unavailable — booting with tenant isolation INACTIVE. ' + - 'Organization boundaries are NOT enforced; wildcard tenant RLS is stripped. (ADR-0093 D5)', + ` ⚠ DEGRADED TENANCY (OS_ALLOW_DEGRADED_TENANCY=1): posture '${tenancyPosture}' requested but ` + + '@objectstack/organizations is unavailable — booting with the organization wall INACTIVE. ' + + 'Organization boundaries are NOT enforced. (ADR-0093 D5)', ), ); } diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 054038822c..827258f39d 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -11,6 +11,7 @@ import { } from '@objectstack/platform-objects/apps'; import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages'; import { resolveTenancyPosture } from '@objectstack/types'; +import { postureEnforcesWall, type OrgScopingEntitlement } from '@objectstack/spec/security'; import { AuthManager, resolveOidcProviderEnabled, @@ -283,6 +284,18 @@ export class AuthPlugin implements Plugin { return false; } }, + // [ADR-0105 D12] Which walled postures the installed multi-org runtime + // entitles. The open side ASKS rather than assuming "package present ⇒ + // every posture": whether `group` and `isolated` are one commercial tier + // or two is packaging policy, and that belongs to the commercial runtime. + // A runtime that declares nothing keeps today's behavior (both). + probeEntitledPostures: () => { + try { + return ctx.getService('org-scoping')?.supportedPostures; + } catch { + return undefined; + } + }, getEngine: () => { try { return ctx.getService('objectql'); @@ -552,18 +565,13 @@ export class AuthPlugin implements Plugin { await this.maybeSeedDevAdmin(ctx); }); - // ADR-0081 D1 — default-organization bootstrap, owned here for every posture - // the OPEN engine enforces (`single` and, per ADR-0105 D12, `group`). - // `isolated` keeps its existing owner (the enterprise organizations package, - // which runs the same idempotent helper with the seed-ownership step - // injected); one crisp owner per posture. - // - // `group` needs this as much as `single` does: the union wall resolves an - // EMPTY access set for a member of no organization and fails closed, so an - // open group deployment with no first organization could not admit its own - // admin. The bootstrap creates exactly one org and binds the first platform - // admin as owner; every further organization is created deliberately. - if (this.options.autoDefaultOrganization !== false && resolveTenancyPosture() !== 'isolated') { + // ADR-0081 D1 — single-org default-organization bootstrap. Every WALLED + // posture (`group` and `isolated`) keeps its existing owner: the enterprise + // organizations package, which runs the same idempotent helper with the + // 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. + if (this.options.autoDefaultOrganization !== false && !postureEnforcesWall(resolveTenancyPosture())) { const runEnsure = async () => { try { const ql: any = ctx.getService('objectql'); diff --git a/packages/plugins/plugin-auth/src/tenancy-service.test.ts b/packages/plugins/plugin-auth/src/tenancy-service.test.ts index 300dd8f626..e151f47ffd 100644 --- a/packages/plugins/plugin-auth/src/tenancy-service.test.ts +++ b/packages/plugins/plugin-auth/src/tenancy-service.test.ts @@ -61,25 +61,39 @@ describe('createTenancyService', () => { expect(t.degraded).toBe(false); }); - // [ADR-0105 D1/D12] `group` is enforced by the OPEN engine — the union wall, - // `accessible_org_ids`, and write stamping are all open code — so it never - // probes for the enterprise package and can never resolve degraded. - describe('group posture (open-enforced)', () => { - it('is active without the enterprise org-scoping service', () => { - const probe = vi.fn(() => false); - const t = createTenancyService({ requested: 'group', probeIsolation: probe }); + // [ADR-0105 D1 / ADR-0081 D2] Multi-organization operation is an ENTITLEMENT. + // The wall's code is open, but activating either walled posture requires the + // enterprise org-scoping runtime — otherwise `group` would be a free multi-org + // back door around the `isolated` gate. The iron rule (cloud ADR-0016) is + // satisfied by refusing to run an unwalled multi-org deployment, not by giving + // the posture away: an unenforceable request resolves to `single` + degraded, + // and the CLI fails fast on that (ADR-0093 D5). + describe('group posture (entitled, like isolated)', () => { + it('is ACTIVE when the enterprise org-scoping service is present', () => { + const t = createTenancyService({ requested: 'group', probeIsolation: () => true }); expect(t.posture).toBe('group'); expect(t.isolationActive).toBe(true); - expect(t.requested).toBe(true); expect(t.degraded).toBe(false); - expect(probe).not.toHaveBeenCalled(); }); - it('never guesses a default org — membership is explicit', async () => { + it('DEGRADES without the enterprise package — never a silent free multi-org', () => { + const probe = vi.fn(() => false); + const t = createTenancyService({ requested: 'group', probeIsolation: probe }); + expect(t.requestedPosture).toBe('group'); + expect(t.posture).toBe('single'); // behaves single-org — nothing walls it + // The probe is lazy — org-scoping registers after plugin-auth — so it + // fires on the first read of a derived fact, not at construction. + expect(probe).toHaveBeenCalled(); + expect(t.isolationActive).toBe(false); + expect(t.requested).toBe(true); + expect(t.degraded).toBe(true); + }); + + it('never guesses a default org while active — membership is explicit', async () => { const engine = makeEngine([{ id: 'org_default', slug: 'default' }]); const t = createTenancyService({ requested: 'group', - probeIsolation: () => false, + probeIsolation: () => true, getEngine: () => engine, }); expect(await t.defaultOrgId()).toBeNull(); @@ -169,3 +183,84 @@ describe('resolveDefaultOrgId', () => { expect(await resolveDefaultOrgId({})).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// [ADR-0105 D12] Posture ENTITLEMENT is declared by the commercial runtime. +// +// Presence-of-package answers "may this deployment run multi-org at all", not +// "which shapes of it". Whether `group` and `isolated` are one tier or two is a +// packaging decision the enterprise runtime owns, so the open core asks instead +// of assuming — and fails closed on anything not entitled. +// --------------------------------------------------------------------------- +describe('posture entitlement declared by the org-scoping runtime', () => { + const installed = () => true; + + it('entitles every walled posture when the runtime declares nothing (back-compat)', () => { + for (const posture of ['group', 'isolated'] as const) { + const t = createTenancyService({ + requested: posture, + probeIsolation: installed, + probeEntitledPostures: () => undefined, + }); + expect(t.posture, posture).toBe(posture); + expect(t.degraded).toBe(false); + } + }); + + it('activates a posture the runtime DOES entitle', () => { + const t = createTenancyService({ + requested: 'group', + probeIsolation: installed, + probeEntitledPostures: () => ['group'], + }); + expect(t.posture).toBe('group'); + expect(t.isolationActive).toBe(true); + expect(t.degraded).toBe(false); + }); + + it('DEGRADES a posture the runtime does not entitle, even though it is installed', () => { + // e.g. a licence covering legal-entity isolation but not the group shape. + const t = createTenancyService({ + requested: 'group', + probeIsolation: installed, + probeEntitledPostures: () => ['isolated'], + }); + expect(t.requestedPosture).toBe('group'); + expect(t.posture).toBe('single'); + expect(t.isolationActive).toBe(false); + expect(t.degraded).toBe(true); // → the CLI refuses to boot (ADR-0093 D5) + }); + + it('fails closed on an EMPTY entitlement set', () => { + const t = createTenancyService({ + requested: 'isolated', + probeIsolation: installed, + probeEntitledPostures: () => [], + }); + expect(t.isolationActive).toBe(false); + expect(t.degraded).toBe(true); + }); + + it('fails closed when the entitlement probe throws', () => { + const t = createTenancyService({ + requested: 'group', + probeIsolation: installed, + probeEntitledPostures: () => { + throw new Error('licence service unreachable'); + }, + }); + expect(t.isolationActive).toBe(false); + expect(t.degraded).toBe(true); + }); + + it('never consults entitlement when the runtime is absent (already degraded)', () => { + const entitle = vi.fn(() => ['group'] as const); + const t = createTenancyService({ + requested: 'group', + probeIsolation: () => false, + probeEntitledPostures: entitle, + }); + expect(t.degraded).toBe(true); + expect(entitle).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/tenancy-service.ts b/packages/plugins/plugin-auth/src/tenancy-service.ts index 0488d77951..99cbe492c2 100644 --- a/packages/plugins/plugin-auth/src/tenancy-service.ts +++ b/packages/plugins/plugin-auth/src/tenancy-service.ts @@ -22,15 +22,22 @@ * business-unit tree. * - `group` — wall = `organization_id IN accessible_org_ids`. Organizations are * membership/invitation boundaries over one shared dataset, with union (MOAC) - * read access. **Enforced by the OPEN engine**: the Layer 0 union wall, the - * `accessible_org_ids` resolution, and the D5 write stamping/validation all - * ship open, because the correctness of a wall is never a paid feature - * (cloud ADR-0016 铁律「强制免费、治理收费」). Managing organizations at - * scale stays commercial. + * read access. * - `isolated` — wall = `organization_id = activeOrganizationId`. The hard - * legal-entity wall, formerly spelled `multi`. Its machinery still comes from - * the enterprise `@objectstack/organizations` package, so this posture keeps - * the historical `org-scoping` probe and can still resolve DEGRADED. + * legal-entity wall, formerly spelled `multi`. + * + * ## Open code, entitled activation + * + * Both walled postures require the enterprise `@objectstack/organizations` + * package (the `org-scoping` service) to ACTIVATE, and both can resolve + * DEGRADED without it. The wall's implementation is open — the Layer 0 + * compiler, `accessible_org_ids` resolution and the D5 write + * stamping/validation all ship in open packages — but multi-organization + * operation is a commercial capability (ADR-0081 D2). + * + * That is not in tension with cloud ADR-0016's 铁律 (强制免费、治理收费): the rule + * guarantees a deployment RUNNING a multi-org shape is safe, which is satisfied + * by refusing to run one unwalled (ADR-0093 D5), not by giving the posture away. * * Registered by plugin-auth (the open-core home, alongside the default-org * bootstrap). @@ -90,10 +97,25 @@ export interface TenancyServiceDeps { * cheap (a service-registry lookup); consumers that read it hot should cache * the result themselves, as SecurityPlugin does at `start()`. * - * Consulted ONLY for the `isolated` posture. `group` is enforced by the open - * engine and never probes. + * Consulted for BOTH walled postures (`group` and `isolated`). */ probeIsolation: () => boolean; + /** + * [ADR-0105 D12] Which walled postures the installed multi-org runtime + * ENTITLES, as declared by that runtime itself (`org-scoping`'s optional + * `supportedPostures`). + * + * Presence-of-package is a coarse entitlement: it answers "may this + * deployment run multi-org at all", not "which shapes of it". Deciding that + * `group` and `isolated` are the same commercial tier — or different ones — + * is a PACKAGING policy, and packaging policy belongs to the commercial + * runtime, not to this open-core service. So the open side asks instead of + * assuming, and fails closed on anything not entitled. + * + * `undefined` (no declaration) means "every walled posture", preserving the + * behavior of every runtime that predates this seam. + */ + probeEntitledPostures?: () => readonly TenancyPosture[] | undefined; /** ObjectQL engine accessor, for {@link TenancyService.defaultOrgId}. */ getEngine?: () => unknown | undefined; logger?: { info?: (msg: string, meta?: any) => void; warn?: (msg: string, meta?: any) => void }; @@ -143,15 +165,30 @@ export function createTenancyService(deps: TenancyServiceDeps): TenancyService { * Can the REQUESTED posture actually be enforced? * * - `single` — nothing to enforce. - * - `group` — always: the union wall, `accessible_org_ids`, and write - * stamping/validation are all open-engine code (ADR-0105 D12). - * - `isolated` — only with the enterprise org-scoping machinery. + * - `group` / `isolated` — only with the org-scoping machinery registered. + * + * BOTH walled postures probe. The wall's CODE is open (the Layer 0 compiler, + * `accessible_org_ids` resolution and D5 stamping all live in open packages), + * but ENABLING a multi-organization posture is an entitlement — exactly the + * shape `isolated` has had since ADR-0081 D2. Open code, entitled activation: + * the two are separate questions, and conflating them is what briefly made + * `group` a free multi-org back door around the `isolated` gate. + * + * This does not weaken cloud ADR-0016's iron rule (强制免费、治理收费). The rule + * is that a deployment RUNNING the group shape must be safe, not that anyone + * may switch it on: a `group` request that cannot be enforced resolves to + * `single` and reports {@link TenancyService.degraded}, and the CLI refuses to + * boot on that unless the operator explicitly opts in (ADR-0093 D5). You never + * silently get unwalled multi-org — you get a refusal. */ const isolationActive = (): boolean => { if (requestedPosture === 'single') return false; - if (requestedPosture === 'group') return true; try { - return !!deps.probeIsolation(); + if (!deps.probeIsolation()) return false; + // The runtime is installed; ask whether it entitles THIS posture. + const entitled = deps.probeEntitledPostures?.(); + if (entitled && !entitled.includes(requestedPosture)) return false; + return true; } catch { return false; } diff --git a/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts b/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts index 6e299767ea..f7b70f43fb 100644 --- a/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts +++ b/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts @@ -360,17 +360,13 @@ describe('authz Layer-0 matrix gate — ADR-0095 D1 (post-extraction)', () => { it('member inserting the SAME-tenant organization_id is allowed (row keeps it)', async () => { expect(await insertOrg(OBJECTS.task, ROLES.member, 'org-1')).toBe('org-1'); }); - it('member inserting with NO organization_id is STAMPED with the active org', async () => { - // [ADR-0105 D5] The engine now owns the stamp. It used to belong solely to - // the enterprise organizations plugin, which left the open `group` posture - // writing NULL-org rows that its own wall then hid. Idempotent w.r.t. that - // plugin: neither overwrites a supplied value, so ordering stays irrelevant. - expect(await insertOrg(OBJECTS.task, ROLES.member, undefined)).toBe('org-1'); - }); - it('member with NO active org inserting without organization_id is left ABSENT (never invented)', async () => { - // [ADR-0105 D5] Stamping a tenant the caller does not have would smuggle a - // row past the very wall the caller already fails closed against. - expect(await insertOrg(OBJECTS.task, ROLES.no_org_member, undefined)).toBe('<>'); + it('member inserting with NO organization_id passes the wall (auto-stamp territory)', async () => { + // plugin-security validates SUPPLIED values; it never fills an absent one. + // Stamping belongs to the enterprise organizations runtime — which is also + // what activates a walled posture, so the two are never out of step + // (ADR-0105 D5/D12). The Layer 0 check must NOT deny an absent value + // (ordering-independent w.r.t. that middleware). + expect(await insertOrg(OBJECTS.task, ROLES.member, undefined)).toBe('<>'); }); it('member with NO active org supplying ANY organization_id is fail-closed DENIED', async () => { expect(await insertOrg(OBJECTS.task, ROLES.no_org_member, 'org-1')).toBe('CRUD_DENY:PermissionDeniedError'); diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index da38084dc7..78c939e594 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -453,19 +453,21 @@ describe('SecurityPlugin', () => { await harness.run(opCtx); // must not throw }); - it('[ADR-0105 D5] a write that omits organization_id is STAMPED with the active org', async () => { + it("a write that doesn't supply organization_id is untouched (auto-stamp's job)", async () => { const harness = await boot(); const opCtx: any = { object: 'task', operation: 'insert', data: { name: 'A' }, context: memberCtx(), }; - await harness.run(opCtx); // must not throw — the stamped value satisfies the wall - // The engine owns the stamp under any wall-enforcing posture; the enterprise - // organizations plugin remains free to run too (neither overwrites a value). - expect(opCtx.data.organization_id).toBe('org-1'); + await harness.run(opCtx); // must not throw — no cross-tenant check on an absent value + // [ADR-0105 D5/D12] SecurityPlugin never stamps organization_id. That stays + // with the enterprise organizations runtime, which is also what activates a + // walled posture — so a forged `org-scoping` registration yields NULL-org + // rows the wall hides (a broken deployment), not a working unlicensed one. + expect(opCtx.data.organization_id).toBeUndefined(); }); - it('[ADR-0105 D5] a BULK insert stamps every row that omits organization_id', async () => { + it('[ADR-0105 D5] a BULK insert leaves rows that omit organization_id untouched', async () => { const harness = await boot(); const opCtx: any = { object: 'task', operation: 'insert', @@ -473,7 +475,7 @@ describe('SecurityPlugin', () => { context: memberCtx(), }; await harness.run(opCtx); - expect(opCtx.data.map((r: any) => r.organization_id)).toEqual(['org-1', 'org-1']); + expect(opCtx.data.map((r: any) => r.organization_id)).toEqual([undefined, 'org-1']); }); it('[ADR-0105 D5] a BULK insert with ONE forged row is denied entirely', async () => { diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 8745dc0a62..ba89e82693 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -41,7 +41,6 @@ import { isPlatformTenantPolicy, isAuthoredTenantPolicy } from './platform-tenan import { normalizeTenancyPosture, postureEnforcesWall, - postureStampsOrganization, type TenancyPosture, } from '@objectstack/spec/security'; import { @@ -1425,21 +1424,21 @@ export class SecurityPlugin implements Plugin { // platform admin on a posture-permitting object is exempt via Layer 0 (same // rule as reads/insert). // - // [ADR-0105 D5] Two changes here, both closing gaps in the above: + // [ADR-0105 D5] BULK inserts are covered here too. The check previously + // required a non-array payload, so an `insert` of an ARRAY of rows could + // carry a forged `organization_id` per row and never meet the wall — the + // same defect #2937 closed for the single-row shape, one call-site down + // (AGENTS.md #10: a `case` label is not enforcement, check the call site). // - // • STAMPING moved into the engine. `organization_id` used to be filled - // only by the enterprise organizations plugin, so the `group` posture — - // which the open engine now enforces — would have written NULL-org rows - // that its own wall then hides. Under any wall-enforcing posture the - // engine stamps the caller's ACTIVE organization onto an insert that - // carries no value, then lets the check below validate it like any - // other. Idempotent w.r.t. the enterprise auto-stamp: whoever runs - // first sets it, and neither overwrites a supplied value. - // • BULK inserts are now covered. The check previously required a - // non-array payload, so an `insert` of an ARRAY of rows could carry a - // forged `organization_id` per row and never meet the wall — the same - // defect #2937 closed for the single-row shape, one call-site down - // (AGENTS.md #10: a `case` label is not enforcement, check the call site). + // This validates SUPPLIED values only; it never fills an absent one. + // Auto-stamping `organization_id` stays with the enterprise + // `@objectstack/organizations` runtime (its Middleware A), which is also + // what ACTIVATES every walled posture — so a deployment that reaches this + // code with a walled posture always has the stamper installed. Keeping the + // stamp there means a forged `org-scoping` registration yields NULL-org + // rows that the wall hides, i.e. a broken deployment rather than a working + // unlicensed one; validation stays here because it is a security property, + // not a packaging one. if ( (opCtx.operation === 'insert' || opCtx.operation === 'update') && opCtx.data && @@ -1452,26 +1451,6 @@ export class SecurityPlugin implements Plugin { !!r && typeof r === 'object' && !Array.isArray(r), ); - if ( - opCtx.operation === 'insert' && - postureStampsOrganization(this.tenancyPosture) && - writeRows.some((r) => r.organization_id == null || r.organization_id === '') && - (await this.isTenantScopedObject(opCtx.object)) - ) { - const activeOrg = opCtx.context?.tenantId; - if (activeOrg != null && activeOrg !== '') { - for (const row of writeRows) { - if (row.organization_id == null || row.organization_id === '') { - row.organization_id = activeOrg; - } - } - } - // No active organization under a walled posture: leave the value - // absent. The row would be unreachable behind its own wall, and the - // read-side Layer 0 already fails such a context closed — inventing a - // tenant here is the one thing that could smuggle a row past it. - } - const suppliedRows = writeRows.filter( (r) => r.organization_id != null && r.organization_id !== '', ); @@ -2841,24 +2820,6 @@ export class SecurityPlugin implements Plugin { if (Object.keys(bag).length > 0) context.rlsMembership = bag; } - /** - * [ADR-0105 D5] Is this object subject to the organization wall — i.e. does it - * carry an `organization_id` column and NOT opt out of tenancy? - * - * Uses the SAME two facts Layer 0 decides with (field set + tenancy posture), - * so the write-side stamp can never disagree with the read-side wall about - * what counts as a tenant object. An unresolvable field set (boot window) - * answers `false`: stamping is an additive convenience, and guessing a column - * onto an object that may not have one would fail the insert outright — the - * #2937 post-image check still guards any value that IS supplied. - */ - private async isTenantScopedObject(object: string): Promise { - const meta = await this.getObjectSecurityMeta(object); - if (this.tenancyDisabledCache.get(object) === true || meta.tenancyDisabled) return false; - const objectFields = await this.getObjectFieldNames(this.metadata, object, this.ql); - return objectFields ? objectFields.has('organization_id') : false; - } - private async computeWriteTenantCheckFilter( permissionSets: PermissionSet[], object: string, diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 2ff949f722..7fad89a029 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -61,13 +61,13 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ // unit-proven at the plugin-security layer, like the #2937 row above. ── { id: 'tenancy-posture-spectrum', summary: 'three tenancy postures — single | group | isolated (ADR-0105 D1/D2)', state: 'enforced', enforcement: 'plugin-auth/tenancy-service.ts resolves the posture in force; plugin-security/tenant-layer.ts computeTenantLayer0Filter switches the Layer 0 predicate on it — inert (single), `organization_id IN accessible_org_ids` (group, MOAC union), `organization_id = activeOrganizationId` (isolated); empty/absent scope → RLS_DENY_FILTER', - note: 'Only the PREDICATE widens; composition is untouched — Layer 0 is still computed independently of the RLS compiler, AND-composed outermost, and crossable only by a true PLATFORM_ADMIN on a posture-permitting object, so ADR-0095 W1/W2 hold in every posture. `group` is enforced by the OPEN engine (cloud ADR-0016 铁律: the wall\'s correctness is never paid); `isolated` keeps its enterprise org-scoping probe and can still resolve degraded. Unit-proven in plugin-security/tenant-layer.test.ts and plugin-auth/tenancy-service.test.ts.' }, + note: 'Only the PREDICATE widens; composition is untouched — Layer 0 is still computed independently of the RLS compiler, AND-composed outermost, and crossable only by a true PLATFORM_ADMIN on a posture-permitting object, so ADR-0095 W1/W2 hold in every posture. BOTH walled postures require the enterprise @objectstack/organizations runtime to ACTIVATE and can resolve degraded without it (ADR-0081 D2): the wall\'s IMPLEMENTATION is open, but enabling multi-organization operation is an entitlement, and the runtime may narrow it further via `supportedPostures`. Cloud ADR-0016\'s 铁律 is satisfied by REFUSING to run an unwalled multi-org deployment (ADR-0093 D5), not by giving the posture away. Unit-proven in plugin-security/tenant-layer.test.ts and plugin-auth/tenancy-service.test.ts.' }, { id: 'accessible-org-ids', summary: 'accessible_org_ids — core-resolved org access set (ADR-0105 D2)', state: 'enforced', enforcement: 'core/security/resolve-authz-context.ts resolveUserAuthzGrants reads every sys_member row for the user under ADR-0091 validity windows; carried on ExecutionContext by every transport (rest-server, runtime resolve-execution-context, mcp, hono) and read directly by the Layer 0 group wall', note: 'ONE read serves both the active-org position projection and the full membership set, so the two facts cannot disagree. A transport that fails to carry the set denies under `group` rather than falling back to the active org — the wall must not depend on which surface the request arrived through. Delegated (on-behalf-of) reads resolve the DELEGATOR\'s own set (explain-engine buildContextForUser), never inherit the live principal\'s.' }, - { id: 'org-stamp-write', summary: 'engine-owned organization_id stamping + bulk write validation (ADR-0105 D5)', state: 'enforced', - enforcement: 'plugin-security/security-plugin.ts step 3.7 — under any wall-enforcing posture an insert that omits organization_id is stamped from ctx.tenantId; every SUPPLIED value (single row AND bulk array) must satisfy the Layer 0 filter or the whole write is denied', - note: 'Stamping used to live solely in the enterprise organizations plugin, which left the open `group` posture writing NULL-org rows its own wall then hid; idempotent w.r.t. that plugin (neither overwrites a supplied value). The bulk path was a genuine hole: the pre-D5 check required a non-array payload, so an INSERT of an ARRAY could carry a forged organization_id per row — the #2937 defect one call site down. No tenant is ever invented: a caller with no active org leaves the value absent rather than smuggling a row past the wall. Unit-proven in plugin-security/security-plugin.test.ts + authz-matrix-gate.test.ts.' }, + { id: 'org-write-validation', summary: 'bulk-aware organization_id write validation (ADR-0105 D5)', state: 'enforced', + enforcement: 'plugin-security/security-plugin.ts step 3.7 — every SUPPLIED organization_id (single row AND bulk array) must satisfy the Layer 0 filter or the whole write is denied', + note: 'The bulk path was a genuine hole: the pre-D5 check required a non-array payload, so an INSERT of an ARRAY could carry a forged organization_id per row — the #2937 defect one call site down. STAMPING an absent value is deliberately NOT done here: it belongs to the enterprise @objectstack/organizations runtime (Middleware A), which is also what ACTIVATES every walled posture (ADR-0105 D12), so the stamper is always present wherever a wall is. Keeping the stamp there means a forged `org-scoping` registration produces NULL-org rows the wall hides — a broken deployment, not a working unlicensed one — while validation stays open because it is a security property, not a packaging one. Unit-proven in plugin-security/security-plugin.test.ts + authz-matrix-gate.test.ts.' }, { id: 'authored-rls-policy-survival', summary: 'app-authored org-scoped RLS policies are never silently stripped (ADR-0105 D3 / F1)', state: 'enforced', enforcement: 'plugin-security/platform-tenant-policies.ts — collectRLSPolicies strips by PROVENANCE (identity against the shipped declaration), not by substring-matching `current_user.organization_id`; an authored policy is retained, warned about once, and fails closed at compile time', note: 'The substring match dropped ANY policy using the token, including app-authored ones — a declared security policy silently unenforced, the ADR-0049 class. getReadFilter shared the defect, so analytics/raw-SQL consumers got an UNSCOPED read. Unit-proven in plugin-security/platform-tenant-policies.test.ts + security-plugin.test.ts.' }, diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 108820987c..e72c18fd5e 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3882,6 +3882,7 @@ "ObjectAccessScopeSchema (const)", "ObjectPermission (type)", "ObjectPermissionSchema (const)", + "OrgScopingEntitlement (interface)", "PLATFORM_CAPABILITIES (const)", "PLATFORM_CAPABILITY_NAMES (const)", "PUBLIC_FORM_SERVER_MANAGED_FIELDS (const)", diff --git a/packages/spec/src/security/tenancy-posture.ts b/packages/spec/src/security/tenancy-posture.ts index faa69b7cb7..f5d9767437 100644 --- a/packages/spec/src/security/tenancy-posture.ts +++ b/packages/spec/src/security/tenancy-posture.ts @@ -20,9 +20,18 @@ * group-wide visibility and cross-org workflow are inherent to the shape, and * a hard per-org wall makes both an architecture problem. * - * The wall's CORRECTNESS is never a paid feature (cloud ADR-0016 铁律): every - * posture is enforced by the open engine. What stays commercial is managing - * organizations at scale, not being safe. + * ## Open code, entitled activation + * + * The wall's IMPLEMENTATION is open — the Layer 0 compiler, `accessible_org_ids` + * resolution and the D5 write stamping all ship in open packages — but ENABLING + * a multi-organization posture (`group` or `isolated`) requires the enterprise + * `@objectstack/organizations` runtime (ADR-0081 D2). The two are separate + * questions and must not be conflated: open code does not mean free activation. + * + * Cloud ADR-0016's 铁律 (强制免费、治理收费) is satisfied by the first half alone — + * it guarantees that a deployment RUNNING a multi-org shape is safe, which the + * platform delivers by REFUSING to run one unwalled (ADR-0093 D5), not by giving + * the posture away. You never silently get an unenforced organization boundary. */ import { z } from 'zod'; @@ -46,9 +55,16 @@ export function postureEnforcesWall(posture: TenancyPosture): boolean { } /** - * Does this posture stamp and validate `organization_id` on write (ADR-0105 D5)? - * True exactly when a wall is enforced — the write side must be the twin of the - * read side or a forged/absent value would slip under the wall. + * Does this posture require `organization_id` to be stamped on write? + * + * True exactly when a wall is enforced: an absent value would land a row behind + * a wall that then hides it. The STAMPER, however, is the enterprise + * `@objectstack/organizations` runtime, not the open engine — the same runtime + * whose presence activates these postures in the first place, so the two can + * never be out of step. This predicate is the shared vocabulary that runtime + * compiles against; the open engine only VALIDATES supplied values (the write + * side of the Layer 0 wall), which is a security property rather than a + * packaging one. */ export function postureStampsOrganization(posture: TenancyPosture): boolean { return postureEnforcesWall(posture); @@ -65,6 +81,23 @@ export function postureUsesUnionScope(posture: TenancyPosture): boolean { return posture === 'group'; } +/** + * [ADR-0105 D12] The shape the `org-scoping` service may expose to declare which + * walled postures the installed multi-organization runtime entitles. + * + * Presence-of-package answers "may this deployment run multi-org at all"; it + * cannot answer "which shapes of it" — that is a packaging decision, and the + * commercial runtime owns it. Declaring the set here lets the enterprise package + * gate `group` and `isolated` independently (different tiers, a license flag, a + * trial) without the open core hard-coding anyone's price list. + * + * Omitting `supportedPostures` entitles every walled posture, which is what + * every runtime predating this seam did. + */ +export interface OrgScopingEntitlement { + readonly supportedPostures?: readonly TenancyPosture[]; +} + /** * Normalize a stored/env-supplied posture value, accepting the legacy `multi` * spelling as `isolated` (ADR-0093 → ADR-0105 rename). Returns `undefined` for