From fa65f3582f6ccc0423c38bc0043f80c0ac01b207 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:46:36 +0000 Subject: [PATCH 1/3] feat(plugin-security): verified platform owner bypasses the Layer 0 org wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org_id tenant filter is no longer appended for a session whose account is the VERIFIED env-declared platform owner (OS_PLATFORM_OWNER_EMAIL under the #11343 verified-email predicate — the same comparison the platform-admin elevation gate makes, now shared via platform-owner-wall-bypass.ts). Fail-closed in every direction: env unset / email mismatch / unverified match all wall exactly as before. Every wall-bypassing computation emits the stable audit event platform_owner_wall_bypass (structured warn-level log). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016SG9S6V15MqeAgkehDcTwk --- .../src/bootstrap-platform-admin.ts | 6 +- .../src/platform-owner-wall-bypass.test.ts | 298 ++++++++++++++++++ .../src/platform-owner-wall-bypass.ts | 71 +++++ .../plugin-security/src/security-plugin.ts | 106 +++++++ 4 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/plugin-security/src/platform-owner-wall-bypass.test.ts create mode 100644 packages/plugins/plugin-security/src/platform-owner-wall-bypass.ts diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index d439334f60..8ec587dc77 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -67,6 +67,7 @@ import { resolveTenancyPosture, } from '@objectstack/types'; import { claimSeedOwnership } from './claim-seed-ownership.js'; +import { matchesDeclaredOwnerEmail } from './platform-owner-wall-bypass.js'; interface BootstrapOptions { /** Logger from PluginContext. */ @@ -439,8 +440,11 @@ export async function bootstrapPlatformAdmin( if (u?.id) byId.set(u.id, u); } } + // [#12974] The email comparison is the SHARED canonical one — the same + // predicate the Layer 0 owner wall bypass keys on (see + // `platform-owner-wall-bypass.ts`, which names this gate as its twin). const owners = [...byId.values()].filter( - (u) => isHumanUser(u) && String(u.email ?? '').trim().toLowerCase() === wanted, + (u) => isHumanUser(u) && matchesDeclaredOwnerEmail(u, declaredOwnerEmail!), ); if (owners.length === 0) { logger?.info?.( diff --git a/packages/plugins/plugin-security/src/platform-owner-wall-bypass.test.ts b/packages/plugins/plugin-security/src/platform-owner-wall-bypass.test.ts new file mode 100644 index 0000000000..b616b4fdb0 --- /dev/null +++ b/packages/plugins/plugin-security/src/platform-owner-wall-bypass.test.ts @@ -0,0 +1,298 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12974] The verified platform OWNER crosses the Layer 0 org wall — pins. + * + * Maintainer ruling 2026-08-29 (on the tracking card), verbatim and + * untranslated: 「能不能简单点,对于超级管理员,配置了环境变量邮箱的,在执行墙的 + * 时候不要强制加上 org_id 的过滤」— when plugin-security arms the Layer 0 + * organization wall, the `org_id` filter is NOT appended for a session whose + * account is the VERIFIED declared platform owner (`OS_PLATFORM_OWNER_EMAIL` + * under the #11343 verified-email predicate). Everyone else's wall is + * byte-identical to before. + * + * The pins hold BOTH fail-closed directions the ruling records (there is no + * shape in which a misconfiguration widens access): + * + * - env unset ⇒ nobody bypasses — the wall arms exactly as today, and the + * probe performs no row I/O at all; + * - email mismatch ⇒ walled (fast negative, no row I/O); + * - email matches but the account is NOT verified ⇒ walled; + * - verified match ⇒ no `org_id` filter — including the org-less session + * that previously hit the fail-closed deny sentinel (the cloud#1676 + * "operator console reads EMPTY" shape), and the `group` union wall; + * - the bypass lifts ONLY Layer 0: authored business RLS (Layer 1) still + * binds the owner, and the write-side Layer 0 twin is the same branch; + * - every wall-bypassing computation carries the stable audit event name + * (`platform_owner_wall_bypass` — structured warn-level log, the ruled + * floor while plugin-audit is not wired into plugin-security). + * + * Harness modeled on `federated-tenant-layer0.test.ts`: a real SecurityPlugin + * over a fake ObjectQL, asserted at `getReadFilter` — the composed + * FilterCondition before any driver sees it. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; +import { RLS_DENY_FILTER } from './rls-compiler.js'; +import { + PLATFORM_OWNER_WALL_BYPASS_EVENT, + isVerifiedPlatformOwnerRow, + matchesDeclaredOwnerEmail, +} from './platform-owner-wall-bypass.js'; + +const OWNER_EMAIL = 'operator@corp.example'; + +/** A member with plain CRUD and NO row-level policies, so the only thing + * `getReadFilter` can return is Layer 0 — the layer under test. */ +const PLAIN_MEMBER: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +} as unknown as PermissionSet; + +/** A LOCAL tenant object with a real `organization_id` — the wall arms here. */ +const TENANT_SCHEMA = { + name: 'task', + fields: { + organization_id: { type: 'text', label: 'Organization' }, + name: { type: 'text', label: 'Name' }, + }, +}; + +/** + * Boot a SecurityPlugin over a fake ObjectQL. `users` maps sys_user id → row, + * served through `findOne` (the same by-id system read the plugin performs); + * the sentinel `org-scoping` service selects the `isolated` posture, `tenancy` + * overrides it where a case needs `group`. + */ +async function boot(opts: { + users?: Record; + tenancy?: { posture: string }; + permissionSets?: PermissionSet[]; +} = {}) { + const users = opts.users ?? {}; + const findOne = vi.fn(async (object: string, o: any) => + object === 'sys_user' ? (users[o?.where?.id] ?? null) : null, + ); + const warn = vi.fn(); + const services: Record = { + manifest: { register: vi.fn() }, + objectql: { registerMiddleware: vi.fn(), getSchema: () => TENANT_SCHEMA, findOne }, + metadata: { get: async () => TENANT_SCHEMA, list: async () => opts.permissionSets ?? [PLAIN_MEMBER] }, + 'org-scoping': { name: 'com.objectstack.org-scoping' }, + }; + if (opts.tenancy) services['tenancy'] = opts.tenancy; + const ctx: Record = { + logger: { info: vi.fn(), warn, error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await plugin.init(ctx as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await plugin.start(ctx as any); + return { plugin, findOne, warn }; +} + +/** Fresh per-test context — the plugin memoizes the owner verdict on it. */ +const sessionCtx = (over: Record = {}) => ({ + userId: 'u_owner', + email: OWNER_EMAIL, + tenantId: 'org-1', + positions: [], + permissions: [], + ...over, +}); + +const readFilter = (plugin: unknown, ctx: unknown) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (plugin as any).getReadFilter('task', ctx); + +const OLD_OWNER = process.env.OS_PLATFORM_OWNER_EMAIL; +beforeEach(() => { + delete process.env.OS_PLATFORM_OWNER_EMAIL; +}); +afterEach(() => { + if (OLD_OWNER === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = OLD_OWNER; +}); + +describe('[#12974] verified-platform-owner Layer 0 wall bypass — fail-closed directions', () => { + it('env UNSET ⇒ nobody bypasses: walled exactly as today, and no sys_user row is read', async () => { + const { plugin, findOne } = await boot({ + users: { u_owner: { id: 'u_owner', email: OWNER_EMAIL, email_verified: true } }, + }); + const filter = await readFilter(plugin, sessionCtx()); + expect(filter).toEqual({ organization_id: 'org-1' }); + // The probe answered on the undeclared env before any I/O. + expect(findOne).not.toHaveBeenCalled(); + }); + + it('email MISMATCH ⇒ walled (fast negative on the server-resolved session email, no row I/O)', async () => { + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; + const { plugin, findOne } = await boot({ + users: { u_member: { id: 'u_member', email: 'member@corp.example', email_verified: true } }, + }); + const filter = await readFilter(plugin, sessionCtx({ userId: 'u_member', email: 'member@corp.example' })); + expect(filter).toEqual({ organization_id: 'org-1' }); + expect(findOne).not.toHaveBeenCalled(); + }); + + it('email matches but the account is NOT verified ⇒ still walled', async () => { + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; + const { plugin } = await boot({ + // No `email_verified` at all — the #11343 allow-list reads absent as + // unverified (the imported/legacy-row shape). + users: { u_owner: { id: 'u_owner', email: OWNER_EMAIL } }, + }); + const filter = await readFilter(plugin, sessionCtx()); + expect(filter).toEqual({ organization_id: 'org-1' }); + }); + + it('session email matches but the sys_user row is GONE ⇒ walled (fail closed)', async () => { + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; + const { plugin } = await boot({ users: {} }); + const filter = await readFilter(plugin, sessionCtx()); + expect(filter).toEqual({ organization_id: 'org-1' }); + }); +}); + +describe('[#12974] verified-platform-owner Layer 0 wall bypass — the door', () => { + it('VERIFIED match ⇒ the org_id filter is NOT appended', async () => { + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; + const { plugin } = await boot({ + users: { u_owner: { id: 'u_owner', email: OWNER_EMAIL, email_verified: true } }, + }); + const filter = await readFilter(plugin, sessionCtx()); + // No Layer 1 policies and Layer 0 lifted → nothing to AND at all. + expect(filter).toBeUndefined(); + }); + + it('org-LESS verified owner under `isolated` ⇒ no fail-closed deny sentinel either (the cloud#1676 empty-screen shape)', async () => { + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; + const { plugin } = await boot({ + users: { u_owner: { id: 'u_owner', email: OWNER_EMAIL, email_verified: true } }, + }); + const filter = await readFilter(plugin, sessionCtx({ tenantId: undefined })); + expect(filter).toBeUndefined(); + // The control: the same org-less session WITHOUT the door still fails closed. + delete process.env.OS_PLATFORM_OWNER_EMAIL; + const walled = await readFilter(plugin, sessionCtx({ tenantId: undefined })); + expect(walled).toEqual({ ...RLS_DENY_FILTER }); + }); + + it('`group` posture: the verified owner crosses the union wall too', async () => { + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; + const { plugin } = await boot({ + users: { u_owner: { id: 'u_owner', email: OWNER_EMAIL, email_verified: true } }, + tenancy: { posture: 'group' }, + }); + const filter = await readFilter(plugin, sessionCtx({ accessible_org_ids: ['org-1', 'org-2'] })); + expect(filter).toBeUndefined(); + // The control: a non-owner member keeps the membership union. + const member = await readFilter( + plugin, + sessionCtx({ userId: 'u_m', email: 'member@corp.example', accessible_org_ids: ['org-1', 'org-2'] }), + ); + expect(member).toEqual({ organization_id: { $in: ['org-1', 'org-2'] } }); + }); + + it('absent session email: the sys_user row is the authoritative answer', async () => { + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; + const { plugin, findOne } = await boot({ + users: { u_owner: { id: 'u_owner', email: OWNER_EMAIL, email_verified: true } }, + }); + const filter = await readFilter(plugin, sessionCtx({ email: undefined })); + expect(filter).toBeUndefined(); + expect(findOne).toHaveBeenCalledWith('sys_user', expect.objectContaining({ where: { id: 'u_owner' } })); + }); +}); + +describe('[#12974] the bypass lifts ONLY Layer 0', () => { + it('an authored Layer 1 (business RLS) policy still binds the verified owner', async () => { + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; + const authored: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + rowLevelSecurity: [ + { name: 'app_name_scope', object: '*', operation: 'all', using: 'name == current_user.id' }, + ], + } as unknown as PermissionSet; + const { plugin } = await boot({ + users: { u_owner: { id: 'u_owner', email: OWNER_EMAIL, email_verified: true } }, + permissionSets: [authored], + }); + const filter = await readFilter(plugin, sessionCtx()); + // Layer 0 gone, Layer 1's authored predicate intact — no `$and`, no org column. + expect(filter).toEqual({ name: 'u_owner' }); + }); + + it('the WRITE-side Layer 0 twin is lifted for the owner and kept for everyone else', async () => { + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; + const { plugin } = await boot({ + users: { u_owner: { id: 'u_owner', email: OWNER_EMAIL, email_verified: true } }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const owner = await (plugin as any).computeWriteTenantCheckFilter([PLAIN_MEMBER], 'task', 'update', sessionCtx()); + expect(owner).toBeNull(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const member = await (plugin as any).computeWriteTenantCheckFilter( + [PLAIN_MEMBER], + 'task', + 'update', + sessionCtx({ userId: 'u_m', email: 'member@corp.example' }), + ); + expect(member).toEqual({ organization_id: 'org-1' }); + }); +}); + +describe('[#12974] audit — the ruled floor', () => { + it('a wall-bypassing computation emits the stable event name; a walled one does not', async () => { + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; + const { plugin, warn } = await boot({ + users: { u_owner: { id: 'u_owner', email: OWNER_EMAIL, email_verified: true } }, + }); + await readFilter(plugin, sessionCtx({ userId: 'u_m', email: 'member@corp.example' })); + const bypassEvents = () => + warn.mock.calls.filter(([, meta]) => meta?.event === PLATFORM_OWNER_WALL_BYPASS_EVENT); + expect(bypassEvents()).toHaveLength(0); + await readFilter(plugin, sessionCtx()); + const fired = bypassEvents(); + expect(fired.length).toBeGreaterThan(0); + expect(fired[0][1]).toMatchObject({ + event: PLATFORM_OWNER_WALL_BYPASS_EVENT, + object: 'task', + userId: 'u_owner', + suppressedFilter: { organization_id: 'org-1' }, + }); + }); +}); + +describe('[#12974] the shared row predicate (the elevation gate’s twin)', () => { + it('matchesDeclaredOwnerEmail — canonical comparison: trimmed, case-insensitive', () => { + expect(matchesDeclaredOwnerEmail({ email: 'Operator@Corp.Example' }, OWNER_EMAIL)).toBe(true); + expect(matchesDeclaredOwnerEmail({ email: ' operator@corp.example ' }, OWNER_EMAIL)).toBe(true); + expect(matchesDeclaredOwnerEmail({ email: OWNER_EMAIL }, 'Operator@CORP.example')).toBe(true); + expect(matchesDeclaredOwnerEmail({ email: 'other@corp.example' }, OWNER_EMAIL)).toBe(false); + expect(matchesDeclaredOwnerEmail({ email: '' }, OWNER_EMAIL)).toBe(false); + expect(matchesDeclaredOwnerEmail({ email: 42 }, OWNER_EMAIL)).toBe(false); + expect(matchesDeclaredOwnerEmail({}, OWNER_EMAIL)).toBe(false); + expect(matchesDeclaredOwnerEmail(null, OWNER_EMAIL)).toBe(false); + }); + + it('isVerifiedPlatformOwnerRow — match AND verified, fail-closed on every other shape', () => { + expect(isVerifiedPlatformOwnerRow({ email: OWNER_EMAIL, email_verified: true }, OWNER_EMAIL)).toBe(true); + expect(isVerifiedPlatformOwnerRow({ email: OWNER_EMAIL, email_verified: 1 }, OWNER_EMAIL)).toBe(true); + expect(isVerifiedPlatformOwnerRow({ email: OWNER_EMAIL }, OWNER_EMAIL)).toBe(false); + expect(isVerifiedPlatformOwnerRow({ email: OWNER_EMAIL, email_verified: false }, OWNER_EMAIL)).toBe(false); + expect(isVerifiedPlatformOwnerRow({ email: 'other@corp.example', email_verified: true }, OWNER_EMAIL)).toBe(false); + expect(isVerifiedPlatformOwnerRow(null, OWNER_EMAIL)).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-security/src/platform-owner-wall-bypass.ts b/packages/plugins/plugin-security/src/platform-owner-wall-bypass.ts new file mode 100644 index 0000000000..dd76e3b2d9 --- /dev/null +++ b/packages/plugins/plugin-security/src/platform-owner-wall-bypass.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12974] The VERIFIED-platform-owner row predicate — the one comparison the + * #11343 verified-owner family makes, extracted so its two in-package + * consumers can never drift: + * + * - **The platform-admin elevation gate** (`bootstrap-platform-admin.ts`, + * the twin this comparison is extracted FROM): its walled arm elevates + * only an account for which BOTH halves below answer yes. It keeps + * consuming the halves separately ({@link matchesDeclaredOwnerEmail} + + * `isEmailVerifiedUserRow`) because its two refusal diagnostics — + * `walled_owner_not_registered` vs `walled_owner_not_verified` — must + * stay distinct. + * - **The Layer 0 owner wall bypass** (`security-plugin.ts` + * `isVerifiedPlatformOwnerSession`, maintainer ruling 2026-08-29 on the + * tracking card, verbatim and untranslated: 「能不能简单点,对于超级管理员, + * 配置了环境变量邮箱的,在执行墙的时候不要强制加上 org_id 的过滤」): the + * `org_id` tenant filter is NOT appended for a session whose account + * satisfies {@link isVerifiedPlatformOwnerRow}. Everyone else's wall is + * byte-identical to before the ruling. + * + * The comparison itself mirrors the elevation gate's owner match, which + * `walled-owner-operator-stamp.ts` (plugin-auth) already mirrors for the + * creation-time stamp: candidate `String(email).trim().toLowerCase()`; + * declared already trimmed by `resolvePlatformOwnerEmail()`, lowercased + * here. The three sites MUST agree — an account the stamp verifies is one + * the gate must elevate and the wall must recognise. + * + * Fail-closed by construction, both directions the ruling pins: + * - no declared owner (env unset/blank) ⇒ `false` for every row — nobody + * bypasses, the wall arms exactly as today; + * - email mismatch, missing row, or an email match whose row is NOT + * verified (`isEmailVerifiedUserRow`'s allow-list, absent-means- + * unverified) ⇒ `false` — still walled. There is no shape in which a + * misconfiguration widens access. + */ + +import { isEmailVerifiedUserRow } from '@objectstack/types'; + +/** + * The stable audit event name stamped on every wall-bypassing computation + * (structured warn-level log today — see the emit site in + * `security-plugin.ts` for why the `sys_audit_log` ledger is not the sink). + * Named after the cloud control-plane precedent (`cross_org_admin_read`). + */ +export const PLATFORM_OWNER_WALL_BYPASS_EVENT = 'platform_owner_wall_bypass'; + +/** + * Does this `sys_user` row's email match the env-declared platform owner — + * the canonical #11184/#11343 comparison (trimmed, case-insensitive), spelled + * once. `declaredEmail` is `resolvePlatformOwnerEmail()`'s output (already + * trimmed); a row with no/blank email never matches. + */ +export function matchesDeclaredOwnerEmail(row: unknown, declaredEmail: string): boolean { + const email = (row as { email?: unknown } | null | undefined)?.email; + if (typeof email !== 'string') return false; + const candidate = email.trim().toLowerCase(); + if (candidate === '') return false; + return candidate === declaredEmail.toLowerCase(); +} + +/** + * Is this `sys_user` row the VERIFIED declared platform owner? — the whole + * predicate the Layer 0 owner wall bypass keys on: declared-owner email + * match AND the #11343 verified-email allow-list. Server-side row facts + * only; never a client-supplied claim. + */ +export function isVerifiedPlatformOwnerRow(row: unknown, declaredEmail: string): boolean { + return matchesDeclaredOwnerEmail(row, declaredEmail) && isEmailVerifiedUserRow(row); +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 349f7f6eac..e0a4e571ea 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -66,6 +66,11 @@ import { normalizeManagedByVocab } from './normalize-managed-by.js'; import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js'; import { RLSCompiler, RLS_DENY_FILTER, policyDeclaresClause } from './rls-compiler.js'; import { computeTenantLayer0Filter, andComposeLayers } from './tenant-layer.js'; +import { + PLATFORM_OWNER_WALL_BYPASS_EVENT, + isVerifiedPlatformOwnerRow, +} from './platform-owner-wall-bypass.js'; +import { resolvePlatformOwnerEmail } from '@objectstack/types'; import { isPlatformTenantPolicy, isAuthoredTenantPolicy } from './platform-tenant-policies.js'; import { isPlatformOwnershipFloorPolicy, @@ -5577,9 +5582,110 @@ export class SecurityPlugin implements Plugin { isPlatformAdmin, }); + // [#12974] Verified platform OWNER crosses the Layer 0 org wall. + // Maintainer ruling 2026-08-29, verbatim and untranslated: 「能不能简单点, + // 对于超级管理员,配置了环境变量邮箱的,在执行墙的时候不要强制加上 org_id + // 的过滤」— when the wall ARMS (layer0 non-null: the `isolated` equality, + // the `group` union, or the fail-closed deny sentinel an org-less session + // otherwise hits), the org filter is NOT appended for a session whose + // account is the VERIFIED declared platform owner (`OS_PLATFORM_OWNER_EMAIL` + // under the #11343 verified-email predicate — the same match the elevation + // gate makes; see `isVerifiedPlatformOwnerSession` for the fail-closed + // ladder). Everyone else's wall is byte-identical to before: the probe + // answers `false` on env-unset before touching any row, and it is not even + // consulted while Layer 0 contributes nothing. + // + // ONLY Layer 0 is lifted. Layer 1 (business RLS, object/field permissions, + // the write `check` path) is untouched by construction — this branch + // rewrites the `layer0` half of the split and nothing else. Reads AND + // writes both come through this computation (`computeRlsFilter` / + // `computeWriteTenantCheckFilter`), so both carry the audit event. + if (layer0 !== null && (await this.isVerifiedPlatformOwnerSession(context))) { + // The audit floor (hard piece 3 of the ruling): a structured warn-level + // log with the stable event name, per wall-bypassing computation — + // plugin-audit is not wired into plugin-security (no dependency in + // either direction; its `audit` service ingress is a CLOSED auth-session + // vocabulary), so the `sys_audit_log` ledger is deliberately not the + // sink here. Named after the cloud precedent (`cross_org_admin_read`). + this.logger.warn?.( + `[security/#12974] ${PLATFORM_OWNER_WALL_BYPASS_EVENT}: verified platform owner crossed ` + + 'the Layer 0 organization wall — the org filter below was NOT appended', + { + event: PLATFORM_OWNER_WALL_BYPASS_EVENT, + object, + operation, + userId: context?.userId, + organizationId: context?.tenantId ?? null, + tenancyPosture: this.tenancyPosture, + suppressedFilter: layer0, + }, + ); + return { layer0: null, layer1 }; + } + return { layer0, layer1 }; } + /** + * [#12974] Is this session's account the VERIFIED declared platform owner — + * the predicate the Layer 0 owner wall bypass keys on? + * + * Server-side facts only, fail-closed at every rung (each `false` below is + * "still walled", never an error): + * + * 1. `resolvePlatformOwnerEmail()` unset/blank ⇒ `false` for everyone — + * read live per call (cheap), before any I/O, so an undeclared owner + * costs nothing and bypasses nobody. + * 2. No authenticated `userId` on the context ⇒ `false`. System contexts + * never reach this method (the middleware's `isSystem` skip and + * `getReadFilter`'s mirror both return earlier), and carry no `userId` + * anyway. + * 3. Fast NEGATIVE on the context's server-resolved session email + * (`resolveAuthzContext`: the better-auth session record or the + * `sys_user` read — never a client-supplied header): a normalized + * mismatch walls without touching the row store, which keeps the wall's + * hot path free of per-request I/O for every non-owner session. Only + * narrowing — a MATCH (or an absent email) still requires the row. + * 4. The authoritative answer is the `sys_user` ROW (system-context by-id + * read, memoized per request-context like `__rlsMembershipStaged` / + * `__preImage`): {@link isVerifiedPlatformOwnerRow} = the canonical + * declared-owner email match (the elevation gate's twin, + * `platform-owner-wall-bypass.ts`) AND the #11343 verified-email + * allow-list (`isEmailVerifiedUserRow` — absent-means-unverified). + * Missing row / unreadable store ⇒ `false`. + * + * The `isSystem: true` read below cannot recurse: system operations + * short-circuit the security middleware before any RLS computation. + */ + private async isVerifiedPlatformOwnerSession(context: any): Promise { + const declared = resolvePlatformOwnerEmail(); + if (!declared) return false; + if (!context || typeof context !== 'object') return false; + const userId = context.userId; + if (typeof userId !== 'string' || userId === '') return false; + if (typeof context.__verifiedPlatformOwner === 'boolean') return context.__verifiedPlatformOwner; + if (typeof context.email === 'string' && context.email.trim() !== '') { + if (context.email.trim().toLowerCase() !== declared.toLowerCase()) { + context.__verifiedPlatformOwner = false; + return false; + } + } + let row: unknown = null; + try { + if (typeof this.ql?.findOne === 'function') { + row = await this.ql.findOne('sys_user', { where: { id: userId }, context: { isSystem: true } }); + } else if (typeof this.ql?.find === 'function') { + const rows = await this.ql.find('sys_user', { where: { id: userId }, limit: 1, context: { isSystem: true } }); + row = Array.isArray(rows) ? rows[0] : ((rows as any)?.value?.[0] ?? null); + } + } catch { + row = null; + } + const verdict = isVerifiedPlatformOwnerRow(row, declared); + context.__verifiedPlatformOwner = verdict; + return verdict; + } + /** * [ADR-0058 D4] Compile the WRITE `check` predicate for a post-image * validation. Scoped to applicable policies that EXPLICITLY declare a `check` From fc001d8f53f83b4cd87866d1cd1b4a082d1c9b52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:50:36 +0000 Subject: [PATCH 2/3] chore: changeset for the plugin-security owner wall bypass Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016SG9S6V15MqeAgkehDcTwk --- .changeset/platform-owner-wall-bypass.md | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .changeset/platform-owner-wall-bypass.md diff --git a/.changeset/platform-owner-wall-bypass.md b/.changeset/platform-owner-wall-bypass.md new file mode 100644 index 0000000000..38ccd5c93f --- /dev/null +++ b/.changeset/platform-owner-wall-bypass.md @@ -0,0 +1,28 @@ +--- +"@objectstack/plugin-security": minor +--- + +feat(plugin-security): the verified platform OWNER bypasses the Layer 0 org wall (#12974) + +Maintainer ruling 2026-08-29, verbatim and untranslated: 「能不能简单点,对于超级管理员, +配置了环境变量邮箱的,在执行墙的时候不要强制加上 org_id 的过滤」 + +When plugin-security arms the Layer 0 organization wall, the `org_id` tenant +filter is no longer appended for a session whose account is the **verified +platform owner** — the `OS_PLATFORM_OWNER_EMAIL` identity, matched under the +existing #11343 verified-email predicate (the SAME comparison the +platform-admin elevation gate makes, now shared through +`platform-owner-wall-bypass.ts`; server-side `sys_user` row facts only, never +a client-supplied claim). This unblocks the one account meant to be +all-seeing: metadata-driven operator screens over PUBLIC tenant objects no +longer read EMPTY for the deployment's declared owner (the cloud#1676 shape). + +Fail-closed in every direction, pinned: env unset ⇒ nobody bypasses (the wall +arms exactly as before, with no row I/O); email mismatch ⇒ walled; email +matches but the account is NOT verified ⇒ walled; only a verified match lifts +the filter. The bypass lifts ONLY Layer 0 — object/field permissions, +business RLS (Layer 1) and the write `check` path are untouched — and the +door serves the single env-declared owner (no lists, no patterns). Every +wall-bypassing computation emits a structured warn-level audit event with the +stable name `platform_owner_wall_bypass` (object, operation, userId, +suppressed filter). From 5a7679b0ead1d0362383881d07fab37b30062aa6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 03:18:07 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(plugin-security):=20scope=20the=20owner?= =?UTF-8?q?=20wall=20bypass=20to=20READS=20=E2=80=94=20writes=20keep=20the?= =?UTF-8?q?=20ADR-0123=20D2=20refusal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dogfood gate measured the write-twin lift breaking the org-less tenant-scoped write refusal (500 where the 403 naming the missing active organization belongs): the dogfood harness declares the seeded admin as OS_PLATFORM_OWNER_EMAIL on walled boots, so the org-less admin session was the verified owner and the write slipped past Layer 0 into a deeper failure. Director correction on the card: the ruling is about the wall's read FILTER; the door is READ-only. The write twin (and the by-id pre-image read, which carries the write operation name) keeps today's behaviour for everyone including the owner, the audit event fires on read bypasses only, and the two affected pins are inverted accordingly. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016SG9S6V15MqeAgkehDcTwk --- .changeset/platform-owner-wall-bypass.md | 22 +++++--- .../src/platform-owner-wall-bypass.test.ts | 52 ++++++++++++++----- .../src/platform-owner-wall-bypass.ts | 11 ++-- .../plugin-security/src/security-plugin.ts | 44 ++++++++++------ 4 files changed, 89 insertions(+), 40 deletions(-) diff --git a/.changeset/platform-owner-wall-bypass.md b/.changeset/platform-owner-wall-bypass.md index 38ccd5c93f..7ce81d4eb0 100644 --- a/.changeset/platform-owner-wall-bypass.md +++ b/.changeset/platform-owner-wall-bypass.md @@ -7,22 +7,28 @@ feat(plugin-security): the verified platform OWNER bypasses the Layer 0 org wall Maintainer ruling 2026-08-29, verbatim and untranslated: 「能不能简单点,对于超级管理员, 配置了环境变量邮箱的,在执行墙的时候不要强制加上 org_id 的过滤」 -When plugin-security arms the Layer 0 organization wall, the `org_id` tenant -filter is no longer appended for a session whose account is the **verified -platform owner** — the `OS_PLATFORM_OWNER_EMAIL` identity, matched under the -existing #11343 verified-email predicate (the SAME comparison the -platform-admin elevation gate makes, now shared through +When plugin-security arms the Layer 0 organization wall on a READ, the +`org_id` tenant filter is no longer appended for a session whose account is +the **verified platform owner** — the `OS_PLATFORM_OWNER_EMAIL` identity, +matched under the existing #11343 verified-email predicate (the SAME +comparison the platform-admin elevation gate makes, now shared through `platform-owner-wall-bypass.ts`; server-side `sys_user` row facts only, never a client-supplied claim). This unblocks the one account meant to be all-seeing: metadata-driven operator screens over PUBLIC tenant objects no longer read EMPTY for the deployment's declared owner (the cloud#1676 shape). +The door is READ-only. WRITES keep today's behaviour for everyone INCLUDING +the owner: an org-less tenant-scoped write is still refused 403 naming the +missing active organization (ADR-0123 D2 — an org-less write would mint +exactly the NULL-organization rows the platform is eliminating), and the +by-id write pre-image read stays walled with it. + Fail-closed in every direction, pinned: env unset ⇒ nobody bypasses (the wall arms exactly as before, with no row I/O); email mismatch ⇒ walled; email matches but the account is NOT verified ⇒ walled; only a verified match lifts -the filter. The bypass lifts ONLY Layer 0 — object/field permissions, +the read filter. The bypass lifts ONLY Layer 0 — object/field permissions, business RLS (Layer 1) and the write `check` path are untouched — and the door serves the single env-declared owner (no lists, no patterns). Every -wall-bypassing computation emits a structured warn-level audit event with the -stable name `platform_owner_wall_bypass` (object, operation, userId, +wall-bypassing read computation emits a structured warn-level audit event +with the stable name `platform_owner_wall_bypass` (object, operation, userId, suppressed filter). diff --git a/packages/plugins/plugin-security/src/platform-owner-wall-bypass.test.ts b/packages/plugins/plugin-security/src/platform-owner-wall-bypass.test.ts index b616b4fdb0..970bb429ce 100644 --- a/packages/plugins/plugin-security/src/platform-owner-wall-bypass.test.ts +++ b/packages/plugins/plugin-security/src/platform-owner-wall-bypass.test.ts @@ -18,14 +18,20 @@ * probe performs no row I/O at all; * - email mismatch ⇒ walled (fast negative, no row I/O); * - email matches but the account is NOT verified ⇒ walled; - * - verified match ⇒ no `org_id` filter — including the org-less session - * that previously hit the fail-closed deny sentinel (the cloud#1676 - * "operator console reads EMPTY" shape), and the `group` union wall; + * - verified match ⇒ no `org_id` filter on READS — including the org-less + * session that previously hit the fail-closed deny sentinel (the + * cloud#1676 "operator console reads EMPTY" shape), and the `group` + * union wall; + * - the door is READ-ONLY (director's correction on the card): the + * WRITE-side Layer 0 twin is KEPT for the owner — an org-less + * tenant-scoped write still resolves the deny sentinel that drives the + * ADR-0123 D2 403 refusal, and an org-carrying owner write keeps the + * equality wall; * - the bypass lifts ONLY Layer 0: authored business RLS (Layer 1) still - * binds the owner, and the write-side Layer 0 twin is the same branch; - * - every wall-bypassing computation carries the stable audit event name - * (`platform_owner_wall_bypass` — structured warn-level log, the ruled - * floor while plugin-audit is not wired into plugin-security). + * binds the owner; + * - every wall-bypassing READ computation carries the stable audit event + * name (`platform_owner_wall_bypass` — structured warn-level log, the + * ruled floor while plugin-audit is not wired into plugin-security). * * Harness modeled on `federated-tenant-layer0.test.ts`: a real SecurityPlugin * over a fake ObjectQL, asserted at `getReadFilter` — the composed @@ -174,7 +180,7 @@ describe('[#12974] verified-platform-owner Layer 0 wall bypass — the door', () expect(filter).toBeUndefined(); }); - it('org-LESS verified owner under `isolated` ⇒ no fail-closed deny sentinel either (the cloud#1676 empty-screen shape)', async () => { + it('org-LESS verified owner under `isolated` ⇒ no fail-closed READ deny sentinel (the cloud#1676 empty-screen shape; the WRITE sentinel stays — see the read-only pins)', async () => { process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; const { plugin } = await boot({ users: { u_owner: { id: 'u_owner', email: OWNER_EMAIL, email_verified: true } }, @@ -234,14 +240,32 @@ describe('[#12974] the bypass lifts ONLY Layer 0', () => { expect(filter).toEqual({ name: 'u_owner' }); }); - it('the WRITE-side Layer 0 twin is lifted for the owner and kept for everyone else', async () => { + it('the WRITE-side Layer 0 twin is KEPT for the owner: the org wall and the org-less deny sentinel both stand', async () => { + // Director's correction on the card (after the dogfood gate red): the door + // is READ-only. The write twin feeds the ADR-0123 D2 refusal — an org-less + // tenant-scoped write must refuse 403 naming the missing active + // organization, for the owner exactly as for everyone else; lifting it + // would mint the NULL-organization rows the platform is eliminating (and + // measurably turned that refusal into a 500 on the dogfood rig). process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; const { plugin } = await boot({ users: { u_owner: { id: 'u_owner', email: OWNER_EMAIL, email_verified: true } }, }); + // Org-carrying owner write: the equality wall stands. // eslint-disable-next-line @typescript-eslint/no-explicit-any const owner = await (plugin as any).computeWriteTenantCheckFilter([PLAIN_MEMBER], 'task', 'update', sessionCtx()); - expect(owner).toBeNull(); + expect(owner).toEqual({ organization_id: 'org-1' }); + // Org-LESS owner write: the deny sentinel stands — this is the exact + // verdict the ADR-0123 D2 refusal derives its 403 from. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ownerOrgless = await (plugin as any).computeWriteTenantCheckFilter( + [PLAIN_MEMBER], + 'task', + 'insert', + sessionCtx({ tenantId: undefined }), + ); + expect(ownerOrgless).toEqual({ ...RLS_DENY_FILTER }); + // Everyone else: unchanged, same wall. // eslint-disable-next-line @typescript-eslint/no-explicit-any const member = await (plugin as any).computeWriteTenantCheckFilter( [PLAIN_MEMBER], @@ -254,14 +278,18 @@ describe('[#12974] the bypass lifts ONLY Layer 0', () => { }); describe('[#12974] audit — the ruled floor', () => { - it('a wall-bypassing computation emits the stable event name; a walled one does not', async () => { + it('a wall-bypassing READ emits the stable event name; a walled read and an owner WRITE do not', async () => { process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; const { plugin, warn } = await boot({ users: { u_owner: { id: 'u_owner', email: OWNER_EMAIL, email_verified: true } }, }); - await readFilter(plugin, sessionCtx({ userId: 'u_m', email: 'member@corp.example' })); const bypassEvents = () => warn.mock.calls.filter(([, meta]) => meta?.event === PLATFORM_OWNER_WALL_BYPASS_EVENT); + await readFilter(plugin, sessionCtx({ userId: 'u_m', email: 'member@corp.example' })); + expect(bypassEvents()).toHaveLength(0); + // Owner WRITE: no bypass happens, so no event may claim one did. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (plugin as any).computeWriteTenantCheckFilter([PLAIN_MEMBER], 'task', 'update', sessionCtx()); expect(bypassEvents()).toHaveLength(0); await readFilter(plugin, sessionCtx()); const fired = bypassEvents(); diff --git a/packages/plugins/plugin-security/src/platform-owner-wall-bypass.ts b/packages/plugins/plugin-security/src/platform-owner-wall-bypass.ts index dd76e3b2d9..3086168e91 100644 --- a/packages/plugins/plugin-security/src/platform-owner-wall-bypass.ts +++ b/packages/plugins/plugin-security/src/platform-owner-wall-bypass.ts @@ -15,10 +15,13 @@ * - **The Layer 0 owner wall bypass** (`security-plugin.ts` * `isVerifiedPlatformOwnerSession`, maintainer ruling 2026-08-29 on the * tracking card, verbatim and untranslated: 「能不能简单点,对于超级管理员, - * 配置了环境变量邮箱的,在执行墙的时候不要强制加上 org_id 的过滤」): the - * `org_id` tenant filter is NOT appended for a session whose account - * satisfies {@link isVerifiedPlatformOwnerRow}. Everyone else's wall is - * byte-identical to before the ruling. + * 配置了环境变量邮箱的,在执行墙的时候不要强制加上 org_id 的过滤」): on + * READS, the `org_id` tenant filter is NOT appended for a session whose + * account satisfies {@link isVerifiedPlatformOwnerRow}. Everyone else's + * wall is byte-identical to before the ruling, and WRITES keep today's + * behaviour for the owner too — the ADR-0123 D2 org-less write refusal + * stands (director's correction on the same card: lifting the write twin + * would mint the NULL-organization rows the platform is eliminating). * * The comparison itself mirrors the elevation gate's owner match, which * `walled-owner-operator-stamp.ts` (plugin-auth) already mirrors for the diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index e0a4e571ea..0a73b06ae2 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -5582,11 +5582,14 @@ export class SecurityPlugin implements Plugin { isPlatformAdmin, }); - // [#12974] Verified platform OWNER crosses the Layer 0 org wall. - // Maintainer ruling 2026-08-29, verbatim and untranslated: 「能不能简单点, - // 对于超级管理员,配置了环境变量邮箱的,在执行墙的时候不要强制加上 org_id - // 的过滤」— when the wall ARMS (layer0 non-null: the `isolated` equality, - // the `group` union, or the fail-closed deny sentinel an org-less session + // [#12974] Verified platform OWNER crosses the Layer 0 org wall — READS + // ONLY. Maintainer ruling 2026-08-29, verbatim and untranslated: 「能不能 + // 简单点,对于超级管理员,配置了环境变量邮箱的,在执行墙的时候不要强制加上 + // org_id 的过滤」— the ruling is about the wall's FILTER on operator + // screens reading; the director's correction (same card, after the dogfood + // gate) scopes the door accordingly. When the READ wall ARMS (layer0 + // non-null on a select/count/aggregate: the `isolated` equality, the + // `group` union, or the fail-closed deny sentinel an org-less session // otherwise hits), the org filter is NOT appended for a session whose // account is the VERIFIED declared platform owner (`OS_PLATFORM_OWNER_EMAIL` // under the #11343 verified-email predicate — the same match the elevation @@ -5595,18 +5598,27 @@ export class SecurityPlugin implements Plugin { // answers `false` on env-unset before touching any row, and it is not even // consulted while Layer 0 contributes nothing. // - // ONLY Layer 0 is lifted. Layer 1 (business RLS, object/field permissions, - // the write `check` path) is untouched by construction — this branch - // rewrites the `layer0` half of the split and nothing else. Reads AND - // writes both come through this computation (`computeRlsFilter` / - // `computeWriteTenantCheckFilter`), so both carry the audit event. - if (layer0 !== null && (await this.isVerifiedPlatformOwnerSession(context))) { + // WRITES keep today's behaviour for everyone INCLUDING the owner (`isWrite` + // guards the branch): an org-less tenant-scoped write is REFUSED 403 naming + // the missing active organization — the ADR-0123 D2 contract, whose verdict + // is DERIVED from this very computation (`computeWriteTenantCheckFilter` → + // the deny sentinel). Lifting the write twin would not grant the owner a + // cross-org write; it would mint exactly the NULL-organization rows the + // platform is eliminating, and it measurably broke the dogfood pin (500 + // where the 403 refusal belongs). The by-id write PRE-IMAGE read carries + // the write operation name through this method, so it stays walled too. + // + // ONLY the read-side Layer 0 is lifted. Layer 1 (business RLS, object/field + // permissions, the write `check` path) is untouched by construction — this + // branch rewrites the `layer0` half of the split and nothing else. + if (!isWrite && layer0 !== null && (await this.isVerifiedPlatformOwnerSession(context))) { // The audit floor (hard piece 3 of the ruling): a structured warn-level - // log with the stable event name, per wall-bypassing computation — - // plugin-audit is not wired into plugin-security (no dependency in - // either direction; its `audit` service ingress is a CLOSED auth-session - // vocabulary), so the `sys_audit_log` ledger is deliberately not the - // sink here. Named after the cloud precedent (`cross_org_admin_read`). + // log with the stable event name, per wall-bypassing READ computation — + // the only bypass kind left. plugin-audit is not wired into + // plugin-security (no dependency in either direction; its `audit` + // service ingress is a CLOSED auth-session vocabulary), so the + // `sys_audit_log` ledger is deliberately not the sink here. Named after + // the cloud precedent (`cross_org_admin_read`). this.logger.warn?.( `[security/#12974] ${PLATFORM_OWNER_WALL_BYPASS_EVENT}: verified platform owner crossed ` + 'the Layer 0 organization wall — the org filter below was NOT appended',