Skip to content

Commit 3e270d4

Browse files
os-warrenclaude
andauthored
fix(showcase): grant the seven navigable objects and the three master-detail children (#16069)
* fix(showcase): grant the seven navigable objects and the three master-detail children The showcase shipped seven `type: 'object'` navigation entries whose targets no permission set granted read on, and three master-detail children with no object-level CRUD grant anywhere. Both rules are advisory, so `objectstack build` stayed green and warned on every run — 33 author-time warnings, 10 of them these. Measured on origin/main (53cbad9): `nav-object-ungranted` x7 (showcase_preference, showcase_expense_report, showcase_team, showcase_category, showcase_business_unit, showcase_field_zoo, showcase_cascade) and `security-master-detail-ungranted` x3 (showcase_expense_line, showcase_field_zoo, showcase_project_membership). After: both at 0, total 33 -> 23, no new warning class. The grants are distributed by persona rather than pooled: read on the `everyone` baseline for every object the shared navigation exposes (navigation is an access claim), reference-data curation on showcase_ops, expense filing and the project-detail children on showcase_contributor. access-matrix.json gains 14 rows and loses none. Two object doc comments claimed a wide `sharingModel` let any seeded persona use the object "without a bespoke permission set". That is the misconception the rule exists to catch — what actually carried the seeded admin was the platform's wildcard set — so both are rewritten to separate gate (1) object CRUD from gate (2) the OWD. Pin: examples/app-showcase/test/nav-and-detail-grants.test.ts runs the shipped rules against the real stack, with two controls that drop a real grant and demand the rules name that object back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * fix(showcase): resolve @objectstack/lint types from source, harden the pin controls `pnpm check:type-source-resolution` went RED on the previous commit: the new `@objectstack/lint` import in the showcase resolved its TYPES through the workspace link to `packages/lint/dist/*.d.ts`. The gate names the remedy and refuses the alternative -- "Add the `paths` rules to the package's tsconfig.json -- widening the registry entry is not the fix" -- so this adds the third `paths` entry beside the two already there, bare-keyed for exact match. It is the type half of the vitest alias in the same PR; the runtime half was already there. The reason bites harder for these two rules than for the existing two entries: both are advisory and return findings rather than throwing, so a `dist` merely behind the checkout raises nothing -- it answers with the OLD rule and the pin passes. Pin controls now match by CONTENT rather than by array position, so each stays a statement about the object it names even on a tree where several grants are missing at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * docs(showcase): drop two unverified runtime claims from the rewritten object comments Self-review catch on this PR's own prose, not on the code. The rewritten comments asserted two things this change never measured: that the objectui live e2e "no longer depends on being admin" (that suite was not run here), and that "every non-admin who clicked the entry got a 403" (no app was booted; the 403 is what `nav-object-ungranted` REPORTS, not something observed). Both are restated as what they are -- the lint rule's finding -- so the comments claim exactly the evidence this PR carries and no more. A showcase comment is read as documentation, so an overclaim there costs the same as an overclaim in the docs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * test(dogfood): grow the persona × CRUD matrix fixtures to the nine newly granted objects The persona × CRUD-cell sweep is DERIVED from the showcase's access-matrix snapshot, so granting the nine navigable objects pulled them into the sweep and the fixture maps had to grow with it — `PAYLOAD[object] is not a function` was the admin control reaching an object with no create-payload builder. Adds a minimal valid payload and marker for all nine, seeds the two new lookup parents (`showcase_expense_report`, `showcase_team`), generalises the EDIT probe so the pure junction object can name its own field/value pair, and re-states the verdict census arithmetic (54/54 → 87/77) row by row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3e9065c commit 3e270d4

10 files changed

Lines changed: 525 additions & 21 deletions

File tree

examples/app-showcase/access-matrix.json

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,39 @@
7878
"modifyAllRecords": false,
7979
"sharingModel": "public_read_write"
8080
},
81+
{
82+
"permissionSet": "showcase_contributor",
83+
"object": "showcase_expense_line",
84+
"create": true,
85+
"read": true,
86+
"edit": true,
87+
"delete": false,
88+
"viewAllRecords": false,
89+
"modifyAllRecords": false,
90+
"sharingModel": "controlled_by_parent"
91+
},
92+
{
93+
"permissionSet": "showcase_contributor",
94+
"object": "showcase_expense_report",
95+
"create": true,
96+
"read": true,
97+
"edit": true,
98+
"delete": false,
99+
"viewAllRecords": false,
100+
"modifyAllRecords": false,
101+
"sharingModel": "public_read_write"
102+
},
103+
{
104+
"permissionSet": "showcase_contributor",
105+
"object": "showcase_field_zoo",
106+
"create": true,
107+
"read": true,
108+
"edit": true,
109+
"delete": false,
110+
"viewAllRecords": false,
111+
"modifyAllRecords": false,
112+
"sharingModel": "public_read_write"
113+
},
81114
{
82115
"permissionSet": "showcase_contributor",
83116
"object": "showcase_invoice",
@@ -275,6 +308,61 @@
275308
"modifyAllRecords": false,
276309
"sharingModel": "public_read"
277310
},
311+
{
312+
"permissionSet": "showcase_member_default",
313+
"object": "showcase_business_unit",
314+
"create": false,
315+
"read": true,
316+
"edit": false,
317+
"delete": false,
318+
"viewAllRecords": false,
319+
"modifyAllRecords": false,
320+
"sharingModel": "public_read_write"
321+
},
322+
{
323+
"permissionSet": "showcase_member_default",
324+
"object": "showcase_cascade",
325+
"create": true,
326+
"read": true,
327+
"edit": true,
328+
"delete": false,
329+
"viewAllRecords": false,
330+
"modifyAllRecords": false,
331+
"sharingModel": "public_read_write"
332+
},
333+
{
334+
"permissionSet": "showcase_member_default",
335+
"object": "showcase_category",
336+
"create": false,
337+
"read": true,
338+
"edit": false,
339+
"delete": false,
340+
"viewAllRecords": false,
341+
"modifyAllRecords": false,
342+
"sharingModel": "public_read_write"
343+
},
344+
{
345+
"permissionSet": "showcase_member_default",
346+
"object": "showcase_expense_report",
347+
"create": false,
348+
"read": true,
349+
"edit": false,
350+
"delete": false,
351+
"viewAllRecords": false,
352+
"modifyAllRecords": false,
353+
"sharingModel": "public_read_write"
354+
},
355+
{
356+
"permissionSet": "showcase_member_default",
357+
"object": "showcase_field_zoo",
358+
"create": false,
359+
"read": true,
360+
"edit": false,
361+
"delete": false,
362+
"viewAllRecords": false,
363+
"modifyAllRecords": false,
364+
"sharingModel": "public_read_write"
365+
},
278366
{
279367
"permissionSet": "showcase_member_default",
280368
"object": "showcase_inquiry",
@@ -286,6 +374,17 @@
286374
"modifyAllRecords": false,
287375
"sharingModel": "private"
288376
},
377+
{
378+
"permissionSet": "showcase_member_default",
379+
"object": "showcase_preference",
380+
"create": true,
381+
"read": true,
382+
"edit": true,
383+
"delete": false,
384+
"viewAllRecords": false,
385+
"modifyAllRecords": false,
386+
"sharingModel": "public_read_write"
387+
},
289388
{
290389
"permissionSet": "showcase_member_default",
291390
"object": "showcase_private_note",
@@ -330,6 +429,17 @@
330429
"modifyAllRecords": false,
331430
"sharingModel": "public_read_write"
332431
},
432+
{
433+
"permissionSet": "showcase_member_default",
434+
"object": "showcase_team",
435+
"create": false,
436+
"read": true,
437+
"edit": false,
438+
"delete": false,
439+
"viewAllRecords": false,
440+
"modifyAllRecords": false,
441+
"sharingModel": "public_read_write"
442+
},
333443
{
334444
"permissionSet": "showcase_ops",
335445
"object": "showcase_announcement",
@@ -341,6 +451,28 @@
341451
"modifyAllRecords": true,
342452
"sharingModel": "public_read"
343453
},
454+
{
455+
"permissionSet": "showcase_ops",
456+
"object": "showcase_business_unit",
457+
"create": true,
458+
"read": true,
459+
"edit": true,
460+
"delete": false,
461+
"viewAllRecords": false,
462+
"modifyAllRecords": false,
463+
"sharingModel": "public_read_write"
464+
},
465+
{
466+
"permissionSet": "showcase_ops",
467+
"object": "showcase_category",
468+
"create": true,
469+
"read": true,
470+
"edit": true,
471+
"delete": false,
472+
"viewAllRecords": false,
473+
"modifyAllRecords": false,
474+
"sharingModel": "public_read_write"
475+
},
344476
{
345477
"permissionSet": "showcase_ops",
346478
"object": "showcase_inquiry",
@@ -364,6 +496,28 @@
364496
"viewAllRecords": false,
365497
"modifyAllRecords": false,
366498
"sharingModel": "public_read_write"
499+
},
500+
{
501+
"permissionSet": "showcase_ops",
502+
"object": "showcase_project_membership",
503+
"create": true,
504+
"read": true,
505+
"edit": true,
506+
"delete": true,
507+
"viewAllRecords": false,
508+
"modifyAllRecords": false,
509+
"sharingModel": "public_read_write"
510+
},
511+
{
512+
"permissionSet": "showcase_ops",
513+
"object": "showcase_team",
514+
"create": true,
515+
"read": true,
516+
"edit": true,
517+
"delete": false,
518+
"viewAllRecords": false,
519+
"modifyAllRecords": false,
520+
"sharingModel": "public_read_write"
367521
}
368522
]
369523
}

examples/app-showcase/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@objectstack/cli": "workspace:*",
3939
"@objectstack/core": "workspace:*",
4040
"@objectstack/formula": "workspace:*",
41+
"@objectstack/lint": "workspace:*",
4142
"@objectstack/objectql": "workspace:*",
4243
"@objectstack/plugin-approvals": "workspace:*",
4344
"@objectstack/plugin-email": "workspace:*",

examples/app-showcase/src/data/objects/cascading-select.object.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,19 @@ import { P } from '@objectstack/spec';
2626
* unbound (a system write) — the acting user is bound from the request on
2727
* authenticated writes (engine `buildEvalUser`).
2828
*
29-
* `sharingModel: 'public_read_write'` so the seeded admin (and the live e2e,
30-
* objectui `e2e/live/cascading-options.spec.ts`) can create records without a
31-
* bespoke permission set; belonging to no permission set, it is intentionally
32-
* absent from the ADR-0090 access-matrix snapshot.
29+
* `sharingModel: 'public_read_write'` is the RECORD baseline (gate ②) — no
30+
* sharing rule is needed for one persona to see another's row. It is NOT what
31+
* makes the object reachable: object-level CRUD (gate ①) comes only from a
32+
* permission set and is checked first. This comment used to say the wide OWD let
33+
* the seeded admin and the live e2e create records "without a bespoke permission
34+
* set". What actually carried the admin was their built-in WILDCARD set: with no
35+
* grant anywhere, `nav-object-ungranted` warned on every build and reports the
36+
* `nav_cascade` entry as permission-denied for every principal that does not
37+
* hold that wildcard set. `showcase_member_default` now grants read/create/edit,
38+
* so the object is in the access-matrix snapshot and a plain member holds the
39+
* capability the navigation entry already claimed for them. Note where the
40+
* narrowing really lives: on one OPTION (`tier: 'restricted'`, admin-only),
41+
* not on the object.
3342
*
3443
* The server verdict is unit-covered by objectql
3544
* `rule-validator.option-visibility.test.ts`; this object is the served fixture

examples/app-showcase/src/data/objects/expense-report.object.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,20 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
3131
*/
3232
export const ExpenseReport = ObjectSchema.create({
3333
name: 'showcase_expense_report',
34-
// [ADR-0090 D1] grandfather stamp: world-writable demo object so any seeded
35-
// persona (and the browser e2e) can create/edit reports without a bespoke
36-
// permission set. Belonging to no permission set, it is intentionally absent
37-
// from the access-matrix snapshot (cf. showcase_cascade).
34+
// [ADR-0090 D1] grandfather stamp: the RECORD baseline is world-writable, so
35+
// no sharing rule or scope depth is needed to see another persona's report.
36+
//
37+
// That is gate ② and it is the whole of what this dial buys. It does NOT make
38+
// the object reachable: object-level CRUD is gate ①, it is granted only by a
39+
// permission set, and it is checked FIRST. This comment used to claim the wide
40+
// OWD let "any seeded persona create/edit reports without a bespoke permission
41+
// set" — measurably false, and the app shipped the proof: the object sat in
42+
// the shared navigation with no grant anywhere, so `nav-object-ungranted`
43+
// warned on every build, reporting the entry as permission-denied for every
44+
// principal except one holding the platform's built-in wildcard admin set.
45+
// The seeded admin holds exactly that set, which is why it looked fine.
46+
// The grants now live on `showcase_contributor` (file/edit) and
47+
// `showcase_member_default` (read), and both appear in access-matrix.json.
3848
sharingModel: 'public_read_write',
3949
label: 'Expense Report',
4050
pluralLabel: 'Expense Reports',

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ export const ContributorPermissionSet = definePermissionSet({
4242
// are `controlled_by_parent` — no line RLS is authored (ADR-0055).
4343
showcase_invoice: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
4444
showcase_invoice_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
45+
// Expense graph — the SAME master/detail shape as the invoice graph above,
46+
// and the reason the CHILD is listed explicitly rather than inherited.
47+
// `controlled_by_parent` derives the child's RECORD scope from its master;
48+
// it does NOT derive the child's OBJECT-level CRUD. That is a separate gate
49+
// (`checkObjectPermission`) and it runs FIRST, so a set that grants the
50+
// report and forgets the line denies 403 before the parent-derived access is
51+
// ever consulted — the silent "can't fill in / can't submit the subtable"
52+
// trap (`security-master-detail-ungranted`, ADR-0055).
53+
showcase_expense_report: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
54+
showcase_expense_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
55+
// Same rule, one object further from intuition: `showcase_field_zoo` is a
56+
// master_detail CHILD of `showcase_project`, which this set grants above.
57+
// Being a synthetic catalogue changes nothing — a detail object still needs
58+
// its own object-level grant to be writable from its master's page.
59+
showcase_field_zoo: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
4560
},
4661
// Field-level security — contributors can read but not edit budget figures.
4762
// Keys MUST be `<object>.<field>` qualified: the runtime evaluator matches
@@ -183,6 +198,20 @@ export const OpsPermissionSet = definePermissionSet({
183198
showcase_announcement: { allowRead: true, allowCreate: true, allowEdit: true, modifyAllRecords: true },
184199
showcase_inquiry: { allowRead: true, allowEdit: true, readScope: 'org', writeScope: 'org' },
185200
showcase_invoice: { allowRead: true },
201+
// Shared reference data — the org geometry and the tagging tree every
202+
// member READS from the baseline set below, and that back-office CURATES
203+
// here. The split is the point: nav-wide readability is a baseline
204+
// question, authorship is a persona question, and they are different
205+
// grants on the same objects.
206+
showcase_team: { allowRead: true, allowCreate: true, allowEdit: true },
207+
showcase_category: { allowRead: true, allowCreate: true, allowEdit: true },
208+
showcase_business_unit: { allowRead: true, allowCreate: true, allowEdit: true },
209+
// `showcase_project_membership` is the team↔project junction — a detail of
210+
// `showcase_team` (its FIRST master_detail field), granted here beside that
211+
// master for the ADR-0055 reason above. `allowDelete` is what un-staffs a
212+
// team, and is also why the junction cannot live on the `everyone` baseline:
213+
// delete is a high-privilege bit the D7 anchor gate refuses there.
214+
showcase_project_membership: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
186215
},
187216
// `setup.access` is a platform capability; `showcase.export_data` is a
188217
// PACKAGE capability this app DECLARES via defineCapability (see
@@ -279,6 +308,40 @@ export const MemberDefaultPermissionSet = definePermissionSet({
279308
// The D7 linter flags this owner-only read as `security-private-no-
280309
// readscope` (info) — here it is exactly the intent.
281310
showcase_private_note: { allowRead: true, allowCreate: true, allowEdit: true },
311+
312+
// ── Navigation is an ACCESS CLAIM (ADR-0090 D6) ──────────────────────
313+
//
314+
// Everything below sits in `showcase_app`'s SHARED navigation, which every
315+
// authenticated member sees. Putting an object there asserts that every
316+
// member may open it, so the baseline set is where that assertion has to be
317+
// paid for — one read grant per shared nav entry, and no further.
318+
//
319+
// None of these needed a WIDER `sharingModel` to become readable and none
320+
// got one: all seven are already `public_read_write`, the widest RECORD
321+
// baseline the model has, and all seven were still unreachable. Object-level
322+
// CRUD (gate ①) and the OWD (gate ②) are independent, gate ① runs first, and
323+
// the only principal it admits with no grant at all is the platform's
324+
// built-in wildcard admin set — which is exactly who an author browses as
325+
// while building the app, and why this class survives to production.
326+
// `nav-object-ungranted` is the check that the two declarations agree.
327+
//
328+
// Read-only here, deliberately. Write lives on the persona that owns the
329+
// data: reference-data curation on `showcase_ops`, expense filing on
330+
// `showcase_contributor`. The two exceptions below carry write because the
331+
// member genuinely owns the row.
332+
showcase_expense_report: { allowRead: true },
333+
showcase_team: { allowRead: true },
334+
showcase_category: { allowRead: true },
335+
showcase_business_unit: { allowRead: true },
336+
showcase_field_zoo: { allowRead: true },
337+
// Every member keeps their own workspace settings (nav "Settings").
338+
showcase_preference: { allowRead: true, allowCreate: true, allowEdit: true },
339+
// The B3 dynamic-options fixture is only a fixture if a member can CREATE a
340+
// row and watch the cascade re-filter. Note where the narrowing actually
341+
// is: not on the object, but on one OPTION — `tier: 'restricted'` is offered
342+
// only to admins and re-checked server-side by the objectql rule validator.
343+
// Object grants and option visibility are different instruments.
344+
showcase_cascade: { allowRead: true, allowCreate: true, allowEdit: true },
282345
},
283346
});
284347

0 commit comments

Comments
 (0)