Skip to content

Commit a285ff2

Browse files
feat(plugin-security)!: a flow that runs as system writes no identity assignment (#25483)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu7jijBoz73vEPbxZxtTo6jC)_ Part of #25251 Clause-②: no (narrowing) Metadata-protocol: semantic This is PR1 of ruling 6354966423 (letter B): the run-time arm, in plugin-security. PR2, the `runAs` describe sentence and the ADR-0087 D3 semantic entry in `packages/spec`, belongs to the spec lane and is handed to its seat; the card stays open for it. ## What changed The data middleware refuses one system context ahead of its `isSystem` bypass: a context that carries `isSystem` and a `flowRunId`, which is what a `runAs: 'system'` flow's data nodes write under (`resolveRunDataContext`, service-automation). Its governed writes to the four identity-assignment objects are refused with `PERMISSION_DENIED` (403) and nothing is written. | Context | Insert, update, delete, transfer, restore or purge on `sys_user_position`, `sys_user_permission_set`, `sys_invitation`, `sys_member` | Reads of the four | Writes to any other object | |---|---|---|---| | `isSystem` + `flowRunId` (`runAs: 'system'` flow, user-triggered or scheduled) | **refused, PERMISSION_DENIED** (was: admitted past every gate) | admitted | admitted | | `isSystem`, no `flowRunId` (seeders, better-auth and its reconciliation, invitation placement, the organization-administrator grant) | admitted, unchanged | admitted | admitted | | `flowRunId`, no `isSystem` (`runAs: 'user'` flow) | not judged here; the ADR-0090 D12 gate judges the user the run acts for, unchanged | per the user | per the user | - New module `flow-system-identity-write-refusal.ts`: `assertFlowSystemIdentityWriteAllowed(opCtx)` and the object set. The predicate is the ruling's, `isSystem` AND `flowRunId`, read by truthiness as the bypass reads `isSystem`. The verbs are the D12 gate's own `GOVERNED_OPERATIONS`, now exported from `delegated-admin-gate.ts` so that a verb added there is refused here too. - The prescription follows the ruling. For the two assignment tables it is to set the flow's `runAs` to `'user'`, so the gate judges the acting user and records them as `granted_by`. For invitations and memberships it is the organization endpoints (`invite-member`, `cancel-invitation`, `update-member-role`, `remove-member`), which apply the invitation's placement and role-cap checks. - `security-plugin.ts`: one import and one call in the data middleware, immediately before `// System operations bypass security`. Nothing else in the file moves. - Comments only: the `delegated-admin-gate.ts` header and `GRANTED_BY_OBJECTS` docblock, which said every system write short-circuits before the gate, and the `resolveRunDataContext` docblock sentence in service-automation that said a system run "reads/writes with full access". - `content/docs/permissions/system-context.mdx` gains row 1b, because `check-system-context-census` holds every `isSystem` read site to a row on the page. - Changeset: `@objectstack/plugin-security` `minor`, with the BREAKING banner (FROM / TO / Unchanged / Fix) and the ADR-0087 `not-required (no-migration-prescription)` marker. The semantic entry itself is PR2's. ## Census (taken before any edit) `create_record`, `update_record`, `delete_record` and `upsert_record` node literals in objectstack at `b2c075290` and hotcrm at `c14ffde5f` (TS, JS, JSON, YAML, MD, MDX; `node_modules` and `dist` excluded): 317 nodes, **0** aimed at any of the four objects. 78 carry no literal target near the node literal; each was read by hand and none names the four. The count is zero, so nothing is worked around. ## Tests - `plugin-security/src/flow-system-identity-write-refusal.test.ts` (44 cases). Most drive the real `SecurityPlugin` middleware over a real `ObjectQL` engine and a real SQL driver, and read the stored rows back: - The predicate, directly, for every verb in `GOVERNED_OPERATIONS` on each of the four objects. `transfer`, `restore` and `purge` have no engine verb to drive. - Through the middleware: insert, update and delete from a user-triggered and a user-less flow system context on each object are refused with the envelope (`code`, `status`, `details`) and the store does not move. - Seeder control: `{ isSystem: true }` inserts, updates and deletes each object. - Reads of the four, and writes to an ordinary object and to `sys_business_unit`, under the flow's system context. - A `runAs: 'user'` context: a tenant-level administrator's grant is admitted and stores `granted_by` = that user. A plain member's is refused by the D12 gate (its own message, `details.userId` = the member), not by this refusal. - `service-automation/src/runas-system-identity-write-refusal.integration.test.ts` (9 cases): real kernel, `ObjectQLPlugin`, driver-sql, the identity objects, the real `SecurityPlugin` and the real `AutomationEngine` CRUD nodes. - A `runAs: 'system'` flow's `create_record` on each of the four objects, user-triggered and scheduled, fails with `innerRefusal.code` `PERMISSION_DENIED` and no row is written. Its `update_record` and `delete_record` on a grant and on a membership are refused too, and the rows do not move. - A `runAs: 'user'` flow triggered by a tenant-level administrator grants the set, with `granted_by` = that administrator. Triggered by a plain member, the D12 gate refuses it. - Controls: a platform writer without a run writes all four objects. A `runAs: 'system'` flow still reads the four and still writes an ordinary object. Pins describe behaviour only. ## Verification - `pnpm --filter @objectstack/plugin-security --filter @objectstack/service-automation test` (under the verify lock, VERDICT `command-exit 0`): plugin-security 215 files, 4509 passed and 45 skipped; service-automation 199 files, 2628 passed. - `pnpm typecheck` in both packages: exit 0, including `check:test-typecheck` (both test layers compile, 0 pinned signatures). - `eslint --no-inline-config` on the six changed TypeScript files: exit 0. - Ablation (`scripts/ablation-replace.mjs`, one behaviour): the middleware's call was disabled behind a planted marker, which was proven in the source (anchor 1 → 0) and in `dist/` (`ablation-dist-preflight`, 2 built files) before the service-automation leg ran, because that suite reads plugin-security from `dist/`. - plugin-security pin: 8 failed, 36 passed. The 8 are the through-the-middleware refusals (four objects, user-triggered and user-less). The predicate block, the seeder controls, the read and other-object controls and the `runAs: 'user'` cases stay green, as expected. - service-automation pin: 5 failed, 4 passed. The 5 are the four `runAs: 'system'` creates and the update and delete case. The `runAs: 'user'` cases and both controls stay green. - Restore: blob equals HEAD, `git diff HEAD` empty, rebuilt, marker absent from all 6 built files, and both pins green again (44/44, 9/9). - A first ablation run also turned the platform-writer control red, as a knock-on: the ablated system flow had written the same membership row the control then inserted. The control now writes its own non-member, and the second run above is clean. - Local gates (`dispatch-gates --commands`, 70 families): on the first pass 67 exited 0. The other three: - `check-system-context-census`: fixed. The new module reads `isSystem` and had no row; row 1b is added and the gate exits 0. - `check-plugin-teardown-shape --self-test`: exit 3, because the clone was shallow. Deepened with `git fetch --unshallow origin main`. - `check:dual-build-cjs-loads`: exit 3, because 7 prerequisite dists were missing. They are now built. - The final-head re-run of the gate union and the full dogfood battery (owed for the `(narrowing)` arm and the `!`) are running now. Their counts go in the `os-dev-report` on #25251. ## Acceptance notes - The branch is the harness-assigned `claude/project-thread-t123rb`, not `claude/issue-25251-…`. The claim comment is the identity record. - File surface, two declared departures from the claim. `delegated-admin-gate.ts` was claimed as comment only; it also gains `export` on `GOVERNED_OPERATIONS` (and a `ReadonlySet` type), so that both checks read one verb list. The package index is unchanged. `content/docs/permissions/system-context.mdx` was out of the claimed surface; the census gate requires the row. - The refusal reads the operation as the engine hands it to the middleware, so it applies at any depth under the flow's context: a cascade or `set_null` leg of a system flow's delete of a parent row, and a hook body's `ctx.api` write, are refused the same way. The census found no system flow that deletes a parent of the four objects. Exempting nested writes would let a hook body reopen what the ruling closes. - Not changed here: ADR-0073's sentence that a `runAs: 'system'` flow "stays god-mode throughout". `docs/adr` is outside this card's surface, and the precedent run-time PR (#21649) did not amend its ADR either. The spec lane's PR2 or a separate ADR edit can carry it. - PR #25456 (draft) edits `security-plugin.ts` and the same docs page; its hunks in `security-plugin.ts` are elsewhere. Whichever lands second merges `main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01YAdmc3d4UL4jFaffAor6eu --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 915e940 commit a285ff2

8 files changed

Lines changed: 742 additions & 4 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
feat(plugin-security)!: a flow that runs as system writes no identity assignment — position holdings, permission-set grants, invitations and memberships are refused from a flow run's system context
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: semantic
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is added, removed, renamed or re-shaped: `runAs` keeps its two values and every flow still saves, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What changes is what `runAs: 'system'` lets a flow's data nodes write, which is the semantic entry the spec lane records under ADR-0087 D3 beside the `runAs` describe sentence. The author's migration is an authoring choice no rewrite can make for them: set the flow's `runAs` to `'user'` so the delegated-administration gate judges the user it acts for, or move invitation and membership changes to the organization endpoints, as the refusal itself says. -->
11+
12+
**BREAKING** for a `runAs: 'system'` flow that writes `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member`, shipped as `minor` under the launch-window convention for breaking changes. No flow shipped in this repository or in hotcrm does: a census of every create, update and delete record node found none aimed at the four objects.
13+
14+
- **FROM:** the data middleware returned `next()` for every `isSystem` context ahead of its gates, so a `runAs: 'system'` run, whose data context is `isSystem` plus the run's `flowRunId`, wrote these four objects past the ADR-0090 D12 delegated-administration gate and the identity write guard. Its grants carried no `granted_by`, and its invitations and memberships never passed the organization endpoints' checks.
15+
- **TO:** an insert, update, delete, transfer, restore or purge of a row of the four objects from a context with `isSystem` and a `flowRunId` is refused with `PERMISSION_DENIED` (`403`) before the bypass, and nothing is written. The write node fails, and the run's result carries the refusal to its caller. The message names the remedy: `runAs: 'user'` for position holdings and permission-set grants, the organization endpoints for invitations and memberships. This holds at any depth of the flow's own write: a cascade leg or a hook's `ctx.api` write under the same context is refused the same way.
16+
- **Unchanged:** every system writer that carries no `flowRunId` (the seeders, the publish materializer, better-auth and its reconciliation, invitation placement, the organization-administrator grant). A `runAs: 'system'` flow's reads of the four objects, and its writes to every other object. A `runAs: 'user'` flow, which carries a `flowRunId` without `isSystem`: it reaches the D12 gate as the user it acts for, which admits a tenant-level or delegated administrator's grant and records that user as `granted_by`, and refuses anyone else's.
17+
- **Fix:** set the flow's `runAs` to `'user'`, so the grant is judged and attributed as the triggering user's own. Change invitations and memberships through `POST /api/v1/auth/organization/invite-member`, `cancel-invitation`, `update-member-role` and `remove-member`.
18+
19+
The package's exports are unchanged.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ that silently does not happen.
9797
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
9898
|:--|:---|:---|:---|:---|
9999
| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: every gate under it in this lane at once, down to and including the write-bypass row that widens the effective write scope to `org` (row 7) — this is the single largest behaviour on the page | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
100+
| 1b | **A flow run's system context is REFUSED governed writes to the identity-assignment objects** — the one check ahead of the middleware's short-circuit | plugin-security | Get: nothing — an insert, update, delete, transfer, restore or purge of `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` from a context carrying `isSystem` and a `flowRunId` (a `runAs: 'system'` flow's data nodes, at any depth of their writes) is refused `PERMISSION_DENIED` before the short-circuit skips the ADR-0090 D12 gate and the identity write guard. Lose: nothing a platform writer held — a system context without a `flowRunId` (the seeders, better-auth and its reconciliation, invitation placement) passes on to the short-circuit unchanged, reads are not judged, and a `runAs: 'user'` run carries no `isSystem`, so the D12 gate judges it as the user it acts for | `packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts#assertFlowSystemIdentityWriteAllowed` |
100101
| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | the step 3.5 guard block and the short-circuit that skips it are both inside `packages/plugins/plugin-security/src/security-plugin.ts#start` |
101102
| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `packages/plugins/plugin-security/src/security-plugin-read-filter.ts#getReadFilter` |
102103
| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable (`getReadableFields`) and writable (`getWritableFields`). Lose: field masking | `packages/plugins/plugin-security/src/security-plugin-field-readers.ts#resolveProjectionFieldMask` |

‎packages/plugins/plugin-security/src/delegated-admin-gate.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@
4848
*
4949
* System/boot writes carry `isSystem` and short-circuit the security
5050
* middleware before this gate (seeders, publish materializer, better-auth
51-
* reconciliation are unaffected).
51+
* reconciliation are unaffected). One system context stops short of that
52+
* bypass: a flow run's (`isSystem` with a `flowRunId`), whose writes to these
53+
* tables, `sys_invitation` included, are refused outright
54+
* (`flow-system-identity-write-refusal.ts`) — a `runAs: 'user'` run is judged
55+
* here instead, as the user it acts for.
5256
*/
5357

5458
import { isGrantActive, securityCatalogReaderOf, type SecurityCatalogReader } from '@objectstack/core';
@@ -171,7 +175,12 @@ const GOVERNED_OBJECTS = new Set([
171175
// site is what matters (AGENTS.md Prime Directive #10).
172176
'sys_member',
173177
]);
174-
const GOVERNED_OPERATIONS = new Set(['insert', 'update', 'delete', 'transfer', 'restore', 'purge']);
178+
/**
179+
* The verbs this gate governs. Exported because the flow-origin system refusal
180+
* (`flow-system-identity-write-refusal.ts`) judges the same set: a verb added
181+
* here is refused there too.
182+
*/
183+
export const GOVERNED_OPERATIONS: ReadonlySet<string> = new Set(['insert', 'update', 'delete', 'transfer', 'restore', 'purge']);
175184
const ANCHOR_POSITIONS = new Set(['everyone', 'guest']);
176185

177186
/**
@@ -191,7 +200,9 @@ const ANCHOR_POSITIONS = new Set(['everyone', 'guest']);
191200
* System writers never reach this gate (`isSystem` short-circuits the security
192201
* middleware first) and keep the id or null they wrote: invitation placement
193202
* writes its issuer, the organization-admin reconcile the attributed human or
194-
* null, the platform-admin promotion null (ADR-0118 D1 — never a sentinel).
203+
* null, the platform-admin promotion null (ADR-0118 D1 — never a sentinel). A
204+
* flow run's system context writes no row here at all: it is refused ahead of
205+
* the bypass (`flow-system-identity-write-refusal.ts`).
195206
*/
196207
const GRANTED_BY_OBJECTS = new Set(['sys_user_position', 'sys_user_permission_set']);
197208

0 commit comments

Comments
 (0)