Skip to content

Commit 9939854

Browse files
fix(showcase): the contributor set's row-level policies apply to every holder of the set (#21104)
Fixes #21052 Clause-②: no Showcase authoring only. No platform, runtime or spec code changes. Landing stays with the maintainer: this is a permission-boundary change, so the PR stays **draft** for the maintainer's review. ## What changed - **`examples/app-showcase/src/security/permission-sets.ts`**: the three row-level policies on `showcase_contributor` (`task_own_rows`, `invoice_own_rows`, `invoice_owner_immutable`) no longer carry a `positions` list. The set itself is their applicability domain, so the showcase contributor set's owner-isolation policies now apply to every holder of the set, whichever way the set is held: through the `contributor` position binding, or as a direct grant (the showcase's delegated admin may hand this set out). A comment beside the policies records why `positions` is not used there. No other set in the file authors row-level policies. - **`packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts`**: before this PR the pin governed sign-ups with a mirror of the contributor set that it declared itself, so it never ran the shipped set. It now boots the showcase with the app's own `isDefault` baseline and grants the app's own `showcase_contributor` twice. One persona gets it directly: a `sys_user_permission_set` row and no position. The other gets it through the `contributor` position: a `sys_user_position` row plus the app's position-to-set binding, and no direct row. A premise case proves each persona holds the set by its route alone, from the rows, the `/security/explain` principal and an rls layer verdict of `narrows`. Then, for **both** holders: - the list equals exactly the invoices the persona owns; - a foreign invoice by id answers 404 `RECORD_NOT_FOUND`, and so does a foreign line by id; - a line under an owned invoice can be created and patched, while a foreign line PATCH answers 403 `PERMISSION_DENIED` and the stored row is unchanged; - re-owning a foreign invoice answers 403 `PERMISSION_DENIED`, and so does reassigning an owned invoice to someone else. The stored owner is unchanged in both cases. - **`packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts`**: the `showcase_task` create payload now sets `assignee` to the acting persona, in the same way the file already sets `owner` on invoices. This file's contributor persona holds the set by a direct grant, so `task_own_rows` now governs it, as it governs every holder. A task created with no assignee was invisible to the persona that created it. That turned the `showcase_contributor x showcase_task` EDIT cell into a record-level 403 instead of a CRUD verdict, measured red on the first run after the fix. With the payload fixed, the cell is a CRUD verdict again. `access-matrix.json` is untouched. The census stays at 87 allow / 77 deny with 9 baseline flips, so no matrix cell changes side. No changeset is needed: `@objectstack/example-showcase` and `@objectstack/dogfood` are both `private: true`, so this diff publishes nothing. ## Verification (merged HEAD `12ed6b654`, after merging `origin/main`) - Dogfood pins, one run: the isolation pin, `showcase-crud-persona-matrix`, `showcase-private-owd`, `showcase-invoice-cbp`, `controlled-by-parent`, `showcase-permission-zoo` and `showcase-expand-crud-gate`. Result: 7 files / 94 tests passed, exit 0. - `pnpm --filter @objectstack/example-showcase validate`: exit 0. Showcase `typecheck`: exit 0. Showcase `vitest run`: 29 files / 387 tests passed. Dogfood `typecheck`: exit 0. `tsc --listFiles` for that program includes both edited test files and `permission-sets.ts`. - `objectstack verify --rls` (showcase): exit 0. All personas: 38 proven, 0 holes. The `contributor` position persona proves `showcase_invoice`, `showcase_invoice_line` and `showcase_task` consistent. - Reverse verification: `scripts/ablation-replace.mjs` put the three `positions` lists back (anchor hit x3, blob change proven on disk) and the isolation pin went red, 6 failed / 6 passed of 12. The tool then restored the file: blob equals HEAD and `git diff HEAD` is empty. The un-mutated pin is 12/12 green. - `node scripts/pm/dispatch-gates.mjs --commands` on `12ed6b654`: all 55 derived commands were run, and `--ran` reconciles 55/55. 54 exited 0. 1 is NOT MEASURED: `check-plugin-teardown-shape.mjs --self-test` exited 3 because its pinned positive-control commit is unreachable from this shallow clone. That is a checker-health battery and does not depend on this diff. - `eslint --no-inline-config --format json` on the 3 changed files: 3 files linted, 0 errors, 0 warnings, and none ignored. `eslint.config.mjs` enables no type-aware linting, so this diff cannot change the verdict on any file it does not touch. ## Acceptance notes - `docs/qa/platform-checklist/areas/search.json` describes `invoice_own_rows` as carrying `positions: ['contributor']` in two places, in the prose of an `expect` step and in a `source` line. After this PR that parenthetical is stale. The file is outside this card's file surface. Noted here, not filed. Carrier: none. - `objectstack verify --rls` builds its personas from the app's declared positions plus a base persona that holds no app set. It never builds a persona that holds an app permission set by direct grant, so that way of holding a set is outside what its green proves. Noted, not filed: it is a gap in what the verifier covers, not a defect. Carrier: none. - No lint looks at a `positions` list on the row-level policy of a set that is not a baseline (`isDefault`) set. Such a lint could help authors. It is not filed because the runtime enforces `positions` exactly as the spec documents. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 53ed3d1 commit 9939854

3 files changed

Lines changed: 238 additions & 96 deletions

File tree

‎examples/app-showcase/src/security/permission-sets.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ export const ContributorPermissionSet = definePermissionSet({
6969
'showcase_project.budget_remaining': { readable: true, editable: false },
7070
},
7171
// Row-level security — contributors only see tasks assigned to them.
72+
//
73+
// No policy below lists `positions`, deliberately. A set's row-level policies
74+
// are part of what the set grants, so they hold for EVERY holder of the set,
75+
// whichever way it is held: through the `contributor` position binding
76+
// (bind-position-sets.ts) or granted directly (the delegated admin below may
77+
// hand this set out). `positions` narrows a policy to callers holding one of
78+
// the listed positions (ADR-0090 P2) — a tool for a policy on a baseline set
79+
// every member resolves, not for a set that is itself the grant.
7280
rowLevelSecurity: [
7381
{
7482
name: 'task_own_rows',
@@ -77,7 +85,6 @@ export const ContributorPermissionSet = definePermissionSet({
7785
object: 'showcase_task',
7886
operation: 'select' as const,
7987
using: 'assignee == current_user.email',
80-
positions: ['contributor'],
8188
enabled: true,
8289
},
8390
// Owner RLS on the MASTER invoice. Because `showcase_invoice_line` is
@@ -92,7 +99,6 @@ export const ContributorPermissionSet = definePermissionSet({
9299
object: 'showcase_invoice',
93100
operation: 'select' as const,
94101
using: 'owner == current_user.email',
95-
positions: ['contributor'],
96102
enabled: true,
97103
},
98104
// [ADR-0058 D4] RLS `check` — write-side post-image validation (NOT a read
@@ -108,7 +114,6 @@ export const ContributorPermissionSet = definePermissionSet({
108114
object: 'showcase_invoice',
109115
operation: 'update' as const,
110116
check: 'owner == current_user.email',
111-
positions: ['contributor'],
112117
enabled: true,
113118
},
114119
],

‎packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,10 @@ interface PayloadCtx {
173173
* TEXT column, because `showcase_contributor`'s RLS selects invoices by
174174
* `owner == current_user.email`: a row created without it would be invisible to
175175
* its own creator, and the contributor's EDIT cell would fail for a record-level
176-
* reason while looking like a CRUD denial.
176+
* reason while looking like a CRUD denial. `assignee` on `showcase_task` is set
177+
* for the same reason — the same set selects tasks by
178+
* `assignee == current_user.email` (`task_own_rows`), and the set's policies
179+
* hold for this persona, which holds the set by a direct grant.
177180
*/
178181
const PAYLOAD: Record<string, (c: PayloadCtx) => Record<string, unknown>> = {
179182
showcase_account: (c) => ({ name: c.mark, status: 'prospect' }),
@@ -222,7 +225,7 @@ const PAYLOAD: Record<string, (c: PayloadCtx) => Record<string, unknown>> = {
222225
// 'ignore'`, not by an index), so re-joining an already-joined pair is a
223226
// legal write rather than a 409 masquerading as a CRUD verdict.
224227
showcase_project_membership: (c) => ({ team: c.teamId, project: c.projectId, engagement: 'owner' }),
225-
showcase_task: (c) => ({ title: c.mark, project: c.projectId, status: 'todo' }),
228+
showcase_task: (c) => ({ title: c.mark, project: c.projectId, status: 'todo', assignee: c.email }),
226229
showcase_team: (c) => ({ name: c.mark }),
227230
};
228231

0 commit comments

Comments
 (0)