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
16 changes: 16 additions & 0 deletions .changeset/record-share-organization-stamp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/plugin-sharing": minor
"@objectstack/objectql": minor
---

`sys_record_share` is tenant-scoped: every grant row now carries `organization_id`, and the rows written before it can be backfilled from the record they grant access to (#14484).

Every `sys_record_share` row on every deployment was written with `organization_id = NULL`: `SharingService.grant` wrote under a bare system context and the row literal never carried the column, so neither the driver's `injectTenantOnInsert` nor the engine's system-write organization rule had anything to stamp from. Reads agreed with writes — the service's own reads are bare-context too — so nothing was visibly broken; what the NULL cost was the cliff: the first tenant-facing read of the table inherits `plugin-security`'s Layer 0, whose strict `organization_id = :tenant` AND-composes over the driver's NULL-tolerant arm and wins, and every existing grant silently disappears — not refused, simply "this person was never granted access". Maintainer ruling 2026-09-02 (decision batch #11 item 3, A adopted — 「#13564 转维护者处理;其他同意」): tenant-scoped, writer-repaired, existing rows backfilled from the record they reference. The per-table order the `sys_file` precedent requires; it covers `sys_record_share` and no other table.

**Writer.** `SharingService.grant` stamps `organization_id` on both halves of its upsert. A rule-materialised grant carries the granting RULE's organization — `SharingRuleService.reconcile` / `reconcileForRecord` now hand `grant` the rule's own `criteriaContext` (`{ isSystem, tenantId: rule.organization_id }`), the same context the rule's criteria sweep ran under, so the grant lands in the organization whose records the rule was allowed to sweep. A direct grant carries the shared RECORD's organization, read off the column its object is walled by (`resolveTenantFieldName`: ADR-0066 opt-out → declared `tenancy.tenantField` → injected `organization_id`), with the acting session's organization as the fallback for a record that carries none — and only for one that carries none: a record whose organization could not be READ is unknown, not organization-less, so that grant carries `null` for the engine's rule below rather than the session's organization (which may not be the record's). The organization rides the write context as `tenantId` as well as the row — the `{ isSystem, tenantId }` shape the #8844 refusal prescribes — so the driver's tenant audit is satisfied and the update half lands through the driver's scope (`organization_id = ? OR IS NULL`, which keeps a pre-repair NULL row in reach). Nothing resolvable ⇒ an explicit `null` on the row, for the engine's ruled rule to decide (below). The service's eleven bare-context READS are unchanged by this change; whether they become tenant-scoped is #13564's question.

**Ledger (`@objectstack/objectql`).** `sys_record_share` leaves `unclassified` in the #13491 per-object tenancy ledger as `tenant-scoped`, with the ruling as the cited fact. Consequence, per the ledger's own admission semantics: an organization-less SYSTEM insert on `sys_record_share` is now derived on a `single` install with exactly one organization and REFUSED loudly on a walled one (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, status 500) — and the engine no longer auto-mutes the driver's tenant-audit warning for elevated writes on it. The only writer in this repository is repaired in the same change and carries the organization on every path that can resolve one — but two shipped paths still resolve none, and on a walled install they now meet that refusal instead of writing a NULL row. **(a)** A platform-global sharing rule (`organization_id = null`; its sweep runs unscoped) matching an organization-less record: the grant resolves `null`, the engine refuses it, and because `reconcile` has no per-grant catch that rule's reconcile aborts mid-loop — grants already written in the pass stay, the remaining grants and the stale-row revocations of that pass do not happen (the boot backfill logs the rule and continues; the write hooks catch). **(b)** A direct grant whose read of the shared record's organization failed: the acting session's organization is deliberately not substituted, so the grant is refused with the same error rather than written into an organization that may not be the record's. On a `single` install with exactly one organization both derive it; with several, the same `null` is refused as `ambiguous-organization`. A third-party writer that inserts `sys_record_share` under a bare system context on a walled install meets the refusal too, and the refusal message says how to carry the organization.

**Ops: the backfill — dry run first, and by default.** `packages/plugins/plugin-sharing/src/backfill-sys-record-share-organizations.ts` scans only rows whose organization column is unset, re-reads each row's record (`object_name` + `record_id`) at repair time, and stamps the row with the record's own organization off the column that object is walled by. `planSysRecordShareOrganizationBackfill(engine)` reads only and returns a report naming every row it would touch; `runSysRecordShareOrganizationBackfill(engine, { dryRun: false })` writes. Nothing runs at boot and nothing is scheduled: this is an operator-invoked module, run once against an affected install, the posture of both precedents. **Orphans — grant rows whose record no longer exists — are left NULL, counted (`totals.orphans`) and logged, never deleted here:** the "record gone ⇒ the row cannot describe any access" invariant is already owned by the `kernel:bootstrapped` orphan sweep (`sweepOrphanedRecordShares`, #5103), which reclaims exactly that population on the next boot; a second deleter would be the fork `record-orphan-cleanup.ts` exists to prevent. Every other row that cannot be derived — an object with no organization column, a record that carries none, a record whose read failed — stays NULL and is reported by reason, for a dry run too. Idempotent by construction: every scan is `WHERE <organization column> IS NULL` and every write fills that column, so the test suite runs the sweep twice and pins the second run at zero writes.

Publishes no runtime code for the backfill: the module is not exported from the package index and not bundled into `dist` (`tsup` builds `src/index.ts`). It is graded rather than skipped because the release notes are where an operator of an affected install learns the repair exists, what it will and will not touch, and that the dry run comes first.
10 changes: 5 additions & 5 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,11 @@ The largest single consumer — **20 of the 109 sites**.
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
| 30 | **Sharing-rule grant materialisation is skipped on all four record-write hooks** | Lose: **no `sys_record_share` rows are created**. A fully configured sharing rule grants **nothing** on seeded data until a rule is re-evaluated or the boot backfill runs. This is the behaviour that motivated #4707. Since #6783 the skip is no longer silent — it emits an INFO notice (rough edge 2) | `rule-hooks.ts:250`, `:274`, `:293`, `:322` |
| 31 | Sharing write verdict short-circuits to `allow` | Get: writes pass the sharing gate unconditionally | `plugin-sharing/src/sharing-service.ts:654` |
| 32 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement | `plugin-sharing/src/sharing-service.ts:920`, `:1007`, `:1597` |
| 33 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1208` |
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1286` (guard at `:1311`) |
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` |
| 31 | Sharing write verdict short-circuits to `allow` | Get: writes pass the sharing gate unconditionally | `plugin-sharing/src/sharing-service.ts:677` |
| 32 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement | `plugin-sharing/src/sharing-service.ts:943`, `:1030`, `:1787` |
| 33 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1238` |
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1476` (guard at `:1501`) |
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1528` |
| 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` |
Expand Down
34 changes: 17 additions & 17 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **67 of the 218 sites are spelled that way**. A
forwarding shim cannot, and **67 of the 219 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand Down Expand Up @@ -147,10 +147,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **218** |
| 175 write call sites | quoted in the merged changeset | **219** |
| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **146 of 218** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 100 decidably elevated, 0 decidably not, 101 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **147 of 219** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 101 decidably elevated, 0 decidably not, 101 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All @@ -161,17 +161,17 @@ at any commit.

Two structural facts do plausibly widen this reading against any hand or regex
one, and both are counted in the generated tables below: the 45 sites reached
through an erased (`any`) receiver, and the 38 that name their object through a
through an erased (`any`) receiver, and the 39 that name their object through a
`const` rather than inline. An instrument that read either the way a person does
would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
100 of 218 (46%) as decidably elevated, with 101 more whose elevation is a
101 of 219 (46%) as decidably elevated, with 101 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `9 / 218`, and say what it is**: the sites whose options argument was
⇒ **Cite `9 / 219`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **32 further sites** have an options argument this
Expand All @@ -183,29 +183,29 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **218** |
| …whose object name is statically decidable | 146 |
| write call sites on the application surface | **219** |
| …whose object name is statically decidable | 147 |
| …whose object name is chosen at run time | 72 |
| …against an object with tenancy ENABLED | 146 |
| …against an object with tenancy ENABLED | 147 |
| …against an object that declares tenancy off | 0 |
| threading a tenant context | 134 |
| threading a tenant context | 135 |
| PROVABLY carrying none (options read, no context key) | **17** |
| …of those, against a decidably tenancy-enabled object | **9** |
| options argument UNREADABLE — may or may not carry one | 67 |
| …of those, against a decidably tenancy-enabled object | 32 |
| threading a decidably ELEVATED (`isSystem`) context | 100 |
| threading a decidably ELEVATED (`isSystem`) context | 101 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 101 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 173 |
| receiver carried a readable engine type | 174 |
| receiver erased, placed by the object NAME | 19 |
| receiver erased, placed by an `object: string` PARAMETER | 15 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 |

| object name spelled inline | 108 |
| object name spelled through a `const` | 38 |
| object name spelled through a `const` | 39 |
| object name is an `object: string` parameter | 19 |
| object name is some other run-time expression | 53 |

Expand All @@ -224,12 +224,12 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-09-02 at `5daab8df0`.
Measured on 2026-09-03 at `98b1cf0b7`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 539 |
| engine-shaped types recognised | 56 |
| tracked non-test sources scanned | 540 |
| engine-shaped types recognised | 57 |
| declared objects in the registry | 297 |
| same-named calls subtracted as non-engine | 130 |

Expand Down
17 changes: 9 additions & 8 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 218 |
| Object name statically decidable | 146 |
| Write call sites | 219 |
| Object name statically decidable | 147 |
| Object name chosen at run time | 72 |
| Against a tenancy-enabled object | 146 |
| Against a tenancy-enabled object | 147 |
| Against an object declaring tenancy off | 0 |
| Threading a tenant context | 134 |
| Threading a tenant context | 135 |
| Provably carrying none | 17 |
| …and decidably tenancy-enabled | 9 |
| Options argument unreadable | 67 |
| …and decidably tenancy-enabled | 32 |
| Threading a decidably elevated context | 100 |
| Threading a decidably elevated context | 101 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 101 |

Expand All @@ -52,12 +52,12 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-09-02 at `5daab8df0`.
Measured on 2026-09-03 at `98b1cf0b7`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 539 |
| engine-shaped types recognised | 56 |
| tracked non-test sources scanned | 540 |
| engine-shaped types recognised | 57 |
| declared objects in the registry | 297 |
| same-named calls subtracted as non-engine | 130 |

Expand Down Expand Up @@ -139,6 +139,7 @@ Measured on 2026-09-02 at `5daab8df0`.
| `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `insert` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 |
| `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `update` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 3 |
| `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `insert` | `sys_position_permission_set` | enabled | context, elevation undecidable | 1 |
| `packages/plugins/plugin-sharing/src/backfill-sys-record-share-organizations.ts` | `update` | `sys_record_share` | enabled | elevated | 1 |
| `packages/plugins/plugin-sharing/src/primary-bu-projection.ts` | `update` | `sys_user` | enabled | elevated | 2 |
| `packages/plugins/plugin-sharing/src/record-orphan-cleanup.ts` | `delete` | `table` | undecidable | options unreadable | 2 |
| `packages/plugins/plugin-sharing/src/share-link-service.ts` | `insert` | `sys_share_link` | enabled | elevated | 1 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ describe('#13491 the inventory — a verdict per object, never a namespace', ()
'sys_automation_run',
'sys_file',
'sys_notification_delivery',
'sys_record_share',
'sys_upload_session',
]);
});
Expand Down
17 changes: 17 additions & 0 deletions packages/objectql/src/tenancy/platform-object-tenancy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,23 @@ export const PLATFORM_OBJECT_TENANCY: Readonly<Record<string, PlatformObjectTena
'`SqlOutbox.enqueue` writes `organization_id` from the organization the messaging service derives ' +
'for the notification (`sql-outbox.ts`, `messaging-service.ts#notificationOrganization`).',
},
// #14484 (writer + backfill), ruled 2026-09-02 — decision batch #11 item 3,
// maintainer verbatim 「#13564 转维护者处理;其他同意」 ("其他同意" adopts A:
// tenant-scoped, writer-repaired, existing rows backfilled from the record
// they grant access to). The per-table order the `sys_file` precedent
// requires; it covers `sys_record_share` and no other table.
sys_record_share: {
tenancy: 'tenant-scoped',
evidence:
'The writer was repaired to stamp `organization_id` on every insert and update ' +
'(`SharingService.grant`, `plugin-sharing/src/sharing-service.ts`): a rule-materialised grant ' +
"carries the granting rule's organization, a direct grant the shared record's. The maintainer ordered " +
'the rows written before it backfilled from the record they reference on 2026-09-02 ' +
'(`backfill-sys-record-share-organizations.ts`). A grant table that cannot say which organization ' +
'a grant belongs to is a defect, not a design: under a wall, a tenant-scoped read would AND ' +
"plugin-security's strict `organization_id = :tenant` over the driver's NULL-tolerant arm and every " +
'organization-less grant would silently disappear.',
},

// ── global ───────────────────────────────────────────────────────────────
// #8672, named verbatim by the 2026-08-31 ruling; the driver predicate is #2734.
Expand Down
Loading
Loading