Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/rls-priority-removed.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 1 addition & 3 deletions content/docs/permissions/rls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ export const ContributorAccess = definePermissionSet({
using: 'assignee == current_user.email',
positions: ['contributor'],
enabled: true,
priority: 10,
},
],
});
Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/security/rls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |


Expand Down
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |

---

Expand Down
3 changes: 0 additions & 3 deletions examples/app-showcase/src/security/permission-sets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -97,7 +95,6 @@ export const ContributorPermissionSet = definePermissionSet({
check: 'owner == current_user.email',
positions: ['contributor'],
enabled: true,
priority: 10,
},
],
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const denyWritesOnManagedObjects = (): Record<string, {
* permission set name appears in the request `ExecutionContext.permissions[]`.
*
* Each entry is run through `PermissionSetSchema.parse(...)` so Zod fills
* in the boolean/`priority`/`enabled` defaults — keeping the literal
* in the boolean/`enabled` defaults — keeping the literal
* source readable while still satisfying the strict output type.
*
* `objects: { '*': … }` uses the wildcard sentinel honoured by
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,65 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => {
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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ const demoSet = PermissionSetSchema.parse({
operation: 'select',
using: 'owner = current_user.email',
enabled: true,
priority: 10,
},
],
});
Expand Down
Loading
Loading