Skip to content

Commit 89daeb0

Browse files
committed
feat(plugin-sharing): expand the field sharing recipient per matched record
The services half of the #14103 ruling (B): `expandRecipient` becomes per-record for the `field` member only. The per-record pass reads the named user-typed column off the matched row (one query for match and recipients), honours `multiple: true`, and fails closed on an empty column. The whole-rule pass (`evaluateRule`, behind the background re-grant, the boot backfill and the REST evaluate endpoint) derives per-record pairs instead of a matched-records x recipients product; the recipient-axis revoke declines `field` rules; the rule-wide switch refuses the member loudly. The bootstrap seeds `field` rules, the `sys_sharing_rule` select stores them, `defineRule` holds the recipient to the field-name grammar, and the "until the executor lands" doc sentences are retired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
1 parent 919beca commit 89daeb0

9 files changed

Lines changed: 1183 additions & 39 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/plugin-sharing": minor
3+
---
4+
5+
feat(plugin-sharing): the `field` sharing recipient is enforced — expanded once per matched record
6+
7+
`ShareRecipientType` gained `field` on the spec side (#14103, maintainer ruling
8+
B): `sharedWith: { type: 'field', value: '<user-field-name>' }` shares each
9+
record the rule's criteria match with the user or users named by that column
10+
on the record. This is the executor half (#15072):
11+
12+
- `SharingRuleService` reads the named user-typed column on each matched
13+
record. A `multiple: true` column shares with every user it names; a single-
14+
user column with the one it names. **Fail-closed on empty**: a null or empty
15+
column materialises no grant — never a match-all principal, never a fallback
16+
to the record owner. `field` is the only recipient resolved per record; every
17+
other kind (`user`, `team`, `position`, `business_unit`,
18+
`unit_and_subordinates`) still expands once per rule.
19+
- The grants re-materialise on the record's own write: the existing
20+
`afterUpdate` hook has no changed-field gating, so an update that touches only
21+
the recipient column re-runs the per-record reconcile, which revokes the
22+
stale grant and materialises the new one. No second trigger was added.
23+
- The whole-rule pass (`evaluateRule` — the background re-grant after an
24+
unbounded bulk write, the `kernel:bootstrapped` backfill and the REST evaluate
25+
endpoint) derives per-record (record, user) pairs for a `field` rule instead
26+
of a matched-records × recipients product, so the rule is as correct after a
27+
bulk write and a restart as it is inline. The recipient-axis revoke
28+
(`revokeRuleGrantsForRetiredRecipients`) declines `field` rules — they have no
29+
rule-wide recipient set to retire against.
30+
- The declared-rule bootstrap seeds `field` rules (previously skipped with a
31+
warning), the `sys_sharing_rule.recipient_type` select accepts `field`, and
32+
`defineRule` refuses a `field` recipient whose `recipientId` is not a field
33+
name (the same grammar the spec applies at parse).
34+
- An active `field` rule whose column the object does not declare as user-typed
35+
grants nobody and says so once per rule.
36+
37+
There is no `manager` recipient: "the owner's manager" is a user field the
38+
application stores on the record, named by a `field` recipient.

content/docs/permissions/permissions-matrix.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative
152152
| **Criteria-Based** | `criteria` | Share records matching a CEL predicate over field values | All opportunities where `record.amount > 100000` are shared with "VP Sales" |
153153

154154
<Callout type="warn">
155-
**Enforcement status:** every authorable rule type is enforced, and so is every recipient type but the newest: the `field` recipient (#14103) parses, and until its per-record executor (#15072) lands the declared-rule bootstrap skips such a rule with a logged warning — never silently, never as a wider grant. v17 reconciled the surface with the runtime (#1878): `owner`-type rules (`type: 'owner'`, `ownedBy`) and `group` / `guest` recipients — previously declared but skipped at seed time — **no longer parse**; `group` became the enforced `team` and `business_unit` joined the enum. That is a stronger statement than "declared but not enforced": a skipped rule is still authorable and is ignored, whereas a removed one is rejected by `SharingRuleSchema`, so a stale definition fails loudly at authoring time instead of silently doing nothing (ADR-0078). See [Sharing Rules](/docs/permissions/sharing-rules#recipient-types).
155+
**Enforcement status:** every authorable rule type is enforced, and so is every recipient type — including the `field` recipient (#14103), expanded once per matched record by its executor (#15072). v17 reconciled the surface with the runtime (#1878): `owner`-type rules (`type: 'owner'`, `ownedBy`) and `group` / `guest` recipients — previously declared but skipped at seed time — **no longer parse**; `group` became the enforced `team` and `business_unit` joined the enum. That is a stronger statement than "declared but not enforced": a skipped rule is still authorable and is ignored, whereas a removed one is rejected by `SharingRuleSchema`, so a stale definition fails loudly at authoring time instead of silently doing nothing (ADR-0078). See [Sharing Rules](/docs/permissions/sharing-rules#recipient-types).
156156
</Callout>
157157

158158
<Callout type="info">

content/docs/permissions/sharing-rules.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ for the first five, once per **matched record** for `field`:
143143
| `position` | Everyone assigned that position (flat expansionpositions have no tree) |
144144
| `unit_and_subordinates` | Everyone in that **business unit and every unit beneath it** (the BU tree is the one hierarchyADR-0090 D3) |
145145
| `business_unit` | Everyone in exactly that business unit (no subtree) |
146-
| `field` | The user or users named by a **user-typed field on each matched record**`value` is that field's name (`assignees`). A `multiple: true` field shares with every user it holds; an empty column shares with nobody. There is deliberately no `manager` recipient: "the owner's manager" is a user field the application stores on the record, named here (maintainer ruling 2026-09-02, objectstack#14103). The per-record executor is objectstack#15072 — until it lands, a `field` rule is skipped with a logged warning at seed, never seeded wider |
146+
| `field` | The user or users named by a **user-typed field on each matched record**`value` is that field's name (`assignees`). A `multiple: true` field shares with every user it holds; an empty column shares with nobody. There is deliberately no `manager` recipient: "the owner's manager" is a user field the application stores on the record, named here (maintainer ruling 2026-09-02, objectstack#14103). Expanded once per matched record by the sharing-rule executor (objectstack#15072), and re-materialised when the record's own write changes that field |
147147

148148
A criteria `condition` must be compilable by the CELfilter pushdown
149149
compiler. A condition the compiler cannot lower is **skipped and logged

content/docs/protocol/objectql/security.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ sharedWith:
383383

384384
`unit_and_subordinates` expands a **business-unit subtree**: the unit named by `value` plus every descendant unit's members (ADR-0057 D5 / ADR-0090 D3 — the former position-tree walk was re-homed onto the `sys_business_unit` tree).
385385

386-
`field` is the **record-relative** recipient (#14103, maintainer ruling 2026-09-02): `value` names a user-typed field on the object, and each matched record is shared with the user or users that column holds on it (`multiple: true` shares with every user it names; an empty column shares with nobody). It expands once per matched record, not once per rule. There is no `manager` recipient — "the owner's manager" is a user field the application stores on the record, named by a `field` recipient. The per-record executor is #15072.
386+
`field` is the **record-relative** recipient (#14103, maintainer ruling 2026-09-02): `value` names a user-typed field on the object, and each matched record is shared with the user or users that column holds on it (`multiple: true` shares with every user it names; an empty column shares with nobody). It expands once per matched record, not once per rule. There is no `manager` recipient — "the owner's manager" is a user field the application stores on the record, named by a `field` recipient. The executor (#15072) derives the rule's grants from each matched record's column, on the record's own write and on every whole-rule pass alike.
387387

388388
### Owner-Based Sharing — removed in v17
389389

@@ -419,7 +419,7 @@ sharedWith:
419419
value: west_region_managers
420420
```
421421

422-
> **Enforcement status.** Criteria rules with `user` / `team` / `position` / `unit_and_subordinates` / `business_unit` recipients compile and enforce (the CEL condition lowers to a runtime filter that materializes `sys_record_share` grants, ADR-0058 D3). The `field` recipient is the contract half of a two-part landing (#14103): it parses, and until its per-record executor (#15072) lands the declared-rule bootstrap skips such a rule with a logged warning — never silently, never as a wider grant. Owner-type rules and the `group` / `guest` recipients are **not** `[experimental — not enforced]` and are no longer skipped at seed time — v17 removed them from the schema, so they do not parse at all (see above). What is still skipped-and-logged is a `condition` the compiler cannot lower (functions, cross-object traversal): it is never seeded as a permissive match-all (ADR-0049).
422+
> **Enforcement status.** Criteria rules with `user` / `team` / `position` / `unit_and_subordinates` / `business_unit` / `field` recipients compile and enforce (the CEL condition lowers to a runtime filter that materializes `sys_record_share` grants, ADR-0058 D3); the `field` recipient (#14103) is expanded once per matched record rather than once per rule (#15072). Owner-type rules and the `group` / `guest` recipients are **not** `[experimental — not enforced]` and are no longer skipped at seed time — v17 removed them from the schema, so they do not parse at all (see above). What is still skipped-and-logged is a `condition` the compiler cannot lower (functions, cross-object traversal): it is never seeded as a permissive match-all (ADR-0049).
423423

424424
> `accessLevel` is one of `read` or `edit`. Sharing widens **which rows** a principal reaches, never **which verbs** they may use — an `edit` share opens *update*, not *delete*: delete comes from ownership, the ADR-0057 DEPTH scopes, or the `modifyAllRecords` bypass, enforced by the sharing layer's own `canDelete` gate (distinct from the `canEdit` update gate) on top of the object-level CRUD gate (ADR-0111 D3). A third level `full` ("Full Access — transfer/share/delete") was authorable through protocol 16 but never granted any of those verbs: both enforcement sites matched `edit`/`full` alike, so it was equivalent to `edit` while telling admins otherwise, and it was removed (#3865, ADR-0078). Stacks still authoring it are rewritten to `edit` at load by the `sharing-rule-access-level-full-to-edit` conversion.
425425

packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* (`criteria_json` JSON filter + `recipient_type`/`recipient_id`). ADR-0057 D6
1010
* makes the RUNTIME shape canonical and translates the authorable fields.
1111
* Every currently-authorable recipient (`user` / `team` / `position` /
12-
* `unit_and_subordinates` / `business_unit`) maps 1:1 and ENFORCES — the
12+
* `unit_and_subordinates` / `business_unit` / `field`) maps 1:1 and ENFORCES — the
1313
* retired `group`/`guest` recipients and `owner`-type rules no longer parse
1414
* at the spec (ADR-0078; `group` was renamed → `team`). What the runtime
1515
* still cannot enforce is SKIPPED (logged) rather than seeded as a match-all
@@ -108,6 +108,10 @@ function mapRecipientType(t: unknown): SharingRuleRecipientType | null {
108108
// ADR-0057 D5: business-unit subtree recipient.
109109
case 'business_unit': return 'business_unit' as SharingRuleRecipientType;
110110
case 'unit_and_subordinates': return 'unit_and_subordinates' as SharingRuleRecipientType;
111+
// [#15072] The RECORD-RELATIVE recipient (#14103, maintainer ruling B):
112+
// `value` is a field NAME, held to the `FieldSchema.name` grammar at parse,
113+
// and `SharingRuleService` reads that column on each matched record.
114+
case 'field': return 'field';
111115
// Defensive only: the authoring enum matches the cases above 1:1, but a
112116
// stale pre-built package could still register a retired shape — skip,
113117
// never seed match-all.

packages/plugins/plugin-sharing/src/bu-tree-recompute.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
* ## Which recipient kinds this covers, measured rather than assumed
2525
*
2626
* `SharingRuleService.expandRecipient` is the one switch that decides, and it
27-
* reads the BU tree for TWO of the six kinds:
27+
* reads the BU tree for TWO of the seven kinds:
2828
*
2929
* | recipient_type | resolver | reads the BU tree |
3030
* |-------------------------|---------------------------------------------|---|
@@ -34,6 +34,7 @@
3434
* | `position` | `PositionGraphService` (`sys_user_position`, `sys_member`) | no |
3535
* | `unit_and_subordinates` | `BusinessUnitGraphService.expandUsers` | YES |
3636
* | `queue` | returns `[]` (no `sys_queue` yet) | no |
37+
* | `field` | the matched record's own user column, per record (#15072; `expandRecipientForRecord`, never rule-wide) | no |
3738
*
3839
* `business_unit` stays in that set after #7807 narrowed it to exactly one
3940
* unit's members. The divergence this file originally noted — `expandRecipient`
@@ -110,7 +111,7 @@ export const BU_TREE_RECOMPUTE_PACKAGE = 'plugin-sharing:bu-tree-recompute';
110111
* The recipient kinds whose expansion reads the business-unit graph.
111112
*
112113
* This set is the non-regression guarantee. A rule recipient that never reads
113-
* the BU tree (`user` / `team` / `position` / `queue`) is not recomputed by a
114+
* the BU tree (`user` / `team` / `position` / `queue` / `field`) is not recomputed by a
114115
* BU write at all — not more cheaply, not at all — so a deployment whose rules
115116
* are all `user`-recipient pays one `sys_sharing_rule` read per BU write and
116117
* nothing else.

0 commit comments

Comments
 (0)