diff --git a/.changeset/platform-owner-wall-bypass.md b/.changeset/platform-owner-wall-bypass.md new file mode 100644 index 0000000000..7ce81d4eb0 --- /dev/null +++ b/.changeset/platform-owner-wall-bypass.md @@ -0,0 +1,34 @@ +--- +"@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 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 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 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/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..970bb429ce --- /dev/null +++ b/packages/plugins/plugin-security/src/platform-owner-wall-bypass.test.ts @@ -0,0 +1,326 @@ +// 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 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; + * - 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 + * 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 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 } }, + }); + 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 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).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], + '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 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 } }, + }); + 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(); + 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..3086168e91 --- /dev/null +++ b/packages/plugins/plugin-security/src/platform-owner-wall-bypass.ts @@ -0,0 +1,74 @@ +// 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 的过滤」): 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 + * 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..0a73b06ae2 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,122 @@ export class SecurityPlugin implements Plugin { isPlatformAdmin, }); + // [#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 + // 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. + // + // 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 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', + { + 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`