From 7016ae1c16f45245fccafdfee9b731cee4a1c969 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 19:48:17 +0000 Subject: [PATCH 1/2] refactor(plugin-security): move SecurityPlugin's leaf readers into security-plugin-object-inputs.ts (wip) The bodies of sixteen private methods (the RLS policy collection and its admission, the unresolved-posture probes, the field-level masks and required-permission folds, the field-name tables) move unedited, re-indented one level, with isDelegatedPrincipalLeg, which only they read. The class keeps every method, its signature and docblock, and hands each body a host literal of exactly the members it reads. Claude-Session: https://claude.ai/code/session_017BoLT8Gaa8t9a7AWVH8qZa Co-authored-by: Claude --- .../src/security-plugin-object-inputs.ts | 484 ++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 399 +++++---------- 2 files changed, 621 insertions(+), 262 deletions(-) create mode 100644 packages/plugins/plugin-security/src/security-plugin-object-inputs.ts diff --git a/packages/plugins/plugin-security/src/security-plugin-object-inputs.ts b/packages/plugins/plugin-security/src/security-plugin-object-inputs.ts new file mode 100644 index 00000000000..a8b2f09c3da --- /dev/null +++ b/packages/plugins/plugin-security/src/security-plugin-object-inputs.ts @@ -0,0 +1,484 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25223 cut 1] The leaf readers of `SecurityPlugin` — the per-object inputs + * every gate layer reads: the row-level security policy collection (with its + * ADR-0105 D3 admission and the authored-tenant-policy warning), the + * unresolved-posture probes, the field-level masks and required-permission + * folds, and the field-name tables — moved out of `security-plugin.ts` whole. + * + * A MOVE, not a redesign. Each function below is the body of the + * `SecurityPlugin` method of the same name as it stood, re-indented one level + * and otherwise unedited: the same reads, the same verdicts, the same caches, + * failing open or closed exactly where they did. The one module-level + * declaration only these bodies read moved with them, also unedited: + * `isDelegatedPrincipalLeg`. + * + * ## How the moved bodies reach the class + * + * The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts` + * header carries the full reasoning), applied per method. The class keeps + * every method — the published `dist/*.d.ts` lists each one's name, + * docblock and position — and each body is one literal of exactly the + * {@link ObjectInputsHost} members that method reads, plus a `.call`; a body + * that reads no member takes no host and is called directly. The `async` + * methods are no longer `async`: each returns the moved function's own + * promise, so a caller awaits the same promise the body settles, with no extra + * wrapper. A method is handed over as a closure that calls it on the plugin at + * call time, so a test double or spy set on the plugin is still the one the + * body reaches — the moved bodies that call one another (the policy + * collection's admission, the probes, the mask and fold helpers, the + * field-name loader) do so through the plugin, exactly as before. + * + * Each member was classified on its own declaration, one rule for all: a field + * declared `readonly` (the caches, `ownershipFloorAlternates`, + * `warnedAuthoredTenantPolicies`) goes by value, because it is never + * reassigned; every other field (`ql`, `metadata`, `logger`, `rlsCompiler`, + * `permissionEvaluator`, `fieldMasker`) and the `orgScopingEnabled` accessor + * goes as a GETTER, so each read happens when the body makes it — `start()` + * assigns `ql`, `metadata` and `logger` after construction, and tests replace + * `permissionEvaluator` on a constructed plugin. + * + * Nothing re-exports this module, so the published surface does not move. + */ + +import type { SecurityPlugin } from './security-plugin.js'; +import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security'; +import type { FieldMaskingRule, NumberComparandDoorFieldMeta } from '@objectstack/spec/data'; +import type { MatchesFilterOptions } from '@objectstack/formula'; +import { intersectFieldMasks } from './explain-engine.js'; +import { declaredComparisonColumns } from './declared-comparison-columns.js'; +import { isPlatformTenantPolicy, isAuthoredTenantPolicy } from './platform-tenant-policies.js'; +import { objectDefaultRowScopePolicy } from './object-default-row-scope.js'; +import { withOwnershipFloorAlternates } from './platform-ownership-policies.js'; +import type { UnresolvedPostureCause } from './unresolved-posture.js'; + +/** + * [ADR-0090 D10; ruling of record 6278784335 on #24341, letter B] Whether + * `context` is a DELEGATED principal's own leg — an agent acting on behalf of + * a user, whose sets are its ceiling and whose row bound is the delegator's + * leg, AND-ed in beside it. The spelling is the read door's own test for the + * delegated case (`onBehalfOf.userId`); the delegator's leg runs on the + * context `resolveDelegatorContext` rebuilt, which carries none. + * + * Read by {@link SecurityPlugin.collectRLSPolicies} alone: a ceiling's silence + * is not Layer 1 silence, so the object's declared default row scope is not + * collected on that leg. Every row bound comes from the delegator, as before + * the identity objects declared their defaults. + */ +function isDelegatedPrincipalLeg(context: unknown): boolean { + const onBehalfOf = (context as { onBehalfOf?: { userId?: unknown } } | null | undefined)?.onBehalfOf; + return typeof onBehalfOf?.userId === 'string' && onBehalfOf.userId !== ''; +} + +/** + * The posture `getObjectSecurityMeta` resolves, as the class declares it: the + * `ObjectSecurityMeta` interface stays in `security-plugin.ts` beside its one + * producer, and the moved signatures name it exactly as they did. + */ +type ObjectSecurityMeta = Awaited>; + +/** + * The `SecurityPlugin` members the functions below read — every one of them, + * and no other; each function's `this` is the `Pick` of the members IT reads. + * Each is typed AS the class member (an indexed access on the class), so a + * change to a member's signature on the class is a change here too, and naming + * a member the class does not have is a compile error. See the module header + * for which members are getters. + */ +export interface ObjectInputsHost { + readonly datetimeFieldNamesCache: SecurityPlugin['datetimeFieldNamesCache']; + readonly fieldMasker: SecurityPlugin['fieldMasker']; + readonly fieldNamesCache: SecurityPlugin['fieldNamesCache']; + readonly logger: SecurityPlugin['logger']; + readonly metadata: SecurityPlugin['metadata']; + readonly numberComparandFieldsCache: SecurityPlugin['numberComparandFieldsCache']; + readonly orgScopingEnabled: SecurityPlugin['orgScopingEnabled']; + readonly ownershipFloorAlternates: SecurityPlugin['ownershipFloorAlternates']; + readonly permissionEvaluator: SecurityPlugin['permissionEvaluator']; + readonly ql: SecurityPlugin['ql']; + readonly rlsCompiler: SecurityPlugin['rlsCompiler']; + readonly tenancyDisabledCache: SecurityPlugin['tenancyDisabledCache']; + readonly warnedAuthoredTenantPolicies: SecurityPlugin['warnedAuthoredTenantPolicies']; + admitCollectedPolicy: SecurityPlugin['admitCollectedPolicy']; + computePartialMaskRules: SecurityPlugin['computePartialMaskRules']; + computeReadPartialMaskRules: SecurityPlugin['computeReadPartialMaskRules']; + foldFieldRequiredPermissions: SecurityPlugin['foldFieldRequiredPermissions']; + loadObjectFieldNames: SecurityPlugin['loadObjectFieldNames']; + probeMetadataOutage: SecurityPlugin['probeMetadataOutage']; + probeUnpublishedDraft: SecurityPlugin['probeUnpublishedDraft']; + warnAuthoredTenantPolicyOnce: SecurityPlugin['warnAuthoredTenantPolicyOnce']; +} + +/** The body of `SecurityPlugin#collectRLSPolicies`; the method's docblock there is its contract. */ +export function collectRLSPolicies( + this: Pick, + permissionSets: PermissionSet[], + objectName: string, + operation: string, + heldPositions: string[] | undefined, + principal: unknown, +): RowLevelSecurityPolicy[] { + const allPolicies: RowLevelSecurityPolicy[] = []; + const floorAlternates = this.ownershipFloorAlternates.all(); + + for (const ps of permissionSets) { + if (ps.rowLevelSecurity) { + // [#21729] A contributed alternate match lands BESIDE the floor policy + // it relieves, in the floor's own domain, and only in a set that ships + // an enabled floor of its limb (`withOwnershipFloorAlternates`). Every + // reader of the collection — the by-id pre-image gate, the bulk write + // filter, the post-image check, `checkAuthoredRowWrite`, explain — sees + // the same list, because this is the one place they all collect from. + for (const policy of withOwnershipFloorAlternates(ps.rowLevelSecurity, floorAlternates)) { + if (this.admitCollectedPolicy(policy, objectName)) allPolicies.push(policy); + } + } + } + + const held = this.rlsCompiler.getApplicablePolicies(objectName, operation, allPolicies, heldPositions); + if (held.length > 0 || isDelegatedPrincipalLeg(principal)) return held; + // [#24164 ruling E′] The held sets are silent on this object and + // operation: the object's own `defaultRowScope`, if it declares one. Read + // from the registry the engine compiles every find against, only here, on + // an empty collection (two in-memory map lookups, no I/O). + const schema = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(objectName) : null; + const objectDefault = objectDefaultRowScopePolicy(objectName, schema); + if (objectDefault === null || !this.admitCollectedPolicy(objectDefault, objectName)) return held; + return this.rlsCompiler.getApplicablePolicies(objectName, operation, [objectDefault], heldPositions); +} + +/** The body of `SecurityPlugin#admitCollectedPolicy`; the method's docblock there is its contract. */ +export function admitCollectedPolicy( + this: Pick, + policy: RowLevelSecurityPolicy, + objectName: string, +): boolean { + if (!this.orgScopingEnabled && isPlatformTenantPolicy(policy)) { + return false; + } + if (!this.orgScopingEnabled && isAuthoredTenantPolicy(policy)) { + this.warnAuthoredTenantPolicyOnce(policy, objectName); + } + return true; +} + +/** The body of `SecurityPlugin#warnAuthoredTenantPolicyOnce`; the method's docblock there is its contract. */ +export function warnAuthoredTenantPolicyOnce( + this: Pick, + policy: RowLevelSecurityPolicy, + objectName: string, +): void { + const key = `${objectName} ${policy.name ?? ''}`; + if (this.warnedAuthoredTenantPolicies.has(key)) return; + this.warnedAuthoredTenantPolicies.add(key); + this.logger.warn?.( + '[security/ADR-0105] authored RLS policy references the active organization but org isolation ' + + 'is inactive — it is retained and will fail closed (zero rows). Install ' + + '@objectstack/organizations (or drop the policy) if this is not intended.', + { object: objectName, policy: policy.name, using: policy.using }, + ); +} + +/** The body of `SecurityPlugin#resolveUnresolvedCause`; the method's docblock there is its contract. */ +export async function resolveUnresolvedCause( + this: Pick, + object: string, +): Promise { + if (await this.probeMetadataOutage(object)) return 'metadata_unavailable'; + return await this.probeUnpublishedDraft(object); +} + +/** The body of `SecurityPlugin#probeMetadataOutage`; the method's docblock there is its contract. */ +export async function probeMetadataOutage( + this: Pick, + object: string, +): Promise { + const service = this.metadata as + | { getDiagnosed?: (type: string, name: string) => Promise<{ degraded?: unknown }> } + | undefined; + if (typeof service?.getDiagnosed !== 'function') return false; + try { + const diagnosed = await service.getDiagnosed('object', object); + return diagnosed?.degraded === true; + } catch { + return false; + } +} + +/** The body of `SecurityPlugin#probeUnpublishedDraft`; the method's docblock there is its contract. */ +export async function probeUnpublishedDraft( + this: Pick, + object: string, +): Promise { + if (typeof this.ql?.findOne !== 'function') return 'unknown'; + try { + const row = await this.ql.findOne('sys_metadata', { + where: { type: 'object', name: object, state: 'draft' }, + context: { isSystem: true }, + }); + return row ? 'unpublished_draft' : 'unknown'; + } catch { + return 'unknown'; + } +} + +/** The body of `SecurityPlugin#computePartialMaskRules`; the method's docblock there is its contract. */ +export function computePartialMaskRules( + this: Pick, + secMeta: { fieldMaskingRules: Record; fieldRequiredPermissions: Record }, + permissionSets: PermissionSet[], + delegatorSets: PermissionSet[] | null, +): Record { + const entries = Object.entries(secMeta.fieldMaskingRules ?? {}); + if (entries.length === 0) return {}; + const held = this.permissionEvaluator.getSystemPermissions(permissionSets); + const delHeld = delegatorSets ? this.permissionEvaluator.getSystemPermissions(delegatorSets) : null; + const out: Record = {}; + for (const [field, rule] of entries) { + const caps = secMeta.fieldRequiredPermissions[field] ?? []; + const unmasked = + caps.length > 0 && + caps.every((c) => held.has(c)) && + (!delHeld || caps.every((c) => delHeld.has(c))); + if (!unmasked) out[field] = rule; + } + return out; +} + +/** The body of `SecurityPlugin#computeQueryGuardFieldPerms`; the method's docblock there is its contract. */ +export function computeQueryGuardFieldPerms( + this: Pick, + objectName: string, + secMeta: Pick, + permissionSets: PermissionSet[], + delegatorSets: PermissionSet[] | null, +): Record { + let guard = this.permissionEvaluator.getFieldPermissions(objectName, permissionSets); + guard = this.foldFieldRequiredPermissions(guard, secMeta.fieldRequiredPermissions, permissionSets); + if (delegatorSets) { + let delegatorGuard = this.permissionEvaluator.getFieldPermissions(objectName, delegatorSets); + delegatorGuard = this.foldFieldRequiredPermissions(delegatorGuard, secMeta.fieldRequiredPermissions, delegatorSets); + guard = intersectFieldMasks(guard, delegatorGuard); + } + const out: Record = { ...guard }; + for (const f of Object.keys(this.computePartialMaskRules(secMeta, permissionSets, delegatorSets))) { + if (out[f]?.readable !== false) { + out[f] = { readable: false, editable: out[f]?.editable ?? false }; + } + } + return out; +} + +/** The body of `SecurityPlugin#computeForbiddenFieldWrites`; the method's docblock there is its contract. */ +export function computeForbiddenFieldWrites( + this: Pick, + objectName: string, + data: Record | Record[], + secMeta: Pick, + permissionSets: PermissionSet[], + delegatorSets: PermissionSet[] | null, +): string[] { + let fieldPerms = this.permissionEvaluator.getFieldPermissions(objectName, permissionSets); + fieldPerms = this.foldFieldRequiredPermissions(fieldPerms, secMeta.fieldRequiredPermissions, permissionSets); + if (delegatorSets) { + let delFieldPerms = this.permissionEvaluator.getFieldPermissions(objectName, delegatorSets); + delFieldPerms = this.foldFieldRequiredPermissions(delFieldPerms, secMeta.fieldRequiredPermissions, delegatorSets); + fieldPerms = intersectFieldMasks(fieldPerms, delFieldPerms); + } + if (Object.keys(fieldPerms).length === 0) return []; + return this.fieldMasker.detectForbiddenWrites(data, fieldPerms); +} + +/** The body of `SecurityPlugin#computeReadPartialMaskRules`; the method's docblock there is its contract. */ +export function computeReadPartialMaskRules( + this: Pick, + secMeta: { fieldMaskingRules: Record; fieldRequiredPermissions: Record }, + permissionSets: PermissionSet[], + delegatorSets: PermissionSet[] | null, + basePerms: Record, + delBasePerms: Record | null, +): Record { + const rules = this.computePartialMaskRules(secMeta, permissionSets, delegatorSets); + for (const f of Object.keys(rules)) { + if (basePerms[f]?.readable === false || delBasePerms?.[f]?.readable === false) { + delete rules[f]; + } + } + return rules; +} + +/** The body of `SecurityPlugin#maskOperationResult`; the method's docblock there is its contract. */ +export function maskOperationResult( + this: Pick, + opCtx: any, + permissionSets: PermissionSet[], + secMeta: ObjectSecurityMeta, + delegatorSets: PermissionSet[] | null, +): void { + if (opCtx.result && ['find', 'findOne', 'insert', 'update'].includes(opCtx.operation)) { + const basePerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets); + // [ADR-0066 D3] AND-gate field-level requiredPermissions into the mask. + let fieldPerms = this.foldFieldRequiredPermissions(basePerms, secMeta.fieldRequiredPermissions, permissionSets); + // [ADR-0090 D10] Mask any field the delegator cannot read, too. + let delBasePerms: Record | null = null; + if (delegatorSets) { + delBasePerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, delegatorSets); + let delFieldPerms = this.foldFieldRequiredPermissions(delBasePerms, secMeta.fieldRequiredPermissions, delegatorSets); + fieldPerms = intersectFieldMasks(fieldPerms, delFieldPerms); + } + // [#8993] Partial masking: fields whose declared rule applies to this + // caller are REPLACED with their masked value instead of deleted — + // EXCEPT where a permission set explicitly marks the field + // non-readable (strictest wins: a masking rule never widens an + // explicit deny, so those callers keep getting the key deleted). + const partialRules = this.computeReadPartialMaskRules( + secMeta, permissionSets, delegatorSets, basePerms, delBasePerms, + ); + if (Object.keys(fieldPerms).length > 0 || Object.keys(partialRules).length > 0) { + opCtx.result = this.fieldMasker.maskResults(opCtx.result, fieldPerms, opCtx.object, partialRules); + } + } +} + +/** The body of `SecurityPlugin#foldFieldRequiredPermissions`; the method's docblock there is its contract. */ +export function foldFieldRequiredPermissions( + this: Pick, + baseFieldPerms: Record, + fieldRequiredPermissions: Record, + permissionSets: PermissionSet[], +): Record { + const entries = Object.entries(fieldRequiredPermissions ?? {}); + if (entries.length === 0) return baseFieldPerms; + const held = this.permissionEvaluator.getSystemPermissions(permissionSets); + const merged: Record = { ...baseFieldPerms }; + for (const [field, caps] of entries) { + if (caps.length > 0 && !caps.every((c) => held.has(c))) { + merged[field] = { readable: false, editable: false }; + } + } + return merged; +} + +/** The body of `SecurityPlugin#getObjectFieldNames`; the method's docblock there is its contract. */ +export async function getObjectFieldNames( + this: Pick, + metadata: any, + objectName: string, + ql?: any, +): Promise | null> { + if (this.fieldNamesCache.has(objectName)) { + return this.fieldNamesCache.get(objectName) ?? null; + } + const result = await this.loadObjectFieldNames(metadata, objectName, ql); + // Only cache positive resolutions — a `null` may simply mean the + // schema isn't registered yet at boot, and we want subsequent calls + // to retry rather than be permanently denied. + if (result) { + this.fieldNamesCache.set(objectName, result); + } + return result; +} + +/** The body of `SecurityPlugin#writeCheckFieldOptions`; the method's docblock there is its contract. */ +export async function writeCheckFieldOptions( + this: Pick, + object: string, +): Promise { + let obj: any; + try { + obj = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(object) : null; + if (!obj || !obj.fields) obj = await this.metadata?.get?.('object', object); + } catch { + return undefined; + } + return declaredComparisonColumns(obj); +} + +/** The body of `SecurityPlugin#loadObjectFieldNames`; the method's docblock there is its contract. */ +export async function loadObjectFieldNames( + this: Pick, + metadata: any, + objectName: string, + ql?: any, +): Promise | null> { + try { + // Prefer ObjectQL's per-engine SchemaRegistry as the source of truth + // for the live field set: it reflects registry-time augmentations + // (system-field auto-injection like `organization_id`) that the + // standalone metadata artifact loaded at boot may not include. + // Fall back to the metadata service for objects ObjectQL doesn't + // know about (system tables registered through other paths). + let obj: any = typeof ql?.getSchema === 'function' ? ql.getSchema(objectName) : null; + if (!obj || !obj.fields) { + obj = await metadata?.get?.('object', objectName); + } + if (!obj || !obj.fields) return null; + // Populate the tenancy opt-out cache alongside the field set so + // the RLS filter pass can decide whether a wildcard + // `organization_id` policy is genuinely "applicable but + // uncompilable" (deny) versus "not applicable on this object" + // (skip without contributing to the deny sentinel). + const tenancyDisabled = + (obj as any)?.tenancy?.enabled === false || + (obj as any)?.systemFields?.tenant === false; + this.tenancyDisabledCache.set(objectName, !!tenancyDisabled); + const set = new Set(['id']); + // [ADR-0053 D-D1 item 7 — #5930] The `datetime` columns, from the same + // declaration (see `datetimeFieldNamesCache`). + const datetime = new Set(); + // [#21242] …and every column's type as the number-comparand verdict + // reads it (see `numberComparandFieldsCache`). The verdict decides which + // are numeric; this pass only records what each column declares. + const number = new Map(); + const noteNumberMeta = (name: string, def: any): void => { + if (!def || typeof def !== 'object' || typeof def.type !== 'string') return; + number.set(name, typeof def.returnType === 'string' ? { type: def.type, returnType: def.returnType } : { type: def.type }); + }; + if (Array.isArray(obj.fields)) { + for (const f of obj.fields) { + if (f?.name) set.add(String(f.name)); + if (f?.name && f.type === 'datetime') datetime.add(String(f.name)); + if (f?.name) noteNumberMeta(String(f.name), f); + } + } else if (typeof obj.fields === 'object') { + for (const key of Object.keys(obj.fields)) { + set.add(key); + const v = (obj.fields as Record)[key]; + if (v && typeof v === 'object' && v.name) set.add(String(v.name)); + if (v && typeof v === 'object' && v.type === 'datetime') datetime.add(key); + noteNumberMeta(key, v); + } + } else { + return null; + } + this.datetimeFieldNamesCache.set(objectName, datetime); + this.numberComparandFieldsCache.set(objectName, number); + return set; + } catch { + return null; + } +} + +/** The body of `SecurityPlugin#extractTargetField`; the method's docblock there is its contract. */ +export function extractTargetField( + using?: string, +): string | null { + if (!using) return null; + // Match `field ==` (canonical CEL), `field =` (legacy SQL-ish), or + // `field IN`/`in`. [#2936] `==` MUST be listed before `=` in the + // alternation: alternation is ordered, so a bare `=` branch would match + // the first `=` of `==` and then the `(?=\s|\()` lookahead — seeing the + // SECOND `=`, not whitespace — would fail, leaving `==` unrecognized (the + // original bug: real seeds/business policies author equality as `==`, so + // the field-existence and tenancy-disabled safety nets that consume this + // were inert for them). `\b` is omitted after the operator because `=` is + // non-word and the next char (space) is non-word too — a word boundary + // cannot exist between two non-word chars — so we require the operator to + // be followed by whitespace or `(` instead. `!=`/`>`/`<`/`>=`/`<=` are + // deliberately NOT recognized: returning `null` keeps such a policy (the + // conservative default), matching the pre-#2936 behavior for any shape the + // regex did not match. + const m = using.match(/^\s*([a-z_][a-z0-9_]*)\s*(?:==|=|IN|in)(?=\s|\()/); + return m ? m[1] : null; +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index e1830499613..b00986ddd9a 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -52,7 +52,6 @@ import { intersectDelegatedScope, d10NarrowingStatement, } from './explain-engine.js'; -import { declaredComparisonColumns } from './declared-comparison-columns.js'; import { jsonColumnCheckRefusalCarriedBy, storedFormCheckJudge } from './rls-check-stored-form.js'; import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; import type { II18nService, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; @@ -89,8 +88,28 @@ import { isVerifiedPlatformOwnerRow, } from './platform-owner-wall-bypass.js'; import { isConfiguredPlatformAdminEmail, resolvePlatformAdminEmails, vetOrganizationClaim, createSecurityCatalogReader, bindSecurityCatalogReader, securityCatalogReaderOf, readDisabledCatalogNames } from '@objectstack/core'; -import { isPlatformTenantPolicy, isAuthoredTenantPolicy } from './platform-tenant-policies.js'; -import { objectDefaultRowScopePolicy } from './object-default-row-scope.js'; +// [#25223 cut 1] The leaf readers (policy collection, unresolved-posture +// probes, field masks, field-name tables); the methods of the same names below +// hand each body the members it reads (`ObjectInputsHost`). +import { + admitCollectedPolicy, + collectRLSPolicies, + computeForbiddenFieldWrites, + computePartialMaskRules, + computeQueryGuardFieldPerms, + computeReadPartialMaskRules, + extractTargetField, + foldFieldRequiredPermissions, + getObjectFieldNames, + loadObjectFieldNames, + maskOperationResult, + probeMetadataOutage, + probeUnpublishedDraft, + resolveUnresolvedCause, + warnAuthoredTenantPolicyOnce, + writeCheckFieldOptions, + type ObjectInputsHost, +} from './security-plugin-object-inputs.js'; import { isPlatformOwnershipFloorPolicy, masterGateCoversOperation, @@ -98,7 +117,6 @@ import { OWD_DELEGATING_ROW_WRITES, owdDeclaresOpenRowWrites, owdOpenWritesCoversOperation, - withOwnershipFloorAlternates, type OwnershipFloorAlternate, } from './platform-ownership-policies.js'; import { OwnershipFloorAlternates } from './ownership-floor-alternates.js'; @@ -468,24 +486,6 @@ function isPrincipalLessContext(context: any): boolean { return positions.length === 0 && explicitPermissionSets.length === 0 && !context?.userId; } -/** - * [ADR-0090 D10; ruling of record 6278784335 on #24341, letter B] Whether - * `context` is a DELEGATED principal's own leg — an agent acting on behalf of - * a user, whose sets are its ceiling and whose row bound is the delegator's - * leg, AND-ed in beside it. The spelling is the read door's own test for the - * delegated case (`onBehalfOf.userId`); the delegator's leg runs on the - * context `resolveDelegatorContext` rebuilt, which carries none. - * - * Read by {@link SecurityPlugin.collectRLSPolicies} alone: a ceiling's silence - * is not Layer 1 silence, so the object's declared default row scope is not - * collected on that leg. Every row bound comes from the delegator, as before - * the identity objects declared their defaults. - */ -function isDelegatedPrincipalLeg(context: unknown): boolean { - const onBehalfOf = (context as { onBehalfOf?: { userId?: unknown } } | null | undefined)?.onBehalfOf; - return typeof onBehalfOf?.userId === 'string' && onBehalfOf.userId !== ''; -} - /** * [#21908] ADR-0096 D5 strict mode — the refusal the engine middleware throws * for a principal-less, non-system context ({@link isPrincipalLessContext}): @@ -10370,33 +10370,16 @@ export class SecurityPlugin implements Plugin { heldPositions: string[] | undefined, principal: unknown, ): RowLevelSecurityPolicy[] { - const allPolicies: RowLevelSecurityPolicy[] = []; - const floorAlternates = this.ownershipFloorAlternates.all(); - - for (const ps of permissionSets) { - if (ps.rowLevelSecurity) { - // [#21729] A contributed alternate match lands BESIDE the floor policy - // it relieves, in the floor's own domain, and only in a set that ships - // an enabled floor of its limb (`withOwnershipFloorAlternates`). Every - // reader of the collection — the by-id pre-image gate, the bulk write - // filter, the post-image check, `checkAuthoredRowWrite`, explain — sees - // the same list, because this is the one place they all collect from. - for (const policy of withOwnershipFloorAlternates(ps.rowLevelSecurity, floorAlternates)) { - if (this.admitCollectedPolicy(policy, objectName)) allPolicies.push(policy); - } - } - } - - const held = this.rlsCompiler.getApplicablePolicies(objectName, operation, allPolicies, heldPositions); - if (held.length > 0 || isDelegatedPrincipalLeg(principal)) return held; - // [#24164 ruling E′] The held sets are silent on this object and - // operation: the object's own `defaultRowScope`, if it declares one. Read - // from the registry the engine compiles every find against, only here, on - // an empty collection (two in-memory map lookups, no I/O). - const schema = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(objectName) : null; - const objectDefault = objectDefaultRowScopePolicy(objectName, schema); - if (objectDefault === null || !this.admitCollectedPolicy(objectDefault, objectName)) return held; - return this.rlsCompiler.getApplicablePolicies(objectName, operation, [objectDefault], heldPositions); + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + const readQl = () => this.ql; + const readRlsCompiler = () => this.rlsCompiler; + const host: Pick = { + admitCollectedPolicy: (...a) => this.admitCollectedPolicy(...a), + ownershipFloorAlternates: this.ownershipFloorAlternates, + get ql() { return readQl(); }, + get rlsCompiler() { return readRlsCompiler(); }, + }; + return collectRLSPolicies.call(host, permissionSets, objectName, operation, heldPositions, principal); } /** @@ -10417,13 +10400,13 @@ export class SecurityPlugin implements Plugin { * copy is (the provenance key is `(object, name, using)`). */ private admitCollectedPolicy(policy: RowLevelSecurityPolicy, objectName: string): boolean { - if (!this.orgScopingEnabled && isPlatformTenantPolicy(policy)) { - return false; - } - if (!this.orgScopingEnabled && isAuthoredTenantPolicy(policy)) { - this.warnAuthoredTenantPolicyOnce(policy, objectName); - } - return true; + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + const readOrgScopingEnabled = () => this.orgScopingEnabled; + const host: Pick = { + get orgScopingEnabled() { return readOrgScopingEnabled(); }, + warnAuthoredTenantPolicyOnce: (...a) => this.warnAuthoredTenantPolicyOnce(...a), + }; + return admitCollectedPolicy.call(host, policy, objectName); } /** @@ -10438,15 +10421,13 @@ export class SecurityPlugin implements Plugin { policy: RowLevelSecurityPolicy, objectName: string, ): void { - const key = `${objectName} ${policy.name ?? ''}`; - if (this.warnedAuthoredTenantPolicies.has(key)) return; - this.warnedAuthoredTenantPolicies.add(key); - this.logger.warn?.( - '[security/ADR-0105] authored RLS policy references the active organization but org isolation ' + - 'is inactive — it is retained and will fail closed (zero rows). Install ' + - '@objectstack/organizations (or drop the policy) if this is not intended.', - { object: objectName, policy: policy.name, using: policy.using }, - ); + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + const readLogger = () => this.logger; + const host: Pick = { + get logger() { return readLogger(); }, + warnedAuthoredTenantPolicies: this.warnedAuthoredTenantPolicies, + }; + warnAuthoredTenantPolicyOnce.call(host, policy, objectName); } /** @@ -10576,9 +10557,13 @@ export class SecurityPlugin implements Plugin { * Every leg that cannot reach a positive verdict lands on `'unknown'`, whose * wording covers all cases. */ - private async resolveUnresolvedCause(object: string): Promise { - if (await this.probeMetadataOutage(object)) return 'metadata_unavailable'; - return await this.probeUnpublishedDraft(object); + private resolveUnresolvedCause(object: string): Promise { + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + const host: Pick = { + probeMetadataOutage: (...a) => this.probeMetadataOutage(...a), + probeUnpublishedDraft: (...a) => this.probeUnpublishedDraft(...a), + }; + return resolveUnresolvedCause.call(host, object); } /** @@ -10625,17 +10610,13 @@ export class SecurityPlugin implements Plugin { * Like the draft probe, this can never turn a resolved posture into a denial * nor a refusal into a grant — it is read after the deny decision is made. */ - private async probeMetadataOutage(object: string): Promise { - const service = this.metadata as - | { getDiagnosed?: (type: string, name: string) => Promise<{ degraded?: unknown }> } - | undefined; - if (typeof service?.getDiagnosed !== 'function') return false; - try { - const diagnosed = await service.getDiagnosed('object', object); - return diagnosed?.degraded === true; - } catch { - return false; - } + private probeMetadataOutage(object: string): Promise { + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + const readMetadata = () => this.metadata; + const host: Pick = { + get metadata() { return readMetadata(); }, + }; + return probeMetadataOutage.call(host, object); } /** @@ -10670,17 +10651,13 @@ export class SecurityPlugin implements Plugin { * "a draft declaration exists but no published one" a true statement about * this runtime, which is all the message claims. */ - private async probeUnpublishedDraft(object: string): Promise { - if (typeof this.ql?.findOne !== 'function') return 'unknown'; - try { - const row = await this.ql.findOne('sys_metadata', { - where: { type: 'object', name: object, state: 'draft' }, - context: { isSystem: true }, - }); - return row ? 'unpublished_draft' : 'unknown'; - } catch { - return 'unknown'; - } + private probeUnpublishedDraft(object: string): Promise { + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + const readQl = () => this.ql; + const host: Pick = { + get ql() { return readQl(); }, + }; + return probeUnpublishedDraft.call(host, object); } /** @@ -10710,20 +10687,12 @@ export class SecurityPlugin implements Plugin { permissionSets: PermissionSet[], delegatorSets: PermissionSet[] | null, ): Record { - const entries = Object.entries(secMeta.fieldMaskingRules ?? {}); - if (entries.length === 0) return {}; - const held = this.permissionEvaluator.getSystemPermissions(permissionSets); - const delHeld = delegatorSets ? this.permissionEvaluator.getSystemPermissions(delegatorSets) : null; - const out: Record = {}; - for (const [field, rule] of entries) { - const caps = secMeta.fieldRequiredPermissions[field] ?? []; - const unmasked = - caps.length > 0 && - caps.every((c) => held.has(c)) && - (!delHeld || caps.every((c) => delHeld.has(c))); - if (!unmasked) out[field] = rule; - } - return out; + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + const readPermissionEvaluator = () => this.permissionEvaluator; + const host: Pick = { + get permissionEvaluator() { return readPermissionEvaluator(); }, + }; + return computePartialMaskRules.call(host, secMeta, permissionSets, delegatorSets); } /** @@ -10756,20 +10725,14 @@ export class SecurityPlugin implements Plugin { permissionSets: PermissionSet[], delegatorSets: PermissionSet[] | null, ): Record { - let guard = this.permissionEvaluator.getFieldPermissions(objectName, permissionSets); - guard = this.foldFieldRequiredPermissions(guard, secMeta.fieldRequiredPermissions, permissionSets); - if (delegatorSets) { - let delegatorGuard = this.permissionEvaluator.getFieldPermissions(objectName, delegatorSets); - delegatorGuard = this.foldFieldRequiredPermissions(delegatorGuard, secMeta.fieldRequiredPermissions, delegatorSets); - guard = intersectFieldMasks(guard, delegatorGuard); - } - const out: Record = { ...guard }; - for (const f of Object.keys(this.computePartialMaskRules(secMeta, permissionSets, delegatorSets))) { - if (out[f]?.readable !== false) { - out[f] = { readable: false, editable: out[f]?.editable ?? false }; - } - } - return out; + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + const readPermissionEvaluator = () => this.permissionEvaluator; + const host: Pick = { + computePartialMaskRules: (...a) => this.computePartialMaskRules(...a), + foldFieldRequiredPermissions: (...a) => this.foldFieldRequiredPermissions(...a), + get permissionEvaluator() { return readPermissionEvaluator(); }, + }; + return computeQueryGuardFieldPerms.call(host, objectName, secMeta, permissionSets, delegatorSets); } /** @@ -10792,15 +10755,15 @@ export class SecurityPlugin implements Plugin { permissionSets: PermissionSet[], delegatorSets: PermissionSet[] | null, ): string[] { - let fieldPerms = this.permissionEvaluator.getFieldPermissions(objectName, permissionSets); - fieldPerms = this.foldFieldRequiredPermissions(fieldPerms, secMeta.fieldRequiredPermissions, permissionSets); - if (delegatorSets) { - let delFieldPerms = this.permissionEvaluator.getFieldPermissions(objectName, delegatorSets); - delFieldPerms = this.foldFieldRequiredPermissions(delFieldPerms, secMeta.fieldRequiredPermissions, delegatorSets); - fieldPerms = intersectFieldMasks(fieldPerms, delFieldPerms); - } - if (Object.keys(fieldPerms).length === 0) return []; - return this.fieldMasker.detectForbiddenWrites(data, fieldPerms); + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + const readFieldMasker = () => this.fieldMasker; + const readPermissionEvaluator = () => this.permissionEvaluator; + const host: Pick = { + get fieldMasker() { return readFieldMasker(); }, + foldFieldRequiredPermissions: (...a) => this.foldFieldRequiredPermissions(...a), + get permissionEvaluator() { return readPermissionEvaluator(); }, + }; + return computeForbiddenFieldWrites.call(host, objectName, data, secMeta, permissionSets, delegatorSets); } /** @@ -10830,13 +10793,11 @@ export class SecurityPlugin implements Plugin { basePerms: Record, delBasePerms: Record | null, ): Record { - const rules = this.computePartialMaskRules(secMeta, permissionSets, delegatorSets); - for (const f of Object.keys(rules)) { - if (basePerms[f]?.readable === false || delBasePerms?.[f]?.readable === false) { - delete rules[f]; - } - } - return rules; + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + const host: Pick = { + computePartialMaskRules: (...a) => this.computePartialMaskRules(...a), + }; + return computeReadPartialMaskRules.call(host, secMeta, permissionSets, delegatorSets, basePerms, delBasePerms); } /** @@ -10859,29 +10820,16 @@ export class SecurityPlugin implements Plugin { secMeta: ObjectSecurityMeta, delegatorSets: PermissionSet[] | null, ): void { - if (opCtx.result && ['find', 'findOne', 'insert', 'update'].includes(opCtx.operation)) { - const basePerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets); - // [ADR-0066 D3] AND-gate field-level requiredPermissions into the mask. - let fieldPerms = this.foldFieldRequiredPermissions(basePerms, secMeta.fieldRequiredPermissions, permissionSets); - // [ADR-0090 D10] Mask any field the delegator cannot read, too. - let delBasePerms: Record | null = null; - if (delegatorSets) { - delBasePerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, delegatorSets); - let delFieldPerms = this.foldFieldRequiredPermissions(delBasePerms, secMeta.fieldRequiredPermissions, delegatorSets); - fieldPerms = intersectFieldMasks(fieldPerms, delFieldPerms); - } - // [#8993] Partial masking: fields whose declared rule applies to this - // caller are REPLACED with their masked value instead of deleted — - // EXCEPT where a permission set explicitly marks the field - // non-readable (strictest wins: a masking rule never widens an - // explicit deny, so those callers keep getting the key deleted). - const partialRules = this.computeReadPartialMaskRules( - secMeta, permissionSets, delegatorSets, basePerms, delBasePerms, - ); - if (Object.keys(fieldPerms).length > 0 || Object.keys(partialRules).length > 0) { - opCtx.result = this.fieldMasker.maskResults(opCtx.result, fieldPerms, opCtx.object, partialRules); - } - } + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + const readFieldMasker = () => this.fieldMasker; + const readPermissionEvaluator = () => this.permissionEvaluator; + const host: Pick = { + computeReadPartialMaskRules: (...a) => this.computeReadPartialMaskRules(...a), + get fieldMasker() { return readFieldMasker(); }, + foldFieldRequiredPermissions: (...a) => this.foldFieldRequiredPermissions(...a), + get permissionEvaluator() { return readPermissionEvaluator(); }, + }; + maskOperationResult.call(host, opCtx, permissionSets, secMeta, delegatorSets); } private foldFieldRequiredPermissions( @@ -10889,38 +10837,29 @@ export class SecurityPlugin implements Plugin { fieldRequiredPermissions: Record, permissionSets: PermissionSet[], ): Record { - const entries = Object.entries(fieldRequiredPermissions ?? {}); - if (entries.length === 0) return baseFieldPerms; - const held = this.permissionEvaluator.getSystemPermissions(permissionSets); - const merged: Record = { ...baseFieldPerms }; - for (const [field, caps] of entries) { - if (caps.length > 0 && !caps.every((c) => held.has(c))) { - merged[field] = { readable: false, editable: false }; - } - } - return merged; + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + const readPermissionEvaluator = () => this.permissionEvaluator; + const host: Pick = { + get permissionEvaluator() { return readPermissionEvaluator(); }, + }; + return foldFieldRequiredPermissions.call(host, baseFieldPerms, fieldRequiredPermissions, permissionSets); } /** * Resolve the column-name set for an object (lowercased). Returns * `null` if the schema can't be loaded — caller should fail-closed. */ - private async getObjectFieldNames( + private getObjectFieldNames( metadata: any, objectName: string, ql?: any, ): Promise | null> { - if (this.fieldNamesCache.has(objectName)) { - return this.fieldNamesCache.get(objectName) ?? null; - } - const result = await this.loadObjectFieldNames(metadata, objectName, ql); - // Only cache positive resolutions — a `null` may simply mean the - // schema isn't registered yet at boot, and we want subsequent calls - // to retry rather than be permanently denied. - if (result) { - this.fieldNamesCache.set(objectName, result); - } - return result; + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + const host: Pick = { + fieldNamesCache: this.fieldNamesCache, + loadObjectFieldNames: (...a) => this.loadObjectFieldNames(...a), + }; + return getObjectFieldNames.call(host, metadata, objectName, ql); } /** @@ -10940,78 +10879,29 @@ export class SecurityPlugin implements Plugin { * hands the same matcher, so the two judges of one policy read one * declaration one way. */ - private async writeCheckFieldOptions(object: string): Promise { - let obj: any; - try { - obj = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(object) : null; - if (!obj || !obj.fields) obj = await this.metadata?.get?.('object', object); - } catch { - return undefined; - } - return declaredComparisonColumns(obj); + private writeCheckFieldOptions(object: string): Promise { + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + const readMetadata = () => this.metadata; + const readQl = () => this.ql; + const host: Pick = { + get metadata() { return readMetadata(); }, + get ql() { return readQl(); }, + }; + return writeCheckFieldOptions.call(host, object); } - private async loadObjectFieldNames( + private loadObjectFieldNames( metadata: any, objectName: string, ql?: any, ): Promise | null> { - try { - // Prefer ObjectQL's per-engine SchemaRegistry as the source of truth - // for the live field set: it reflects registry-time augmentations - // (system-field auto-injection like `organization_id`) that the - // standalone metadata artifact loaded at boot may not include. - // Fall back to the metadata service for objects ObjectQL doesn't - // know about (system tables registered through other paths). - let obj: any = typeof ql?.getSchema === 'function' ? ql.getSchema(objectName) : null; - if (!obj || !obj.fields) { - obj = await metadata?.get?.('object', objectName); - } - if (!obj || !obj.fields) return null; - // Populate the tenancy opt-out cache alongside the field set so - // the RLS filter pass can decide whether a wildcard - // `organization_id` policy is genuinely "applicable but - // uncompilable" (deny) versus "not applicable on this object" - // (skip without contributing to the deny sentinel). - const tenancyDisabled = - (obj as any)?.tenancy?.enabled === false || - (obj as any)?.systemFields?.tenant === false; - this.tenancyDisabledCache.set(objectName, !!tenancyDisabled); - const set = new Set(['id']); - // [ADR-0053 D-D1 item 7 — #5930] The `datetime` columns, from the same - // declaration (see `datetimeFieldNamesCache`). - const datetime = new Set(); - // [#21242] …and every column's type as the number-comparand verdict - // reads it (see `numberComparandFieldsCache`). The verdict decides which - // are numeric; this pass only records what each column declares. - const number = new Map(); - const noteNumberMeta = (name: string, def: any): void => { - if (!def || typeof def !== 'object' || typeof def.type !== 'string') return; - number.set(name, typeof def.returnType === 'string' ? { type: def.type, returnType: def.returnType } : { type: def.type }); - }; - if (Array.isArray(obj.fields)) { - for (const f of obj.fields) { - if (f?.name) set.add(String(f.name)); - if (f?.name && f.type === 'datetime') datetime.add(String(f.name)); - if (f?.name) noteNumberMeta(String(f.name), f); - } - } else if (typeof obj.fields === 'object') { - for (const key of Object.keys(obj.fields)) { - set.add(key); - const v = (obj.fields as Record)[key]; - if (v && typeof v === 'object' && v.name) set.add(String(v.name)); - if (v && typeof v === 'object' && v.type === 'datetime') datetime.add(key); - noteNumberMeta(key, v); - } - } else { - return null; - } - this.datetimeFieldNamesCache.set(objectName, datetime); - this.numberComparandFieldsCache.set(objectName, number); - return set; - } catch { - return null; - } + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + const host: Pick = { + datetimeFieldNamesCache: this.datetimeFieldNamesCache, + numberComparandFieldsCache: this.numberComparandFieldsCache, + tenancyDisabledCache: this.tenancyDisabledCache, + }; + return loadObjectFieldNames.call(host, metadata, objectName, ql); } /** @@ -11033,22 +10923,7 @@ export class SecurityPlugin implements Plugin { * deliberately skips. */ private extractTargetField(using?: string): string | null { - if (!using) return null; - // Match `field ==` (canonical CEL), `field =` (legacy SQL-ish), or - // `field IN`/`in`. [#2936] `==` MUST be listed before `=` in the - // alternation: alternation is ordered, so a bare `=` branch would match - // the first `=` of `==` and then the `(?=\s|\()` lookahead — seeing the - // SECOND `=`, not whitespace — would fail, leaving `==` unrecognized (the - // original bug: real seeds/business policies author equality as `==`, so - // the field-existence and tenancy-disabled safety nets that consume this - // were inert for them). `\b` is omitted after the operator because `=` is - // non-word and the next char (space) is non-word too — a word boundary - // cannot exist between two non-word chars — so we require the operator to - // be followed by whitespace or `(` instead. `!=`/`>`/`<`/`>=`/`<=` are - // deliberately NOT recognized: returning `null` keeps such a policy (the - // conservative default), matching the pre-#2936 behavior for any shape the - // regex did not match. - const m = using.match(/^\s*([a-z_][a-z0-9_]*)\s*(?:==|=|IN|in)(?=\s|\()/); - return m ? m[1] : null; + // [#25223 cut 1] The body moved to `security-plugin-object-inputs.ts` (see `ObjectInputsHost`). + return extractTargetField(using); } } From a7cd7c6258a8d8eb81624ba0e05fba79ef8dbe23 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 19:48:17 +0000 Subject: [PATCH 2/2] test(plugin-security): say why the raw registration door alone pins the unwritable-master resolution (wip) Claude-Session: https://claude.ai/code/session_017BoLT8Gaa8t9a7AWVH8qZa Co-authored-by: Claude --- .../controlled-by-parent-unwritable-master-write.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-unwritable-master-write.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-unwritable-master-write.test.ts index b4120515035..e7cdfb6097c 100644 --- a/packages/plugins/plugin-security/src/controlled-by-parent-unwritable-master-write.test.ts +++ b/packages/plugins/plugin-security/src/controlled-by-parent-unwritable-master-write.test.ts @@ -14,6 +14,13 @@ * - every insert, single or batch, whether or not its body names a master; * - every update whose body carries the reference key, a master or `null`. * + * Why the raw door alone pins it (ruling 6345869077 item 6): the three doors + * that skip the parse — `defineStack` with `strict: false`, plugin code's raw + * `registerApp` / `registerObject`, and an 18.0.0-era `sys_metadata` row the + * boot registers under `[metadata_spec_invalid]` — all end in the one + * `SchemaRegistry` entry the gate's resolver reads through `ql.getSchema()`, + * so a declaration from any of them reaches the gate in the same form. + * * One rule for both flags, for a member and for a platform admin alike, on an * ordinary object and on a `sys_`-named one (which the engine treats * differently on insert — the rule does not). What stays as it was: