From 8a4f461f7b1410e71d39f7558e093eaf1fa4dfff Mon Sep 17 00:00:00 2001 From: os-dev Date: Tue, 18 Aug 2026 15:24:10 +0000 Subject: [PATCH 1/2] fix(app-showcase): retire the sharing rules no gate can consult, re-home the position/compound demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `showcase_project` and `showcase_task` are `sharingModel: 'public_read_write'` by deliberate ADR-0090 D1 declaration, so sharing has nothing left to widen there and `assertNotInertGrant` refuses every grant a rule on them would materialise. Two rules failed their boot backfill on every boot and a third was dead without any diagnostic. ADR-0049 enforce-or-remove: removed. The capabilities they carried — a `position` recipient and a compound CEL condition (ADR-0058 D3) — move to `showcase_contact` (OWD `private`, and the `showcase_manager` set grants it `allowRead`), where the grant is real. inert-wirings.test.ts gains the guard that fails the build on the next such declaration, in both of its shapes. --- .../src/security/bind-position-sets.ts | 13 +- examples/app-showcase/src/security/index.ts | 4 +- .../src/security/sharing-rules.ts | 117 +++++++++-------- .../app-showcase/test/inert-wirings.test.ts | 118 ++++++++++++++++++ 4 files changed, 196 insertions(+), 56 deletions(-) diff --git a/examples/app-showcase/src/security/bind-position-sets.ts b/examples/app-showcase/src/security/bind-position-sets.ts index 6188fda71e..32210c631e 100644 --- a/examples/app-showcase/src/security/bind-position-sets.ts +++ b/examples/app-showcase/src/security/bind-position-sets.ts @@ -29,7 +29,14 @@ * framework cannot infer. */ -const BINDINGS: ReadonlyArray = [ +/** + * The persona bindings, exported because they are the only place that answers + * "which permission sets does a position actually hold?" — the question a + * sharing rule with a `position` recipient has to answer before its grant can + * mean anything (a share row for a principal with no object-level `allowRead` + * is never consulted). `inert-wirings.test.ts` §6 reads this list. + */ +export const POSITION_PERMISSION_SET_BINDINGS: ReadonlyArray = [ ['contributor', 'showcase_contributor'], ['manager', 'showcase_manager'], ['exec', 'showcase_executive'], @@ -72,7 +79,7 @@ async function findOneByName(ctx: BindHostContext, object: string, name: string) export function registerShowcasePositionBindings(ctx: BindHostContext): void { const run = async (): Promise => { let created = 0; - for (const [positionName, setName] of BINDINGS) { + for (const [positionName, setName] of POSITION_PERMISSION_SET_BINDINGS) { const position = await findOneByName(ctx, 'sys_position', positionName); const set = await findOneByName(ctx, 'sys_permission_set', setName); if (!position?.id || !set?.id) { @@ -100,7 +107,7 @@ export function registerShowcasePositionBindings(ctx: BindHostContext): void { }); } } - ctx.logger?.info?.('[showcase] position bindings ensured', { created, total: BINDINGS.length }); + ctx.logger?.info?.('[showcase] position bindings ensured', { created, total: POSITION_PERMISSION_SET_BINDINGS.length }); }; // Bind on `kernel:bootstrapped` — the anchor that fires only after every diff --git a/examples/app-showcase/src/security/index.ts b/examples/app-showcase/src/security/index.ts index d524252b2a..c4acc081d1 100644 --- a/examples/app-showcase/src/security/index.ts +++ b/examples/app-showcase/src/security/index.ts @@ -51,9 +51,7 @@ export { } from './capabilities.js'; export { - RedProjectSharingRule, - HighValueRedProjectRule, NewInquiryFieldOpsRule, - ContributorTaskSharingRule, + KeyAccountQualifiedContactRule, allSharingRules, } from './sharing-rules.js'; diff --git a/examples/app-showcase/src/security/sharing-rules.ts b/examples/app-showcase/src/security/sharing-rules.ts index 701e434c30..4053da1935 100644 --- a/examples/app-showcase/src/security/sharing-rules.ts +++ b/examples/app-showcase/src/security/sharing-rules.ts @@ -11,43 +11,51 @@ * `position` (flat holder expansion) and `unit_and_subordinates` * (business-unit SUBTREE expansion — the unit named by `value` plus every * descendant unit's members, ADR-0057 D5 / ADR-0090 D3). + * + * ## Where a rule may live (ADR-0111 D7 / ADR-0049), and why two were retired + * + * "Sharing only ever WIDENS" has a consequence that is easy to author past: + * on an object whose OWD is already the widest one, there is nothing left to + * widen, so a `sys_record_share` row on it is never consulted by any gate. + * `SharingService.inertGrantReason` states that as a verdict and + * `assertNotInertGrant` REFUSES the write — which is why a rule anchored on + * such an object does not merely under-deliver, it fails its boot backfill: + * + * WARN SharingServicePlugin: boot rule backfill failed for rule + * {"rule":"…","error":"SHARING_NOT_ENABLED: '…' is not under + * record-sharing enforcement (public sharing model or no 'owner_id' + * field); a share row on it would never be consulted"} + * + * `showcase_project` and `showcase_task` are `sharingModel: + * 'public_read_write'` by DELIBERATE declaration — each carries an explicit + * ADR-0090 D1 grandfather stamp, and that OWD is load-bearing well beyond the + * security demo (it is what lets a `showcase_contributor` PATCH a project row + * it did not create, the write floor pinned by + * `owd-public-read-write-write-floor.dogfood.test.ts`). So no sharing rule can + * ever take effect there, and ADR-0049's enforce-or-remove leaves exactly one + * honest move: remove. + * + * Retired here, therefore, and NOT re-homed onto another public object: + * + * • `share_red_projects_with_execs` — criteria rule on `showcase_project`. + * • `share_open_tasks_with_manager` — criteria rule on `showcase_task`. + * This one was ITSELF a repair: it replaced the owner-based + * `share_contributor_tasks_with_manager`, which `type: 'owner'` made + * silently skipped at seed time (ADR-0078: nothing on the authoring + * surface may be silently inert). That repair moved the inertness instead + * of removing it — the replacement validated, seeded, and then failed its + * backfill on every boot. The lesson is written down rather than repeated: + * a sharing-rule demonstration belongs on an object under record-sharing + * ENFORCEMENT, and `inert-wirings.test.ts` §6 now fails the build if a + * future rule lands on a public one again. + * + * The capabilities those two carried — a `position` recipient, and a COMPOUND + * CEL condition (ADR-0058 D3) — are not dropped: both live on + * `KeyAccountQualifiedContactRule` below, on an object where the grant is real. */ import { defineSharingRule } from '@objectstack/spec/security'; -/** criteria-based: red-health projects are shared up to executives. */ -export const RedProjectSharingRule = defineSharingRule({ - type: 'criteria', - name: 'share_red_projects_with_execs', - label: 'Red Projects → Executives', - description: 'Automatically share at-risk (red health) projects with executives.', - object: 'showcase_project', - condition: "record.health == 'red'", - accessLevel: 'read', - sharedWith: { type: 'position', value: 'exec' }, - active: true, -}); - -/** - * [ADR-0058 D3 / closes #1887] criteria-based with a COMPOUND CEL condition. - * Before #1887 a multi-clause `&&` condition was silently skipped (the sharing - * rule was decorative metadata); now it compiles to a compound `criteria_json` - * and enforces. Shares only projects that are BOTH at-risk (red) AND high-budget - * with managers — the AND matters: a red but low-budget project is NOT shared. - */ -export const HighValueRedProjectRule = defineSharingRule({ - type: 'criteria', - name: 'share_high_value_red_projects_with_managers', - label: 'High-Value Red Projects → Managers', - description: - 'Share at-risk (red health) projects over the budget threshold with managers (compound condition, ADR-0058 D3).', - object: 'showcase_project', - condition: "record.health == 'red' && record.budget > 100000", - accessLevel: 'read', - sharedWith: { type: 'position', value: 'manager' }, - active: true, -}); - /** * Business-unit SUBTREE recipient (`unit_and_subordinates`): new inquiries are * shared for triage with everyone in the Field Operations unit — AND every @@ -72,31 +80,40 @@ export const NewInquiryFieldOpsRule = defineSharingRule({ }); /** - * criteria-based: open (not-done) tasks are shared read-only with managers - * for oversight. + * [ADR-0058 D3 / closes #1887] criteria-based with a COMPOUND CEL condition, + * and the showcase's `position`-recipient demonstration. * - * This replaces the retired owner-based `share_contributor_tasks_with_manager` - * demonstration rule: `type: 'owner'` (`ownedBy`) no longer parses — it - * depended on live position membership, which the static materialiser cannot - * track, so it validated but was silently skipped at seed time (ADR-0078: - * nothing on the authoring surface may be silently inert). The enforced - * equivalent is a criteria rule scoping the rows managers need. + * Before #1887 a multi-clause `&&` condition was silently skipped (the sharing + * rule was decorative metadata); now it compiles to a compound `criteria_json` + * and enforces. The AND matters, and the seed data demonstrates it in BOTH + * directions rather than one: of the seeded contacts, three are `qualified` + * (only one of them at Northwind) and many are at Northwind (none of those + * others `qualified`) — so a row satisfying either clause alone is NOT shared, + * and exactly the row satisfying both is. + * + * ## Why this object and this recipient + * + * `showcase_contact` is OWD `private`, so the grant is one the read gate + * actually consults (ADR-0111 D7), and the `showcase_manager` permission set + * grants `showcase_contact.allowRead` — the object-level bit a share row still + * needs before any record-level widening can be observed. Both halves are + * pinned by `inert-wirings.test.ts` §6, which is the guard that would have + * caught the two rules retired above at authoring time. */ -export const ContributorTaskSharingRule = defineSharingRule({ +export const KeyAccountQualifiedContactRule = defineSharingRule({ type: 'criteria', - name: 'share_open_tasks_with_manager', - label: 'Open Tasks → Manager', - description: 'Share open (not-done) tasks with managers for oversight.', - object: 'showcase_task', - condition: 'record.done == false', + name: 'share_key_account_qualified_contacts_with_managers', + label: 'Key-Account Qualified Contacts → Managers', + description: + 'Share qualified contacts at the key account with managers (compound condition, ADR-0058 D3).', + object: 'showcase_contact', + condition: "record.stage == 'qualified' && record.company == 'Northwind'", accessLevel: 'read', sharedWith: { type: 'position', value: 'manager' }, active: true, }); export const allSharingRules = [ - RedProjectSharingRule, - HighValueRedProjectRule, NewInquiryFieldOpsRule, - ContributorTaskSharingRule, + KeyAccountQualifiedContactRule, ]; diff --git a/examples/app-showcase/test/inert-wirings.test.ts b/examples/app-showcase/test/inert-wirings.test.ts index b7aa966235..01752d75e0 100644 --- a/examples/app-showcase/test/inert-wirings.test.ts +++ b/examples/app-showcase/test/inert-wirings.test.ts @@ -6,6 +6,7 @@ import stack from '../objectstack.config.js'; import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security'; import { FILE_REFERENCE_TYPES, valueSchemaFor } from '@objectstack/spec/data'; import { healthFor, sweepProjectHealth, bindShowcaseJobRuntime } from '../src/automation/jobs/index.js'; +import { POSITION_PERMISSION_SET_BINDINGS } from '../src/security/bind-position-sets.js'; import { ADMIN_EMAIL, PHONE_DEMO_USER, @@ -532,3 +533,120 @@ describe('seeded notify recipients resolve to a real user (#7746)', () => { ); }); }); + +// ─────────────────────────────────────────────────────────────────────────── +// 6. Sharing rules — the grant must be one a gate would CONSULT (#9237) +// ─────────────────────────────────────────────────────────────────────────── +/** + * The same bug class as §1-§5, on the security surface: a declaration the + * runtime accepts at authoring time and then refuses at boot, announced only + * by a line in the boot warning block. + * + * Sharing only ever WIDENS an object's OWD baseline, so on an object whose OWD + * is already the widest there is nothing to widen and a `sys_record_share` row + * would never be consulted. `SharingService.inertGrantReason` states that as a + * verdict and `assertNotInertGrant` REFUSES the write, so the rule's boot + * backfill fails per rule: + * + * WARN SharingServicePlugin: boot rule backfill failed for rule + * {"rule":"share_open_tasks_with_manager","error":"SHARING_NOT_ENABLED: + * 'showcase_task' is not under record-sharing enforcement …"} + * + * Measured on the stock showcase before #9237: TWO such WARNs per boot, and a + * THIRD rule in the same state that produced no diagnostic at all — its + * compound condition matched zero seeded rows, so `reconcile` never reached + * `grant` and never threw. The silent one is the reason this guard reads the + * DECLARATION rather than the boot log: a rule can be just as dead without a + * warning to notice. + * + * ⛔ The runtime's other inertness arm — "no `owner_id` field" — is + * deliberately NOT reproduced here. `owner_id` is INJECTED by the schema + * registry for ordinary business objects, so it is absent from the authored + * metadata this guard reads and present on the schema the runtime judges; + * asserting it here would fail every object that (correctly) does not declare + * it by hand. + */ +describe('sharing rules target an object under record-sharing enforcement (#9237)', () => { + interface AuthoredRule { + name: string; + object: string; + sharedWith?: { type?: string; value?: string }; + } + interface AuthoredSet { + name: string; + isDefault?: boolean; + objects?: Record; + } + + const rules = ((stack as { sharingRules?: unknown[] }).sharingRules ?? []) as AuthoredRule[]; + const objects = ((stack as { objects?: unknown[] }).objects ?? []) as Array<{ + name: string; + sharingModel?: string; + }>; + const sets = ((stack as { permissions?: unknown[] }).permissions ?? []) as AuthoredSet[]; + + /** The permission sets a holder of `position` effectively carries. */ + function setsHeldBy(position: string): AuthoredSet[] { + const held = new Set( + POSITION_PERMISSION_SET_BINDINGS.filter(([p]) => p === position).map(([, s]) => s), + ); + // Every authenticated member also holds the `everyone` baseline (the + // `isDefault` set, ADR-0090 D5) IN ADDITION to their explicit grants. + return sets.filter((s) => held.has(s.name) || s.isDefault === true); + } + + it('the stack declares sharing rules (guard is not vacuous)', () => { + expect(rules.length).toBeGreaterThan(0); + }); + + it('no rule is anchored on an object whose OWD leaves nothing to widen', () => { + // `effectiveSharingModel` maps BOTH of these to 'public'; the + // `controlled_by_parent` case has its own refusal ("share the master + // record instead"). Either way the grant is refused, so either way the + // declaration is inert. + const INERT_OWD = new Set(['public_read_write', 'controlled_by_parent']); + const offenders: string[] = []; + for (const rule of rules) { + const target = objects.find((o) => o.name === rule.object); + if (!target) { + offenders.push(`${rule.name} → '${rule.object}' (no such object)`); + continue; + } + if (INERT_OWD.has(String(target.sharingModel))) { + offenders.push(`${rule.name} → ${rule.object} (sharingModel '${target.sharingModel}')`); + } + } + expect( + offenders, + `sharing rule(s) on an object not under record-sharing enforcement — the boot backfill ` + + `refuses these with SHARING_NOT_ENABLED: ${offenders.join(', ')}`, + ).toEqual([]); + }); + + it('every rule names an audience that can READ the object it shares', () => { + // The second way a grant goes unconsulted: object-level CRUD is decided + // BEFORE record-level visibility, so a share row minted for a principal + // holding no `allowRead` on that object widens nothing. This arm is what + // stops the retired project/task rules being "fixed" by re-homing them + // onto a private object whose recipient cannot read it either. + const offenders: string[] = []; + for (const rule of rules) { + const recipient = rule.sharedWith ?? {}; + // Non-`position` recipients expand to ordinary members, whose grant is + // the `everyone` baseline — represented by the isDefault set. + const candidates = + recipient.type === 'position' + ? setsHeldBy(String(recipient.value)) + : sets.filter((s) => s.isDefault === true); + const canRead = candidates.some((s) => s.objects?.[rule.object]?.allowRead === true); + if (!canRead) { + offenders.push(`${rule.name} → ${recipient.type}:${recipient.value} on ${rule.object}`); + } + } + expect( + offenders, + `sharing rule(s) whose audience holds no allowRead on the shared object — the share row ` + + `is never reached: ${offenders.join(', ')}`, + ).toEqual([]); + }); +}); From 17fc6a40b6dde3162ee3b8a40188c485bffb6c9e Mon Sep 17 00:00:00 2001 From: os-dev Date: Tue, 18 Aug 2026 15:42:30 +0000 Subject: [PATCH 2/2] test(dogfood,checklist): move the sharing-rule proofs onto the rule that enforces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR-0058 D3 proof gains the half it could never carry on a public-OWD object: that the grant its compound rule reconciles is one the gates CONSULT, with the refusal on showcase_project as the contrast. The platform-checklist evaluate clause was unrunnable as written — it drove a rule whose evaluate is refused SHARING_NOT_ENABLED rather than returning a reconcile result. It now drives the surviving rule, and a step assigns the manager position so the recipient expansion is non-empty. --- ...howcase-sharing-rules-enforce-or-remove.md | 31 ++++++++ .../areas/access-security.json | 19 +++-- ...coped-sharing-rule-listing.dogfood.test.ts | 14 ++-- ...howcase-d3-d4-capabilities.dogfood.test.ts | 72 +++++++++++++++---- ...e-declarative-rbac-seeding.dogfood.test.ts | 60 ++++++++++------ 5 files changed, 149 insertions(+), 47 deletions(-) create mode 100644 .changeset/showcase-sharing-rules-enforce-or-remove.md diff --git a/.changeset/showcase-sharing-rules-enforce-or-remove.md b/.changeset/showcase-sharing-rules-enforce-or-remove.md new file mode 100644 index 0000000000..41bf92d64c --- /dev/null +++ b/.changeset/showcase-sharing-rules-enforce-or-remove.md @@ -0,0 +1,31 @@ +--- +"@objectstack/example-showcase": patch +--- + +Retire the showcase sharing rules no gate could consult; re-home the position/compound demo (#9237) + +Booting `examples/app-showcase` logged two WARNs per boot — `SharingServicePlugin: boot +rule backfill failed for rule` for `share_open_tasks_with_manager` and +`share_red_projects_with_execs`. Both sat on objects declaring +`sharingModel: 'public_read_write'`, where sharing has nothing left to widen, so +`assertNotInertGrant` (ADR-0111 D7) refused every grant they reconciled. A third rule, +`share_high_value_red_projects_with_managers`, was in exactly the same state and produced +no diagnostic at all: its compound condition matched no seeded row, so `reconcile` never +reached `grant` and never threw. + +`showcase_project` and `showcase_task` are `public_read_write` by deliberate ADR-0090 D1 +declaration and that OWD is load-bearing beyond the security demo, so no rule can ever take +effect there. ADR-0049 enforce-or-remove leaves one honest move, and all three are removed +rather than re-homed onto another public object — the shape the previous repair took, which +moved the inertness instead of removing it. + +The two capabilities they carried are kept: a `position` recipient and a compound CEL +condition (ADR-0058 D3) now live on `share_key_account_qualified_contacts_with_managers`, +targeting `showcase_contact` (OWD `private`, and the `showcase_manager` set grants it +`allowRead` — the object-level bit a share row still needs). The seeded contacts +demonstrate the AND in both directions: rows satisfying either clause alone are not +shared. + +`inert-wirings.test.ts` gains the guard that fails the build on the next such declaration, +in both of its shapes — a rule anchored where the OWD leaves nothing to widen, and a rule +whose audience holds no `allowRead` on the object it shares. diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index aa9dd9774d..4e1196e1a2 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -1005,7 +1005,7 @@ "title": "Per-record manual shares grant, scope, and revoke access on a private-OWD record; rule evaluate reconciles the audience", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -1019,7 +1019,8 @@ "requires": [ "showcase_private_note (sharingModel: private — member_default grants create/read/edit, so a non-owner sees nothing without a share)", "three fresh sign-ups (A/B/C) whose sys_user ids are resolved via a system-context read", - "the seeded criteria sharing rule share_red_projects_with_execs on showcase_project + at least one red-health showcase_project (examples/app-showcase/src/security/sharing-rules.ts) for the evaluate clause" + "the seeded criteria sharing rule share_key_account_qualified_contacts_with_managers on showcase_contact (OWD private) + at least one qualified contact at company Northwind — seeded as Nora West (examples/app-showcase/src/security/sharing-rules.ts) — for the evaluate clause", + "a holder of the manager position, so the rule's recipient expansion is non-empty: without one the reconcile answers grantsCreated 0 and materializes nothing, which is a fixture gap, not a defect" ] }, "steps": [ @@ -1033,7 +1034,7 @@ "as B (a non-manager on A's note): POST /api/v1/data/showcase_private_note//shares granting themselves — must be refused (creating a share is not a reader's power, ADR-0111 D5)", "as A: DELETE /api/v1/data/showcase_private_note//shares/ → 204; then as B: GET the note by id again — refused once more", "revoke-scope probe: attempt DELETE of the same shareId through a DIFFERENT record's path (/data/showcase_private_note//shares/) — must be refused (the URL's object/id is the revoke scope, ADR-0111 D4)", - "as admin: POST /api/v1/sharing/rules/share_red_projects_with_execs/evaluate — capture the reconcile result; read sys_record_share for the matched red project" + "as admin: assign the manager position to one persona (Setup -> Access Control), then POST /api/v1/sharing/rules/share_key_account_qualified_contacts_with_managers/evaluate — capture the reconcile result; read sys_record_share for the matched contact" ], "acceptance": [ { @@ -1073,9 +1074,9 @@ "evidence": "the mis-scoped DELETE trace + survival read" }, { - "clause": "rule evaluate reconciles the audience: POST /sharing/rules/share_red_projects_with_execs/evaluate returns {ruleId, matchedRecords>=1, grantsCreated/grantsUpdated} and a sys_record_share row exists for the matched red project with source 'rule' and source_id = the rule's ROW ID (sys_sharing_rule.id, e.g. srule_…), NOT the rule name", + "clause": "rule evaluate reconciles the audience: POST /sharing/rules/share_key_account_qualified_contacts_with_managers/evaluate returns {ruleId, matchedRecords>=1, grantsCreated/grantsUpdated} and a sys_record_share row exists for the matched contact with source 'rule' and source_id = the rule's ROW ID (sys_sharing_rule.id, e.g. srule_…), NOT the rule name", "oracle": "api", - "verify": "the SharingRuleEvaluationResult body (packages/plugins/plugin-sharing/src/sharing-rule-service.ts evaluateRule) + the materialized rule-sourced share row: assert share.source_id === the sys_sharing_rule row's id, not 'share_red_projects_with_execs'. The row id is the stable FK the reconcile path relies on — purgeRuleGrants(ruleId) deletes on where {source:'rule', source_id: ruleId} with ruleId = rule.id (sharing-rule-service.ts), and sys-sharing-rule.object.ts documents 'source_id={rule.id}' — so a run asserting the NAME here would be asserting a value the implementation never writes", + "verify": "the SharingRuleEvaluationResult body (packages/plugins/plugin-sharing/src/sharing-rule-service.ts evaluateRule) + the materialized rule-sourced share row: assert share.source_id === the sys_sharing_rule row's id, not 'share_key_account_qualified_contacts_with_managers'. The row id is the stable FK the reconcile path relies on — purgeRuleGrants(ruleId) deletes on where {source:'rule', source_id: ruleId} with ruleId = rule.id (sharing-rule-service.ts), and sys-sharing-rule.object.ts documents 'source_id={rule.id}' — so a run asserting the NAME here would be asserting a value the implementation never writes", "evidence": "evaluate response + the sys_record_share read (both the row's source_id and the sys_sharing_rule id it must equal)" } ], @@ -1091,7 +1092,7 @@ "packages/rest/src/rest-server.ts (registerRecordShareEndpoints ~L7246-7331; registerSharingRuleEndpoints evaluate ~L7477-7493)", "packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts (recipient_type/recipient_id/access_level/source fields)", "packages/plugins/plugin-sharing/src/sharing-rule-service.ts (evaluateRule → SharingRuleEvaluationResult)", - "examples/app-showcase/src/security/sharing-rules.ts (share_red_projects_with_execs seeded criteria rule)", + "examples/app-showcase/src/security/sharing-rules.ts (share_key_account_qualified_contacts_with_managers seeded criteria rule)", "ADR-0111 D1/D4/D5" ], "history": [ @@ -1106,6 +1107,12 @@ "date": "2026-08-11", "change": "clause 6 text corrected against the implementation (run #7637): a rule-materialized share carries source_id = the sys_sharing_rule ROW ID, not the rule name — the stable FK purgeRuleGrants reconciles on. Semantic intent was already right; the asserted value was not", "ref": "#7687" + }, + { + "revision": 3, + "date": "2026-08-18", + "change": "the evaluate clause was UNRUNNABLE as written and had been since it was authored: it drove share_red_projects_with_execs, a rule on showcase_project, whose public_read_write OWD leaves sharing nothing to widen — so evaluate\u0027s reconcile hit assertNotInertGrant and the call was refused (SHARING_NOT_ENABLED, ADR-0111 D7) rather than returning a reconcile result. That rule is retired; the clause now drives share_key_account_qualified_contacts_with_managers on showcase_contact (OWD private), and a step assigns the manager position so the recipient expansion is non-empty", + "ref": "#9237" } ] }, diff --git a/packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts b/packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts index 66af2541a4..cb37856855 100644 --- a/packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts +++ b/packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts @@ -44,12 +44,18 @@ import { bootStack, type VerifyStack } from '@objectstack/verify'; const RULES = '/sharing/rules'; const SYS = { isSystem: true } as const; -/** The four rules `examples/app-showcase` declares, all seeded org-less. */ +/** + * The rules `examples/app-showcase` declares, all seeded org-less. + * + * [#9237] Two of the former four were retired rather than re-homed: they sat + * on `showcase_project` / `showcase_task`, whose `public_read_write` OWD + * leaves sharing nothing to widen, so their boot backfill was refused + * (`SHARING_NOT_ENABLED`) on every boot. What this file measures — an org-bound + * admin's READ over org-less rows — is unchanged by their number. + */ const SEEDED_RULE_NAMES = [ - 'share_red_projects_with_execs', - 'share_high_value_red_projects_with_managers', 'share_new_inquiries_with_field_ops', - 'share_open_tasks_with_manager', + 'share_key_account_qualified_contacts_with_managers', ]; interface RuleRow { diff --git a/packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts b/packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts index 68c29b7d24..f3d6427303 100644 --- a/packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts @@ -4,6 +4,16 @@ // • ADR-0058 D3 / #1887 — a COMPOUND sharing `condition` (`&&`) compiles to a // compound criteria_json and enforces (the AND matters; before #1887 it was // silently skipped). +// +// [#9237] The compound rule moved from `showcase_project` to +// `showcase_contact`, and the D3 proof got its missing half with it. On +// `showcase_project` (OWD `public_read_write`) sharing has nothing to +// widen, so every grant the rule reconciled was REFUSED +// (`SHARING_NOT_ENABLED`, ADR-0111 D7) — this file could assert the +// criteria compiled and matched, and never that anything was ENFORCED, +// while its own header claimed both. `showcase_contact` is OWD `private`, +// so the grant lands and the enforcement half is now measured rather than +// implied. // • ADR-0058 D4 — an RLS `check` clause validates the write POST-IMAGE: a // contributor cannot reassign an invoice they own to a different owner. // @@ -16,6 +26,8 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug import { PermissionSetSchema } from '@objectstack/spec/security'; const MEMBER = 'd34-member@verify.test'; +/** The showcase's compound (ADR-0058 D3) sharing rule — re-homed by #9237. */ +const RULE = 'share_key_account_qualified_contacts_with_managers'; const SYS = { context: { isSystem: true } } as const; // Member set: invoice CRUD + an OWNER read/pre-image policy AND the D4 CHECK @@ -25,7 +37,7 @@ const memberSet = PermissionSetSchema.parse({ name: 'showcase_d34_member', label: 'D3/D4 Member', objects: { - showcase_project: { allowRead: true }, + showcase_contact: { allowRead: true }, showcase_invoice: { allowRead: true, allowCreate: true, allowEdit: true }, }, rowLevelSecurity: [ @@ -55,10 +67,12 @@ describe('showcase: D3 compound sharing (#1887) + D4 RLS check', () => { const idOf = (r: any) => r?.id ?? r?.record?.id ?? r; const acct = idOf(await ql.insert('showcase_account', { name: 'D34 Co', status: 'prospect' }, SYS)); - // Three projects: only the at-risk (red) AND high-budget (>100k) one matches. - await ql.insert('showcase_project', { id: 'pj_red_hi', name: 'Red-Hi', account: acct, status: 'planned', health: 'red', budget: 250000 }, SYS); - await ql.insert('showcase_project', { id: 'pj_red_lo', name: 'Red-Lo', account: acct, status: 'planned', health: 'red', budget: 40000 }, SYS); - await ql.insert('showcase_project', { id: 'pj_grn_hi', name: 'Grn-Hi', account: acct, status: 'planned', health: 'green', budget: 250000 }, SYS); + // Three contacts: only the `qualified` AND key-account ('Northwind') one + // matches. The other two each satisfy exactly ONE clause, so the AND is + // measured in both directions rather than one. + await ql.insert('showcase_contact', { id: 'ct_q_key', name: 'Q-Key', email: 'q.key@d34.example', company: 'Northwind', stage: 'qualified' }, SYS); + await ql.insert('showcase_contact', { id: 'ct_q_other', name: 'Q-Other', email: 'q.other@d34.example', company: 'Contoso', stage: 'qualified' }, SYS); + await ql.insert('showcase_contact', { id: 'ct_new_key', name: 'New-Key', email: 'new.key@d34.example', company: 'Northwind', stage: 'new' }, SYS); // An invoice owned by the member (system insert sidesteps authoring rules). invId = idOf(await ql.insert('showcase_invoice', { name: 'INV-D34', account: acct, owner: MEMBER, status: 'draft' }, SYS)); @@ -68,26 +82,54 @@ describe('showcase: D3 compound sharing (#1887) + D4 RLS check', () => { // ── ADR-0058 D3 / #1887 ──────────────────────────────────────────────────── it('compound sharing condition is SEEDED as a compound criteria_json (not skipped)', async () => { - const rule = await ql.findOne('sys_sharing_rule', { where: { name: 'share_high_value_red_projects_with_managers' }, context: SYS.context }); + const rule = await ql.findOne('sys_sharing_rule', { where: { name: RULE }, context: SYS.context }); expect(rule, 'compound rule was seeded').toBeTruthy(); expect(JSON.parse(rule.criteria_json)).toEqual({ - $and: [{ health: 'red' }, { budget: { $gt: 100000 } }], + $and: [{ stage: 'qualified' }, { company: 'Northwind' }], }); }); - it('the compound criteria_json matches ONLY the project satisfying BOTH clauses', async () => { - const rule = await ql.findOne('sys_sharing_rule', { where: { name: 'share_high_value_red_projects_with_managers' }, context: SYS.context }); + it('the compound criteria_json matches ONLY the contact satisfying BOTH clauses', async () => { + const rule = await ql.findOne('sys_sharing_rule', { where: { name: RULE }, context: SYS.context }); const criteria = JSON.parse(rule.criteria_json); - // Apply the SEEDED compound criteria to our three projects: only red-AND-high passes. - const hit = await ql.find('showcase_project', { - where: { $and: [criteria, { id: { $in: ['pj_red_hi', 'pj_red_lo', 'pj_grn_hi'] } }] }, + // Apply the SEEDED compound criteria to our three contacts: only qualified-AND-key passes. + const hit = await ql.find('showcase_contact', { + where: { $and: [criteria, { id: { $in: ['ct_q_key', 'ct_q_other', 'ct_new_key'] } }] }, fields: ['id'], context: SYS.context, }); - expect((hit ?? []).map((r: any) => r.id).sort()).toEqual(['pj_red_hi']); + expect((hit ?? []).map((r: any) => r.id).sort()).toEqual(['ct_q_key']); // The rule also evaluates end-to-end (matched count includes seed data). const rules: any = stack.kernel.getService('sharingRules'); - const res = await rules.evaluateRule('share_high_value_red_projects_with_managers', SYS.context); - expect(res.matchedRecords, 'rule is evaluable + at least Red-Hi matches').toBeGreaterThanOrEqual(1); + const res = await rules.evaluateRule(RULE, SYS.context); + expect(res.matchedRecords, 'rule is evaluable + at least Q-Key matches').toBeGreaterThanOrEqual(1); + }); + + it('[#9237] the grant the rule reconciles is one the gates CONSULT — not refused as inert', async () => { + // The half this proof could never carry on `showcase_project`. The verdict + // is caller-independent (ADR-0111 D7) and computed from the OWD alone, so + // it is asserted directly rather than inferred from a reconcile count that + // an empty recipient expansion would leave silently at zero — which is + // exactly how the sibling rule on `showcase_project` produced no boot WARN + // while being just as dead. + const sharing: any = await stack.kernel.getServiceAsync('sharing'); + const granted = await sharing.grant( + { object: 'showcase_contact', recordId: 'ct_q_key', recipientType: 'user', recipientId: 'u_d34_probe', accessLevel: 'read' }, + { isSystem: true }, + ); + expect(granted, 'a share row on a private-OWD object is accepted').toBeTruthy(); + + // The contrast that names the retired rules' defect, on the object they + // used to sit on: same call, same caller, refused for the OWD alone. The + // record id deliberately names no row — the D7 verdict is computed from + // the OBJECT before any record is read, which is precisely why it is + // caller- and record-independent. Both the code and the object are + // asserted, so a refusal arriving for some other reason cannot pass. + await expect( + sharing.grant( + { object: 'showcase_project', recordId: 'pj_absent', recipientType: 'user', recipientId: 'u_d34_probe', accessLevel: 'read' }, + { isSystem: true }, + ), + ).rejects.toThrow(/SHARING_NOT_ENABLED: 'showcase_project' is not under record-sharing enforcement/); }); // ── ADR-0058 D4 ──────────────────────────────────────────────────────────── diff --git a/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts b/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts index 350778d655..f50a21936e 100644 --- a/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts @@ -35,31 +35,47 @@ describe('showcase: declarative RBAC seeding (ADR-0057 D6 / #2077)', () => { it('declared criteria sharing rule lands in sys_sharing_rule, CEL→criteria_json translated', async () => { const rules = await ql.find('sys_sharing_rule', { where: {}, context: { isSystem: true } }); - const red = (rules ?? []).find((r: any) => r.name === 'share_red_projects_with_execs'); - expect(red, 'criteria rule seeded (was count = 0)').toBeTruthy(); - expect(red.object_name).toBe('showcase_project'); - expect(red.recipient_type).toBe('position'); - expect(red.recipient_id).toBe('exec'); - // condition "record.health == 'red'" → JSON FilterCondition { health: 'red' } - const criteria = JSON.parse(red.criteria_json); - expect(criteria).toEqual({ health: 'red' }); + const inquiry = (rules ?? []).find((r: any) => r.name === 'share_new_inquiries_with_field_ops'); + expect(inquiry, 'criteria rule seeded (was count = 0)').toBeTruthy(); + expect(inquiry.object_name).toBe('showcase_inquiry'); + expect(inquiry.recipient_type).toBe('unit_and_subordinates'); + expect(inquiry.recipient_id).toBe('bu_field_ops'); + // condition "record.status == 'new'" → JSON FilterCondition { status: 'new' } + const criteria = JSON.parse(inquiry.criteria_json); + expect(criteria).toEqual({ status: 'new' }); }); - it('retired owner-based rule is gone; its criteria replacement seeds and enforces', async () => { + it('every retired demonstration rule stays gone; the position demo seeds where it enforces', async () => { const rules = await ql.find('sys_sharing_rule', { where: {}, context: { isSystem: true } }); - // `type: 'owner'` was removed from the authoring spec (never enforced — - // ADR-0078): the old demonstration rule can no longer exist. - const owner = (rules ?? []).find((r: any) => r.name === 'share_contributor_tasks_with_manager'); - expect(owner, 'retired owner-based rule must not reappear').toBeFalsy(); - // Its replacement is a real criteria rule — seeded WITH translated - // criteria_json (not skipped, not match-all). - const open = (rules ?? []).find((r: any) => r.name === 'share_open_tasks_with_manager'); - expect(open, 'criteria replacement seeded').toBeTruthy(); - expect(open.object_name).toBe('showcase_task'); - expect(open.recipient_type).toBe('position'); - expect(open.recipient_id).toBe('manager'); - const criteria = JSON.parse(open.criteria_json); - expect(criteria).toEqual({ done: false }); + // Three generations of the same ADR-0078 lesson, each retired for being + // inert in a different way, none of which may reappear: + // • `type: 'owner'` never parsed — silently skipped at seed time; + // • its criteria replacement on `showcase_task`, and the two rules on + // `showcase_project`, seeded fine and then had every grant REFUSED + // (`SHARING_NOT_ENABLED`) because those objects are `public_read_write` + // — sharing has nothing to widen there (#9237). + for (const retired of [ + 'share_contributor_tasks_with_manager', + 'share_open_tasks_with_manager', + 'share_red_projects_with_execs', + 'share_high_value_red_projects_with_managers', + ]) { + expect( + (rules ?? []).find((r: any) => r.name === retired), + `retired rule must not reappear: ${retired}`, + ).toBeFalsy(); + } + // The surviving `position`-recipient demonstration lives on an object under + // record-sharing enforcement, so its grant is one the gates consult. + const contacts = (rules ?? []).find( + (r: any) => r.name === 'share_key_account_qualified_contacts_with_managers', + ); + expect(contacts, 'position-recipient rule seeded').toBeTruthy(); + expect(contacts.object_name).toBe('showcase_contact'); + expect(contacts.recipient_type).toBe('position'); + expect(contacts.recipient_id).toBe('manager'); + const criteria = JSON.parse(contacts.criteria_json); + expect(criteria).toEqual({ $and: [{ stage: 'qualified' }, { company: 'Northwind' }] }); }); it('re-seed is idempotent (no duplicate rows on a second boot)', async () => {