Skip to content
Draft
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
38 changes: 38 additions & 0 deletions .changeset/plugin-sharing-field-recipient.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@objectstack/plugin-sharing": minor
---

feat(plugin-sharing): the `field` sharing recipient is enforced — expanded once per matched record

`ShareRecipientType` gained `field` on the spec side (#14103, maintainer ruling
B): `sharedWith: { type: 'field', value: '<user-field-name>' }` shares each
record the rule's criteria match with the user or users named by that column
on the record. This is the executor half (#15072):

- `SharingRuleService` reads the named user-typed column on each matched
record. A `multiple: true` column shares with every user it names; a single-
user column with the one it names. **Fail-closed on empty**: a null or empty
column materialises no grant — never a match-all principal, never a fallback
to the record owner. `field` is the only recipient resolved per record; every
other kind (`user`, `team`, `position`, `business_unit`,
`unit_and_subordinates`) still expands once per rule.
- The grants re-materialise on the record's own write: the existing
`afterUpdate` hook has no changed-field gating, so an update that touches only
the recipient column re-runs the per-record reconcile, which revokes the
stale grant and materialises the new one. No second trigger was added.
- The whole-rule pass (`evaluateRule` — the background re-grant after an
unbounded bulk write, the `kernel:bootstrapped` backfill and the REST evaluate
endpoint) derives per-record (record, user) pairs for a `field` rule instead
of a matched-records × recipients product, so the rule is as correct after a
bulk write and a restart as it is inline. The recipient-axis revoke
(`revokeRuleGrantsForRetiredRecipients`) declines `field` rules — they have no
rule-wide recipient set to retire against.
- The declared-rule bootstrap seeds `field` rules (previously skipped with a
warning), the `sys_sharing_rule.recipient_type` select accepts `field`, and
`defineRule` refuses a `field` recipient whose `recipientId` is not a field
name (the same grammar the spec applies at parse).
- An active `field` rule whose column the object does not declare as user-typed
grants nobody and says so 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.
2 changes: 1 addition & 1 deletion content/docs/permissions/permissions-matrix.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ Sharing rules extend access beyond ownership and the depth axis. The declarative
| **Criteria-Based** | `criteria` | Share records matching a CEL predicate over field values | All opportunities where `record.amount > 100000` are shared with "VP Sales" |

<Callout type="warn">
**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).
**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).
</Callout>

<Callout type="info">
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/sharing-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ for the first five, once per **matched record** for `field`:
| `position` | Everyone assigned that position (flat expansion — positions have no tree) |
| `unit_and_subordinates` | Everyone in that **business unit and every unit beneath it** (the BU tree is the one hierarchy — ADR-0090 D3) |
| `business_unit` | Everyone in exactly that business unit (no subtree) |
| `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 |
| `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 |

A criteria `condition` must be compilable by the CEL → filter pushdown
compiler. A condition the compiler cannot lower is **skipped and logged —
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ The largest single consumer — **17 of the 106 sites**.
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:469`, `:523`, `:527`, `:600`, `:630` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:202`, `:427` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:278`, `:503` |

### 4. Approvals, reports, attachments, comments, knowledge

Expand Down
4 changes: 2 additions & 2 deletions content/docs/protocol/objectql/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ sharedWith:

`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).

`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.
`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.

### Owner-Based Sharing — removed in v17

Expand Down Expand Up @@ -419,7 +419,7 @@ sharedWith:
value: west_region_managers
```

> **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).
> **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).

> `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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* (`criteria_json` JSON filter + `recipient_type`/`recipient_id`). ADR-0057 D6
* makes the RUNTIME shape canonical and translates the authorable fields.
* Every currently-authorable recipient (`user` / `team` / `position` /
* `unit_and_subordinates` / `business_unit`) maps 1:1 and ENFORCES — the
* `unit_and_subordinates` / `business_unit` / `field`) maps 1:1 and ENFORCES — the
* retired `group`/`guest` recipients and `owner`-type rules no longer parse
* at the spec (ADR-0078; `group` was renamed → `team`). What the runtime
* still cannot enforce is SKIPPED (logged) rather than seeded as a match-all
Expand Down Expand Up @@ -108,6 +108,10 @@ function mapRecipientType(t: unknown): SharingRuleRecipientType | null {
// ADR-0057 D5: business-unit subtree recipient.
case 'business_unit': return 'business_unit' as SharingRuleRecipientType;
case 'unit_and_subordinates': return 'unit_and_subordinates' as SharingRuleRecipientType;
// [#15072] The RECORD-RELATIVE recipient (#14103, maintainer ruling B):
// `value` is a field NAME, held to the `FieldSchema.name` grammar at parse,
// and `SharingRuleService` reads that column on each matched record.
case 'field': return 'field';
// Defensive only: the authoring enum matches the cases above 1:1, but a
// stale pre-built package could still register a retired shape — skip,
// never seed match-all.
Expand Down
5 changes: 3 additions & 2 deletions packages/plugins/plugin-sharing/src/bu-tree-recompute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
* ## Which recipient kinds this covers, measured rather than assumed
*
* `SharingRuleService.expandRecipient` is the one switch that decides, and it
* reads the BU tree for TWO of the six kinds:
* reads the BU tree for TWO of the seven kinds:
*
* | recipient_type | resolver | reads the BU tree |
* |-------------------------|---------------------------------------------|---|
Expand All @@ -34,6 +34,7 @@
* | `position` | `PositionGraphService` (`sys_user_position`, `sys_member`) | no |
* | `unit_and_subordinates` | `BusinessUnitGraphService.expandUsers` | YES |
* | `queue` | returns `[]` (no `sys_queue` yet) | no |
* | `field` | the matched record's own user column, per record (#15072; `expandRecipientForRecord`, never rule-wide) | no |
*
* `business_unit` stays in that set after #7807 narrowed it to exactly one
* unit's members. The divergence this file originally noted — `expandRecipient`
Expand Down Expand Up @@ -110,7 +111,7 @@ export const BU_TREE_RECOMPUTE_PACKAGE = 'plugin-sharing:bu-tree-recompute';
* The recipient kinds whose expansion reads the business-unit graph.
*
* This set is the non-regression guarantee. A rule recipient that never reads
* the BU tree (`user` / `team` / `position` / `queue`) is not recomputed by a
* the BU tree (`user` / `team` / `position` / `queue` / `field`) is not recomputed by a
* BU write at all — not more cheaply, not at all — so a deployment whose rules
* are all `user`-recipient pays one `sys_sharing_rule` read per BU write and
* nothing else.
Expand Down
Loading
Loading