diff --git a/.changeset/rls-priority-removed.md b/.changeset/rls-priority-removed.md new file mode 100644 index 0000000000..e5e68c8f58 --- /dev/null +++ b/.changeset/rls-priority-removed.md @@ -0,0 +1,38 @@ +--- +"@objectstack/spec": major +"@objectstack/example-showcase": patch +--- + +refactor(spec)!: remove the RLS-policy `priority` key — it promised conflict resolution that cannot exist (#3896 audit) + +`RowLevelSecurityPolicySchema.priority` was documented as *"Policy priority for +conflict resolution"*. The 2026-07-30 security-subset liveness re-verification +found that **nothing ever read it** — and, stronger, that nothing ever could: +applicable policies **OR-combine** (any match allows access, most permissive +wins — the schema's own describe said so), so there is never a conflict to +order and evaluation order cannot change an outcome. A semantically-void knob +on a security policy is worse than dead: an author — very often an AI +(ADR-0033) — reads it as a precedence lever and reasons about policy +interactions that do not exist. + +Removed per the `tool.requiresConfirmation` (#3715) / `DynamicLoadingConfig` +(#3950) precedent, inside the v17 breaking window: + +- **Tombstoned, not silently stripped** (`retiredKey`, #3855 pattern): an + authored `priority` fails `tsc` (the input type is `never`) and rejects at + parse with the prescription itself — *"policies OR-combine (most permissive + wins), so there is no conflict to order. Delete the key — policy outcomes are + unchanged."* +- **ADR-0087 D2 conversion + D3 chain step** (`permission-rls-priority-removed`): + `os migrate meta` deletes the key from authored sources mechanically — a pure + lossless delete, no semantic residue. spec-changes.json and the protocol + upgrade guide carry the entry. +- The policy factory helpers (`ownerPolicy`, `tenantPolicy`, …), the showcase + example's permission sets, and `content/docs/permissions/rls.mdx` no longer + author it; the docs table's `enabled` row now states the (since-enforced) + contract instead. +- Liveness ledger entry updated to record the removal; the tombstone and entry + age out ~two majors from now. + +Dropping the key changes **no policy outcome anywhere** — that impossibility of +effect is the entire reason for the removal. diff --git a/content/docs/permissions/rls.mdx b/content/docs/permissions/rls.mdx index 1659ef4c6b..7173122a70 100644 --- a/content/docs/permissions/rls.mdx +++ b/content/docs/permissions/rls.mdx @@ -48,7 +48,6 @@ export const ContributorAccess = definePermissionSet({ using: 'assignee == current_user.email', positions: ['contributor'], enabled: true, - priority: 10, }, ], }); @@ -63,8 +62,7 @@ export const ContributorAccess = definePermissionSet({ | `using` | `string` | Predicate for rows the user may **see / act on** (compiled into the query filter) | | `check` | `string` | Predicate rows must satisfy **after a write**. Omit it and `using` is reused | | `positions` | `string[]` | Which positions the policy applies to. Omit = everyone | -| `enabled` | `boolean` | Default `true` | -| `priority` | `number` | Default `0` | +| `enabled` | `boolean` | Default `true`. `false` switches the policy off — a disabled policy is not evaluated | At least one of `using` / `check` is required. diff --git a/content/docs/references/security/rls.mdx b/content/docs/references/security/rls.mdx index 426381ab5d..ba35b0280b 100644 --- a/content/docs/references/security/rls.mdx +++ b/content/docs/references/security/rls.mdx @@ -221,7 +221,7 @@ const result = RLSEvaluationResult.parse(data); | **check** | `string` | optional | Validation condition for INSERT/UPDATE (defaults to USING clause if not specified - enforced at application level) | | **positions** | `string[]` | optional | Positions this policy applies to (omit for all) | | **enabled** | `boolean` | ✅ | Whether this policy is active | -| **priority** | `integer` | ✅ | Policy evaluation priority (higher = evaluated first) | +| **priority** | `any` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0 (#3896 security audit). It never had an effect and could not: applicable policies OR-combine (most permissive wins), so there is no conflict to order. Delete the key — policy outcomes are unchanged. | | **tags** | `string[]` | optional | Policy categorization tags | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index a90db55e64..de1fd7fa51 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -124,6 +124,8 @@ Finally it removes agent `tools` (#3894): the legacy inline `{type,name,descript Beyond those spec-surface removals, it graduates the seven flow-node config key aliases the executors still tolerated (#3796): the CRUD nodes' `object` (use `objectName`) — the last tenant of the `readAliasedConfig` executor shim, which is deleted with it — plus the six open-coded fallbacks that never went through that shim: notify `to`/`subject`/`body`/`url` (use `recipients`/`title`/`message`/`actionUrl`) and script `functionName`/`input` (use `function`/`inputs`). All are pure key renames with unchanged values and replay losslessly. Like the sharing-rule access level above they keep a load-path acceptance window: none carried a prior deprecation warning, and `FlowNodeSchema.config` is an unconstrained record, so no schema tombstone can reject them — the conversion layer is the only seam that can declare, convert, and retire them. +And it removes the RLS-policy key `priority` (#3896 security audit): promised "conflict resolution" that cannot exist, because applicable policies OR-combine (most permissive wins) — there is never a conflict to order, and nothing ever read the key (call graph closed across the collection site, the projection round-trip and the compiler). A pure lossless delete: outcomes are identical with or without it; the schema tombstones the key with the same prescription. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -136,6 +138,7 @@ Beyond those spec-surface removals, it graduates the seven flow-node config key | `flow-node-crud-object-alias` | `flow.node.config.objectName` | CRUD flow-node config key 'object' → 'objectName' (#3796 — `readAliasedConfig` shim graduation) | live — protocol 17 loader accepts the old shape | | `flow-node-notify-config-aliases` | `flow.node.notify.config` | notify flow-node config keys 'to' → 'recipients', 'subject' → 'title', 'body' → 'message', 'url' → 'actionUrl' (#3796) | live — protocol 17 loader accepts the old shape | | `flow-node-script-config-aliases` | `flow.node.script.config` | script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796) | live — protocol 17 loader accepts the old shape | +| `permission-rls-priority-removed` | `permission.rowLevelSecurity.priority` | RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome) | retired — `migrate meta` only | --- diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index bfcc2f4706..10dc056613 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -64,7 +64,6 @@ export const ContributorPermissionSet = definePermissionSet({ using: 'assignee == current_user.email', positions: ['contributor'], enabled: true, - priority: 10, }, // Owner RLS on the MASTER invoice. Because `showcase_invoice_line` is // `controlled_by_parent`, a contributor seeing only their own invoices also @@ -80,7 +79,6 @@ export const ContributorPermissionSet = definePermissionSet({ using: 'owner == current_user.email', positions: ['contributor'], enabled: true, - priority: 10, }, // [ADR-0058 D4] RLS `check` — write-side post-image validation (NOT a read // filter). On UPDATE the new row must still be owned by the caller, so a @@ -97,7 +95,6 @@ export const ContributorPermissionSet = definePermissionSet({ check: 'owner == current_user.email', positions: ['contributor'], enabled: true, - priority: 10, }, ], }); diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index fd795d6753..8b9800d74b 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -82,7 +82,7 @@ const denyWritesOnManagedObjects = (): Record { expect(body?.apps ?? []).toEqual([]); }); + // ── permission.tabPermissions authored end-to-end ───────────────────────── + // Added by the 2026-07 security-subset liveness re-verification. Until then + // this file only MENTIONED tabPermissions in its header while exercising the + // route and requiredPermissions — and per ADR-0054, binding a ledger entry to + // a proof that does not author the property is the preview-renderer mistake + // in a nicer costume. This authors the property on a permission set, both + // directions of the rank merge included. + it('a permission set hiding an app via tabPermissions drops it from /me/apps — and a more-visible grant wins it back', async () => { + const tabTok = await stack.signUp('tab-member@verify.test'); + const tabUser = await ql.findOne('sys_user', { where: { email: 'tab-member@verify.test' }, context: SYS }); + expect(tabUser?.id, 'dedicated tab-probe member resolved').toBeTruthy(); + + // Control: before any tabPermissions grant, the member sees the app. + const before = await stack.apiAs(tabTok, 'GET', '/me/apps'); + const beforeNames = (((await before.json()) as any)?.apps ?? []).map((a: any) => a?.name); + expect(beforeNames, 'control: visible before any tab grant').toContain('showcase_app'); + + // Author the property: a set whose ONLY job is tabPermissions.hidden. + const hideSet = await ql.insert( + 'sys_permission_set', + { + name: 'tab_probe_hide', + label: 'Tab probe — hide showcase', + tab_permissions: JSON.stringify({ showcase_app: 'hidden' }), + }, + { context: SYS }, + ); + const hideSetId = hideSet?.id ?? (await ql.findOne('sys_permission_set', { where: { name: 'tab_probe_hide' }, context: SYS }))?.id; + expect(hideSetId, 'hide set stored').toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: tabUser.id, permission_set_id: hideSetId }, { context: SYS }); + + const hidden = await stack.apiAs(tabTok, 'GET', '/me/apps'); + const hiddenNames = (((await hidden.json()) as any)?.apps ?? []).map((a: any) => a?.name); + expect(hiddenNames, 'tabPermissions.hidden drops the app for this principal').not.toContain('showcase_app'); + + // Rank merge: hidden(0) loses to visible(3) from ANY other resolved set — + // most-visible wins across the principal's sets, matching hono's tabRank. + const showSet = await ql.insert( + 'sys_permission_set', + { + name: 'tab_probe_show', + label: 'Tab probe — show showcase', + tab_permissions: JSON.stringify({ showcase_app: 'visible' }), + }, + { context: SYS }, + ); + const showSetId = showSet?.id ?? (await ql.findOne('sys_permission_set', { where: { name: 'tab_probe_show' }, context: SYS }))?.id; + await ql.insert('sys_user_permission_set', { user_id: tabUser.id, permission_set_id: showSetId }, { context: SYS }); + + const merged = await stack.apiAs(tabTok, 'GET', '/me/apps'); + const mergedNames = (((await merged.json()) as any)?.apps ?? []).map((a: any) => a?.name); + expect(mergedNames, 'most-visible wins: a visible grant overrides the hidden one').toContain('showcase_app'); + + // The probe must not leak into other principals' assertions. + const others = await stack.apiAs(memberTok, 'GET', '/me/apps'); + const otherNames = (((await others.json()) as any)?.apps ?? []).map((a: any) => a?.name); + expect(otherNames, 'unrelated principals are untouched by the probe sets').toContain('showcase_app'); + }); + // ── #2753: the baseline binds to the everyone anchor at bootstrap ──────── it('bootstrap binds member_default to the everyone position (one channel, D5)', async () => { const everyone = await ql.findOne('sys_position', { where: { name: 'everyone' }, context: SYS }); diff --git a/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts b/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts index 057329e855..9bb80cfbce 100644 --- a/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts @@ -39,7 +39,6 @@ const demoSet = PermissionSetSchema.parse({ operation: 'select', using: 'owner = current_user.email', enabled: true, - priority: 10, }, ], }); diff --git a/packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts b/packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts new file mode 100644 index 0000000000..6b91c7c954 --- /dev/null +++ b/packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0057 D1 — scope-depth WRITE grants on the real showcase app. +// +// `writeScope` widens the owner-set an owner-scoped (`private`) object accepts +// EDITS from, the way `readScope` widens reads — and the two axes are +// consulted INDEPENDENTLY (permission-evaluator.getEffectiveScope reads +// `op.writeScope` for the write class, absent → 'own'; sharing-service.canEdit +// widens its owner-match by `__writeScope`). The sibling proof +// (showcase-scope-depth) covers the read axis; until the 2026-07 security- +// subset re-verification the write axis was `live` on two cited readers but +// carried no runtime proof — this file is that proof. +// +// Three postures, each asserted on POST-STATE (a SYSTEM read of the row after +// the attempt), not just the HTTP status: +// 1. writeScope 'unit' → a BU co-member's note IS editable; a CHILD-BU +// member's note is NOT ('unit' does not descend). +// 2. writeScope absent under readScope 'unit' → the co-member's note is +// READABLE yet NOT editable — the write axis defaults to 'own' and gates +// on its own, independent of how wide reads are. +// 3. the hierarchy seam fails CLOSED: same 'unit' grant, no enterprise +// resolver registered → co-member edit is denied (owner-only), never +// fail-open. +// +// @proof: showcase-scope-depth-write + +import { describe, it, expect, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { PermissionSetSchema } from '@objectstack/spec/security'; + +const OBJ = '/data/showcase_private_note'; +const WHO = ['alice', 'bob', 'carol'] as const; +type Who = (typeof WHO)[number]; + +function writeProfile(tag: string, writeScope?: 'unit') { + return PermissionSetSchema.parse({ + name: `wscope_${tag}_profile`, + label: `Write-scope ${tag}`, + isDefault: true, + objects: { + showcase_private_note: { + allowRead: true, + allowCreate: true, + allowEdit: true, + readScope: 'unit', + // Posture 2 authors NO writeScope at all — the axis under test must + // come from the schema-default 'own', not from an explicit value. + ...(writeScope ? { writeScope } : {}), + }, + }, + }); +} + +interface World { + stack: VerifyStack; + tokens: Record; + noteIds: Record; + ql: any; +} + +// BU world: alice+carol ∈ bu_parent, bob ∈ bu_child (child of parent). +// Each owns one note. Reference hierarchy resolver as in showcase-scope-depth +// (test fixture for the enterprise seam) — only the 'unit' branch is needed. +async function bootWriteWorld(tag: string, opts: { writeScope?: 'unit'; withResolver?: boolean }): Promise { + const stack = await bootStack(showcaseStack, { + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, writeProfile(tag, opts.writeScope)], + }), + }); + await stack.signIn(); + const tokens = {} as Record; + for (const who of WHO) tokens[who] = await stack.signUp(`wscope-${who}-${tag}@verify.test`); + + const ql: any = await stack.kernel.getServiceAsync('objectql'); + const SYS = { isSystem: true } as const; + const sys = (o: string, d: any) => ql.insert(o, d, { context: SYS }); + + if (opts.withResolver !== false) { + (stack.kernel as any).registerService('hierarchy-scope-resolver', { + async resolveOwnerIds(c: any, sc: string): Promise { + const meId = String(c.userId ?? ''); + const ids = new Set([meId]); + if (sc !== 'unit') return [...ids]; + const mine = await ql.find('sys_business_unit_member', { where: { user_id: meId }, fields: ['business_unit_id'], context: SYS }); + const buIds = [...new Set((mine ?? []).map((r: any) => String(r.business_unit_id ?? '')).filter(Boolean))]; + if (!buIds.length) return [...ids]; + const members = await ql.find('sys_business_unit_member', { where: { business_unit_id: { $in: buIds } }, fields: ['user_id'], context: SYS }); + for (const m of members ?? []) { const u = String(m.user_id ?? ''); if (u) ids.add(u); } + return [...ids]; + }, + }); + } + + const id = {} as Record; + for (const who of WHO) { + id[who] = (await ql.findOne('sys_user', { where: { email: `wscope-${who}-${tag}@verify.test` }, context: SYS }))?.id; + expect(id[who], `${who} resolved`).toBeTruthy(); + } + + let orgId = (await ql.findOne('sys_organization', { where: {}, context: SYS }).catch(() => null))?.id; + if (!orgId) { orgId = `org_w_${tag}`; await sys('sys_organization', { id: orgId, name: 'WScope Org', slug: `wscope_${tag}` }).catch(() => {}); } + + await sys('sys_business_unit', { id: `bu_wparent_${tag}`, name: 'Parent', kind: 'division', organization_id: orgId, active: true }); + await sys('sys_business_unit', { id: `bu_wchild_${tag}`, name: 'Child', kind: 'department', parent_business_unit_id: `bu_wparent_${tag}`, organization_id: orgId, active: true }); + await sys('sys_business_unit_member', { id: `wm_a_${tag}`, business_unit_id: `bu_wparent_${tag}`, user_id: id.alice }); + await sys('sys_business_unit_member', { id: `wm_c_${tag}`, business_unit_id: `bu_wparent_${tag}`, user_id: id.carol }); + await sys('sys_business_unit_member', { id: `wm_b_${tag}`, business_unit_id: `bu_wchild_${tag}`, user_id: id.bob }); + + const noteIds = {} as Record; + for (const who of WHO) { + const r = await stack.apiAs(tokens[who], 'POST', OBJ, { title: `${who} ${tag} note` }); + expect(r.status, `${who} creates note`).toBeLessThan(300); + const body: any = await r.json().catch(() => ({})); + noteIds[who] = body?.id ?? body?.record?.id + ?? (await ql.findOne('showcase_private_note', { where: { title: `${who} ${tag} note` }, context: SYS }))?.id; + expect(noteIds[who], `${who} note id resolved`).toBeTruthy(); + } + return { stack, tokens, noteIds, ql }; +} + +/** PATCH as `who`, then report the row's REAL title from a system read. */ +async function tryEdit(w: World, who: Who, targetId: string, newTitle: string): Promise<{ status: number; titleAfter: string }> { + const r = await w.stack.apiAs(w.tokens[who], 'PATCH', `${OBJ}/${targetId}`, { title: newTitle }); + const row = await w.ql.findOne('showcase_private_note', { where: { id: targetId }, context: { isSystem: true } }); + return { status: r.status, titleAfter: String(row?.title ?? '') }; +} + +describe('showcase: scope-depth write — `unit` widens edits to BU co-members (ADR-0057 D1)', () => { + let w: World; + afterAll(async () => { await w?.stack?.stop(); }); + + it('boots', async () => { w = await bootWriteWorld('u', { writeScope: 'unit' }); }, 120_000); + + it("a co-member's note IS editable under writeScope 'unit'", async () => { + const r = await tryEdit(w, 'alice', w.noteIds.carol, 'edited by alice'); + expect(r.status, 'co-member edit accepted').toBeLessThan(300); + expect(r.titleAfter, 'the write actually landed').toBe('edited by alice'); + }); + + it("a CHILD-BU member's note is NOT editable — 'unit' does not descend", async () => { + const r = await tryEdit(w, 'alice', w.noteIds.bob, 'must not land'); + expect(r.status, 'child-BU edit rejected').toBeGreaterThanOrEqual(400); + expect(r.titleAfter, 'row untouched').toBe('bob u note'); + }); +}); + +describe('showcase: scope-depth write — absent writeScope stays owner-only even under a wide readScope', () => { + let w: World; + afterAll(async () => { await w?.stack?.stop(); }); + + it('boots', async () => { w = await bootWriteWorld('d', { /* no writeScope */ }); }, 120_000); + + it("the co-member's note is READABLE (readScope 'unit')…", async () => { + const r = await w.stack.apiAs(w.tokens.alice, 'GET', `${OBJ}/${w.noteIds.carol}`); + expect(r.status, 'read widened by readScope').toBe(200); + }); + + it('…yet NOT editable — the write axis defaults to own and gates independently', async () => { + const r = await tryEdit(w, 'alice', w.noteIds.carol, 'must not land'); + expect(r.status, 'write denied despite readability').toBeGreaterThanOrEqual(400); + expect(r.titleAfter, 'row untouched').toBe('carol d note'); + }); + + it('the owner still edits their own note', async () => { + const r = await tryEdit(w, 'alice', w.noteIds.alice, 'alice edits herself'); + expect(r.status).toBeLessThan(300); + expect(r.titleAfter).toBe('alice edits herself'); + }); +}); + +describe('open edition — a hierarchy writeScope fails CLOSED without the enterprise resolver', () => { + let w: World; + afterAll(async () => { await w?.stack?.stop(); }); + + it('boots', async () => { w = await bootWriteWorld('f', { writeScope: 'unit', withResolver: false }); }, 120_000); + + it("writeScope 'unit' degrades to owner-only — a co-member edit is denied, never fail-open", async () => { + const r = await tryEdit(w, 'alice', w.noteIds.carol, 'must not land'); + expect(r.status, 'unresolvable hierarchy scope = deny').toBeGreaterThanOrEqual(400); + expect(r.titleAfter, 'row untouched').toBe('carol f note'); + }); +}); diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 14025e09bf..542ee3bfec 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -1,5 +1,5 @@ { - "description": "Ratchet of every AUTHORABLE key in the spec \u2014 what a metadata author may write, which for this platform IS the third-party API. Auto-updated on additions (commit the change). A key that disappears without a tombstone fails gen:schema, because these schemas are not .strict() and Zod would silently strip it. \"[RETIRED]\" marks a tombstoned key that still rejects with an upgrade prescription. See #3855, ADR-0059 \u00a75.", + "description": "Ratchet of every AUTHORABLE key in the spec — what a metadata author may write, which for this platform IS the third-party API. Auto-updated on additions (commit the change). A key that disappears without a tombstone fails gen:schema, because these schemas are not .strict() and Zod would silently strip it. \"[RETIRED]\" marks a tombstoned key that still rejects with an upgrade prescription. See #3855, ADR-0059 §5.", "keys": [ "ai/AIKnowledge:indexes", "ai/AIKnowledge:sources", @@ -5746,7 +5746,7 @@ "security/RowLevelSecurityPolicy:object", "security/RowLevelSecurityPolicy:operation", "security/RowLevelSecurityPolicy:positions", - "security/RowLevelSecurityPolicy:priority", + "security/RowLevelSecurityPolicy:priority [RETIRED]", "security/RowLevelSecurityPolicy:tags", "security/RowLevelSecurityPolicy:using", "security/SharingRule:accessLevel", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 50d7987985..168fcb9d21 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -225,6 +225,7 @@ binding lands one class at a time (ADR-0054 §3), never as a big-bang backfill. | RLS `check` (post-image) | `permission.rowLevelSecurity.check` | `showcase-d3-d4-capabilities` | | Master-detail controlled-by-parent | `object.sharingModel` | `controlled-by-parent#cbp-controlled-by-parent` | | Scope-depth read grants | `permission.objects.readScope` | `showcase-scope-depth` | +| Scope-depth write grants | `permission.objects.writeScope` | `showcase-scope-depth-write#showcase-scope-depth-write` | | Ownership anchor + bulk writes | `permission.objects.modifyAllRecords` | `owner-anchor-and-bulk-writes` | | Delegation of duty | `position.delegatable` | `delegation-of-duty#delegation-of-duty` | | Static readonly write | `field.readonly` | `showcase-static-readonly#readonly-static-write` | @@ -235,6 +236,7 @@ binding lands one class at a time (ADR-0054 §3), never as a big-bang backfill. | Data lifecycle (ADR-0057) | `object.lifecycle` | `storage-growth#adr0057-lifecycle-bounded-growth` | | Webhook materialization | `webhook.object` | `webhook-materialization#webhook-materialization` | | Object semantic roles (ADR-0085) | `object.highlightFields`, `.stageField`, `.fieldGroups` | `semantic-roles#semantic-roles-served` | +| Tab visibility | `permission.tabPermissions` | `me-apps-and-everyone-baseline#me-apps-and-everyone-baseline` | **Registered but unbound.** A proof with no authorable property to ratchet is still registered — otherwise its `@proof:` tag reads as an orphan — and records @@ -246,7 +248,6 @@ still registered — otherwise its `@proof:` tag reads as an orphan — and reco | `permission-set-projection` | a storage invariant (ADR-0094), not an authorable property | | `flow-runas-schedule` | guards `flow.runAs`, already bound to `flow-runas-identity` — one entry carries one `proof` | | `showcase-scope-depth-fallback` | guards `permission.objects.readScope`, already bound — this is the CLI-wiring sibling | -| `me-apps-and-everyone-baseline` | enforces `app.requiredPermissions` / `app.tabPermissions`; `app` is not governed yet | | `showcase-agent-intersection` | a runtime principal-resolution invariant (`onBehalfOf`), not authorable | | `showcase-agent-scope-ceiling` | the OAuth-scope → ceiling-set mapping lives in the runtime resolver | | `showcase-bu-hierarchy-sharing` | stack-level `sharingRules`, not a governed per-type property | diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 0ecb5e39cb..5d380d0ca7 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -105,8 +105,9 @@ "writeScope": { "status": "live", "verifiedAt": "2026-07-30", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:224 (getEffectiveScope write branch) + packages/plugins/plugin-sharing/src/sharing-service.ts:230 (canEdit owner-set widened by __writeScope; 'org' short-circuits to unrestricted)", - "note": "ADR-0057 D1 — write access DEPTH (same enum as readScope). Re-verified 2026-07-30: both cited reads confirmed. Sibling of proofed readScope with no dogfood proof of its own — a proof-binding candidate (ADR-0054)." + "proof": "packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts#showcase-scope-depth-write", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:224 (getEffectiveScope write branch; absent → 'own') + packages/plugins/plugin-sharing/src/sharing-service.ts:230 (canEdit owner-set widened by __writeScope; 'org' short-circuits to unrestricted)", + "note": "ADR-0057 D1 — write access DEPTH (same enum as readScope). Re-verified 2026-07-30 and proof-bound the same day: the proof asserts POST-STATE in three postures — 'unit' edits a BU co-member's row and does NOT descend into a child BU; an ABSENT writeScope stays owner-only even under a 'unit' readScope (the axes gate independently); and the hierarchy seam fails CLOSED without the enterprise resolver." } } }, @@ -135,8 +136,9 @@ "tabPermissions": { "status": "live", "verifiedAt": "2026-07-30", + "proof": "packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts#me-apps-and-everyone-baseline", "evidence": "packages/plugins/plugin-hono-server/src/hono-plugin.ts:1200 (tabRank merges all four values — hidden/default_off/default_on/visible — across resolved sets, most-visible wins; app hidden ⇒ dropped from /me/apps) + packages/runtime/src/security/resolve-execution-context.ts:205 + packages/rest/src/rest-server.ts:1551 (projection)", - "note": "Re-verified 2026-07-30 — the previous note ('only hidden is read') UNDERSTATED it: the rank merge reads every value. Exercised by packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts (registered in proof-registry.mts, unbound there because the binding was recorded against the ungoverned `app` type — this governed entry could carry it instead)." + "note": "Re-verified 2026-07-30 — the previous note ('only hidden is read') UNDERSTATED it: the rank merge reads every value. Proof BOUND the same day after the dogfood test was extended to actually AUTHOR the property on a permission set (it had only mentioned tabPermissions in its header): hidden drops the app from /me/apps, a more-visible sibling grant wins it back." }, "rowLevelSecurity": { "children": { @@ -193,9 +195,8 @@ "priority": { "status": "dead", "verifiedAt": "2026-07-30", - "authorWarn": true, - "authorHint": "priority has no effect and CANNOT have one: applicable policies OR-combine (most permissive wins, per the schema's own describe), so there is no conflict to order and no evaluation-order effect on the outcome. Remove it from the policy.", - "note": "CORRECTED 2026-07-30 (was live citing rls-compiler.ts, which never reads it — confirmed across the load site, the projection round-trip and the compiler). Not merely unimplemented: under OR-combination the promised 'conflict resolution' semantics cannot exist, so this is a REMOVE candidate (#3715/#3950 precedent, v17 window) rather than an implement candidate." + "evidence": "packages/spec/src/security/rls.zod.ts (retiredKey tombstone — authored values REJECT with the prescription; z.input types the key never)", + "note": "REMOVED 2026-07-30, same day the audit corrected it (was live citing rls-compiler.ts, which never reads it — confirmed across the load site, the projection round-trip and the compiler). Not merely unimplemented: under OR-combination the promised 'conflict resolution' semantics cannot exist, so it went straight to the #3715/#3950 remove disposition inside the v17 breaking window. Tombstoned rather than deleted so the removal is audible; drop the tombstone (and this entry) ~two majors out." }, "tags": { "status": "dead", diff --git a/packages/spec/scripts/liveness/proof-registry.mts b/packages/spec/scripts/liveness/proof-registry.mts index c1e564d9c5..1a6416cc7c 100644 --- a/packages/spec/scripts/liveness/proof-registry.mts +++ b/packages/spec/scripts/liveness/proof-registry.mts @@ -341,9 +341,25 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [ + 'wiring rather than an injected bootstrap set — one entry, one `proof` ref, so it cannot also ' + 'bind.', }, + { + id: 'scope-depth-write', + label: 'Scope-depth write grants', + summary: + 'writeScope widens the owner-set an owner-scoped object accepts EDITS from, independently of ' + + "readScope: a co-member row can be readable yet not editable (absent writeScope defaults to 'own' " + + "under a 'unit' read), 'unit' widens to BU co-members without descending into child BUs, and the " + + 'hierarchy seam fails CLOSED to owner-only when the enterprise resolver is absent. Added by the ' + + '2026-07-30 #3896 security-subset re-verification: the entry was live on two confirmed readers ' + + 'but was the only scope axis with no runtime proof — its sibling readScope has carried one since ' + + 'ADR-0054 phase 1.', + proofId: 'showcase-scope-depth-write', + proofRef: 'packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts#showcase-scope-depth-write', + bound: true, + ledgerBindings: [{ type: 'permission', path: 'objects.writeScope' }], + }, { id: 'app-tab-permissions', - label: 'GET /me/apps + anchor-bindable baseline', + label: 'Tab visibility (/me/apps) + anchor-bindable baseline', summary: '/me/apps used to read `metadata.list(\'app\')` while stack apps live in the ENGINE REGISTRY, ' + 'returning [] for every principal — leaving `tabPermissions` and `AppSchema.requiredPermissions` ' @@ -352,11 +368,17 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [ + 'every boot and the baseline flowed only through the fallback channel ADR-0090 D5 rejected.', proofId: 'me-apps-and-everyone-baseline', proofRef: 'packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts#me-apps-and-everyone-baseline', - bound: false, - ledgerBindings: [], - blockedReason: - 'the properties it enforces (`app.requiredPermissions`, `app.tabPermissions`) belong to the ' - + '`app` type, which is not yet governed by the ledger (rollout). Bind when `app` lands.', + // Bound 2026-07-30 (the #3896 security-subset re-verification). The proof + // originally only MENTIONED tabPermissions while exercising the route and + // `app.requiredPermissions` — binding then would have been the + // owner-anchor/allowTransfer mistake (a proof cited for a property it never + // authors). It now authors the property on the PERMISSION-SET side — a set + // whose only content is `tabPermissions: { showcase_app: 'hidden' }` drops + // the app from /me/apps for its holder, and a second, more-visible grant + // wins it back (the tabRank most-visible-wins merge) — which is the + // governed surface (`permission.tabPermissions`; `app` remains ungoverned). + bound: true, + ledgerBindings: [{ type: 'permission', path: 'tabPermissions' }], }, { id: 'agent-delegator-intersection', diff --git a/packages/spec/scripts/liveness/proof-registry.test.ts b/packages/spec/scripts/liveness/proof-registry.test.ts index aaebbd38ed..fcab4d093b 100644 --- a/packages/spec/scripts/liveness/proof-registry.test.ts +++ b/packages/spec/scripts/liveness/proof-registry.test.ts @@ -123,6 +123,14 @@ describe('registry invariants', () => { 'permission/rowLevelSecurity.check', 'permission/objects.readScope', 'permission/objects.modifyAllRecords', + // Bound 2026-07-30 (#3896 security-subset re-verification) — after the + // me-apps dogfood proof was extended to actually AUTHOR the property on + // a permission set, rather than mentioning it in its header. + 'permission/tabPermissions', + // Bound 2026-07-30, same sweep: writeScope was the only scope axis + // without a runtime proof; its new dogfood file asserts post-state in + // three postures (widen / independent-of-read / fail-closed seam). + 'permission/objects.writeScope', ].sort(), ); }); diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 9ce8ea6df3..212c8e650d 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -115,6 +115,12 @@ "to": "script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796)", "conversionId": "flow-node-script-config-aliases", "toMajor": 17 + }, + { + "surface": "permission.rowLevelSecurity.priority", + "to": "RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome)", + "conversionId": "permission-rls-priority-removed", + "toMajor": 17 } ], "migrated": [ @@ -431,6 +437,12 @@ "to": "script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796)", "conversionId": "flow-node-script-config-aliases", "toMajor": 17 + }, + { + "surface": "permission.rowLevelSecurity.priority", + "to": "RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome)", + "conversionId": "permission-rls-priority-removed", + "toMajor": 17 } ], "migrated": [], diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 9a131974d1..4647c4083a 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -1112,6 +1112,73 @@ const flowNodeScriptConfigAliases: MetadataConversion = { }, }; +/** + * RLS-policy `priority` removed (protocol 17, #3896 security audit). + * + * A pure DELETE with no rename target, because the promised semantics never + * existed: applicable policies OR-combine (any match allows access — most + * permissive wins), so there is no conflict for a priority to resolve and + * evaluation order cannot change an outcome. The 2026-07-30 security-subset + * liveness re-verification closed the call graph — collection site, projection + * round-trip, compiler — and found NO reader, ever. Dropping the key is + * therefore strictly lossless: outcomes are identical with or without it. + * + * `retiredFromLoadPath`: the schema tombstones the key (`retiredKey`, tsc + * `never` + a parse-time prescription), same posture as its step-17 siblings. + */ +const permissionRlsPriorityRemoved: MetadataConversion = { + id: 'permission-rls-priority-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'permission.rowLevelSecurity.priority', + summary: "RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome)", + apply(stack, emit) { + return mapCollection(stack, 'permissions', (ps, path) => { + const rls = (ps as { rowLevelSecurity?: unknown }).rowLevelSecurity; + if (!Array.isArray(rls)) return ps; + let touched = false; + const next = rls.map((policy, i) => { + if (!isDict(policy) || !('priority' in policy)) return policy; + const { priority: _dropped, ...rest } = policy; + emit({ from: 'priority', to: '(removed)', path: `${path}.rowLevelSecurity[${i}].priority` }); + touched = true; + return rest; + }); + return touched ? { ...ps, rowLevelSecurity: next } : ps; + }); + }, + fixture: { + before: { + permissions: [{ + name: 'contributor', + label: 'Contributor', + rowLevelSecurity: [{ + name: 'own_tasks', + object: 'crm_task', + operation: 'select', + using: 'assignee == current_user.email', + enabled: true, + priority: 10, + }], + }], + }, + after: { + permissions: [{ + name: 'contributor', + label: 'Contributor', + rowLevelSecurity: [{ + name: 'own_tasks', + object: 'crm_task', + operation: 'select', + using: 'assignee == current_user.email', + enabled: true, + }], + }], + }, + expectedNotices: 1, + }, +}; + /** * All conversions, keyed by the protocol major that introduced the canonical * shape. Newest majors last; ordering within a major is application order. @@ -1130,6 +1197,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { const result = RowLevelSecurityPolicySchema.parse(policy); expect(result.name).toBe('tenant_isolation'); expect(result.enabled).toBe(true); // default - expect(result.priority).toBe(0); // default + expect('priority' in result, 'retired key contributes nothing to the parsed output').toBe(false); }); it('should validate a complete policy with all fields', () => { @@ -50,7 +50,6 @@ describe('Row-Level Security (RLS) Protocol', () => { check: 'assigned_to_id IN (SELECT id FROM users WHERE manager_id = current_user.id)', positions: ['manager', 'director'], enabled: true, - priority: 10, tags: ['team_access', 'hierarchy'], }; @@ -117,16 +116,23 @@ describe('Row-Level Security (RLS) Protocol', () => { expect(result.enabled).toBe(true); }); - it('should default priority to 0 if not specified', () => { + it('priority is RETIRED: absent parses clean, authored rejects with the prescription', () => { + // Removed by the 2026-07-30 #3896 security audit: policies OR-combine + // (most permissive wins), so the promised "conflict resolution" cannot + // exist and nothing ever read the key. The tombstone keeps the removal + // audible instead of silently stripping an authored value. const policy = { name: 'test_policy', object: 'account', operation: 'select', using: 'owner_id == current_user.id', }; + expect('priority' in RowLevelSecurityPolicySchema.parse(policy)).toBe(false); - const result = RowLevelSecurityPolicySchema.parse(policy); - expect(result.priority).toBe(0); + const authored = { ...policy, priority: 10 } as never; + const r = RowLevelSecurityPolicySchema.safeParse(authored); + expect(r.success).toBe(false); + expect(JSON.stringify(!r.success ? r.error.issues : [])).toContain('removed in @objectstack/spec 17.0.0'); }); it('should handle policies for all operations', () => { @@ -457,12 +463,10 @@ describe('Row-Level Security (RLS) Protocol', () => { using: '1 == 1', // Always true - see everything positions: ['ceo', 'cfo', 'cto'], enabled: true, - priority: 100, // Highest priority }; const result = RowLevelSecurityPolicySchema.parse(policy); expect(result.using).toBe('1 == 1'); - expect(result.priority).toBe(100); }); }); diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index 3e91183896..daf93c613d 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; +import { retiredKey } from '../shared/retired-key'; /** * # Row-Level Security (RLS) Protocol @@ -382,17 +383,26 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => z.object({ .describe('Whether this policy is active'), /** - * Policy priority for conflict resolution. - * Higher numbers = higher priority. - * When multiple policies apply, the most permissive wins (OR logic). - * Priority is only used for ordering evaluation (performance). - * - * @default 0 + * REMOVED — `priority` promised "conflict resolution" that cannot exist. + * + * Applicable policies OR-combine (any match allows access — the doc above + * `RLSCompiler.compileFilter` and this schema's own former describe both say + * most-permissive-wins), so there is never a conflict to order and evaluation + * order cannot change an outcome. Nothing ever read the key: the 2026-07-30 + * security-subset liveness re-verification (#3896 follow-up) closed the call + * graph across the collection site, the projection round-trip and the + * compiler, and found no consumer. A semantically-void knob on a SECURITY + * policy is worse than dead — an author (very often an AI, ADR-0033) reads it + * as a precedence lever and reasons about policy interactions that do not + * exist. Removed per the #3715 / #3950 precedent; tombstoned so the removal + * is audible (tsc `never` + the parse-time prescription) instead of a silent + * strip. */ - priority: z.number() - .int() - .default(0) - .describe('Policy evaluation priority (higher = evaluated first)'), + priority: retiredKey( + '`rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0 (#3896 security audit). ' + + 'It never had an effect and could not: applicable policies OR-combine (most permissive wins), ' + + 'so there is no conflict to order. Delete the key — policy outcomes are unchanged.', + ), /** * Tags for policy categorization and reporting. @@ -546,7 +556,6 @@ export const RLS = { operation: 'all', using: `${ownerField} == current_user.id`, enabled: true, - priority: 0, }), /** @@ -566,7 +575,6 @@ export const RLS = { using: `${tenantField} == current_user.organization_id`, check: `${tenantField} == current_user.organization_id`, enabled: true, - priority: 0, }), /** @@ -580,7 +588,6 @@ export const RLS = { using: condition, positions, enabled: true, - priority: 0, }), /** @@ -594,6 +601,5 @@ export const RLS = { using: '1 == 1', // Always true positions, enabled: true, - priority: 0, }), } as const;