From 774832baa06f01e32a16727cecfc6a5df6c49ee9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 22:41:18 +0000 Subject: [PATCH 1/4] fix(showcase): grant the seven navigable objects and the three master-detail children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (53cbad9f7): `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 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- examples/app-showcase/access-matrix.json | 154 ++++++++++++++++++ examples/app-showcase/package.json | 1 + .../data/objects/cascading-select.object.ts | 15 +- .../src/data/objects/expense-report.object.ts | 17 +- .../src/security/permission-sets.ts | 63 +++++++ .../test/nav-and-detail-grants.test.ts | 114 +++++++++++++ examples/app-showcase/vitest.config.ts | 8 + pnpm-lock.yaml | 3 + 8 files changed, 367 insertions(+), 8 deletions(-) create mode 100644 examples/app-showcase/test/nav-and-detail-grants.test.ts diff --git a/examples/app-showcase/access-matrix.json b/examples/app-showcase/access-matrix.json index 50b6ac7cec..721b25a7e4 100644 --- a/examples/app-showcase/access-matrix.json +++ b/examples/app-showcase/access-matrix.json @@ -78,6 +78,39 @@ "modifyAllRecords": false, "sharingModel": "public_read_write" }, + { + "permissionSet": "showcase_contributor", + "object": "showcase_expense_line", + "create": true, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "controlled_by_parent" + }, + { + "permissionSet": "showcase_contributor", + "object": "showcase_expense_report", + "create": true, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_contributor", + "object": "showcase_field_zoo", + "create": true, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, { "permissionSet": "showcase_contributor", "object": "showcase_invoice", @@ -275,6 +308,61 @@ "modifyAllRecords": false, "sharingModel": "public_read" }, + { + "permissionSet": "showcase_member_default", + "object": "showcase_business_unit", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_member_default", + "object": "showcase_cascade", + "create": true, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_member_default", + "object": "showcase_category", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_member_default", + "object": "showcase_expense_report", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_member_default", + "object": "showcase_field_zoo", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, { "permissionSet": "showcase_member_default", "object": "showcase_inquiry", @@ -286,6 +374,17 @@ "modifyAllRecords": false, "sharingModel": "private" }, + { + "permissionSet": "showcase_member_default", + "object": "showcase_preference", + "create": true, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, { "permissionSet": "showcase_member_default", "object": "showcase_private_note", @@ -330,6 +429,17 @@ "modifyAllRecords": false, "sharingModel": "public_read_write" }, + { + "permissionSet": "showcase_member_default", + "object": "showcase_team", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, { "permissionSet": "showcase_ops", "object": "showcase_announcement", @@ -341,6 +451,28 @@ "modifyAllRecords": true, "sharingModel": "public_read" }, + { + "permissionSet": "showcase_ops", + "object": "showcase_business_unit", + "create": true, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_ops", + "object": "showcase_category", + "create": true, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, { "permissionSet": "showcase_ops", "object": "showcase_inquiry", @@ -364,6 +496,28 @@ "viewAllRecords": false, "modifyAllRecords": false, "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_ops", + "object": "showcase_project_membership", + "create": true, + "read": true, + "edit": true, + "delete": true, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_ops", + "object": "showcase_team", + "create": true, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" } ] } diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 7b3ddb7abd..42075eb8db 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -38,6 +38,7 @@ "@objectstack/cli": "workspace:*", "@objectstack/core": "workspace:*", "@objectstack/formula": "workspace:*", + "@objectstack/lint": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/plugin-approvals": "workspace:*", "@objectstack/plugin-email": "workspace:*", diff --git a/examples/app-showcase/src/data/objects/cascading-select.object.ts b/examples/app-showcase/src/data/objects/cascading-select.object.ts index ba0f66f5ff..0cd43ca181 100644 --- a/examples/app-showcase/src/data/objects/cascading-select.object.ts +++ b/examples/app-showcase/src/data/objects/cascading-select.object.ts @@ -26,10 +26,17 @@ import { P } from '@objectstack/spec'; * unbound (a system write) — the acting user is bound from the request on * authenticated writes (engine `buildEvalUser`). * - * `sharingModel: 'public_read_write'` so the seeded admin (and the live e2e, - * objectui `e2e/live/cascading-options.spec.ts`) can create records without a - * bespoke permission set; belonging to no permission set, it is intentionally - * absent from the ADR-0090 access-matrix snapshot. + * `sharingModel: 'public_read_write'` is the RECORD baseline (gate ②) — no + * sharing rule is needed for one persona to see another's row. It is NOT what + * makes the object reachable: object-level CRUD (gate ①) comes only from a + * permission set and is checked first. This comment used to say the wide OWD let + * the seeded admin and the live e2e create records "without a bespoke permission + * set"; what actually carried them was the admin's built-in WILDCARD set, and + * every other member hit a 403 on the `nav_cascade` entry + * (`nav-object-ungranted`, warned on every build). `showcase_member_default` now + * grants read/create/edit, so the object is in the access-matrix snapshot and + * the e2e no longer depends on being admin. Note where the narrowing really + * lives: on one OPTION (`tier: 'restricted'`, admin-only), not on the object. * * The server verdict is unit-covered by objectql * `rule-validator.option-visibility.test.ts`; this object is the served fixture diff --git a/examples/app-showcase/src/data/objects/expense-report.object.ts b/examples/app-showcase/src/data/objects/expense-report.object.ts index 10eb212be5..a038635999 100644 --- a/examples/app-showcase/src/data/objects/expense-report.object.ts +++ b/examples/app-showcase/src/data/objects/expense-report.object.ts @@ -31,10 +31,19 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; */ export const ExpenseReport = ObjectSchema.create({ name: 'showcase_expense_report', - // [ADR-0090 D1] grandfather stamp: world-writable demo object so any seeded - // persona (and the browser e2e) can create/edit reports without a bespoke - // permission set. Belonging to no permission set, it is intentionally absent - // from the access-matrix snapshot (cf. showcase_cascade). + // [ADR-0090 D1] grandfather stamp: the RECORD baseline is world-writable, so + // no sharing rule or scope depth is needed to see another persona's report. + // + // That is gate ② and it is the whole of what this dial buys. It does NOT make + // the object reachable: object-level CRUD is gate ①, it is granted only by a + // permission set, and it is checked FIRST. This comment used to claim the wide + // OWD let "any seeded persona create/edit reports without a bespoke permission + // set" — measurably false, and the app shipped the proof: the object sat in + // the shared navigation with no grant anywhere, so `nav-object-ungranted` + // warned on every build and every non-admin who clicked the entry got a 403. + // It worked for the seeded admin only, who holds the platform's wildcard set. + // The grants now live on `showcase_contributor` (file/edit) and + // `showcase_member_default` (read), and both appear in access-matrix.json. sharingModel: 'public_read_write', label: 'Expense Report', pluralLabel: 'Expense Reports', diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index 8dbffa09f8..7573d92387 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -42,6 +42,21 @@ export const ContributorPermissionSet = definePermissionSet({ // are `controlled_by_parent` — no line RLS is authored (ADR-0055). showcase_invoice: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, showcase_invoice_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + // Expense graph — the SAME master/detail shape as the invoice graph above, + // and the reason the CHILD is listed explicitly rather than inherited. + // `controlled_by_parent` derives the child's RECORD scope from its master; + // it does NOT derive the child's OBJECT-level CRUD. That is a separate gate + // (`checkObjectPermission`) and it runs FIRST, so a set that grants the + // report and forgets the line denies 403 before the parent-derived access is + // ever consulted — the silent "can't fill in / can't submit the subtable" + // trap (`security-master-detail-ungranted`, ADR-0055). + showcase_expense_report: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + showcase_expense_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + // Same rule, one object further from intuition: `showcase_field_zoo` is a + // master_detail CHILD of `showcase_project`, which this set grants above. + // Being a synthetic catalogue changes nothing — a detail object still needs + // its own object-level grant to be writable from its master's page. + showcase_field_zoo: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, }, // Field-level security — contributors can read but not edit budget figures. // Keys MUST be `.` qualified: the runtime evaluator matches @@ -183,6 +198,20 @@ export const OpsPermissionSet = definePermissionSet({ showcase_announcement: { allowRead: true, allowCreate: true, allowEdit: true, modifyAllRecords: true }, showcase_inquiry: { allowRead: true, allowEdit: true, readScope: 'org', writeScope: 'org' }, showcase_invoice: { allowRead: true }, + // Shared reference data — the org geometry and the tagging tree every + // member READS from the baseline set below, and that back-office CURATES + // here. The split is the point: nav-wide readability is a baseline + // question, authorship is a persona question, and they are different + // grants on the same objects. + showcase_team: { allowRead: true, allowCreate: true, allowEdit: true }, + showcase_category: { allowRead: true, allowCreate: true, allowEdit: true }, + showcase_business_unit: { allowRead: true, allowCreate: true, allowEdit: true }, + // `showcase_project_membership` is the team↔project junction — a detail of + // `showcase_team` (its FIRST master_detail field), granted here beside that + // master for the ADR-0055 reason above. `allowDelete` is what un-staffs a + // team, and is also why the junction cannot live on the `everyone` baseline: + // delete is a high-privilege bit the D7 anchor gate refuses there. + showcase_project_membership: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, }, // `setup.access` is a platform capability; `showcase.export_data` is a // PACKAGE capability this app DECLARES via defineCapability (see @@ -279,6 +308,40 @@ export const MemberDefaultPermissionSet = definePermissionSet({ // The D7 linter flags this owner-only read as `security-private-no- // readscope` (info) — here it is exactly the intent. showcase_private_note: { allowRead: true, allowCreate: true, allowEdit: true }, + + // ── Navigation is an ACCESS CLAIM (ADR-0090 D6) ────────────────────── + // + // Everything below sits in `showcase_app`'s SHARED navigation, which every + // authenticated member sees. Putting an object there asserts that every + // member may open it, so the baseline set is where that assertion has to be + // paid for — one read grant per shared nav entry, and no further. + // + // None of these needed a WIDER `sharingModel` to become readable and none + // got one: all seven are already `public_read_write`, the widest RECORD + // baseline the model has, and all seven were still unreachable. Object-level + // CRUD (gate ①) and the OWD (gate ②) are independent, gate ① runs first, and + // the only principal it admits with no grant at all is the platform's + // built-in wildcard admin set — which is exactly who an author browses as + // while building the app, and why this class survives to production. + // `nav-object-ungranted` is the check that the two declarations agree. + // + // Read-only here, deliberately. Write lives on the persona that owns the + // data: reference-data curation on `showcase_ops`, expense filing on + // `showcase_contributor`. The two exceptions below carry write because the + // member genuinely owns the row. + showcase_expense_report: { allowRead: true }, + showcase_team: { allowRead: true }, + showcase_category: { allowRead: true }, + showcase_business_unit: { allowRead: true }, + showcase_field_zoo: { allowRead: true }, + // Every member keeps their own workspace settings (nav "Settings"). + showcase_preference: { allowRead: true, allowCreate: true, allowEdit: true }, + // The B3 dynamic-options fixture is only a fixture if a member can CREATE a + // row and watch the cascade re-filter. Note where the narrowing actually + // is: not on the object, but on one OPTION — `tier: 'restricted'` is offered + // only to admins and re-checked server-side by the objectql rule validator. + // Object grants and option visibility are different instruments. + showcase_cascade: { allowRead: true, allowCreate: true, allowEdit: true }, }, }); diff --git a/examples/app-showcase/test/nav-and-detail-grants.test.ts b/examples/app-showcase/test/nav-and-detail-grants.test.ts new file mode 100644 index 0000000000..faf5ad5841 --- /dev/null +++ b/examples/app-showcase/test/nav-and-detail-grants.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateNavAccess, + validateSecurityPosture, + NAV_OBJECT_UNGRANTED, + SECURITY_MASTER_DETAIL_UNGRANTED, +} from '@objectstack/lint'; + +import stack from '../objectstack.config.js'; + +/** + * The showcase's navigation and its permission sets must keep agreeing + * (ADR-0090 D6 `nav-object-ungranted`; ADR-0055 `security-master-detail- + * ungranted`). + * + * ## Why this pin exists in this shape + * + * Both rules are ADVISORY: they warn and the build still exits 0. The showcase + * shipped seven ungranted nav entries and three ungranted detail objects for + * long enough that the warnings became scenery, and `objectstack build` was + * green through all of it. So a pin asserting "the build succeeds" would have + * passed on the broken tree — it has to assert on the FINDINGS. + * + * It runs the SHIPPED rule implementations against the REAL stack rather than + * re-deriving them here. A re-derivation is a second opinion that drifts: the + * rule could be tightened (or broken) and this file would keep answering about + * its own old copy. `vitest.config.ts` aliases `@objectstack/lint` to its + * `src/`, so the verdict is about the rule source in this checkout, not about + * `packages/lint/dist` (`pnpm check:test-source-alias`). + * + * ## Population — what this covers, and what it does NOT + * + * COVERS, for this app's stack only: + * - every `type: 'object'` navigation 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 through some permission set + * this stack declares (`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 would need its own pin; + * - the other 23 author-time warnings this build still emits (approval + * staffing, deprecated React props, roll-up titles, planned properties, + * `security-private-no-readscope`); this pin is deliberately scoped to the + * 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. Moving every grant onto one set, or widening one to a + * `'*'` wildcard, satisfies both rules and this pin — `access-matrix.json` + * is the artifact that makes that visible, because any such change shows up + * as reviewable snapshot drift and fails `objectstack build` until committed; + * - runtime behaviour. This is a static agreement check on declarations; it + * does not boot the app or issue a request. + */ + +type AnyRec = Record; +const asStack = stack as unknown as AnyRec; + +/** The stack with `objectName`'s grant removed from EVERY permission set. */ +function withoutGrant(objectName: string): AnyRec { + const permissions = (asStack.permissions as AnyRec[]).map((ps) => { + const objects = ps?.objects as AnyRec | undefined; + if (!objects || !(objectName in objects)) return ps; + const stripped = { ...objects }; + delete stripped[objectName]; + return { ...ps, objects: stripped }; + }); + return { ...asStack, permissions }; +} + +const navFindings = (s: AnyRec) => validateNavAccess(s).filter((f) => f.rule === NAV_OBJECT_UNGRANTED); +const detailFindings = (s: AnyRec) => + validateSecurityPosture(s).filter((f) => f.rule === SECURITY_MASTER_DETAIL_UNGRANTED); + +describe('showcase navigation and detail objects are granted (#14453)', () => { + it('every navigable object is readable through some declared permission set', () => { + const offenders = navFindings(asStack).map((f) => `${f.where} -> ${f.path}`); + expect( + offenders, + `nav entries exposing an object no permission set grants read on:\n ${offenders.join('\n ')}`, + ).toEqual([]); + }); + + it('every master-detail child holds an object-level CRUD grant', () => { + const offenders = detailFindings(asStack).map((f) => `${f.where} -> ${f.path}`); + expect( + offenders, + `detail objects with no object-level CRUD grant:\n ${offenders.join('\n ')}`, + ).toEqual([]); + }); + + // ── Controls ───────────────────────────────────────────────────────────── + // Without these the two assertions above could go green because the rules + // stopped finding anything at all — a rule renamed, an exemption widened, a + // stack shape the collector no longer walks. Each control removes ONE real + // grant and demands the corresponding rule name that exact object back. + + it('CONTROL: dropping the grant on a nav object makes the nav rule fire on it', () => { + const findings = navFindings(withoutGrant('showcase_cascade')); + expect(findings.map((f) => f.path)).toContain('apps[0].navigation[3].children[11].objectName'); + expect(findings[0]?.message).toContain('showcase_cascade'); + }); + + it('CONTROL: dropping the grant on a detail child makes the master-detail rule fire on it', () => { + const findings = detailFindings(withoutGrant('showcase_expense_line')); + expect(findings.map((f) => f.where)).toContain('object "showcase_expense_line"'); + expect(findings[0]?.message).toContain('has no object-level CRUD grant in any permission set'); + }); +}); diff --git a/examples/app-showcase/vitest.config.ts b/examples/app-showcase/vitest.config.ts index fdf00cea36..a5b82a9761 100644 --- a/examples/app-showcase/vitest.config.ts +++ b/examples/app-showcase/vitest.config.ts @@ -33,9 +33,17 @@ export default defineConfig({ // exist to measure. `pnpm check:test-source-alias` is the gate, and its // registry is shrink-only: the alias is the sanctioned remedy, never a new // registry entry. + // + // `test/nav-and-detail-grants.test.ts` (#14453) runs the SHIPPED + // `validateNavAccess` / `validateSecurityPosture` rules against this app's + // own stack, so the same reasoning applies with the sign that matters most + // here: both rules are ADVISORY, so a `dist` merely BEHIND this checkout + // raises nothing — it answers with the OLD rule and the pin passes. Aliased + // to `src/`, that pin is a verdict about the rule in this tree. alias: [ { find: /^@objectstack\/formula$/, replacement: path.resolve(__dirname, '../../packages/formula/src/index.ts') }, { find: /^@objectstack\/plugin-email$/, replacement: path.resolve(__dirname, '../../packages/plugins/plugin-email/src/index.ts') }, + { find: /^@objectstack\/lint$/, replacement: path.resolve(__dirname, '../../packages/lint/src/index.ts') }, ], }, test: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f44871995..65614ce890 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,9 @@ importers: '@objectstack/formula': specifier: workspace:* version: link:../../packages/formula + '@objectstack/lint': + specifier: workspace:* + version: link:../../packages/lint '@objectstack/objectql': specifier: workspace:* version: link:../../packages/objectql From 5a31720ea1e9f4c1b81d6b076abbd8c7ed439a0c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 23:05:40 +0000 Subject: [PATCH 2/4] 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 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- .../app-showcase/test/nav-and-detail-grants.test.ts | 11 +++++++++-- examples/app-showcase/tsconfig.json | 11 ++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/examples/app-showcase/test/nav-and-detail-grants.test.ts b/examples/app-showcase/test/nav-and-detail-grants.test.ts index faf5ad5841..018845da87 100644 --- a/examples/app-showcase/test/nav-and-detail-grants.test.ts +++ b/examples/app-showcase/test/nav-and-detail-grants.test.ts @@ -102,13 +102,20 @@ describe('showcase navigation and detail objects are granted (#14453)', () => { it('CONTROL: dropping the grant on a nav object makes the nav rule fire on it', () => { const findings = navFindings(withoutGrant('showcase_cascade')); + // Matched by CONTENT, not by position: on a tree where more than one grant + // is missing this must still be a statement about `showcase_cascade`. expect(findings.map((f) => f.path)).toContain('apps[0].navigation[3].children[11].objectName'); - expect(findings[0]?.message).toContain('showcase_cascade'); + expect(findings.map((f) => f.message).join('\n')).toContain( + 'navigation exposes object "showcase_cascade"', + ); }); it('CONTROL: dropping the grant on a detail child makes the master-detail rule fire on it', () => { const findings = detailFindings(withoutGrant('showcase_expense_line')); expect(findings.map((f) => f.where)).toContain('object "showcase_expense_line"'); - expect(findings[0]?.message).toContain('has no object-level CRUD grant in any permission set'); + expect(findings.map((f) => f.message).join('\n')).toContain( + 'detail object "showcase_expense_line" (master_detail "expense_report" → ' + + '"showcase_expense_report") has no object-level CRUD grant', + ); }); }); diff --git a/examples/app-showcase/tsconfig.json b/examples/app-showcase/tsconfig.json index 20167e6b9f..c97e33c638 100644 --- a/examples/app-showcase/tsconfig.json +++ b/examples/app-showcase/tsconfig.json @@ -37,9 +37,18 @@ // a star is an EXACT match, and the `@objectstack/plugin-email*` spelling would // fold every subpath onto this one target and type-check green against the wrong // module. + // + // Same rule, third instance, and the one where a stale `dist` is quietest: + // `test/nav-and-detail-grants.test.ts` (#14453) runs `@objectstack/lint`'s + // `validateNavAccess` / `validateSecurityPosture` over this app's own stack. + // Both are ADVISORY rules that return findings rather than throwing, so a + // `dist` merely BEHIND this checkout raises nothing at all — it answers with + // the OLD rule and the pin passes. The runtime half is the matching alias in + // vitest.config.ts; this is the type half. "paths": { "@objectstack/formula": ["../../packages/formula/src/index.ts"], - "@objectstack/plugin-email": ["../../packages/plugins/plugin-email/src/index.ts"] + "@objectstack/plugin-email": ["../../packages/plugins/plugin-email/src/index.ts"], + "@objectstack/lint": ["../../packages/lint/src/index.ts"] } }, // This package took the widened-`include` route rather than a sibling From 00c12c3b6e875220fdca4396df1da197c951c63f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 23:11:33 +0000 Subject: [PATCH 3/4] 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 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- .../src/data/objects/cascading-select.object.ts | 14 ++++++++------ .../src/data/objects/expense-report.object.ts | 5 +++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/examples/app-showcase/src/data/objects/cascading-select.object.ts b/examples/app-showcase/src/data/objects/cascading-select.object.ts index 0cd43ca181..3701652530 100644 --- a/examples/app-showcase/src/data/objects/cascading-select.object.ts +++ b/examples/app-showcase/src/data/objects/cascading-select.object.ts @@ -31,12 +31,14 @@ import { P } from '@objectstack/spec'; * makes the object reachable: object-level CRUD (gate ①) comes only from a * permission set and is checked first. This comment used to say the wide OWD let * the seeded admin and the live e2e create records "without a bespoke permission - * set"; what actually carried them was the admin's built-in WILDCARD set, and - * every other member hit a 403 on the `nav_cascade` entry - * (`nav-object-ungranted`, warned on every build). `showcase_member_default` now - * grants read/create/edit, so the object is in the access-matrix snapshot and - * the e2e no longer depends on being admin. Note where the narrowing really - * lives: on one OPTION (`tier: 'restricted'`, admin-only), not on the object. + * set". What actually carried the admin was their built-in WILDCARD set: with no + * grant anywhere, `nav-object-ungranted` warned on every build and reports the + * `nav_cascade` entry as permission-denied for every principal that does not + * hold that wildcard set. `showcase_member_default` now grants read/create/edit, + * so the object is in the access-matrix snapshot and a plain member holds the + * capability the navigation entry already claimed for them. Note where the + * narrowing really lives: on one OPTION (`tier: 'restricted'`, admin-only), + * not on the object. * * The server verdict is unit-covered by objectql * `rule-validator.option-visibility.test.ts`; this object is the served fixture diff --git a/examples/app-showcase/src/data/objects/expense-report.object.ts b/examples/app-showcase/src/data/objects/expense-report.object.ts index a038635999..60b76b5723 100644 --- a/examples/app-showcase/src/data/objects/expense-report.object.ts +++ b/examples/app-showcase/src/data/objects/expense-report.object.ts @@ -40,8 +40,9 @@ export const ExpenseReport = ObjectSchema.create({ // OWD let "any seeded persona create/edit reports without a bespoke permission // set" — measurably false, and the app shipped the proof: the object sat in // the shared navigation with no grant anywhere, so `nav-object-ungranted` - // warned on every build and every non-admin who clicked the entry got a 403. - // It worked for the seeded admin only, who holds the platform's wildcard set. + // warned on every build, reporting the entry as permission-denied for every + // principal except one holding the platform's built-in wildcard admin set. + // The seeded admin holds exactly that set, which is why it looked fine. // The grants now live on `showcase_contributor` (file/edit) and // `showcase_member_default` (read), and both appear in access-matrix.json. sharingModel: 'public_read_write', From 2f5268f290d5a7e215a914b54d984f9dafa9bfe9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 11:28:35 +0000 Subject: [PATCH 4/4] =?UTF-8?q?test(dogfood):=20grow=20the=20persona=20?= =?UTF-8?q?=C3=97=20CRUD=20matrix=20fixtures=20to=20the=20nine=20newly=20g?= =?UTF-8?q?ranted=20objects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- ...owcase-crud-persona-matrix.dogfood.test.ts | 150 ++++++++++++++++-- 1 file changed, 138 insertions(+), 12 deletions(-) diff --git a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts index ee50839d8d..b8975c3a8d 100644 --- a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts @@ -103,10 +103,24 @@ const effective = (row: MatrixRow, verb: Verb): boolean => /** The `sys_*` rows this file does not judge — named so the exclusion is visible. */ const UNJUDGED_ROWS = matrix.entries.filter((e) => !e.object.startsWith('showcase_')); -/** Marker field per object: what a created row is findable by afterwards. */ +/** + * Marker field per object: what a created row is findable by afterwards. + * + * ⚠️ NOT every object has one. `showcase_project_membership` is a pure + * junction — two required master-detail parents, a select and a percent, with + * no free-text column to stamp a mark into — so it is deliberately absent here + * and the one place that reads this map (the denied-CREATE absence probe) + * asserts the entry exists before using it. Today no matrix row denies create + * on that object, so the probe never runs for it; when one arrives, the sweep + * fails RED naming the object instead of querying `where: { undefined: … }` + * and passing vacuously. + */ const MARKER: Record = { showcase_account: 'name', showcase_announcement: 'title', + showcase_business_unit: 'name', + showcase_cascade: 'name', + showcase_category: 'name', // [#9308 fixture 2] The share-link object. It enters this sweep because the // matrix gained a `showcase_client_liaison × showcase_client_brief` row, and // the sweep is DERIVED from the matrix — a new granted object arrives here @@ -114,13 +128,18 @@ const MARKER: Record = { // assertion below changes. showcase_client_brief: 'title', showcase_contact: 'name', + showcase_expense_line: 'merchant', + showcase_expense_report: 'name', + showcase_field_zoo: 'name', showcase_inquiry: 'name', showcase_invoice: 'name', showcase_invoice_line: 'description', + showcase_preference: 'name', showcase_private_note: 'title', showcase_product: 'name', showcase_project: 'name', showcase_task: 'title', + showcase_team: 'name', }; interface PayloadCtx { @@ -131,6 +150,18 @@ interface PayloadCtx { projectId: string; /** The invoice a line should hang off — the caller's OWN where they have one. */ invoiceId: string; + /** + * The expense report an expense line hangs off, and the team a project + * membership joins. Both are seeded rows and both stay seeded rows for + * EVERY persona — unlike `invoiceId`, which has to be the caller's own. + * The difference is record-level and worth naming: `showcase_invoice` + * carries an owner-scoped RLS rule, whereas `showcase_expense_report` and + * `showcase_team` are `public_read_write`, so a persona reaches the seeded + * parent exactly as well as one it made itself. (`showcase_expense_line` is + * `controlled_by_parent`, so its record scope is the report's — also open.) + */ + expenseReportId: string; + teamId: string; } /** @@ -147,23 +178,73 @@ interface PayloadCtx { const PAYLOAD: Record Record> = { showcase_account: (c) => ({ name: c.mark, status: 'prospect' }), showcase_announcement: (c) => ({ title: c.mark }), + showcase_business_unit: (c) => ({ name: c.mark }), + // `country` / `province` / `tier` are all left unset. The B3 fixture's + // narrowing lives on one OPTION (`tier: 'restricted'`, admin-only) and the + // objectql rule-validator judges SUBMITTED values, so a payload that submits + // none of them cannot turn a CRUD verdict into an `invalid_option` 400 — the + // exact confusion the admin control exists to prevent. + showcase_cascade: (c) => ({ name: c.mark }), + showcase_category: (c) => ({ name: c.mark }), // `status` is left at its `draft` default on purpose: this sweep judges CRUD // bits, and a brief that is not `published` cannot be mint-eligible for a // share link — so a row this sweep leaves behind can never become an // accidental share-link fixture for another file. showcase_client_brief: (c) => ({ title: c.mark, project: c.projectId }), showcase_contact: (c) => ({ name: c.mark, email: `contact-${Date.now()}@probe.test` }), + showcase_expense_line: (c) => ({ + expense_report: c.expenseReportId, + merchant: c.mark, + amount: 12.5, + status: 'submitted', + }), + // `status: 'draft'` is load-bearing, not filler. Both expense approval flows + // (`showcase_expense_signoff`, `showcase_committee_quorum`) start on + // `record-after-update` with `status == "submitted" && previous.status != + // "submitted"` and approve with `lockRecord: true`. A row this sweep creates + // as `draft`, and only ever PATCHes on its `name` marker, can never launch + // one — so a persona's EDIT/DELETE cell can never come back as a record lock + // wearing a permission verdict's clothes. + showcase_expense_report: (c) => ({ name: c.mark, status: 'draft' }), + // `f_master_detail` is the zoo's required master (a project). Everything else + // in the zoo is optional by design — it is a catalogue of field types, not a + // form with a required core. + showcase_field_zoo: (c) => ({ name: c.mark, f_master_detail: c.projectId }), showcase_inquiry: (c) => ({ name: c.mark, email: `inq-${Date.now()}@probe.test`, message: 'matrix probe' }), showcase_invoice: (c) => ({ name: c.mark, account: c.accountId, status: 'draft', owner: c.email }), showcase_invoice_line: (c) => ({ invoice: c.invoiceId, product: c.productId, quantity: 1, description: c.mark }), + showcase_preference: (c) => ({ name: c.mark }), showcase_private_note: (c) => ({ title: c.mark }), showcase_product: (c) => ({ name: c.mark, sku: `SKU-${Date.now()}` }), showcase_project: (c) => ({ name: c.mark, account: c.accountId, status: 'planned', owner: c.email }), + // The team↔project junction: BOTH master-detail parents are required, and + // neither is unique-constrained (the seed dedupes on the pair by `mode: + // 'ignore'`, not by an index), so re-joining an already-joined pair is a + // legal write rather than a 409 masquerading as a CRUD verdict. + showcase_project_membership: (c) => ({ team: c.teamId, project: c.projectId, engagement: 'owner' }), showcase_task: (c) => ({ title: c.mark, project: c.projectId, status: 'todo' }), + showcase_team: (c) => ({ name: c.mark }), }; -/** Field patched on the EDIT probe — always an unrestricted one. */ -const EDIT_FIELD: Record = { ...MARKER }; +/** + * The EDIT probe per object: which field a persona patches, and to what. + * + * Every object with a free-text MARKER stamps `-edited` into it. The + * junction has no such column (see MARKER), so it names its own pair: a fresh + * membership is created `engagement: 'owner'` above and the probe moves it to + * `reviewer` — an unrestricted field, a value the option set accepts, and one + * that always differs from the created row, which is what makes both the + * "persisted" and the "changed nothing" assertions below meaningful. + */ +const EDIT_PROBE: Record { field: string; value: unknown }> = { + ...Object.fromEntries( + Object.entries(MARKER).map(([object, field]) => [ + object, + (mark: string) => ({ field, value: `${mark}-edited` }), + ]), + ), + showcase_project_membership: () => ({ field: 'engagement', value: 'reviewer' }), +}; const OBJECTS = [...new Set(ROWS.map((r) => r.object))].sort(); const SETS = [...new Set(ROWS.map((r) => r.permissionSet))].sort(); @@ -191,7 +272,14 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { const persona = new Map }>(); /** Admin-created foreign probe row per object. */ const foreign = new Map(); - const seed = { accountId: '', productId: '', projectId: '', invoiceId: '' }; + const seed = { + accountId: '', + productId: '', + projectId: '', + invoiceId: '', + expenseReportId: '', + teamId: '', + }; const ctxFor = (email: string, mark: string, invoiceId?: string): PayloadCtx => ({ mark, @@ -200,6 +288,8 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { productId: seed.productId, projectId: seed.projectId, invoiceId: invoiceId || seed.invoiceId, + expenseReportId: seed.expenseReportId, + teamId: seed.teamId, }); const bodyOf = async (r: Response) => { @@ -221,8 +311,15 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { seed.productId = await firstId('showcase_product'); seed.projectId = await firstId('showcase_project'); seed.invoiceId = await firstId('showcase_invoice'); + seed.expenseReportId = await firstId('showcase_expense_report'); + seed.teamId = await firstId('showcase_team'); expect( - seed.accountId && seed.productId && seed.projectId && seed.invoiceId, + seed.accountId && + seed.productId && + seed.projectId && + seed.invoiceId && + seed.expenseReportId && + seed.teamId, 'the showcase seed provides the lookup parents every payload needs', ).toBeTruthy(); @@ -301,9 +398,16 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { } else { expect(created.status, why('create')).toBe(403); expect(createdJson?.code, `${label}: the ledgered denial code`).toBe('PERMISSION_DENIED'); - // Clause 2 — a denied CREATE leaves NO row behind. + // Clause 2 — a denied CREATE leaves NO row behind. Proving an ABSENCE + // needs a field to look the row up by; without one the query below + // would find nothing for the wrong reason and pass vacuously. + const marker = MARKER[row.object]; + expect( + marker, + `${label}: proving a denied create left nothing needs a MARKER field for ${row.object}`, + ).toBeTruthy(); const left = await ql.find(row.object, { - where: { [MARKER[row.object]]: mark }, + where: { [marker]: mark }, context: SYS, }); expect(left?.length ?? 0, `${label}: the denied create persisted nothing`).toBe(0); @@ -326,8 +430,7 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { // otherwise. Either way the target EXISTS, so a refusal cannot be a // 404 in disguise. const editTarget = p.own.get(row.object) ?? foreign.get(row.object)!; - const editField = EDIT_FIELD[row.object]; - const editValue = `${mark}-edited`; + const { field: editField, value: editValue } = EDIT_PROBE[row.object](mark); const edited = await stack.apiAs(p.token, 'PATCH', `/data/${row.object}/${editTarget}`, { [editField]: editValue, }); @@ -444,10 +547,33 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { // `showcase_client_liaison` set: // • × showcase_client_brief — create/read/edit allow, delete deny (3/1) // • × showcase_project — read allow; create/edit/delete deny (1/3) + // + // 54/54 → 87/77 with the fourteen rows #14453 added. That issue closed the + // gap where nine showcase objects sat in the shared navigation with no + // permission set granting them at all; granting them is what pulled those + // objects into this matrix-derived sweep, so the census moves with it + // (+33 allow, +23 deny): + // • showcase_contributor × showcase_expense_line (3/1) + // • showcase_contributor × showcase_expense_report (3/1) + // • showcase_contributor × showcase_field_zoo (3/1) + // • showcase_member_default × showcase_business_unit (1/3) read-only + // • showcase_member_default × showcase_cascade (3/1) + // • showcase_member_default × showcase_category (1/3) read-only + // • showcase_member_default × showcase_expense_report (1/3) read-only + // • showcase_member_default × showcase_field_zoo (1/3) read-only + // • showcase_member_default × showcase_preference (3/1) + // • showcase_member_default × showcase_team (1/3) read-only + // • showcase_ops × showcase_business_unit (3/1) + // • showcase_ops × showcase_category (3/1) + // • showcase_ops × showcase_project_membership (4/0) the only + // row in the whole matrix granting all four bits — `allowDelete` is + // what un-staffs a team from a project. + // • showcase_ops × showcase_team (3/1) // No pre-existing cell changed side; `git diff` on access-matrix.json is - // the check that this is still true. - expect(allow.length, 'the ALLOW half is what catches an over-tightening regression').toBe(54); - expect(deny.length, 'the DENY half is what catches a widening regression').toBe(54); + // the check that this is still true. (None of the nine objects carried ANY + // row before, which is also why the baseline-flip count below is unmoved.) + expect(allow.length, 'the ALLOW half is what catches an over-tightening regression').toBe(87); + expect(deny.length, 'the DENY half is what catches a widening regression').toBe(77); // Every set and every object of the business-object matrix was really driven. expect([...new Set(VERDICTS.map((c) => c.set))].sort()).toEqual(SETS);