From cc59ef2f2e6855dfabdecdff8cb4565a4744419f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:16:06 +0000 Subject: [PATCH 1/6] wip(plugin-security): collectRLSPolicies applies an object's declared default row scope on an empty collection Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../plugin-security/src/explain-engine.ts | 69 ++++++++++++++-- .../src/object-default-row-scope.ts | 81 +++++++++++++++++++ .../plugin-security/src/rls-compiler.ts | 22 +++++ .../plugin-security/src/security-plugin.ts | 78 +++++++++++++----- 4 files changed, 226 insertions(+), 24 deletions(-) create mode 100644 packages/plugins/plugin-security/src/object-default-row-scope.ts diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 6b9a9ced433..08118f7e662 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -54,7 +54,7 @@ import type { PermissionEvaluator } from './permission-evaluator.js'; import { superuserBypassBitForOperation } from './permission-evaluator.js'; import { rlsOperationForVerb } from './lifecycle-verb-rls-operation.js'; import { ExplainObjectNotFoundError } from './errors.js'; -import { RLS_DENY_FILTER, compiledPolicyNameOf } from './rls-compiler.js'; +import { RLS_DENY_FILTER, compiledObjectDefaultRowScopeOf, compiledPolicyNameOf } from './rls-compiler.js'; import { declaredComparisonColumns } from './declared-comparison-columns.js'; import { declaredJsonStoredColumns, @@ -1070,6 +1070,47 @@ function policyMembersOf(node: unknown): unknown[] { return Array.isArray(list) ? list.flatMap(policyMembersOf) : [node]; } +/** + * [#24164 ruling E′] The object default row scopes a composed row filter + * applies: each member {@link policyMembersOf} finds whose compiled filter the + * compiler recorded as an object's declared default, once per policy name. + * Empty when every member came from a held permission set (the default only + * ever applies to an empty collection, so a filter is one or the other). + */ +function objectDefaultRowScopesOf(filters: readonly unknown[]): Array<{ name?: string; label?: string; description?: string }> { + const out = new Map(); + for (const filter of filters) { + for (const member of policyMembersOf(filter)) { + const scope = compiledObjectDefaultRowScopeOf(member); + if (scope && !out.has(scope.name ?? '')) out.set(scope.name ?? '', scope); + } + } + return [...out.values()]; +} + +/** + * [#24164 ruling E′] The sentence explain adds where an object's default row + * scope is the row-level predicate: the source is the object's declaration, + * named with the label and description it declares, never a permission set. + */ +function objectDefaultRowScopeSource( + object: string, + scopes: ReadonlyArray<{ name?: string; label?: string; description?: string }>, +): string { + const named = scopes + .map((scope) => { + const about = [scope.label, scope.description].filter( + (text): text is string => typeof text === 'string' && text.trim() !== '', + ); + return `'${scope.name ?? '(unnamed)'}'${about.length > 0 ? ` (${about.join(' — ')})` : ''}`; + }) + .join(', '); + return ( + ` Source: the object default row scope ${named} declared on '${object}', not a permission set — ` + + 'no permission set held carries a row policy for this object and operation.' + ); +} + /** * [#20431] The names of the policies in a compiled row filter whose own filter * `refuses` — the attribution the RLS write check logs for the same refusal. @@ -1775,16 +1816,30 @@ async function applyRecordAttribution( const deny = isDenyAll(layer1); const m = matches(layer1); const excluded = deny || m === false; + // [#24164 ruling E′] An object's default row scope is named as itself, + // reached through the object's declaration rather than a held set. + const [objectDefault] = objectDefaultRowScopesOf([layer1]); rlsLayer.record = { outcome: excluded ? 'excluded' : 'admitted', rowFilter: layer1, matchesRecord: deny ? false : m, - rules: [{ kind: 'rls_policy', name: 'business_rls', predicate: layer1, effect: excluded ? 'excludes' : 'admits' }], - detail: deny + rules: [ + objectDefault + ? { + kind: 'rls_policy', + name: objectDefault.name ?? '(unnamed)', + via: 'object_default', + predicate: layer1, + effect: excluded ? 'excludes' : 'admits', + } + : { kind: 'rls_policy', name: 'business_rls', predicate: layer1, effect: excluded ? 'excludes' : 'admits' }, + ], + detail: (deny ? 'Business RLS composes to DENY ALL for this principal.' : excluded ? 'The record does not satisfy the business row-level predicate.' - : 'The record satisfies the business row-level predicate.', + : 'The record satisfies the business row-level predicate.') + + (objectDefault ? objectDefaultRowScopeSource(object, [objectDefault]) : ''), }; } } @@ -2454,6 +2509,9 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, asker: // `__rls_deny__` denial is now reported as `denies` while `readFilter` keeps // reporting the predicate that was ACTUALLY composed. if (filterParts.some(isDenyAllSentinel)) readFilter = { id: DENY_ALL_SENTINEL_ID }; + // [#24164 ruling E′] Where the narrowing is an object's declared default row + // scope, say so: its source is the object, not a set the principal holds. + const objectDefaults = objectDefaultRowScopesOf(filterParts); layers.push({ layer: 'rls', verdict: denyAll ? 'denies' : readFilter ? 'narrows' : 'not_applicable', @@ -2461,7 +2519,8 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, asker: ? 'Row-level security composes to DENY ALL for this principal.' : readFilter ? 'Row-level security narrows the row set (see readFilter for the composed predicate)' + - (delegatorFilter ? ' — intersection of agent + delegator filters (D10).' : '.') + (delegatorFilter ? ' — intersection of agent + delegator filters (D10).' : '.') + + (objectDefaults.length > 0 ? objectDefaultRowScopeSource(object, objectDefaults) : '') : 'No RLS policy applies.', contributors: [], }); diff --git a/packages/plugins/plugin-security/src/object-default-row-scope.ts b/packages/plugins/plugin-security/src/object-default-row-scope.ts new file mode 100644 index 00000000000..a980c72dd6b --- /dev/null +++ b/packages/plugins/plugin-security/src/object-default-row-scope.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24164 ruling E′, item 2] An object's declared default row scope + * (`ObjectSchema.defaultRowScope`) as the one policy + * `SecurityPlugin.collectRLSPolicies` applies when no permission set the + * caller holds carries a row policy for that object and operation. + * + * ## What "carries a row policy" means + * + * Exactly what the collection already means by an EMPTY collection: the held + * sets' policies for this object and operation, after the ADR-0105 D3 + * admission, `enabled`, the `positions` domain and `'*'` matching + * (`RLSCompiler.getApplicablePolicies`). So a held `'*'` policy counts, a + * policy switched off or outside the caller's positions does not, and a held + * policy for another operation does not either: an object whose held sets + * scope only `update` reads under its default. The default is never added to + * a non-empty collection: an authored set policy is used as authored. + * + * The default then takes the path a held policy takes, from the same + * admission onward: `getApplicablePolicies` judges its own `enabled`, + * `positions` and `operation`, and the compiler compiles its `using` / + * `check`. A `using` that does not lower is dropped with the same WARN and + * fails closed to the same `RLS_DENY_FILTER` an authored policy's does, + * because it is the same code. Superuser-bypass principals never reach the + * collection, so the default cannot touch them. + * + * ## Why the registry, and what the read costs + * + * The declaration is read off `ql.getSchema(object)`, the ObjectQL registry's + * resolved object: the schema the engine compiles every find against, and the + * source `getObjectSecurityMeta` prefers. An object the registry does not + * resolve is refused by the engine's own door before any read runs. The read + * is two in-memory map lookups behind the registry's merged-object cache, and + * it is made only when the held collection is empty: no I/O and no parse on + * any path. + * + * ## Provenance + * + * The policy built here is recognised by IDENTITY ({@link + * isObjectDefaultRowScopePolicy}), never by spelling, for the reason + * `platform-ownership-policies.ts` gives for the floor's alternates: a held + * policy byte-identical to an object's default is still the set's own policy, + * and explain must name it as such. `RLSCompiler.compileFilter` carries the + * mark onto the compiled filter, which is how explain names an applied + * default by its declaration rather than by a permission set. + */ + +import type { RowLevelSecurityPolicy } from '@objectstack/spec/security'; + +/** The policies {@link objectDefaultRowScopePolicy} built — an object's default, by identity. */ +const OBJECT_DEFAULT_INSTANCES = new WeakSet(); + +/** + * The declared default row scope of `objectName` as one row-level security + * policy, with `object` set to the declaring object (the key implies it, and + * a stray `object` on a body that reached the registry unparsed cannot + * retarget it). `null` when the schema declares none, which keeps an object + * with no default exactly as it was. + * + * Built fresh per call: the collection hands it to filters and compilers that + * never write to it, and a fresh object keeps two registered objects that + * spread one shared scope literal from ever sharing a policy instance. + */ +export function objectDefaultRowScopePolicy(objectName: string, schema: unknown): RowLevelSecurityPolicy | null { + if (schema === null || typeof schema !== 'object') return null; + const scope = (schema as { defaultRowScope?: unknown }).defaultRowScope; + if (scope === null || typeof scope !== 'object' || Array.isArray(scope)) return null; + const policy = { ...(scope as Omit), object: objectName } as RowLevelSecurityPolicy; + OBJECT_DEFAULT_INSTANCES.add(policy); + return policy; +} + +/** + * True iff `policy` is an object's declared default row scope, as + * {@link objectDefaultRowScopePolicy} built it — never a held set's policy, + * whatever it spells. + */ +export function isObjectDefaultRowScopePolicy(policy: object): boolean { + return OBJECT_DEFAULT_INSTANCES.has(policy); +} diff --git a/packages/plugins/plugin-security/src/rls-compiler.ts b/packages/plugins/plugin-security/src/rls-compiler.ts index fee12defce9..176f8b5ba3b 100644 --- a/packages/plugins/plugin-security/src/rls-compiler.ts +++ b/packages/plugins/plugin-security/src/rls-compiler.ts @@ -54,6 +54,9 @@ import { // this plugin. This file holds no second literal: it returns the spec's object // and exports it under the name every consumer here already imports. import { RLS_DENY_FILTER as SPEC_RLS_DENY_FILTER } from '@objectstack/spec/data'; +// [#24164 ruling E′] An object's declared default row scope, recognised by +// identity so its compiled filter can say where it came from. +import { isObjectDefaultRowScopePolicy } from './object-default-row-scope.js'; /** * Why a policy's predicate produced no filter — the compiler's OWN answer, @@ -712,6 +715,24 @@ export function compiledPolicyNameOf(node: unknown): string | undefined { return node !== null && typeof node === 'object' ? POLICY_OF_COMPILED_FILTER.get(node) : undefined; } +/** + * [#24164 ruling E′] The object default row scope each compiled policy filter + * came from, recorded by {@link RLSCompiler.compileFilter} beside + * {@link POLICY_OF_COMPILED_FILTER}, for the same reason and in the same way: + * the compiled filter is built fresh, so its identity is its policy's. + */ +const OBJECT_DEFAULT_OF_COMPILED_FILTER = new WeakMap(); + +/** + * [#24164 ruling E′] The object's declared default row scope whose compiled + * filter `node` is, or `undefined` for any other node (a held set's policy, a + * composed `$or` / `$and`, the deny sentinel). Lets explain name an applied + * default as the object's declaration, never as a permission set's policy. + */ +export function compiledObjectDefaultRowScopeOf(node: unknown): RowLevelSecurityPolicy | undefined { + return node !== null && typeof node === 'object' ? OBJECT_DEFAULT_OF_COMPILED_FILTER.get(node) : undefined; +} + /** * Is this field constraint an emptied membership, and what CONSTANT does it * evaluate to? `{ $in: [] }` is constant FALSE on every backend ("IN () @@ -1017,6 +1038,7 @@ export class RLSCompiler { if (comparands.ok) { filters.push(comparands.filter); POLICY_OF_COMPILED_FILTER.set(comparands.filter, (policy as { name?: string }).name ?? '(unnamed)'); + if (isObjectDefaultRowScopePolicy(policy)) OBJECT_DEFAULT_OF_COMPILED_FILTER.set(comparands.filter, policy); } else { deniedBy.push({ policy, cause: { reason: 'refused-comparand', detail: comparands.detail } }); } diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 29e1bca1e26..1e3792351de 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -86,6 +86,7 @@ import { } 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'; import { isPlatformOwnershipFloorPolicy, masterGateCoversOperation, @@ -8993,7 +8994,26 @@ export class SecurityPlugin implements Plugin { } /** - * Collect all RLS policies from permission sets applicable to the given object/operation. + * The row-level security policies that apply to `objectName` for + * `operation`: the one collection every reader composes from — the read + * door's Layer 1, the by-id pre-image gate, the bulk write filter, the + * post-image check, `checkAuthoredRowWrite` and explain. + * + * [#24164 ruling E′, item 2] When no held set carries a row policy for this + * object and operation, the object's declared default row scope + * (`ObjectSchema.defaultRowScope`) applies in its place: + * - "carries" is this collection's own emptiness, after the ADR-0105 D3 + * admission, `enabled`, the `positions` domain and `'*'` matching, so a + * held `'*'` policy counts and a held policy for another operation does + * not (`object-default-row-scope.ts` states the reading); + * - an authored set policy is used as authored: the default is never added + * to a non-empty collection; + * - the default takes the path a held policy takes from the admission on, + * so its `using` / `check` compile, and fail closed, exactly as an + * authored policy's do; + * - a superuser-bypass principal never reaches this method (the bypass is + * decided by the caller, before the collection), and an object that + * declares no default collects exactly what it collected before. */ private collectRLSPolicies( permissionSets: PermissionSet[], @@ -9013,28 +9033,48 @@ export class SecurityPlugin implements Plugin { // 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)) { - // [ADR-0105 D3] When org isolation is NOT active, strip the tenant - // policies the PLATFORM itself ships — there is no meaningful active - // organization to compare against, so they would match zero rows (or - // be dropped by the field-existence safety net) for no benefit. - // - // Provenance, not pattern-matching (finding F1): the former substring - // test on `current_user.organization_id` also swallowed app-authored - // policies, silently unenforcing a declared security property. An - // authored policy now always reaches the compiler and fails closed - // there, where the operator can see it. - if (!this.orgScopingEnabled && isPlatformTenantPolicy(policy)) { - continue; - } - if (!this.orgScopingEnabled && isAuthoredTenantPolicy(policy)) { - this.warnAuthoredTenantPolicyOnce(policy, objectName); - } - allPolicies.push(policy); + if (this.admitCollectedPolicy(policy, objectName)) allPolicies.push(policy); } } } - return this.rlsCompiler.getApplicablePolicies(objectName, operation, allPolicies, heldPositions); + const held = this.rlsCompiler.getApplicablePolicies(objectName, operation, allPolicies, heldPositions); + if (held.length > 0) 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); + } + + /** + * [ADR-0105 D3] Whether one policy enters the collection under this + * deployment's tenancy posture. When org isolation is NOT active, the tenant + * policies the PLATFORM itself ships are stripped — there is no meaningful + * active organization to compare against, so they would match zero rows (or + * be dropped by the field-existence safety net) for no benefit. + * + * Provenance, not pattern-matching (finding F1): the former substring test + * on `current_user.organization_id` also swallowed app-authored policies, + * silently unenforcing a declared security property. An authored policy now + * always reaches the compiler and fails closed there, where the operator can + * see it. + * + * An object's default row scope passes the same test, so a default that + * spells a shipped tenant policy is stripped exactly as that policy's set + * 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; } /** From 6bea692908ad8cbf9a771f0757901b11fbec3256 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:25:05 +0000 Subject: [PATCH 2/6] test(plugin-security): pin the object default row scope at the collection point Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../src/object-default-row-scope.test.ts | 435 ++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 packages/plugins/plugin-security/src/object-default-row-scope.test.ts diff --git a/packages/plugins/plugin-security/src/object-default-row-scope.test.ts b/packages/plugins/plugin-security/src/object-default-row-scope.test.ts new file mode 100644 index 00000000000..a9eeac77062 --- /dev/null +++ b/packages/plugins/plugin-security/src/object-default-row-scope.test.ts @@ -0,0 +1,435 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24164 ruling E′, items 2 and 6] An object's declared default row scope + * (`ObjectSchema.defaultRowScope`) applies at the one collection point, + * `collectRLSPolicies`, when no held permission set carries a row policy for + * the object and operation. + * + * Driven on the real `SecurityPlugin` + `ObjectQL` over a real `SqlDriver`. + * The identity objects are the platform's own declarations, each given in the + * fixture the default its shipped sets copy today (`sys_user`'s two literals + * joined into the one `||` policy the key allows); `qa_note` is a test object + * that declares a default with no shipped copy anywhere. Rows are written + * straight into the tables, past every scope. + * + * The pins, in the ruling's order: + * 1. A principal with no baseline, holding only an authored `'*'` read set, + * reads only its own `sys_user`, `sys_session` and `sys_scim_user` rows + * plus its organisation's members on `sys_user` (CONTROL: without the + * declarations it reads every row). + * 2. An authored policy on such an object is applied as authored, and the + * default is not added to it. What "carries a policy" means is the + * collection's own reading: a held `'*'` policy counts, a held policy for + * another operation or outside the caller's positions does not. + * 3. The platform administrator is unchanged. + * 4. Explain names the object default. + * 5. A deployment with the baseline is byte-for-byte unchanged. + * 6. A default whose `using` does not lower is refused the way an authored + * policy's is: dropped with the compiler's WARN, failing closed to zero + * rows, on the same object with the same predicate. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; +import { SysMember, SysScimUser, SysSession, SysUser } from '@objectstack/platform-objects/identity'; +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; + +const SYS = { context: { isSystem: true } } as never; + +/** The defaults the identity objects are to declare: the literals the shipped sets copy. */ +const IDENTITY_DEFAULTS: Record> = { + sys_user: { + name: 'sys_user_default_scope', + operation: 'select', + using: 'id == current_user.id || id in current_user.org_user_ids', + }, + sys_session: { name: 'sys_session_default_scope', operation: 'all', using: 'user_id == current_user.id' }, + sys_scim_user: { name: 'sys_scim_user_default_scope', operation: 'select', using: 'user_id == current_user.id' }, +}; + +const NOTE_DEFAULT = { + name: 'qa_note_owner', + label: 'Own notes', + description: 'A note is readable and writable by its author only.', + operation: 'all', + using: 'owner_ref == current_user.id', +}; + +/** Outside the pushdown subset: a function call never lowers to a row filter. */ +const UNLOWERABLE = 'size(owner_ref) > 3'; + +const noteObject = (name: string, defaultRowScope?: Record) => ({ + name, + label: name, + systemFields: { tenant: false }, + fields: { + name: { name: 'name', type: 'text' }, + owner_ref: { name: 'owner_ref', type: 'text' }, + region: { name: 'region', type: 'text' }, + }, + ...(defaultRowScope ? { defaultRowScope } : {}), +}); + +/** Organisation X: `u_x` and `u_x2`. Organisation Y: `u_y`. */ +const ORG_X_USERS = ['u_x', 'u_x2']; +const USERS = [...ORG_X_USERS, 'u_y']; + +const authored = (set: Record): PermissionSet => PermissionSetSchema.parse(set) as PermissionSet; + +/** The ruling's principal: an authored `'*'` read set, no row policy of its own. */ +const STAR_READER = authored({ name: 'qa_star_reader', label: 'Star reader', objects: { '*': { allowRead: true } } }); +const STAR_WRITER = authored({ + name: 'qa_star_writer', + label: 'Star writer', + objects: { '*': { allowRead: true, allowEdit: true } }, +}); +/** An authored policy on the object: used as authored. */ +const NORTH_READER = authored({ + name: 'qa_north_reader', + label: 'North reader', + objects: { '*': { allowRead: true } }, + rowLevelSecurity: [{ name: 'qa_note_north', object: 'qa_note', operation: 'select', using: "region == 'north'" }], +}); +/** A held `'*'` policy is a held policy for every object it reaches. */ +const WILDCARD_READER = authored({ + name: 'qa_wildcard_reader', + label: 'Wildcard reader', + objects: { '*': { allowRead: true } }, + rowLevelSecurity: [{ name: 'qa_named_rows', object: '*', operation: 'select', using: "name != 'zzz'" }], +}); +/** A held policy for another operation, and one outside the caller's positions. */ +const UPDATE_SCOPED = authored({ + name: 'qa_update_scoped', + label: 'Update scoped', + objects: { '*': { allowRead: true, allowEdit: true } }, + rowLevelSecurity: [{ name: 'qa_note_north_writes', object: 'qa_note', operation: 'update', using: "region == 'north'" }], +}); +const MANAGER_SCOPED = authored({ + name: 'qa_manager_scoped', + label: 'Manager scoped', + objects: { '*': { allowRead: true } }, + rowLevelSecurity: [ + { name: 'qa_note_managers', object: 'qa_note', operation: 'select', using: "region == 'north'", positions: ['qa_manager'] }, + ], +}); +/** The same un-lowerable predicate, authored in a set, on a sibling object that declares no default. */ +const UNLOWERABLE_AUTHORED = authored({ + name: 'qa_unlowerable_authored', + label: 'Unlowerable authored', + objects: { '*': { allowRead: true } }, + rowLevelSecurity: [{ name: 'qa_note_bare_size', object: 'qa_note_bare', operation: 'select', using: UNLOWERABLE }], +}); + +const AUTHORED_SETS = [STAR_READER, STAR_WRITER, NORTH_READER, WILDCARD_READER, UPDATE_SCOPED, MANAGER_SCOPED, UNLOWERABLE_AUTHORED]; + +const principal = (permissions: string[], extra: Record = {}) => ({ + userId: 'u_x', + tenantId: 'org_x', + positions: [] as string[], + permissions, + posture: 'MEMBER', + org_user_ids: ORG_X_USERS, + email: 'u_x@example.test', + ...extra, +}); +const PLATFORM_ADMIN = { + userId: 'p_admin', tenantId: 'org_x', positions: [], permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN', +}; + +/** A plausible value for a required column the fixture does not care about. */ +function filler(type: string, key: string): unknown { + if (type === 'boolean') return true; + if (type === 'number') return 0; + if (type === 'datetime') return '2026-01-01T00:00:00.000Z'; + return key; +} + +/** One row of `schema`: every required column filled, then those of `values` it declares. */ +function row(schema: { fields: unknown }, values: Record): Record { + const fields = schema.fields as Record; + const out: Record = {}; + for (const [name, def] of Object.entries(fields)) if (def.required) out[name] = filler(def.type, `${name}-${String(values.id)}`); + for (const [name, value] of Object.entries(values)) if (name in fields) out[name] = value; + return out; +} + +type Table = (name: string) => { insert(rows: Array>): Promise }; +const engines: ObjectQL[] = []; +afterEach(async () => { + for (const engine of engines.splice(0)) { + try { await engine.destroy(); } catch { /* noop */ } + } +}); + +interface Shape { + /** Compose the platform baseline (`member_default`) beside every principal's own sets. */ + baseline: boolean; + /** The identity objects and `qa_note` declare their defaults. */ + declared: boolean; +} + +async function boot(shape: Shape) { + const identity = [SysUser, SysSession, SysScimUser].map((schema) => + shape.declared ? { ...schema, defaultRowScope: IDENTITY_DEFAULTS[schema.name] } : schema, + ); + const notes = [ + noteObject('qa_note', shape.declared ? NOTE_DEFAULT : undefined), + noteObject('qa_note_unlowerable', shape.declared ? { name: 'qa_note_unlowerable_scope', operation: 'select', using: UNLOWERABLE } : undefined), + noteObject('qa_note_bare'), + ]; + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver(driver as never, true); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.object-default-row-scope', + name: 'Object default row scope', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [...identity, SysMember, ...notes], + } as never); + await engine.syncSchemas(); + // The authz resolver reads these on every grant resolution; registered after + // the DDL and left unprovisioned, as the SCIM row-scope pin does. + for (const o of [SysPosition, SysUserPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); + } + + const sets = [...defaultPermissionSets, ...AUTHORED_SETS]; + const services: Record = { + 'org-scoping': { name: 'com.objectstack.org-scoping' }, + tenancy: { posture: 'isolated' }, + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { get: async (_t: string, name: string) => engine.getSchema(name) ?? null, list: async () => sets }, + }; + let security: any; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const ctx = { + logger, + registerService: (name: string, impl: unknown) => { if (name === 'security') security = impl; }, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ defaultPermissionSets: sets, ...(shape.baseline ? {} : { fallbackPermissionSet: null }) }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + + const table = (driver as unknown as { knex: Table }).knex; + const sysUser = identity[0]; + await table('sys_user').insert(USERS.map((id) => row(sysUser, { id, name: id, email: `${id}@example.test` }))); + await table('sys_session').insert(USERS.map((id) => row(identity[1], { id: `sess:${id}`, user_id: id }))); + await table('sys_scim_user').insert(USERS.map((id) => row(identity[2], { id: `scim:${id}`, user_id: id }))); + for (const object of ['qa_note', 'qa_note_unlowerable', 'qa_note_bare']) { + await table(object).insert([ + { id: `${object}:mine_north`, name: 'mine north', owner_ref: 'u_x', region: 'north' }, + { id: `${object}:mine_south`, name: 'mine south', owner_ref: 'u_x', region: 'south' }, + { id: `${object}:theirs_north`, name: 'theirs north', owner_ref: 'u_y', region: 'north' }, + { id: `${object}:theirs_south`, name: 'theirs south', owner_ref: 'u_y', region: 'south' }, + ]); + } + + const ids = async (object: string, context: object): Promise => { + const rows = (await engine.find(object, { context: { ...context } } as never)) as Array<{ id: string }>; + return rows.map((r) => r.id).sort(); + }; + return { engine, security, logger, ids }; +} + +const notes = (...keys: string[]) => keys.map((k) => `qa_note:${k}`).sort(); + +describe('[#24164 E′] no baseline, an authored `*` read set: the identity objects read under their declared defaults', () => { + it('reads only its own sys_user, sys_session and sys_scim_user rows, plus its organisation members on sys_user', async () => { + const { ids } = await boot({ baseline: false, declared: true }); + const reader = principal([STAR_READER.name]); + expect(await ids('sys_user', reader)).toEqual(['u_x', 'u_x2']); + expect(await ids('sys_session', reader)).toEqual(['sess:u_x']); + expect(await ids('sys_scim_user', reader)).toEqual(['scim:u_x']); + }); + + it('CONTROL: without the declarations the same principal reads every row of all three tables', async () => { + const { ids } = await boot({ baseline: false, declared: false }); + const reader = principal([STAR_READER.name]); + expect(await ids('sys_user', reader)).toEqual([...USERS].sort()); + expect(await ids('sys_session', reader)).toEqual(USERS.map((u) => `sess:${u}`).sort()); + expect(await ids('sys_scim_user', reader)).toEqual(USERS.map((u) => `scim:${u}`).sort()); + }); + + it('a test object reads under its default too, and the CONTROL object without one reads every row', async () => { + const { ids } = await boot({ baseline: false, declared: true }); + const reader = principal([STAR_READER.name]); + expect(await ids('qa_note', reader)).toEqual(notes('mine_north', 'mine_south')); + expect(await ids('qa_note_bare', reader)).toHaveLength(4); + }); +}); + +describe('[#24164 E′] an authored set policy is used as authored; "carries" is the collection\'s own reading', () => { + it('a held policy for the object is applied alone: the default is not added to it', async () => { + const { ids } = await boot({ baseline: false, declared: true }); + // `mine_south` is the author's own row, which the default admits: absent, so nothing was OR-ed in. + expect(await ids('qa_note', principal([NORTH_READER.name]))).toEqual(notes('mine_north', 'theirs_north')); + }); + + it('a held `*` policy carries a policy for the object, so the default does not apply', async () => { + const { ids } = await boot({ baseline: false, declared: true }); + expect(await ids('qa_note', principal([WILDCARD_READER.name]))).toHaveLength(4); + }); + + it('a held policy for another operation does not count: reads still run under the default', async () => { + const { ids } = await boot({ baseline: false, declared: true }); + expect(await ids('qa_note', principal([UPDATE_SCOPED.name]))).toEqual(notes('mine_north', 'mine_south')); + }); + + it('a held policy outside the caller\'s positions does not count; inside them it is used as authored', async () => { + const { ids } = await boot({ baseline: false, declared: true }); + expect(await ids('qa_note', principal([MANAGER_SCOPED.name]))).toEqual(notes('mine_north', 'mine_south')); + expect(await ids('qa_note', principal([MANAGER_SCOPED.name], { positions: ['qa_manager'] }))).toEqual( + notes('mine_north', 'theirs_north'), + ); + }); + + it('the write doors read the same collection: the default scopes the by-id update and judges its post-image', async () => { + const { engine } = await boot({ baseline: false, declared: true }); + const writer = { context: principal([STAR_WRITER.name]) } as never; + await engine.update('qa_note', { id: 'qa_note:mine_north', name: 'renamed' }, writer); + expect(((await engine.findOne('qa_note', { where: { id: 'qa_note:mine_north' } }, SYS)) as { name: string }).name).toBe('renamed'); + + const refusals: Array<[string, () => Promise, { code: string; status: number }]> = [ + // Pre-image: a row outside the default's scope is one the caller cannot + // read, which a by-id write answers as absent. + [ + 'pre-image', + () => engine.update('qa_note', { id: 'qa_note:theirs_north', name: 'taken' }, writer), + { code: 'RECORD_NOT_FOUND', status: 404 }, + ], + // Post-image: the default's `using` stands in as the check, so handing the row away is refused. + [ + 'post-image', + () => engine.update('qa_note', { id: 'qa_note:mine_south', owner_ref: 'u_y' }, writer), + { code: 'PERMISSION_DENIED', status: 403 }, + ], + ]; + for (const [leg, write, envelope] of refusals) { + const error = await write().then(() => null, (e: { code?: string; status?: number }) => e); + expect(error, leg).not.toBeNull(); + expect({ code: error?.code, status: error?.status }, leg).toEqual(envelope); + } + const untouched = (await engine.find('qa_note', { where: { owner_ref: 'u_y' } }, SYS)) as Array<{ id: string; name: string }>; + expect(untouched.map((r) => `${r.id}=${r.name}`).sort()).toEqual(['qa_note:theirs_north=theirs north', 'qa_note:theirs_south=theirs south']); + }); + + it('checkAuthoredRowWrite reads the default as the declaration it is, exactly as a held copy of it would be read', async () => { + const { security } = await boot({ baseline: false, declared: true }); + const writer = principal([STAR_WRITER.name]); + expect(await security.checkAuthoredRowWrite('qa_note', 'qa_note:mine_south', 'update', writer)).toBe('admit'); + expect(await security.checkAuthoredRowWrite('qa_note', 'qa_note:theirs_south', 'update', writer)).toBe('abstain'); + // CONTROL: no default, no held policy — nothing declares an admission. + expect(await security.checkAuthoredRowWrite('qa_note_bare', 'qa_note_bare:mine_south', 'update', writer)).toBe('abstain'); + }); +}); + +describe('[#24164 E′] the platform administrator is unchanged', () => { + it('reads every row of every object, with the defaults declared and without them', async () => { + for (const declared of [true, false]) { + const { ids } = await boot({ baseline: false, declared }); + expect(await ids('sys_user', PLATFORM_ADMIN), `declared: ${declared}`).toEqual([...USERS].sort()); + expect(await ids('sys_session', PLATFORM_ADMIN), `declared: ${declared}`).toHaveLength(3); + expect(await ids('sys_scim_user', PLATFORM_ADMIN), `declared: ${declared}`).toHaveLength(3); + expect(await ids('qa_note', PLATFORM_ADMIN), `declared: ${declared}`).toHaveLength(4); + } + }); +}); + +describe('[#24164 E′] explain names the object default, never a permission set', () => { + const rlsLayer = (decision: { layers: Array<{ layer: string }> }) => + decision.layers.find((l) => l.layer === 'rls') as { verdict: string; detail: string; contributors: unknown[]; record?: any }; + + it('object-level: the rls layer names the default, its label and description, and the declaring object', async () => { + const { security } = await boot({ baseline: false, declared: true }); + const layer = rlsLayer(await security.explain({ object: 'qa_note', operation: 'read' }, principal([STAR_READER.name]))); + expect(layer.verdict).toBe('narrows'); + expect(layer.contributors).toEqual([]); + for (const subject of ["'qa_note_owner'", NOTE_DEFAULT.label, NOTE_DEFAULT.description, "'qa_note'", 'object default row scope']) { + expect(layer.detail).toContain(subject); + } + }); + + it('record-grained: the rule is the default by name, reached through the object default', async () => { + const { security } = await boot({ baseline: false, declared: true }); + const reader = principal([STAR_READER.name]); + const mine = rlsLayer(await security.explain({ object: 'qa_note', operation: 'read', recordId: 'qa_note:mine_north' }, reader)); + expect(mine.record.rules).toEqual([expect.objectContaining({ kind: 'rls_policy', name: 'qa_note_owner', via: 'object_default', effect: 'admits' })]); + }); + + it('CONTROL: an authored policy is not named as an object default', async () => { + const { security } = await boot({ baseline: false, declared: true }); + const reader = principal([NORTH_READER.name]); + const layer = rlsLayer(await security.explain({ object: 'qa_note', operation: 'read' }, reader)); + expect(layer.verdict).toBe('narrows'); + expect(layer.detail).not.toContain('object default'); + const record = rlsLayer(await security.explain({ object: 'qa_note', operation: 'read', recordId: 'qa_note:mine_north' }, reader)); + expect(record.record.rules).toEqual([expect.objectContaining({ kind: 'rls_policy', name: 'business_rls' })]); + expect(record.record.rules[0].via).toBeUndefined(); + }); +}); + +describe('[#24164 E′] a deployment with the baseline is byte-for-byte unchanged', () => { + const PERSONAS = { + member_default: principal(['member_default'], { positions: ['org_member'] }), + viewer_readonly: principal(['viewer_readonly'], { positions: ['org_member'] }), + organization_admin: principal(['organization_admin'], { positions: ['org_admin'], posture: 'TENANT_ADMIN' }), + platform_admin: PLATFORM_ADMIN, + }; + + it('every shipped persona reads and is explained exactly as without the declarations', async () => { + const now = await boot({ baseline: true, declared: true }); + const before = await boot({ baseline: true, declared: false }); + for (const [persona, context] of Object.entries(PERSONAS)) { + for (const object of ['sys_user', 'sys_session', 'sys_scim_user']) { + const label = `${persona} on ${object}`; + const served = await before.ids(object, context); + expect(await now.ids(object, context), label).toEqual(served); + for (const operation of ['read', 'update']) { + const explained = JSON.stringify(await before.security.explain({ object, operation }, { ...context })); + expect(JSON.stringify(await now.security.explain({ object, operation }, { ...context })), `${label} (${operation})`).toBe(explained); + } + } + } + // CONTROL: the comparison has teeth — the baseline really narrows these reads. + expect(await now.ids('sys_user', PERSONAS.member_default)).toEqual(['u_x', 'u_x2']); + expect(await now.ids('sys_session', PERSONAS.member_default)).toEqual(['sess:u_x']); + }); +}); + +describe('[#24164 E′] a default whose `using` does not lower is refused the way an authored policy\'s is', () => { + it('both read zero rows, and both are dropped with the compiler\'s uncompilable-predicate WARN', async () => { + const { ids, logger } = await boot({ baseline: false, declared: true }); + expect(await ids('qa_note_unlowerable', principal([STAR_READER.name]))).toEqual([]); + expect(await ids('qa_note_bare', principal([UNLOWERABLE_AUTHORED.name]))).toEqual([]); + const warned = logger.warn.mock.calls.map((call) => String(call[0])).filter((line) => line.includes('uncompilable predicate')); + for (const policy of ["policy 'qa_note_unlowerable_scope' on 'qa_note_unlowerable'", "policy 'qa_note_bare_size' on 'qa_note_bare'"]) { + expect(warned.some((line) => line.includes(policy)), policy).toBe(true); + } + }); + + it('explain reports the same verdict for both: row-level security denies', async () => { + const { security } = await boot({ baseline: false, declared: true }); + const verdictOf = async (object: string, permissions: string[]) => + ((await security.explain({ object, operation: 'read' }, principal(permissions))).layers as Array<{ layer: string; verdict: string }>) + .find((l) => l.layer === 'rls')?.verdict; + expect(await verdictOf('qa_note_unlowerable', [STAR_READER.name])).toBe('denies'); + expect(await verdictOf('qa_note_bare', [UNLOWERABLE_AUTHORED.name])).toBe('denies'); + }); +}); From 23e7bab478f96385c11c4f277f328632410e55ba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:28:04 +0000 Subject: [PATCH 3/6] wip(spec, lint): defaultRowScope flips to live; its texts say what the reader does Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../src/validate-org-axis-red-lines.test.ts | 13 ++-- packages/spec/liveness/object.json | 74 +++++++++---------- packages/spec/src/data/object.zod.ts | 54 ++++++++------ packages/spec/src/security/rls.zod.ts | 16 ++-- 4 files changed, 86 insertions(+), 71 deletions(-) diff --git a/packages/lint/src/validate-org-axis-red-lines.test.ts b/packages/lint/src/validate-org-axis-red-lines.test.ts index f873db7c5de..a2cb5c56a98 100644 --- a/packages/lint/src/validate-org-axis-red-lines.test.ts +++ b/packages/lint/src/validate-org-axis-red-lines.test.ts @@ -73,9 +73,11 @@ function sharingRule(input: SharingRule): Record { /** * Same guard for the permission-set fixtures rule ① reads (#5009). * - * `permissions[].rowLevelSecurity` is the ONLY authorable RLS surface — - * `ObjectSchema` declares no `rowLevelSecurity` (nor `rls`) and is `.strict()`, - * so the object-level traversal this rule used to carry could not run against a + * `permissions[].rowLevelSecurity` is the ONLY authorable LIST of RLS policies. + * An object declares at most ONE policy, `defaultRowScope` (#24164 ruling E′: + * the policy that applies when no held set carries one for the object), and no + * `rowLevelSecurity` (nor `rls`) list; `ObjectSchema` is `.strict()`, so the + * object-level list traversal this rule used to carry could not run against a * stack that parses. Building rule ①'s fixtures through the real schema is what * keeps that from quietly coming back: a fixture for a surface the spec does * not have now fails HERE. @@ -349,11 +351,12 @@ const MANIFEST = { id: 'com.example.org-axis-probe', name: 'org_axis_probe', ver const ORG_WALKING_POLICY = { name: 'rollup', using: 'parent_organization_id = current_user.organization_id' }; describe('validateOrgAxisRedLines — undeclared keys are the schema’s job, not this rule’s (#5009)', () => { - it('object-level RLS is not an authoring surface: `ObjectSchema` declares neither `rowLevelSecurity` nor `rls`', () => { + it('an object-level RLS policy LIST is not an authoring surface: `ObjectSchema` declares neither `rowLevelSecurity` nor `rls` (its one object-level policy is `defaultRowScope`)', () => { const objectKeys = Object.keys(ObjectSchema.shape); expect(objectKeys).not.toContain('rowLevelSecurity'); expect(objectKeys).not.toContain('rls'); - // The only declared home for RLS policies is the permission set. + // The only declared home for a LIST of RLS policies is the permission set; + // an object's own single policy is `defaultRowScope`. expect(Object.keys(PermissionSetSchema.shape)).toContain('rowLevelSecurity'); // And `ObjectSchema` is `.strict()`, so this is not a silent strip: a stack diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index 5be046dabce..43c38b5ddda 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -159,60 +159,58 @@ "note": "ADR-0055 high-risk class (sharing): the `controlled_by_parent` value derives a detail object's access from its master — the security layer injects `masterFK IN (accessible master ids)` on reads and requires master edit-access on by-id writes. The proof asserts a member who cannot read the master can neither read nor by-id-write the detail, and is not over-blocked on a master they own. 2026-08-28: RE-ANCHORED (commit 93ea19bca) and REPOINTED — `sharing-service.ts:54` had rotted onto a blank line between an interface and a `makeShareId` docblock. Re-closed by hand against c459da6bc." }, "defaultRowScope": { - "status": "planned", - "verifiedAt": "2026-10-18", - "evidence": "packages/spec/src/data/object.zod.ts#ObjectSchemaBase (declares the key, optional, typed as the default-row-scope shape); packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (the shape: RowLevelSecurityPolicySchema's own members without `object`, with the same two rules, refineRowLevelSecurityPolicy)", - "authorWarn": true, - "authorHint": "Not applied yet: no server reads `defaultRowScope` in this release, so it filters no rows. Until the security plugin's row-policy collection applies it, restrict rows with a permission set's `rowLevelSecurity` policy.", - "note": "PLANNED, the reader pending — #24164 ruling E′ item 1 (spec half, #24338). The one reader is to be plugin-security's `collectRLSPolicies` (packages/plugins/plugin-security/src/security-plugin.ts), carried by objectstack-ai/objectstack#24340: measured before the key existed, `git grep defaultRowScope` over the whole tree at origin/main b2e78701f7 returns zero hits (control: `sharingModel` under packages/plugins hits), and objectui at the `.objectui-sha` pin 0707b4ae37 returns zero too (same control hits), so this declaration adds no reader and an object declaring the key is filtered exactly as before. The writers are to be the platform identity objects (packages/platform-objects), carried by objectstack-ai/objectstack#24341, which is blocked on this key alone, so its declarations can land before the reader does; until #24340 lands they too filter nothing. Flip to `live` on the commit that lands #24340, citing `collectRLSPolicies` as the evidence and its explain source as the second read point, and drop `authorWarn` / `authorHint` in that same edit: a warned `live` row reds this gate. Drilled into `children` rather than deferred to `permission/rowLevelSecurity`: every member is RowLevelSecurityPolicySchema's own instance, but the key sets differ (`object`, `priority` and `tags` are left out here), so the gate's equality check would refuse the deferral; each child is `planned` for the same reason as the container, and only the container carries `authorWarn`, so authoring the key warns once. Flip the children with the container.", + "status": "live", + "verifiedAt": "2026-10-19", + "evidence": "packages/plugins/plugin-security/src/security-plugin.ts#collectRLSPolicies (when the held sets' collection for the object and operation is empty, reads the registry schema's `defaultRowScope` and applies it as one policy through the same ADR-0105 D3 admission and `getApplicablePolicies` filter; the read door's Layer 1, the by-id pre-image gate, the bulk write filter, the post-image check, `checkAuthoredRowWrite` and explain all compose from this one collection); packages/plugins/plugin-security/src/object-default-row-scope.ts#objectDefaultRowScopePolicy (builds that policy, `object` implied, marked by identity); packages/plugins/plugin-security/src/explain-engine.ts#objectDefaultRowScopeSource (the second read point: explain names the applied default as the object's declaration, never a permission set)", + "note": "LIVE since #24340 (#24164 ruling E′ item 2), flipped from `planned` with `authorWarn` / `authorHint` dropped in the same edit, as the row's own note asked. The reader is plugin-security's `collectRLSPolicies`: when no held set carries a row policy for the object and operation (the collection's own emptiness, so a held `'*'` policy counts and a policy for another operation does not), the object's default applies; an authored set policy is used as authored; a superuser-bypass principal never reaches the collection; an object with no default collects as before. A `using` that does not lower fails closed exactly as an authored policy's does (the same compiler). Pinned through the real SecurityPlugin + ObjectQL over SqlDriver in packages/plugins/plugin-security/src/object-default-row-scope.test.ts, and ablated there: removing the branch turns every default-dependent pin red and leaves the controls green. The writers are still to be the platform identity objects (#24341); no object in this tree declares the key yet. Drilled into `children` rather than deferred to `permission/rowLevelSecurity` because the key sets differ (`object`, `priority` and `tags` are left out here), so the gate's equality check would refuse the deferral; the children are flipped with the container.", "children": { "name": { - "status": "planned", - "verifiedAt": "2026-10-18", - "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `name`: RowLevelSecurityPolicySchema's own member instance, the policy name, snake_case; the explain source a reader names)", - "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + "status": "live", + "verifiedAt": "2026-10-19", + "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts#compileFilter (the uncompilable-predicate and fail-closed WARN lines name the policy by `name`); packages/plugins/plugin-security/src/explain-engine.ts#objectDefaultRowScopeSource (explain names the applied default by `name`)", + "note": "Live with its container (#24340): the default is collected as one policy and passes the held policies' own applicability filter and compiler, so this member is read exactly where the permission-set `rowLevelSecurity` member of the same instance is." }, "label": { - "status": "planned", - "verifiedAt": "2026-10-18", - "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `label`: RowLevelSecurityPolicySchema's own member instance, the human-readable policy label)", - "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + "status": "live", + "verifiedAt": "2026-10-19", + "evidence": "packages/plugins/plugin-security/src/explain-engine.ts#objectDefaultRowScopeSource (draws `label` beside the default's name in the rls layer's detail, verbatim; an absent or blank label draws nothing)", + "note": "Live by the display-key rule (README, 'Designer previews count as consumers'): explain shows the label to the administrator reading why a row is visible, which for a display key is the whole claimed effect." }, "description": { - "status": "planned", - "verifiedAt": "2026-10-18", - "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `description`: RowLevelSecurityPolicySchema's own member instance, the policy description)", - "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + "status": "live", + "verifiedAt": "2026-10-19", + "evidence": "packages/plugins/plugin-security/src/explain-engine.ts#objectDefaultRowScopeSource (draws `description` after the label in the rls layer's detail, verbatim; an absent or blank description draws nothing)", + "note": "Live with `label`, on the same reading: explain shows it to the administrator reading the access decision." }, "operation": { - "status": "planned", - "verifiedAt": "2026-10-18", - "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `operation`: RowLevelSecurityPolicySchema's own member instance, which operations the scope applies to)", - "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + "status": "live", + "verifiedAt": "2026-10-19", + "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts#getApplicablePolicies (`policy.operation === 'all'`, else compared against the mapped RLS op; the default passes this filter after the held collection comes back empty)", + "note": "Live with its container (#24340): the default is collected as one policy and passes the held policies' own applicability filter and compiler, so this member is read exactly where the permission-set `rowLevelSecurity` member of the same instance is." }, "using": { - "status": "planned", - "verifiedAt": "2026-10-18", - "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `using`: RowLevelSecurityPolicySchema's own member instance, the row predicate)", - "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + "status": "live", + "verifiedAt": "2026-10-19", + "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts#compileFilter (`policy.using` is the predicate on the read pass, and on a `check` pass for a policy that declares no `check`); packages/plugins/plugin-security/src/security-plugin.ts#writeCheckPolicies (hands USING-only policies to that `check` pass only when no applicable policy for the write declares `check`)", + "note": "Live with its container (#24340): the default is collected as one policy and passes the held policies' own applicability filter and compiler, so this member is read exactly where the permission-set `rowLevelSecurity` member of the same instance is." }, "check": { - "status": "planned", - "verifiedAt": "2026-10-18", - "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `check`: RowLevelSecurityPolicySchema's own member instance, the write-image check, refused on a `select` / `delete` scope)", - "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + "status": "live", + "verifiedAt": "2026-10-19", + "evidence": "packages/plugins/plugin-security/src/security-plugin.ts#writeCheckPolicies (the policies that declare `check` when any applicable policy for the write does, otherwise each applicable policy's `using`); packages/plugins/plugin-security/src/rls-compiler.ts#compileFilter (`policyDeclaresClause(policy, 'check') ? check : policy.using` on the `check` pass)", + "note": "Live with its container (#24340): the default is collected as one policy and passes the held policies' own applicability filter and compiler, so this member is read exactly where the permission-set `rowLevelSecurity` member of the same instance is." }, "positions": { - "status": "planned", - "verifiedAt": "2026-10-18", - "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `positions`: RowLevelSecurityPolicySchema's own member instance, the positions the scope applies to)", - "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + "status": "live", + "verifiedAt": "2026-10-19", + "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts#getApplicablePolicies (`const domain = (policy as { positions?: string[] }).positions` — the applicability domain, matched flat against the caller's held positions)", + "note": "Live with its container (#24340): the default is collected as one policy and passes the held policies' own applicability filter and compiler, so this member is read exactly where the permission-set `rowLevelSecurity` member of the same instance is." }, "enabled": { - "status": "planned", - "verifiedAt": "2026-10-18", - "evidence": "packages/spec/src/security/rls.zod.ts#DefaultRowScopeSchema (declares `enabled`: RowLevelSecurityPolicySchema's own member instance, whether the scope is active (default true))", - "note": "Planned with its container: no server reads `defaultRowScope` until the security plugin's `collectRLSPolicies` does." + "status": "live", + "verifiedAt": "2026-10-19", + "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts#getApplicablePolicies (`(policy as { enabled?: boolean }).enabled === false` excluded before object / positions / operation matching; absent = active per the schema default)", + "note": "Live with its container (#24340): the default is collected as one policy and passes the held policies' own applicability filter and compiler, so this member is read exactly where the permission-set `rowLevelSecurity` member of the same instance is." } } }, diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index edb6930a733..7b46063f5e8 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2973,35 +2973,43 @@ const ObjectSchemaBase = strictObject( * The object's default row scope (#24164 ruling E′, item 1): one row-level * security policy, the permission-set `rowLevelSecurity` entry shape without * `object` ({@link DefaultRowScopeSchema}). Layer 1 silence on an object, no - * held set carrying a row policy for it, then means this policy rather than - * no filter. + * held set carrying a row policy for it, means this policy rather than no + * filter. * - * The describe states the CONTRACT, not shipped behaviour (Prime Directive - * #10, the claim as narrow as the enforcement): its one reader is to be - * plugin-security's `collectRLSPolicies`, the collection every read, write - * and explain door uses (#24340), and until that lands no server reads the - * key; its writers are to be the platform identity objects (#24341), and no - * object declares it yet. The liveness row is `planned` with an author - * warning until the reader lands. + * Its one reader is plugin-security's `collectRLSPolicies` (#24340, ruling + * item 2), the collection every read, write and explain door composes from. + * When the held sets' collection for this object and the operation is empty + * — the collection's own emptiness, so a held `'*'` policy counts and a held + * policy for another operation, switched off or outside the caller's + * positions does not — the reader collects this policy in its place, for the + * declaring object, and judges it with the same applicability filter and the + * same compiler as a held policy. An authored set policy is used as + * authored, with the default not added; a superuser-bypass principal never + * reaches the collection (the bypass is decided before it); an object with no + * default collects exactly what it collected before. * - * The ruling's semantics, which the reader is to meet: when no held set - * carries a row policy for this object, the default applies; an authored set - * policy is used as authored; a superuser-bypass principal is untouched (the - * bypass is decided before the collection); an object with no default keeps - * today's behaviour. + * A `using` the row-filter compiler cannot lower is dropped with the + * compiler's WARN and fails closed to zero rows, exactly as an authored + * policy's does; the authoring lint (`RLS_PREDICATE_UNENFORCEABLE`) walks + * permission sets only and does not judge this key. Explain names an applied + * default as the object's own declaration, with its `label` and + * `description`. The writers are to be the platform identity objects + * (#24341); no object in this tree declares the key yet. */ defaultRowScope: DefaultRowScopeSchema.optional().describe( "The object's default row scope: one row-level security policy, written like a permission set's " + '`rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is ' - + 'refused). The contract the security plugin\'s row-policy collection, the one step every read, write ' - + 'and explain passes through, is to meet once it reads the key: when no permission set the caller ' - + 'holds carries a row policy for this object, this policy is to apply; a policy an authored set ' - + 'carries for the object is to be used as authored, with this default not added to it; a principal ' - + 'whose superuser bypass skips row policies is to be unaffected; an object that declares none keeps ' - + 'the behaviour it has without the key. The platform\'s identity objects are to declare it for their ' - + 'own tables. Not applied yet: no server reads this key in this release, so declaring it filters no ' - + 'rows. Until the collection applies it, restrict rows with a permission set\'s `rowLevelSecurity` ' - + 'policy.', + + 'refused). The security plugin\'s row-policy collection, the one step every read, write and explain ' + + 'passes through, applies it when no permission set the caller holds carries a row policy for this ' + + 'object and the operation: a held policy for every object (`\'*\'`) counts, while one for another ' + + 'operation, switched off, or outside the caller\'s positions does not. A policy an authored set carries ' + + 'for the object is used as authored, with this default not added to it; a principal whose superuser ' + + 'bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has ' + + 'without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` ' + + 'decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it ' + + 'governs return no rows, with one warning in the server log. Authoring checks do not judge this ' + + 'predicate, so keep it inside the lowerable subset. Explain names an applied default as the object\'s ' + + 'own, not a permission set\'s.', ), /** diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index 12caf499ed1..4717235d272 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -666,7 +666,7 @@ const DEFAULT_ROW_SCOPE_OBJECT_GUIDANCE = * An object's default row scope: {@link RowLevelSecurityPolicySchema} without * `object`, which the declaring object implies. It is the value of * `ObjectSchema.defaultRowScope` (`data/object.zod.ts`), whose describe states - * the contract the security plugin's row-policy collection is to meet. + * what the security plugin's row-policy collection does with it. * * ## Why it is rebuilt, not cut * @@ -692,10 +692,16 @@ const DEFAULT_ROW_SCOPE_OBJECT_GUIDANCE = * a policy copied from a permission set fails loudly instead of parsing with a * target the runtime would never read. * - * Reader and writers (#24164 ruling E′): the reader is to be the security - * plugin's `collectRLSPolicies` (#24340); the writers are to be the platform - * identity objects (#24341). Neither exists yet: until the reader lands, no - * server reads the key, and until the writers land, no object declares it. + * Reader and writers (#24164 ruling E′): the reader is the security plugin's + * `collectRLSPolicies` (#24340). When no held permission set carries a row + * policy for the declaring object and the operation, it collects this scope as + * one policy for that object and judges it exactly as it judges a held + * policy: the same applicability filter reads `operation`, `positions` and + * `enabled`, the same compiler compiles `using` and `check` (an un-lowerable + * predicate fails closed there, as an authored one does), and explain names + * it by `name`, `label` and `description` as the object's own. The writers are + * to be the platform identity objects (#24341); until they land, no object in + * this tree declares it. */ export const DefaultRowScopeSchema = lazySchema(() => { const { object: _implied, priority: _priority, tags: _tags, ...members } = RowLevelSecurityPolicySchema.shape; From a078a76ca821de27a8142cb2c338f5beb96c05cc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:37:31 +0000 Subject: [PATCH 4/6] chore(spec, plugin-security): changesets and the regenerated reference pages and liveness counts Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .changeset/24340-default-row-scope-live.md | 10 ++++++++++ .changeset/24340-rls-object-default-scope.md | 20 +++++++++++++++++++ content/docs/references/api/metadata.mdx | 2 +- content/docs/references/data/object.mdx | 2 +- content/docs/references/system/migration.mdx | 4 ++-- packages/spec/liveness/state-counts/object.md | 2 +- 6 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 .changeset/24340-default-row-scope-live.md create mode 100644 .changeset/24340-rls-object-default-scope.md diff --git a/.changeset/24340-default-row-scope-live.md b/.changeset/24340-default-row-scope-live.md new file mode 100644 index 00000000000..a80caeedade --- /dev/null +++ b/.changeset/24340-default-row-scope-live.md @@ -0,0 +1,10 @@ +--- +'@objectstack/spec': patch +--- + +`defaultRowScope`'s describe, TSDoc and liveness row now say what the security plugin's reader does with the key + +Clause-②: no — only describe, TSDoc and liveness-ledger text move; `ObjectSchema` and `DefaultRowScopeSchema` accept and refuse exactly what they did. + +- `ObjectSchema.defaultRowScope`'s describe no longer says the key is not applied. It states what the row-policy collection does: the default applies when no permission set the caller holds carries a row policy for the object and the operation (a `'*'` policy counts; one switched off, outside the caller's positions, or for another operation does not), it is judged like a held policy, a `using` the compiler cannot lower makes the reads it governs return no rows with one server warning, and authoring checks do not judge that predicate. The TSDoc on the key and on `DefaultRowScopeSchema` say the same. +- `liveness/object.json`: the `defaultRowScope` row and its eight members are `live`, citing the security plugin's collection, compiler and explain as their readers. Authoring the key no longer raises the `liveness-planned-property` warning. diff --git a/.changeset/24340-rls-object-default-scope.md b/.changeset/24340-rls-object-default-scope.md new file mode 100644 index 00000000000..1bf1586e078 --- /dev/null +++ b/.changeset/24340-rls-object-default-scope.md @@ -0,0 +1,20 @@ +--- +'@objectstack/plugin-security': minor +--- + +fix(plugin-security)!: the row-policy collection applies an object's declared `defaultRowScope` when no permission set the caller holds carries a row policy for the object + +Clause-②: no (narrowing) — the narrowing is real only for a principal with no platform baseline whose permission sets carry no row policy for an object that declares a default; no object in this release declares one, and no export, option, schema or stored shape moves. + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +`ObjectSchema.defaultRowScope` declares an object's default row-level security policy. Until now no server read it. The security plugin's row-policy collection (`collectRLSPolicies`), which every read, write and explain composes from, now applies it: + +- **When it applies.** When the policies the caller's permission sets carry for the object and the operation come out empty, the object's default is collected in their place, for the declaring object. Empty means what the collection already meant by it: a held policy for every object (`'*'`) counts, while a held policy that is switched off, outside the caller's positions, or for another operation does not. +- **How it is judged.** Exactly as a held policy: its `operation`, `positions` and `enabled` decide where it applies, and its `using` and `check` compile through the same compiler. So it scopes reads, the by-id and bulk write filters, and the post-image check, and `checkAuthoredRowWrite` reads it as the declaration it is. A `using` the compiler cannot lower is dropped with the same warning and fails closed to zero rows, as an authored policy's does. +- **Explain** names an applied default as the object's own declaration, with its `name`, `label` and `description`: the row-level layer's `detail` says so, and a record-grained explanation names the rule after the default, reached `via: 'object_default'`. +- **Unchanged:** a policy an authored set carries for the object is used as authored, with the default not added to it; a principal whose superuser bypass skips row policies never reaches the collection; an object that declares no default collects exactly what it collected before. A deployment that composes the platform baseline is unaffected for the identity objects, whose shipped sets carry their own policies. + +What changes for you: nothing until an object declares `defaultRowScope`. Once one does, a principal whose permission sets carry no row policy for it reads only the rows the default admits. To give such a principal a different scope, put a row policy for the object in a permission set it holds; that policy is then used instead of the default. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 30c8a1efa91..c29d3e597a9 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -969,7 +969,7 @@ Metadata query with filtering, sorting, and pagination | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | -| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The contract the security plugin's row-policy collection, the one step every read, write and explain passes through, is to meet once it reads the key: when no permission set the caller holds carries a row policy for this object, this policy is to apply; a policy an authored set carries for the object is to be used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is to be unaffected; an object that declares none keeps the behaviour it has without the key. The platform's identity objects are to declare it for their own tables. Not applied yet: no server reads this key in this release, so declaring it filters no rows. Until the collection applies it, restrict rows with a permission set's `rowLevelSecurity` policy. | +| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. Authoring checks do not judge this predicate, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 1dd558fa6f7..5d9983bde2d 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -173,7 +173,7 @@ const result = ApiMethod.parse(data); | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | -| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The contract the security plugin's row-policy collection, the one step every read, write and explain passes through, is to meet once it reads the key: when no permission set the caller holds carries a row policy for this object, this policy is to apply; a policy an authored set carries for the object is to be used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is to be unaffected; an object that declares none keeps the behaviour it has without the key. The platform's identity objects are to declare it for their own tables. Not applied yet: no server reads this key in this release, so declaring it filters no rows. Until the collection applies it, restrict rows with a permission set's `rowLevelSecurity` policy. | +| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. Authoring checks do not judge this predicate, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 4ae29fd1a3d..152f875fb74 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -355,7 +355,7 @@ Create a new object | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | -| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The contract the security plugin's row-policy collection, the one step every read, write and explain passes through, is to meet once it reads the key: when no permission set the caller holds carries a row policy for this object, this policy is to apply; a policy an authored set carries for the object is to be used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is to be unaffected; an object that declares none keeps the behaviour it has without the key. The platform's identity objects are to declare it for their own tables. Not applied yet: no server reads this key in this release, so declaring it filters no rows. Until the collection applies it, restrict rows with a permission set's `rowLevelSecurity` policy. | +| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. Authoring checks do not judge this predicate, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | @@ -649,7 +649,7 @@ Create a new object | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | -| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The contract the security plugin's row-policy collection, the one step every read, write and explain passes through, is to meet once it reads the key: when no permission set the caller holds carries a row policy for this object, this policy is to apply; a policy an authored set carries for the object is to be used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is to be unaffected; an object that declares none keeps the behaviour it has without the key. The platform's identity objects are to declare it for their own tables. Not applied yet: no server reads this key in this release, so declaring it filters no rows. Until the collection applies it, restrict rows with a permission set's `rowLevelSecurity` policy. | +| **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. Authoring checks do not judge this predicate, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | diff --git a/packages/spec/liveness/state-counts/object.md b/packages/spec/liveness/state-counts/object.md index 73b536971df..793774a7b43 100644 --- a/packages/spec/liveness/state-counts/object.md +++ b/packages/spec/liveness/state-counts/object.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `object` | 53 | 0 | 0 | 1 | 8 | 62 | +| `object` | 61 | 0 | 0 | 1 | 0 | 62 | From bc1e9e77b198d9a2d00413627d60e36d7e82a089 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:46:31 +0000 Subject: [PATCH 5/6] test(plugin-security): type the explain rls-layer accessor Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../plugin-security/src/object-default-row-scope.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-security/src/object-default-row-scope.test.ts b/packages/plugins/plugin-security/src/object-default-row-scope.test.ts index a9eeac77062..03c4d8b0b79 100644 --- a/packages/plugins/plugin-security/src/object-default-row-scope.test.ts +++ b/packages/plugins/plugin-security/src/object-default-row-scope.test.ts @@ -353,8 +353,12 @@ describe('[#24164 E′] the platform administrator is unchanged', () => { }); describe('[#24164 E′] explain names the object default, never a permission set', () => { - const rlsLayer = (decision: { layers: Array<{ layer: string }> }) => - decision.layers.find((l) => l.layer === 'rls') as { verdict: string; detail: string; contributors: unknown[]; record?: any }; + type RlsLayer = { layer: string; verdict: string; detail: string; contributors: unknown[]; record?: any }; + const rlsLayer = (decision: { layers: RlsLayer[] }): RlsLayer => { + const layer = decision.layers.find((l) => l.layer === 'rls'); + if (!layer) throw new Error('explain returned no rls layer'); + return layer; + }; it('object-level: the rls layer names the default, its label and description, and the declaring object', async () => { const { security } = await boot({ baseline: false, declared: true }); From ef9a7a01e62870ad04766c9b9a9ef665c38ad775 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 04:26:33 +0000 Subject: [PATCH 6/6] docs(spec): the pending defaultRowScope release note and the form ledger's omit reason say the reader is in Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .changeset/24338-object-default-row-scope.md | 2 +- .../spec/src/system/metadata-form-zod-reconciliation.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/24338-object-default-row-scope.md b/.changeset/24338-object-default-row-scope.md index be0c78014dc..d5154221dd1 100644 --- a/.changeset/24338-object-default-row-scope.md +++ b/.changeset/24338-object-default-row-scope.md @@ -11,7 +11,7 @@ Clause-②: yes (widening) - **What it parses.** Every member of `RowLevelSecurityPolicySchema` except `object`, the same member schemas: `name`, `label`, `description`, `operation`, `using`, `check`, `positions` and `enabled` (default `true`). The policy's two rules hold as written: at least one of `using` / `check`, and no `check` on a `select` or `delete` policy. - **What it refuses.** `object` is refused by name, with a prescription to delete it: the target is implied. Any other unknown key is refused too, the policy's retired `priority` and `tags` included. -- **What it does today: nothing.** No server reads the key in this release, so declaring it filters no rows. The describe states the contract the security plugin's row-policy collection is to meet once it reads the key: the default is to apply when no permission set the caller holds carries a row policy for the object; a policy an authored set carries is to be used as authored; a superuser-bypass principal is to be unaffected; an object without the key keeps its behaviour. No object declares the key yet; the platform's identity objects are to declare it for their own tables. Authoring the key raises the liveness lint's `liveness-planned-property` warning until then. Restrict rows with a permission set's `rowLevelSecurity` policy in the meantime. +- **What it does.** The security plugin's row-policy collection (`collectRLSPolicies`), the one step every read, write and explain passes through, applies the key: when the permission sets the caller holds carry no row policy for the object and the operation (a `'*'` policy counts; one switched off, outside the caller's positions, or for another operation does not), the object's default is used in their place and judged like a held policy. A policy an authored set carries for the object is used as authored, with the default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object without the key keeps its behaviour. A `using` the row-filter compiler cannot lower makes the reads it governs return no rows, with one server warning. The platform's identity objects are to declare it for their own tables in a separate change. See the `@objectstack/plugin-security` entry in this release for the reader. - **`composeStacks` with `objectConflict: 'merge'`** now refuses two stacks that declare a different `defaultRowScope` on the same object, the way it refuses every other fixed-shape config object (`access`, `enable`, `publicSharing`, …). The refusal message's list of those objects names `defaultRowScope` too. Nothing composed before is affected, because no stack declared the key. - **`@objectstack/metadata-core`: the field-level-security projection classifies the new key.** When an object document is served to a caller who cannot read some of its fields, `defaultRowScope` is treated like a validation rule: the whole policy is dropped from the served document when its `using`, `check` or prose names a field that caller cannot read, and served unchanged otherwise. No behaviour moves until an object declares the key, and no object declares it yet. - Additive: every object that parsed before still parses, and its parsed output is unchanged. diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index f46ba8a82c2..b754b103e1e 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -533,7 +533,7 @@ const LEDGER: ReadonlyArray = [ type: 'object', path: ROOT_PATH, key: 'defaultRowScope', - why: "not offered while nothing applies it (#24164 ruling E′, item 1): no server reads the object's default row scope until the security plugin's `collectRLSPolicies` does (#24340), so a form row would let an author declare a row restriction that filters nothing, the declared-not-enforced trap Prime Directive #10 names; its writers are to be the platform identity objects, declared in code (#24341), and none declares it yet. Revisit when the reader lands: a row policy is a composite of a select, two predicate strings and a positions list, a form-face addition rather than a reconciliation", + why: "not offered by this reconciliation (#24164 ruling E′, item 1): the reader is in — the security plugin's `collectRLSPolicies` applies the object's default row scope when no held set carries a row policy for the object and operation (#24340) — but a row policy is a composite of a select, two predicate strings and a positions list, so offering it is a form-face addition rather than a reconciliation of a scalar key; its writers are to be the platform identity objects, declared in code (#24341)", }, { kind: 'omit',