Skip to content

fix(showcase): grant the seven navigable objects and the three master-detail children - #16069

Draft
os-warren wants to merge 3 commits into
mainfrom
claude/issue-14453-showcase-permission-gaps
Draft

fix(showcase): grant the seven navigable objects and the three master-detail children#16069
os-warren wants to merge 3 commits into
mainfrom
claude/issue-14453-showcase-permission-gaps

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #14453

Reproduction, before anything was edited

pnpm --filter @objectstack/example-showcase build on origin/main at 53cbad9f7,
exit 0. The build's own verdict line:

  ⚠ 33 author-time warning(s) — see above

Histogram of those 33 by the rule id each finding prints (sums to 33, i.e. the
histogram is the whole population, not a sample):

count rule
10 approval-approvers-may-resolve-empty
7 nav-object-ungranted
5 react-prop-deprecated
3 security-master-detail-ungranted
2 security-private-no-readscope
2 liveness-planned-property
1 view/row-color-without-colors
1 title-unresolvable
1 flow-loop-body-uncontained
1 flow-draft-status-ambiguous

The card's "seven" is exact. Its master-detail claim is low by two. The card
names showcase_field_zoo as the detail object with no CRUD grant; the build
names three.

The seven, verbatim from the build (message body elided after the first clause,
which is identical in all seven):

  ⚠ app "showcase_app" · nav "nav_settings": navigation exposes object "showcase_preference", but no permission set this stack declares grants read on it — the entry renders, and opening it fails permission-denied for every principal except one holding the platform's built-in wildcard admin set. It works when you browse as an administrator and breaks for the users the app ships permission sets for.
    rule: nav-object-ungranted  at apps[0].navigation[2].children[3].objectName
object nav entry path
showcase_preference nav_settings apps[0].navigation[2].children[3].objectName
showcase_expense_report nav_expense_reports apps[0].navigation[3].children[5].objectName
showcase_team nav_teams apps[0].navigation[3].children[7].objectName
showcase_category nav_categories apps[0].navigation[3].children[8].objectName
showcase_business_unit nav_business_units apps[0].navigation[3].children[9].objectName
showcase_field_zoo nav_field_zoo apps[0].navigation[3].children[10].objectName
showcase_cascade nav_cascade apps[0].navigation[3].children[11].objectName

And the three, not one:

  ⚠ object "showcase_expense_line": detail object "showcase_expense_line" (master_detail "expense_report" → "showcase_expense_report") has no object-level CRUD grant in any permission set. …
    rule: security-master-detail-ungranted  at objects[7].fields.expense_report
  ⚠ object "showcase_field_zoo": detail object "showcase_field_zoo" (master_detail "f_master_detail" → "showcase_project") has no object-level CRUD grant in any permission set. …
    rule: security-master-detail-ungranted  at objects[9].fields.f_master_detail
  ⚠ object "showcase_project_membership": detail object "showcase_project_membership" (master_detail "team" → "showcase_team") has no object-level CRUD grant in any permission set. …
    rule: security-master-detail-ungranted  at objects[17].fields.team

So the real population is 10 findings over 9 distinct objects
showcase_field_zoo appears in both classes.

One measurement that changed the shape of the fix

The nav-object-ungranted hint offers three remedies: grant read, gate the entry
with requiredPermissions/visible
, or drop it. validate-nav-access.ts never
reads either gating key — collectNavExposures walks type, objectName, id,
children and nothing else. Measured against the shipped rule, with both controls:

CONTROL ungated+ungranted (MUST be 1): 1 finding(s) -> nav-object-ungranted
CONTROL granted            (MUST be 0): 0 finding(s)
PROBE requiredPermissions             : 1 finding(s) -> nav-object-ungranted
PROBE visible:false                   : 1 finding(s) -> nav-object-ungranted

Only grant and drop actually clear the finding. Dropping seven working demo
surfaces out of the navigation is the silencing failure mode, so every one of the
seven is granted. (The ineffective hint is a defect in the rule, not in this app —
filed separately, not touched here.)

What an app author is supposed to learn from the result

A wide sharingModel does not make an object reachable. All seven navigable
objects were already public_read_write — the widest RECORD baseline the model
offers — and all seven were unreachable anyway. Object-level CRUD (gate ①,
checkObjectPermission, granted only by a permission set) and the OWD (gate ②) are
independent, and gate ① runs first. The only principal it admits with no grant is
the platform's built-in wildcard admin set — which is exactly who an author browses
as while building, which is why this class survives to production.

The showcase was not merely silent about this; it taught the opposite. Two object
doc comments said the wide OWD let personas use the object "without a bespoke
permission set"
, and named the absence from access-matrix.json as intentional.
Both are rewritten.

controlled_by_parent derives RECORD access, never OBJECT CRUD. That is the
whole content of the three master-detail findings: showcase_expense_line is a
controlled_by_parent child of a granted master and still needed its own grant —
the "can't submit the subtable" 403 the rule exists for (ADR-0055).

Navigation is an access claim. Putting an object in the shared navigation
asserts that every member may open it, so the everyone baseline is where that gets
paid for. Grants are therefore distributed by persona rather than pooled:

set objects added bits
showcase_member_default (isDefault, the everyone baseline) the 7 navigable objects read; plus create/edit on showcase_preference and showcase_cascade, the two rows a member genuinely owns
showcase_contributor showcase_expense_report, showcase_expense_line, showcase_field_zoo read/create/edit — filing expenses (the report plus its controlled_by_parent line), and showcase_field_zoo, a child of the showcase_project this set already grants
showcase_ops showcase_team, showcase_category, showcase_business_unit, showcase_project_membership read/create/edit; allowDelete only on the junction

Two narrowing decisions worth naming, because they are the difference between this
and a blanket grant:

  • Read on the baseline, write on the persona. Reference data (showcase_team,
    showcase_category, showcase_business_unit) is readable by every member and
    authored only by ops.

  • allowDelete stays off the baseline. Un-staffing a team is
    showcase_project_membership delete, which lives on showcase_ops. The D7 anchor gate
    refuses delete on an everyone-suggested set, and that refusal is the reason for the
    placement rather than a constraint routed around. Measured against
    describeAnchorForbiddenBits, with the control that the bits actually granted on the
    baseline are the permitted ones:

    CONTROL read/create/edit on everyone (MUST be null): null
    PROBE   allowDelete on everyone  (MUST be non-null): delete/purge/transfer on 'b'
    

showcase_cascade also shows where narrowing genuinely belongs in that app: not on
the object, but on one option (tier: 'restricted', admin-only, re-checked
server-side by the objectql rule validator).

showcase_field_zoo — disposition

The card flagged it as possibly a separate case: "a detail object may legitimately
be reached only through its master."
Measured false for this object. It sits in
the shared navigation as nav_field_zoo under Data Model, so it must be directly
reachable, and it is the one object that draws both warnings. Its
f_master_detail → showcase_project field exists to exhibit the master_detail
field type in a field-type catalogue, not to make it a subtable. Both findings
collapse into one disposition: grant it — read on the baseline (it is navigable) and
read/create/edit on showcase_contributor (which already grants its master).

The card's framing is right about a different object: showcase_expense_line is
controlled_by_parent, is not in navigation, and is reached only through its
master — and per ADR-0055 it needed the grant anyway. That is the separate case.

After

  ⚠ 23 author-time warning(s) — see above

nav-object-ungranted 7 → 0, security-master-detail-ungranted 3 → 0, every other
class byte-identical in count (10/5/2/2/1/1/1/1). 33 − 10 = 23 — the count dropped by
exactly the number of entries fixed, and no new class appeared. In particular
security-private-no-readscope stayed at 2. That is measured, not reasoned — read back
out of the built artifact, with a control proving the field is being read at all:

  showcase_preference            public_read_write
  showcase_expense_report        public_read_write
  showcase_team                  public_read_write
  showcase_category              public_read_write
  showcase_business_unit         public_read_write
  showcase_field_zoo             public_read_write
  showcase_cascade               public_read_write
  showcase_expense_line          controlled_by_parent
  showcase_project_membership    public_read_write
private-or-unset among the 9 (drives security-private-no-readscope): 0
CONTROL showcase_private_note (must be private): private

The rule fires only on private/unset OWD, and none of the nine is either — so adding
read to them could not produce a new finding of that class, and did not.

access-matrix.json (the ADR-0090 D6 snapshot gate, which errors the build on drift)
went 33 → 47 entries. The whole capability delta, diffed by (set, object) key so
additions, removals and in-place widenings are counted separately:

ADDED rows: 14  REMOVED rows: 0   MODIFIED rows: 0
  + showcase_contributor       showcase_expense_line          create+read+edit         owd=controlled_by_parent
  + showcase_contributor       showcase_expense_report        create+read+edit         owd=public_read_write
  + showcase_contributor       showcase_field_zoo             create+read+edit         owd=public_read_write
  + showcase_member_default    showcase_business_unit         read                     owd=public_read_write
  + showcase_member_default    showcase_cascade               create+read+edit         owd=public_read_write
  + showcase_member_default    showcase_category              read                     owd=public_read_write
  + showcase_member_default    showcase_expense_report        read                     owd=public_read_write
  + showcase_member_default    showcase_field_zoo             read                     owd=public_read_write
  + showcase_member_default    showcase_preference            create+read+edit         owd=public_read_write
  + showcase_member_default    showcase_team                  read                     owd=public_read_write
  + showcase_ops               showcase_business_unit         create+read+edit         owd=public_read_write
  + showcase_ops               showcase_category              create+read+edit         owd=public_read_write
  + showcase_ops               showcase_project_membership    create+read+edit+delete  owd=public_read_write
  + showcase_ops               showcase_team                  create+read+edit         owd=public_read_write

No existing row moved, no viewAllRecords / modifyAllRecords bit appears anywhere in
the delta, and the single delete is the ops-held junction. The build named this drift
as an error before the snapshot was regenerated; the listing above is the review artifact
the ADR asks for.

The pin, and its population

examples/app-showcase/test/nav-and-detail-grants.test.ts runs the shipped
validateNavAccess and validateSecurityPosture against the real stack.

It deliberately does not assert "the build exits 0" — the build exited 0 on the
broken tree too, for all 33 warnings. Both rules are advisory; only the findings
carry the verdict.

vitest.config.ts aliases @objectstack/lint to its src/, joining the two aliases
already there. The reason is sharper for an advisory rule than for the existing two:
a dist behind the checkout does not error, it answers with the old rule and the
pin passes.

Covers, for this app's stack only:

  • every type: 'object' nav entry in showcase_app at any depth (top level, groups,
    areas, nested children) whose target is an object this stack defines — each must be
    readable via some declared set (allowRead/viewAllRecords/modifyAllRecords, as
    buildAccessMatrix folds them);
  • every non-system object in this stack carrying a master_detail field — each must
    hold an object-level CRUD grant (any of the six bits) in some declared set.

Does NOT cover:

  • the other example apps (app-crm, app-todo, app-multi-package) — each needs its
    own pin;
  • the other 23 warnings this build still emits — the pin is scoped to two rules and
    says nothing about the total;
  • whether the grant is the RIGHT one. Both rules ask only that some set grants
    the object. Pooling every grant onto one set, or widening one to a '*' wildcard,
    satisfies both rules and this pin. access-matrix.json is what makes that visible:
    any such change is snapshot drift and errors the build until reviewed and committed;
  • runtime behaviour — this is a static agreement check on declarations, it boots
    nothing and issues no request.

Two of the four tests are controls: each strips one real grant from every set and
demands the rule name that object back, matched by content rather than by position.
Without them the two positive assertions could go green because the rules stopped
finding anything at all.

Mutation proof

Fix committed first (774832baa), then mutated — the restore leg needs HEAD to
already carry the implementation.

PRE  hash=7573d923875e91f989e2a2ec8869de6e017cce2a (== HEAD blob)
MUTATION removed 14 grant line(s)
MUT  hash=9ddcfccb12cdcd7dac268ad69be60dcd4441b6f0 (changed => mutation on disk)
MUT  grant-lines for the 9 reverted objects: 0  (MUST be 0)
PIN_EXIT_UNDER_MUTATION=1

RED, quoting the assertion text:

AssertionError: nav entries exposing an object no permission set grants read on:
  app "showcase_app" · nav "nav_settings" -> apps[0].navigation[2].children[3].objectName
  app "showcase_app" · nav "nav_expense_reports" -> apps[0].navigation[3].children[5].objectName
  app "showcase_app" · nav "nav_teams" -> apps[0].navigation[3].children[7].objectName
  app "showcase_app" · nav "nav_categories" -> apps[0].navigation[3].children[8].objectName
  app "showcase_app" · nav "nav_business_units" -> apps[0].navigation[3].children[9].objectName
  app "showcase_app" · nav "nav_field_zoo" -> apps[0].navigation[3].children[10].objectName
  app "showcase_app" · nav "nav_cascade" -> apps[0].navigation[3].children[11].objectName: expected [ …(7) ] to deeply equal []

AssertionError: detail objects with no object-level CRUD grant:
  object "showcase_project_membership" -> objects[6].fields.team
  object "showcase_expense_line" -> objects[11].fields.expense_report
  object "showcase_field_zoo" -> objects[12].fields.f_master_detail: expected [ …(3) ] to deeply equal []

The RED population is the same 7 + 3 the build warned about — the pin covers the whole
reproduction, not a sample of it.

The objects[N] indices differ between the two readings (objects[6]/[11]/[12] here vs
objects[17]/[7]/[9] in the build) and that is expected, not a discrepancy: the pin reads
the config's own export while the build reads the normalized stack, which orders the
objects array differently. The rules address objects by NAME, and the three names match
exactly. The nav paths, which come from a structure normalization does not reorder, are
identical in both.

Restore ran from trap '…' EXIT INT TERM with an absolute path
(REPO_ROOT="$(git rev-parse --show-toplevel)"), spelled git checkout HEAD -- ABSOLUTE_PATH
so it reads the HEAD blob rather than a mutated index:

git diff HEAD (MUST be empty):        (no output)
restored blob = 7573d923875e91f989e2a2ec8869de6e017cce2a
HEAD blob     = 7573d923875e91f989e2a2ec8869de6e017cce2a
RESTORE OK: blob equality holds

Neither hash is empty; blob equality and the empty git diff HEAD are both required
and both hold.

Changeset

None, deliberately. Every source path here is under examples/app-showcase, whose
package.json is "private": true — nothing published moves. packages/lint is
added only as a workspace:* devDependency of that private example (with the matching
pnpm-lock.yaml row); no published package's manifest or output changes. Carrying the
skip-changeset label accordingly.

Out-of-scope finding, filed not fixed

#16065nav-object-ungranted's hint prescribes requiredPermissions/visible as a
remedy, and validateNavAccess reads neither. Different package, different defect class,
so it is filed rather than ridden along. It is the measurement quoted above under "One
measurement that changed the shape of the fix".

Verification

All exit codes captured bare (redirect, then read) — never through a pipe.

command exit verdict quoted from the tool
pnpm --filter @objectstack/example-showcase build (on origin/main 53cbad9f7) 0 ⚠ 33 author-time warning(s) — see above
pnpm --filter @objectstack/example-showcase build (at head, before snapshot regen) 1 ✗ Access matrix drift (14 changes) — capability changes must be reviewed
objectstack build --update-access-matrix 0 → Access matrix snapshot written to access-matrix.json (ADR-0090 D6) — review the diff.
pnpm --filter @objectstack/example-showcase build (at 00c12c3b6) 0 ⚠ 23 author-time warning(s) — see above; nav-object-ungranted 0, security-master-detail-ungranted 0, control rule react-prop-deprecated still 5
vitest run test/nav-and-detail-grants.test.ts (at 00c12c3b6) 0 Test Files 1 passed (1) / Tests 4 passed (4) — 2 assertions + 2 controls
pnpm --filter @objectstack/example-showcase typecheck 0 echoed tsc --noEmit — a real run, not a zero-package match
pnpm --filter @objectstack/example-showcase test 0 Test Files 28 passed (28) / Tests 379 passed (379)
pnpm lint (repo-wide, eslint . --no-inline-config) 0 clean; the whole population, not a narrowed subset
pnpm check:type-source-resolution 1 → 0 RED on the first commit, then check-type-source-resolution OK — 125 tsc program(s) across 78 packages scanned
pnpm check:dual-build-cjs-loads 3 → 0 first reading was PREREQUISITE NOT MET … ⛔ This is NOT a pass: nothing was measured; after building the 7 packages it named, ✓ check:dual-build-cjs-loads — 103 published require entry point(s) across 66 package(s) load

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack reconciles at 44
families
(its Reconciliation line, not the local-gate block, which is 32 of the 44).
All 44 ran on the first commit — that is how check:type-source-resolution was caught —
and all 44 ran again as a union on the final commit 00c12c3b6, on a clean tree
(git status --porcelain empty), with the family set re-derived at that head and
byte-identical to the first derivation. That union is 44/44 exit 0, no failure
output produced.

One union run was discarded rather than reported: it was started at 5a31720ea and
two comment files were edited under it mid-sweep, so roughly the first three quarters of
its gates had graded a tree the last quarter had not. A partly-stale union reads exactly
like a clean one, so it was stopped and re-run from scratch on the committed tree instead.

The typecheck's coverage was itself checked rather than assumed: tsc --noEmit --listFiles
puts the new pin and all three edited source files in the program, and resolves
@objectstack/lint to packages/lint/src/* rather than dist — so the green is about the
source in this checkout.

NOT MEASURED

  • Runtime. No app was booted and no request issued. That a granted member can now
    actually open nav_cascade follows from the model, not from an observation made here.
  • The other example apps. app-crm, app-todo and app-multi-package were not built or
    examined; whether they carry the same class is unknown, not answered.
  • The other 23 warnings. Out of scope by the card, untouched, unexamined.
  • CI's own conclusions. The gate families were derived and run locally; the authoritative
    reading is the PR's own jobs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y


Generated by Claude Code

…-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
…e 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
… 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

Copy link
Copy Markdown
Contributor

Contract review (clause ②) — no gate · content PASS · NOT LANDABLE (red CI, real) — PR #16069 at head 00c12c3b (Fixes #14453)

Reviewed by the director seat at tier (claude-fable-5-1, session session_01TezFG8ZMrNH6n5VTNpPpdH), 2026-09-06 04:37Z, on the label applied 04:07Z.

Clause ② answer: no. Every changed path is under examples/app-showcase (@objectstack/example-showcase, "private": true) plus the lockfile: no published .d.ts, no published payload. access-matrix.json is the app's own ADR-0090 snapshot regenerated by the tool (+14 rows, 0 removed, 0 modified, no viewAllRecords/modifyAllRecords bit anywhere). @objectstack/lint enters as a devDependency only. skip-changeset is the right disposition for a private example.

Content, read anyway: the nine grants are distributed by persona as the dev report describes (read on the showcase_member_default baseline for every shared-nav object, curation on showcase_ops, filing and the showcase_project children on showcase_contributor); the two object doc-comments that taught the wrong gate order are rewritten; the new test/nav-and-detail-grants.test.ts pins both rules at 0 with a CONTROL per rule, aliased to src/ for both type and runtime halves. Sound.

Why it cannot land yet — two reds, both this PR's to clear

  1. Dogfood Regression Gate (3/3) is a real failure caused by this PR, not a flake:
    FAIL  isolated  test/showcase-crud-persona-matrix.dogfood.test.ts > showcase: persona × CRUD-cell matrix (#9481)
    TypeError: PAYLOAD[object] is not a function
     ❯ test/showcase-crud-persona-matrix.dogfood.test.ts:253:80
    
    That suite derives OBJECTS from every showcase_* row of examples/app-showcase/access-matrix.json and needs a PAYLOAD builder (and a MARKER field) per object. The nine objects this PR adds to the matrix — showcase_business_unit, showcase_cascade, showcase_category, showcase_expense_line, showcase_expense_report, showcase_field_zoo, showcase_preference, showcase_project_membership, showcase_team — have no builder; on origin/main the gap set is empty. Add the nine builders/markers (the matrix is the tests(access-security): pin the read side of FLS mask/strip and the persona×CRUD cell matrix — coverage gaps from QA run #9401 #9481 instrument, so a silent exclusion is not the fix; if an object genuinely cannot be created through /data, exclude it by name the way the file already does for sys_* rows, with the reason).
  2. Check Changeset red (23:21:10Z) predates the skip-changeset label (23:23:28Z). The dev report's "half-state: label did NOT land" is stale — the timeline shows the label landed at 23:23:28Z. The check re-runs on the next push; no PM action needed.

needs:contract-review comes off this PR (card #14453 never carried it), with provenance pointing here. Ready / auto-merge stay untouched; routed to the dispatching seat (session_01XpTx2tbq3pZRYAdoGt6E6Y, os-warren). Post the new head here — the extension will re-read only the dogfood delta.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants