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
40 changes: 40 additions & 0 deletions .changeset/sharing-rule-recipient-reconcile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-sharing": minor
---

feat(security)!: reconcile the SharingRule authoring surface with the enforced runtime — rename `group` → `team`, add `business_unit`, prune `guest` + owner-type rules (#1878)

The authoring `ShareRecipientType` enum had drifted behind the ADR-0090 D3
rename and the enforced runtime: the runtime expands `team` (via
`sys_team`/`sys_team_member`) and `business_unit`, but the authoring enum
still offered the pre-rename `group` (silently skipped at seed time) and
omitted the two live recipients. After this change **every authorable
recipient and rule type is enforced** — nothing on the SharingRule surface
validates and then silently does nothing (ADR-0078).

- **`sharedWith.type: 'group'` → `'team'`** (wire-rename): the enum member is
renamed to match the runtime vocabulary and now maps through the seed
bootstrap to the live `TeamGraphService` expansion. Flat `sys_team`
membership; enforced.
- **`business_unit` added** to the authoring enum — exactly one business
unit's members (no subtree; use `unit_and_subordinates` for the subtree).
The runtime + bootstrap already enforced it; only the enum omitted it.
- **`guest` removed** — it had no runtime recipient mapping. Anonymous access
is served by the public-form grant and share links, not sharing rules.
- **Owner-type rules removed** (`type: 'owner'`, `ownedBy`,
`OwnerSharingRuleSchema` + its type export): they depend on live
team/position membership, which the static materialiser cannot track, so
they validated but never materialised a share. They return as an enforced
form if membership-reactive re-materialisation is designed.
`SharingRuleSchema` is now the criteria form; the `queue` recipient stays
runtime-reserved (no `sys_queue` yet) and deliberately non-authorable.

**Migration** (stale definitions now fail parse with the valid options listed):
- `sharedWith: { type: 'group', … }` → `sharedWith: { type: 'team', … }`.
- `sharedWith: { type: 'guest', … }` → delete the rule; expose the records
via a public form or share link instead.
- `type: 'owner'` rules → rewrite as a `type: 'criteria'` rule scoping the
rows by field values (see the migrated examples:
`share_open_tasks_with_manager` in app-showcase,
`share_active_leads_with_manager` in app-crm), or use a scope-depth grant.
23 changes: 14 additions & 9 deletions content/docs/permissions/sharing-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,27 +132,32 @@ export const AccountTeamSharingRule = defineSharingRule({

### Recipient types

`sharedWith` (and `ownedBy`) accept a `{ type, value }` recipient:
`sharedWith` accepts a `{ type, value }` recipient. **Every authorable
recipient is enforced** — each expands to concrete users at seed time and
materializes `sys_record_share` grants:

| `type` | Shares with |
|:--|:--|
| `user` | A single user |
| `group` | All members of a public group — **declared but not enforced**: skipped at seed time with a warning (ADR-0049) |
| `team` | All members of a `sys_team` (flat collaboration grouping) |
| `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) |
| `guest` | Public / anonymous (`guest`) principals — **declared but not enforced**: no runtime recipient mapping, skipped at seed time with a warning (ADR-0049) |
| `business_unit` | Everyone in exactly that business unit (no subtree) |

A criteria `condition` must be compilable by the CEL → filter pushdown
compiler. A condition the compiler cannot lower is **skipped and logged —
never seeded as a permissive match-all** (ADR-0049): a bad condition
under-shares rather than over-shares.

## Owner-Based Sharing Rules

> **[Experimental — not enforced.]** Owner-based (`type: 'owner'`) rules
> depend on live position membership and have no static `criteria_json`
> equivalent, so the seeder **skips them with a warning** — they materialize
> no record shares today. Use a criteria rule or a scope-depth grant.
> **Retired shapes.** The pre-ADR-0090 `group` recipient was renamed to
> `team`, and the `guest` recipient was removed — anonymous access is served
> by the [public-form grant](/docs/permissions/authorization) and share
> links, not sharing rules. Owner-based rules (`type: 'owner'`, `ownedBy`)
> were also removed: they depended on live membership the static seeder
> cannot track, so they validated but never materialized a share. Use a
> criteria rule or a scope-depth grant instead; none of these shapes parses
> anymore, so a stale definition fails loudly at authoring time instead of
> silently doing nothing (ADR-0078).

## Analytics and Dataset Read Scope

Expand Down
62 changes: 6 additions & 56 deletions content/docs/references/security/sharing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ The baseline security posture for an object.
## TypeScript Usage

```typescript
import { CriteriaSharingRule, OWDModel, OwnerSharingRule, ShareRecipientType, SharingLevel, SharingRule, SharingRuleType } from '@objectstack/spec/security';
import type { CriteriaSharingRule, OWDModel, OwnerSharingRule, ShareRecipientType, SharingLevel, SharingRule, SharingRuleType } from '@objectstack/spec/security';
import { CriteriaSharingRule, OWDModel, ShareRecipientType, SharingLevel, SharingRule, SharingRuleType } from '@objectstack/spec/security';
import type { CriteriaSharingRule, OWDModel, ShareRecipientType, SharingLevel, SharingRule, SharingRuleType } from '@objectstack/spec/security';

// Validate data
const result = CriteriaSharingRule.parse(data);
Expand All @@ -37,7 +37,7 @@ const result = CriteriaSharingRule.parse(data);
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional | |
| **accessLevel** | `Enum<'read' \| 'edit' \| 'full'>` | optional | |
| **sharedWith** | `{ type: Enum<'user' \| 'group' \| 'position' \| 'unit_and_subordinates' \| 'guest'>; value: string }` | ✅ | The recipient of the shared access |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **type** | `'criteria'` | ✅ | |
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). e.g. P`record.department == "Sales"` |

Expand All @@ -54,36 +54,17 @@ const result = CriteriaSharingRule.parse(data);
* `controlled_by_parent`


---

## OwnerSharingRule

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Unique rule name (snake_case) |
| **label** | `string` | optional | Human-readable label |
| **description** | `string` | optional | Administrative notes |
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | ✅ | |
| **accessLevel** | `Enum<'read' \| 'edit' \| 'full'>` | ✅ | |
| **sharedWith** | `{ type: Enum<'user' \| 'group' \| 'position' \| 'unit_and_subordinates' \| 'guest'>; value: string }` | ✅ | The recipient of the shared access |
| **type** | `'owner'` | ✅ | |
| **ownedBy** | `{ type: Enum<'user' \| 'group' \| 'position' \| 'unit_and_subordinates' \| 'guest'>; value: string }` | ✅ | Source group/position whose records are being shared |


---

## ShareRecipientType

### Allowed Values

* `user`
* `group`
* `team`
* `position`
* `unit_and_subordinates`
* `guest`
* `business_unit`


---
Expand All @@ -101,14 +82,6 @@ const result = CriteriaSharingRule.parse(data);

## SharingRule

### Union Options

This schema accepts one of the following structures:

#### Option 1

**Type:** `criteria`

### Properties

| Property | Type | Required | Description |
Expand All @@ -119,40 +92,17 @@ This schema accepts one of the following structures:
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional | |
| **accessLevel** | `Enum<'read' \| 'edit' \| 'full'>` | optional | |
| **sharedWith** | `{ type: Enum<'user' \| 'group' \| 'position' \| 'unit_and_subordinates' \| 'guest'>; value: string }` | ✅ | The recipient of the shared access |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **type** | `'criteria'` | ✅ | |
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). e.g. P`record.department == "Sales"` |

---

#### Option 2

**Type:** `owner`

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Unique rule name (snake_case) |
| **label** | `string` | optional | Human-readable label |
| **description** | `string` | optional | Administrative notes |
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional | |
| **accessLevel** | `Enum<'read' \| 'edit' \| 'full'>` | optional | |
| **sharedWith** | `{ type: Enum<'user' \| 'group' \| 'position' \| 'unit_and_subordinates' \| 'guest'>; value: string }` | ✅ | The recipient of the shared access |
| **type** | `'owner'` | ✅ | |
| **ownedBy** | `{ type: Enum<'user' \| 'group' \| 'position' \| 'unit_and_subordinates' \| 'guest'>; value: string }` | ✅ | Source group/position whose records are being shared |

---


---

## SharingRuleType

### Allowed Values

* `owner`
* `criteria`


Expand Down
22 changes: 12 additions & 10 deletions examples/app-crm/src/security/sharing-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,21 @@ export const HighValueOpportunitySharingRule = defineSharingRule({
});

/**
* Owner-based sharing: leads owned by a Sales Rep are shared read-only
* with their manager so managers can coach on individual pipelines.
* Criteria-based sharing: in-flight leads (not yet converted or disqualified)
* are shared read-only with Sales Managers for coaching visibility.
*
* Replaces the retired owner-based `share_rep_leads_with_manager` rule:
* `type: 'owner'` (`ownedBy`) no longer parses — it depended on live position
* membership and was silently skipped at seed time (ADR-0078). The enforced
* criteria form scopes the same coaching set by pipeline state instead.
*/
export const RepLeadSharingRule = defineSharingRule({
type: 'owner',
name: 'share_rep_leads_with_manager',
label: "Rep's Leads → Manager (read-only)",
description: "Share each rep's leads with the Sales Manager role for coaching visibility.",
type: 'criteria',
name: 'share_active_leads_with_manager',
label: 'Active Leads → Manager (read-only)',
description: 'Share in-flight (not converted/disqualified) leads with Sales Managers for coaching visibility.',
object: 'crm_lead',
ownedBy: {
type: 'position',
value: 'sales_rep',
},
condition: "record.status != 'converted' && record.status != 'disqualified'",
accessLevel: 'read',
sharedWith: {
type: 'position',
Expand Down
7 changes: 4 additions & 3 deletions examples/app-crm/test/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,12 @@ describe('app-crm minimal metadata bundle', () => {
expect(stack.i18n!.supportedLocales).toContain('zh-CN');
});

it('has sharing rules (criteria + owner types)', () => {
it('has criteria sharing rules (the enforced form — owner-type was retired)', () => {
const rules = stack.sharingRules ?? [];
expect(rules.length).toBeGreaterThanOrEqual(2);
expect(rules.some((r) => r.type === 'criteria')).toBe(true);
expect(rules.some((r) => r.type === 'owner')).toBe(true);
// `type: 'owner'` no longer parses (never enforced; ADR-0078): every
// declared rule is the enforced criteria form.
expect(rules.every((r) => r.type === 'criteria')).toBe(true);
});

// #3420 — official examples must boot warning-free. A generic (non-better-auth)
Expand Down
25 changes: 13 additions & 12 deletions examples/app-showcase/src/security/sharing-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,22 +72,23 @@ export const NewInquiryFieldOpsRule = defineSharingRule({
});

/**
* owner-based: a contributor's tasks are shared read-only with managers.
* criteria-based: open (not-done) tasks are shared read-only with managers
* for oversight.
*
* [experimental — not enforced] Owner-type rules depend on live position
* membership and have no static `criteria_json` equivalent, so the seed
* bootstrap SKIPS them (logged) rather than seeding a permissive match-all
* (ADR-0049: nothing silently over-shares). Kept here to demonstrate the
* authoring shape; managers actually reach contributor tasks via the
* criteria rules above and their depth grants.
* 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.
*/
export const ContributorTaskSharingRule = defineSharingRule({
type: 'owner',
name: 'share_contributor_tasks_with_manager',
label: 'Contributor Tasks → Manager',
description: "Share each contributor's tasks with managers for oversight.",
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',
ownedBy: { type: 'position', value: 'contributor' },
condition: 'record.done == false',
accessLevel: 'read',
sharedWith: { type: 'position', value: 'manager' },
active: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,21 @@
* bootstrapDeclaredSharingRules — seed stack-declared `sharingRules` into
* `sys_sharing_rule` (ADR-0057 D6, closes #2077; reconciles #1887).
*
* The spec authoring shape (`SharingRuleSchema`: CEL `condition`, `ownedBy`,
* The spec authoring shape (`SharingRuleSchema`: CEL `condition`,
* `sharedWith{type,value}`) diverges from the enforced runtime shape
* (`criteria_json` JSON filter + `recipient_type`/`recipient_id`). ADR-0057 D6
* makes the RUNTIME shape canonical and translates only the directly-mappable
* fields. Parts the runtime cannot enforce statically are SKIPPED (logged
* `[experimental]`) rather than seeded as a match-all rule — silently
* over-sharing would be worse than not enforcing (ADR-0049):
* - `owner`-type rules (`ownedBy`): role membership is dynamic, no static
* `criteria_json` equivalent.
* 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
* 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
* rule — silently over-sharing would be worse than not enforcing (ADR-0049):
* - a CEL `condition` the canonical compiler cannot lower (functions,
* cross-object traversal) — ADR-0058 D2. Compound predicates (AND/OR,
* comparisons, null, in) DO lower now and are enforced (ADR-0058 D3, #1887).
* - `sharedWith.type` of `group`/`guest`: no runtime recipient mapping.
* comparisons, null, in) DO lower and are enforced (ADR-0058 D3, #1887).
* - defensively, any stale pre-built package that still registers an old
* `owner`-type / unmapped-recipient shape.
*
* Seeding upserts via `SharingRuleService.defineRule` (idempotent by name) and
* MUST run before `listRules()`/`bindRuleHooks` so the lifecycle hooks bind to
Expand All @@ -35,11 +37,17 @@ type Logger = { info?: (m: string, meta?: any) => void; warn?: (m: string, meta?
function mapRecipientType(t: unknown): SharingRuleRecipientType | null {
switch (t) {
case 'user': return 'user';
// Flat sys_team membership (ADR-0090 D3 vocabulary; the pre-D3 `group`
// spelling was retired) — expanded by TeamGraphService in expandRecipient.
case 'team': return 'team';
case 'position': return 'position';
// 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;
default: return null; // group / guest — no runtime mapping yet
// 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.
default: return null;
}
}

Expand Down Expand Up @@ -96,9 +104,11 @@ export async function bootstrapDeclaredSharingRules(
logger?.warn?.('[sharing-rule] skipped (unmappable recipient) [experimental]', { rule: r.name, sharedWith: r.sharedWith?.type });
skipped += 1; continue;
}
// owner-type rules have no static criteria_json equivalent.
// Defensive: `owner`-type rules were removed from the authoring spec
// (live-membership-dependent, no static criteria_json equivalent) — this
// guards stale pre-built packages that still register the old shape.
if (r.type === 'owner') {
logger?.warn?.('[sharing-rule] skipped owner-based rule (no static criteria) [experimental]', { rule: r.name });
logger?.warn?.('[sharing-rule] skipped owner-based rule (retired authoring shape — use a criteria rule)', { rule: r.name });
skipped += 1; continue;
}
// criteria rules: translate CEL → filter. Empty condition = match-all (intentional).
Expand Down
4 changes: 2 additions & 2 deletions packages/qa/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
note: 'Proof file carries the forged owner_id/organization_id submit case; the route-level matrix is covered unit-side in rest public-form-routes.test.ts + plugin-security security-plugin.test.ts (publicFormGrant strip suite).' },
{ id: 'record-share', summary: 'manual record shares (sys_record_share)', state: 'enforced',
enforcement: 'plugin-sharing/sharing-service.ts buildReadFilter/canEdit' },
{ id: 'sharing-rules', summary: 'criteria/owner sharing rules', state: 'enforced',
enforcement: 'plugin-sharing/sharing-rule-service.ts (materialized into sys_record_share)', proof: 'showcase-bu-hierarchy-sharing.dogfood.test.ts' },
{ id: 'sharing-rules', summary: 'criteria sharing rules (recipients: user/team/position/unit_and_subordinates/business_unit)', state: 'enforced',
enforcement: 'plugin-sharing/sharing-rule-service.ts (materialized into sys_record_share); every authorable recipient expands in expandRecipient. The never-enforced owner-type rules + group/guest recipients were removed from the authoring spec (ADR-0078; group renamed → team)', proof: 'showcase-bu-hierarchy-sharing.dogfood.test.ts' },
{ id: 'hierarchy-widening', summary: 'hierarchy widening — a unit + its subordinate units gain access', state: 'enforced',
enforcement: 'plugin-sharing/business-unit-graph.ts BusinessUnitGraphService subtree (business_unit recipient) — ADR-0057 D5 re-homed off the never-existent sys_position.parent', proof: 'showcase-bu-hierarchy-sharing.dogfood.test.ts' },
{ id: 'rls-compiler-fail-closed', summary: 'uncompilable RLS predicate is surfaced/denied, not dropped', state: 'enforced',
Expand Down
Loading