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
65 changes: 65 additions & 0 deletions .changeset/16712-position-catalog-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
'@objectstack/plugin-security': minor
---

fix(plugin-security)!: a `sys_user_position` write whose `position` names no `sys_position` row in the writer's catalog is refused (#16712, #20297), instead of answering 201 over an assignment that grants nothing

Clause-②: yes

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is renamed, retired or re-typed: `sys_user_position.position` stays a `Field.text`, every object key and every stored row parse exactly as before, and no stored assignment is rewritten or refused on read, so `objectstack migrate meta` has nothing to act on. What narrows is the ACCEPT SET of one runtime write path: a non-system insert, or an update that changes `position`, whose value names no `sys_position` row in the writer's organization or among the organization-less rows is refused `400 VALIDATION_FAILED` with the envelope the row's sibling lookup columns already answer with. The refusal text names the value and its own fix, and the measured in-repo and consumer writers (hotcrm, hotclm) all write catalog names, so there is no document for a migration to act on. -->

**BREAKING** accept-set narrowing on the `sys_user_position` write path —
shipped as `minor` under the launch-window convention (`check-changeset-no-major`
refuses `major`; breaking-ness is carried by this banner and the ADR-0087
disposition, not by the level). Maintainer-confirmed ruling on #16712 (option A),
with the catalog it reads settled on #20297: the writer's organization, under
the platform's standing tenancy rule.

**What changed.** `sys_user_position.position` is the position's machine NAME
(`sys_position.name`), but it is declared `Field.text`, so a value naming no
catalog row — most often the position's record ID, written where its name
belongs — was stored with a `201` and then resolved to nothing: the holder got
no permission set, no sharing rule reached them, and they signed in to an app
that reads nothing, with no error anywhere. Such a write is now refused:

- `400 VALIDATION_FAILED`, one `fields[]` entry per offending value at
`field: 'position'`, `code: 'reference_not_found'`,
`constraint: { target: 'sys_position', targetField: 'name' }` — the same
envelope a bad `user_id` or `organization_id` on the same row already gets.
- The message names the value and says the column takes the catalog NAME. When
the value is the record id of a position the writer's own organization can
see, it names that position and says to write its name.

**Which writes.** Every non-system insert (one row or a batch, refused whole),
every non-system update by id that CHANGES `position`, and every predicate
update (`multi: true`) that sets it. The check runs after authorization: a
caller who may not write the table is still refused `403` on authority and never
sees the catalog verdict.

**Whose catalog.** The writer's: the positions of the writer's own
organization plus the organization-less ones — the same reach the engine gives
its own lookup-reference check. A name that only ANOTHER organization's
catalog carries is refused exactly like any unknown name, with the same
envelope and the same message, so the answer says nothing about other
organizations. On a single-organization deployment the declared positions
carry no organization and any other position can only carry the one
organization there is, so every writer sees the whole catalog. A writer whose
context names no organization sees every organization's positions.

**What did not change.**

- A **deactivated** position is still a catalog row: an assignment naming it is
accepted and, as before, grants nothing (ADR-0049).
- **Stored rows** are untouched. An update that edits another column, or echoes
the unchanged `position` back, is not judged, so an existing row whose name
is no longer in the catalog stays editable.
- **System-context writes** are not judged — the seed loader (which on a fresh
single-organization boot writes `stack.data` before the declared position
catalog exists), invitation acceptance and the platform's own bootstraps. That
is the same stand-down the engine's lookup check takes.

**Who is affected.** A client, script or AI author that writes a position's id,
a misspelled name, a name not yet created, or — on a deployment that walls
organizations off — a name only another organization has. The fix is the one
the refusal names: write the name of a position in the writer's own catalog, or
create the position there first.
19 changes: 10 additions & 9 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.

This page is **the authority** for what that flag actually does. It exists
because the flag is not one concept: it is a single boolean read at **105
because the flag is not one concept: it is a single boolean read at **106
distinct sites across 19 packages**, and knowing three of those behaviours gives
no hint that the other hundred-and-four exist. Every documented app-side bug
traced to `isSystem` had the same shape — the metadata was complete and correct,
Expand Down Expand Up @@ -125,6 +125,7 @@ that silently does not happen.
| 21 | **`readonly` strip bypassed — INSERT** | objectql | Same, on create — one gate over BOTH create-side passes since the 2026-09-03 ruling moved the static-`readonly` strip in beside the runtime-owned one and deleted the DataProtocol ingress copy. `isSystem` is the **only** exemption on this path: `preserveAudit` is deliberately not read on create, so a non-system historical import is still stripped | `packages/objectql/src/engine.ts#insert` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `packages/objectql/src/engine.ts#insert`, `packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `packages/objectql/src/engine.ts#assertReferencesResolve` |
| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no `sys_position` row is not refused | plugin-security | Get: a system writer can store an assignment naming no catalog row — the seed loader writes `stack.data` from `AppPlugin.start()`, before `kernel:ready` seeds the declared position catalog, so a refusal there would fail every authored assignment seed on a fresh boot. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `position`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when no catalog row the writer's context reaches carries the name: its organization's rows plus the organization-less ones, read as `{ ...context, isSystem: true }`, never a bare `{ isSystem: true }`, so a name only another organization carries is refused like one nobody carries. It is the same stand-down, and the same tenant scope, as the engine's referential-integrity check for a lookup column | `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `packages/objectql/src/engine.ts#buildDriverOptions` |
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite`, `#assertEngineOwnedWriteAllowed` |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` |
Expand All @@ -136,7 +137,7 @@ that silently does not happen.

### 3. Sharing (`plugin-sharing`)

The largest single consumer — **17 of the 105 sites**.
The largest single consumer — **17 of the 106 sites**.

| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
Expand Down Expand Up @@ -279,7 +280,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
independent decisions, and a seed loader plausibly wants the first two but not
the third. The concept is nevertheless **staying as one boolean**:

- **Shipped semantics.** `isSystem` is a published contract with 105 read sites
- **Shipped semantics.** `isSystem` is a published contract with 106 read sites
in 19 packages. Splitting it is a breaking contract change across all of them.
(The ruling was taken when the census read 80 sites in 18 packages; the count
has grown, which strengthens rather than weakens the argument.)
Expand Down Expand Up @@ -353,16 +354,16 @@ still holds equal to the census on every pull request:
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
| — parsed as a declaration | 22 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property **read** | 111 | ✅ |
| — parsed as a property **read** | 112 | ✅ |
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
| — the remainder: text inside comments and string literals | 358 | — |
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **105** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 102 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **106** | ✅ |
| — behaviour-bearing (rows 1–61 above) | 103 | ✅ |
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
| Packages containing at least one elevation read | **19** | ✅ |
| Files containing at least one elevation read | 44 | ✅ |
| — the distinct symbols those reads live in — what this page anchors | 88 | ✅ |
| Files containing at least one elevation read | 45 | ✅ |
| — the distinct symbols those reads live in — what this page anchors | 89 | ✅ |
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |

The six rows marked — are a **dated decomposition, not a live claim**: they were
Expand Down Expand Up @@ -426,7 +427,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`.
Renaming a symbol is now a loud red instead of a silent misdirection.

⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor
cannot say WHICH read inside a function it means, and **8** of the **44**
cannot say WHICH read inside a function it means, and **8** of the **45**
anchored files hold more than one read inside a single symbol. So the population
check runs per file at symbol granularity: every file the census finds a read in
must be anchored, and the set of symbols this page cites into that file must
Expand Down
Loading
Loading