Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/showcase-sharing-rules-enforce-or-remove.md
Original file line numberDiff line numberDiff line change
@@ -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.
19 changes: 13 additions & 6 deletions docs/qa/platform-checklist/areas/access-security.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": [
Expand All@@ -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": [
Expand All@@ -1033,7 +1034,7 @@
"as B (a non-manager on A's note): POST /api/v1/data/showcase_private_note/<A id>/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/<A id>/shares/<shareId> → 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/<some other id>/shares/<shareId>) — 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": [
{
Expand DownExpand Up@@ -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)"
}
],
Expand All@@ -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": [
Expand All@@ -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"
}
]
},
Expand Down
13 changes: 10 additions & 3 deletions examples/app-showcase/src/security/bind-position-sets.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,7 +29,14 @@
* framework cannot infer.
*/

const BINDINGS: ReadonlyArray<readonly [position: string, permissionSet: string]> = [
/**
* 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<readonly [position: string, permissionSet: string]> = [
['contributor', 'showcase_contributor'],
['manager', 'showcase_manager'],
['exec', 'showcase_executive'],
Expand DownExpand Up@@ -72,7 +79,7 @@ async function findOneByName(ctx: BindHostContext, object: string, name: string)
export function registerShowcasePositionBindings(ctx: BindHostContext): void {
const run = async (): Promise<void> => {
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) {
Expand DownExpand Up@@ -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
Expand Down
4 changes: 1 addition & 3 deletions examples/app-showcase/src/security/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,9 +51,7 @@ export {
} from './capabilities.js';

export {
RedProjectSharingRule,
HighValueRedProjectRule,
NewInquiryFieldOpsRule,
ContributorTaskSharingRule,
KeyAccountQualifiedContactRule,
allSharingRules,
} from './sharing-rules.js';
117 changes: 67 additions & 50 deletions examples/app-showcase/src/security/sharing-rules.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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,
];
Loading
Loading