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
19 changes: 19 additions & 0 deletions .changeset/30621-sharing-rule-object-not-enforced.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/plugin-sharing': patch
---

fix(plugin-sharing)!: a sharing rule on an object without record sharing is refused at save, not saved to do nothing

Clause-②: no (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped. What moves is the save door's answer to a sys_sharing_rule row whose object no sharing gate consults; a rule already stored keeps its row and can still be switched off, so there is nothing for a migration to rewrite. -->

**BREAKING** accept-set narrowing at the sharing-rule save doors (`@objectstack/plugin-sharing`): a rule whose object is not under record-sharing enforcement is refused with `422 SHARING_NOT_ENABLED` instead of saved.

- **Before.** An administrator could save a sharing rule on an object no sharing gate consults, such as `sys_session`, `sys_account` or `sys_api_key` (a system object with no `owner_id`), a public object without an owner, a `controlled_by_parent` detail or a bypass-listed object. The save answered `201`, the rule showed as active in Setup, and its reconcile then refused every grant (`SHARING_NOT_ENABLED`) and wrote no `sys_record_share`, so the rule's recipients saw nothing more than before. Nothing told the administrator.
- **Now.** Both save doors refuse that rule and save nothing: the `/data` door (Setup's New and edit forms, scripts, imports) through a `sys_sharing_rule` hook guard, and `POST /api/v1/sharing/rules` (`SharingRuleService.defineRule`). The answer is `422 SHARING_NOT_ENABLED`, the pair the per-record share routes and `POST /api/v1/sharing/rules/:idOrName/evaluate` already answer for the same verdict. Its sentence names the object and says how to make it one a rule applies to, for example "give the object a private or read-only sharing model (`sharingModel: 'private'` or `'read'`) and an `owner_id` field", or "author the rule on its master object instead". An update is refused when it moves a rule onto such an object.

The verdict is the reconcile's own: the save doors read the same ADR-0111 D7 inert-grant check (`SharingService.sharingRuleObjectRefusal`, over `inertGrantReason`) that the grant pre-flight throws on, so the two cannot disagree. A public object that has an owner column is still admitted, because the grant service there still grants an external member of the record's organization (#25549). System writes pass, as with the declared-name guard.

To fix a rejected save, author the rule on an object that uses record sharing, or turn record sharing on for the object as the sentence says. A rule already stored on such an object is not migrated or deleted: it keeps sharing nothing, and an edit that leaves it on its object (for example switching it off) still saves.
2 changes: 1 addition & 1 deletion content/docs/kernel/runtime-services/sharing-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ mask AND-ed with object CRUD, not a fourth `access_level`.
- `PERMISSION_DENIED` (403) — the caller does not hold `canManageShares` on the record (ADR-0111 D1). On the sharing-**rule** surface (`ISharingRuleService`, declared in the same canonical source — `listRules` / `getRule` / `defineRule` / `deleteRule` / `evaluateRule`) the same code carries a second condition: the caller holds `manage_sharing` but their session resolves **no active organization**, and an org-scoped capability with no organization has no tenant whose rules it authorizes. System contexts and platform operators (`manage_platform_settings`, or the `platform_admin` position) are unaffected — see [Rule administration](/docs/permissions/sharing-rules).
- `NOT_FOUND` (404) — the record is missing **or not visible to the caller** (indistinguishable by design), or a `revoke` share id does not exist / does not belong to the `scope` record.
- `CONFLICT` (409) — `revoke` on a rule-materialised share (`source != 'manual'`); the next rule reconciliation would silently re-grant it. Deactivate or edit the sharing rule instead.
- `SHARING_NOT_ENABLED` (422) — `grant` on an object the sharing gates never consult for the recipient (a public sharing model, unless the recipient is an `external` member of the record's organization, whose owner-only floor does consult it; no `owner_id` field; a bypass object, `controlled_by_parent`, or a **federated** object whose `owner_id` is the platform's injected anchor rather than a real remote column — the platform provisions no storage for a federated object, so the gates read that column off a table that has not got it and can never admit).
- `SHARING_NOT_ENABLED` (422) — `grant` on an object the sharing gates never consult for the recipient (a public sharing model, unless the recipient is an `external` member of the record's organization, whose owner-only floor does consult it; no `owner_id` field; a bypass object, `controlled_by_parent`, or a **federated** object whose `owner_id` is the platform's injected anchor rather than a real remote column — the platform provisions no storage for a federated object, so the gates read that column off a table that has not got it and can never admit). On the sharing-**rule** surface, `defineRule` (and a `sys_sharing_rule` write through the `/data` door) answers the same code when the rule's object is one no gate consults for any recipient: the rule is refused at save instead of saved to share nothing.

## Enforcement is automatic — do not re-check it in a hook

Expand Down
16 changes: 13 additions & 3 deletions content/docs/permissions/sharing-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,22 @@ the rows it owns and the rows granted to it:
the object (for example `supplier in current_user.<key>`). Such a policy
lifts the owner-only floor for that object, and the policy bounds the rows.

On a `public_read_write` object, or a system object with no `sharingModel`, a
share or sharing rule takes effect for external recipients only: the grant is
accepted for a recipient who is an `external` member of the record's
On a `public_read_write` object, or a system object with no `sharingModel`,
whose `owner_id` is real, a share or sharing rule takes effect for external
recipients only: the grant is accepted for a recipient who is an `external` member of the record's
organization, every other recipient is refused with `SHARING_NOT_ENABLED`
(422), and a rule's evaluation writes rows for its external recipients alone.

A sharing rule on an object that no sharing gate consults for any recipient is
refused when it is saved: an object with no real `owner_id` (a system object
without one, such as `sys_session`, or a federated object whose `owner_id` is
the platform's injected anchor), a `controlled_by_parent` detail, or a bypass
object. It is refused with `SHARING_NOT_ENABLED` (422) and a sentence that
names the object and how to turn record sharing on for it. This holds for Setup's form and the
`/data` door as well as `POST /api/v1/sharing/rules`, and for an edit that moves
a rule onto such an object. A rule saved before this check keeps its row and
can still be switched off.

`externalSharingModel`, the external dial ADR-0090 D11 declared, was removed
in `@objectstack/spec` 18 (ADR-0049 enforce-or-remove): while it existed no
request resolved an external principal, so the value changed no answer. The schema
Expand Down
4 changes: 3 additions & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ One of the largest consumers of the flag;
| 37 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `buildSharingMiddleware` |
| 38 | 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 | `createLink`, `revokeLink`, `listLinks` |
| 39 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `bindRuleProvenanceStamp` |
| 40 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface and the declared-name guard on `sys_sharing_rule` writes (a new row or a rename taking a declared rule's name, 409) — `defineRule` makes that check itself before its own system write, and the other system writers are the platform's machinery (the ownership ceremony, migrations) | `assertCanManageRules`, `bindDeclaredRuleNameGuard` |
| 40 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface and the declared-name guard on `sys_sharing_rule` writes (a new row or a rename taking a declared rule's name, 409) — `defineRule` makes that check itself before its own system write, and the other system writers are the platform's machinery (the ownership ceremony, migrations); and the object-enforcement guard on the same writes (a rule whose object is not under record-sharing enforcement, 422 `SHARING_NOT_ENABLED`), which `defineRule` likewise makes for its non-system callers | `assertCanManageRules`, `bindDeclaredRuleNameGuard`, `bindRuleObjectEnforcementGuard`, `defineRule` |

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

Expand Down Expand Up @@ -601,13 +601,15 @@ and CI refuses one that differs from what the tree produces.
| `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | 1 | 58 |
| `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite` | 1 | 25 |
| `packages/plugins/plugin-sharing/src/rule-hooks.ts#bindRuleHooks` | 1 | 31 |
| `packages/plugins/plugin-sharing/src/rule-hooks.ts#bindRuleObjectEnforcementGuard` | 1 | 40 |
| `packages/plugins/plugin-sharing/src/share-link-service.ts#createLink` | 3 | 38 |
| `packages/plugins/plugin-sharing/src/share-link-service.ts#listLinks` | 1 | 38 |
| `packages/plugins/plugin-sharing/src/share-link-service.ts#revokeLink` | 1 | 38 |
| `packages/plugins/plugin-sharing/src/sharing-plugin.ts#buildSharingMiddleware` | 1 | 37 |
| `packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts#bindRuleProvenanceStamp` | 1 | 39 |
| `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules` | 1 | 40 |
| `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#bindDeclaredRuleNameGuard` | 1 | 40 |
| `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#defineRule` | 1 | 40 |
| `packages/plugins/plugin-sharing/src/sharing-service.ts#assertCanManageShares` | 1 | 33 |
| `packages/plugins/plugin-sharing/src/sharing-service.ts#bypassVerdict` | 1 | 32 |
| `packages/plugins/plugin-sharing/src/sharing-service.ts#canManageShares` | 1 | 33 |
Expand Down
22 changes: 22 additions & 0 deletions packages/plugins/plugin-sharing/src/rule-criteria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,25 @@ export class SharingCriteriaValidationError extends Error {
this.fields = [{ field, code: 'required', message }];
}
}

/**
* [#30621] A sharing rule refused at save because its object is not under
* record-sharing enforcement: the rule could never share anything.
*
* `code` + `status` are the declared envelope the `/data` door and the
* sharing-rule routes answer at its own status: `SHARING_NOT_ENABLED` (422),
* the pair the per-record share routes and `POST .../evaluate` already answer
* for the same verdict. The message is `SharingService.sharingRuleObjectRefusal`'s
* sentence, which names the object and how to turn record sharing on for it.
*/
export class SharingRuleObjectNotEnforcedError extends Error {
readonly code = 'SHARING_NOT_ENABLED';
readonly status = 422;
/** The object the rule names. */
readonly object: string;
constructor(object: string, message: string) {
super(message);
this.name = 'SharingRuleObjectNotEnforcedError';
this.object = object;
}
}
48 changes: 47 additions & 1 deletion packages/plugins/plugin-sharing/src/rule-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import type { SharingRuleService } from './sharing-rule-service.js';
import type { SharingRuleRow } from '@objectstack/spec/contracts';
import { isMatchAllCriteria, SharingCriteriaValidationError } from './rule-criteria.js';
import { isMatchAllCriteria, SharingCriteriaValidationError, SharingRuleObjectNotEnforcedError } from './rule-criteria.js';
import type { SharingService } from './sharing-service.js';
import {
RULE_RECOMPUTE_ROW_CAP,
RuleRegrantQueue,
Expand All @@ -29,6 +30,9 @@ export const RULE_REBIND_TRIGGER_PACKAGE = 'plugin-sharing:rule-rebind';
*/
export const RULE_CRITERIA_GUARD_PACKAGE = 'plugin-sharing:rule-criteria-guard';

/** Package id of {@link bindRuleObjectEnforcementGuard}'s hooks. */
export const RULE_OBJECT_ENFORCEMENT_GUARD_PACKAGE = 'plugin-sharing:rule-object-enforcement-guard';

/*
* RETIRED — the `isSystem` materialisation skip, and with it the INFO line that
* announced it (`SYSTEM_WRITE_SKIP_NOTICE`, #6783 / #4707 demand 3).
Expand Down Expand Up @@ -355,3 +359,45 @@ export function bindRuleCriteriaGuard(engine: MinimalEngine, logger?: MinimalLog
engine.registerHook('beforeUpdate', guard(false), opts);
logger?.info?.('[sharing-rule] criteria guard bound on sys_sharing_rule (ADR-0049)');
}

/**
* [#30621] Refuse a `sys_sharing_rule` write whose object is not under
* record-sharing enforcement.
*
* Before, such a rule saved (`201`) and its reconcile then met the ADR-0111 D7
* inert-grant guard and wrote no `sys_record_share`: the rule sat in Setup
* looking active and never applied. The verdict here is the reconcile's own
* ({@link SharingService.sharingRuleObjectRefusal}), so the two cannot disagree.
*
* An insert is checked. An update is checked only when it MOVES the rule onto
* another object (`object_name` supplied and different from the pre-image),
* so an administrator can still switch off a rule saved before this guard.
* System writes pass, as with the declared-name guard: `defineRule` makes the
* same check itself for its non-system callers, and the remaining system
* writers are the platform's own machinery.
*/
export function bindRuleObjectEnforcementGuard(
engine: MinimalEngine,
sharing: Pick<SharingService, 'sharingRuleObjectRefusal'>,
): void {
if (typeof engine.registerHook !== 'function') return;
if (typeof engine.unregisterHooksByPackage === 'function') {
engine.unregisterHooksByPackage(RULE_OBJECT_ENFORCEMENT_GUARD_PACKAGE);
}
const guard = (insert: boolean) => (ctx: any) => {
if ((ctx?.session as any)?.isSystem) return;
const data = ctx?.input?.data;
if (!data || typeof data !== 'object' || Array.isArray(data)) return;
const object = data.object_name;
if (typeof object !== 'string' || object.length === 0) return;
if (!insert) {
const previous = ctx?.previous as Record<string, unknown> | undefined;
if (previous && typeof previous === 'object' && previous.object_name === object) return;
}
const refusal = sharing.sharingRuleObjectRefusal(object);
if (refusal) throw new SharingRuleObjectNotEnforcedError(object, refusal);
};
const opts = { object: 'sys_sharing_rule', packageId: RULE_OBJECT_ENFORCEMENT_GUARD_PACKAGE, priority: 100 };
engine.registerHook('beforeInsert', guard(true), opts);
engine.registerHook('beforeUpdate', guard(false), opts);
}
12 changes: 11 additions & 1 deletion packages/plugins/plugin-sharing/src/sharing-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,13 @@ import {
import { SharingRuleService, bindDeclaredRuleNameGuard, bindSharingRuleActivationReconcile } from './sharing-rule-service.js';
import { ShareLinkService } from './share-link-service.js';
import { registerShareLinkRoutes } from './share-link-routes.js';
import { bindRuleHooks, unbindAllRuleHooks, bindRuleCriteriaGuard, RULE_REBIND_TRIGGER_PACKAGE } from './rule-hooks.js';
import {
bindRuleHooks,
unbindAllRuleHooks,
bindRuleCriteriaGuard,
bindRuleObjectEnforcementGuard,
RULE_REBIND_TRIGGER_PACKAGE,
} from './rule-hooks.js';
import { bindRuleProvenanceStamp, unbindRuleProvenanceStamp } from './sharing-rule-provenance.js';
import { bindPrimaryBuHooks, backfillPrimaryBu } from './primary-bu-projection.js';
import { bindBusinessUnitTreeRecompute } from './bu-tree-recompute.js';
Expand Down Expand Up @@ -981,6 +987,10 @@ export class SharingServicePlugin implements Plugin {
// match-all criteria gate has to sit on the table itself too.
bindRuleCriteriaGuard(engine, ctx.logger as any);

// [#30621] …and so must the refusal of a rule whose object is not
// under record-sharing enforcement: it would save and share nothing.
if (this.service) bindRuleObjectEnforcementGuard(engine, this.service);

// [ADR-0131 D6 regime C] …and so must the refusal of a row, new or
// renamed, that takes a declared rule's name: `defineRule` makes it
// for its own callers. A declared rule is customised by a clone
Expand Down
Loading
Loading