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
58 changes: 58 additions & 0 deletions .changeset/sharing-reconcile-per-grant-organization-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
"@objectstack/plugin-sharing": minor
---

fix(plugin-sharing): one refused grant no longer aborts a sharing rule's reconcile pass — its stale-row revocations still run (#14754)

After #14484 `sys_record_share` is `tenant-scoped` in the #13491 ledger, so on a
walled install an organization-less system insert on it is refused loudly with
`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (#8844). `SharingService.grant`
resolves the organization on every path that can; a platform-global sharing
rule (`organization_id = null`, its sweep unscoped) materialising a grant onto
an organization-LESS record resolves none, and meets that refusal.

`SharingRuleService.reconcile` / `reconcileForRecord` had no per-grant catch, so
the refusal propagated and **that rule's pass aborted mid-loop**. Two things
were lost, and they are not equally serious:

- the remaining grants — recoverable, the next pass writes them;
- **the stale-row revocations of that pass** — not recoverable by waiting,
because every subsequent pass meets the same organization-less record and
dies in the same place. A stale over-grant of that rule therefore persisted
indefinitely, and the record kept aborting the pass until it was repaired by
hand. That is the security-relevant half.

Measured while pinning this, and it sharpens the point: the engine returns
organization-less rows **last** in a rule's criteria sweep (the driver's
NULL-org compatibility arm is appended to the scoped arm). So a refused grant
is nearly always one of the final attempts of a pass, and what an abort
destroyed was hardly ever "the remaining grants" — it was almost entirely the
revoke loop that runs after the whole upsert loop.

Both loops now attempt each grant individually. A refusal is logged with the
rule, object, record, recipient and the engine's own code, counted, and the
pass **continues** — the remaining grants and, above all, the stale-row
revocations still run.

**The catch is deliberately narrow.** Only
`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` is absorbed; every other error
rethrows unchanged. A catch-all would swallow real defects and report a pass
that "completed" having written nothing. It would also silently retire a
reviewed decision: `record-share-organization-stamp.test.ts` deliberately pins
the abort on the OTHER error a reconcile pass can meet here — the scoped update
half answering `RECORD_NOT_FOUND` for a row stamped with a different
organization — which the 2026-09-02 contract review left standing on "loud
beats a wrong count". Those three pins are unchanged and still green.

**Why `minor` rather than `patch`.** The repair is a bug fix, but it reports
through a new key. `reconcile` / `reconcileForRecord` / `evaluateRule` /
`evaluateAllForRecord` now return `SharingRuleReconcilePassResult` — the spec's
`SharingRuleEvaluationResult` plus `grantsRefused: number` — and that type is
newly exported from the package index. Purely additive: the contract in
`@objectstack/spec` is untouched, its six declared fields are unchanged, and a
consumer typed against `ISharingRuleService` keeps compiling as it did. Same
shape as `fix(runtime): tell an action handler when its caller-scope record load
was refused` (#14143), which shipped `minor` for the same reason.

`grantsRefused > 0` does **not** mean the pass failed. It means the pass met a
record it cannot grant on and carried on — which is the whole point.
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 — **20 of the 109 sites**.
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 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:157`, `:382` |
| 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:194`, `:419` |

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

Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/plugin-sharing/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export {
export {
SharingRuleService,
type SharingRuleServiceOptions,
// [#14754] The spec's `SharingRuleEvaluationResult` plus the pass's
// `grantsRefused` count. Additive: the six declared fields are unchanged, and
// a consumer typed against the spec contract keeps compiling untouched.
type SharingRuleReconcilePassResult,
} from './sharing-rule-service.js';
export {
ShareLinkService,
Expand Down
Loading
Loading