Repository navigation
Commit a285ff2
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
- .changeset
- content/docs/permissions
- packages
- plugins/plugin-security/src
- services/service-automation/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
97 | 97 | | |
98 | 98 | | |
99 | 99 | | |
| 100 | + | |
100 | 101 | | |
101 | 102 | | |
102 | 103 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
51 | | - | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
52 | 56 | | |
53 | 57 | | |
54 | 58 | | |
| |||
171 | 175 | | |
172 | 176 | | |
173 | 177 | | |
174 | | - | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
175 | 184 | | |
176 | 185 | | |
177 | 186 | | |
| |||
191 | 200 | | |
192 | 201 | | |
193 | 202 | | |
194 | | - | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
195 | 206 | | |
196 | 207 | | |
197 | 208 | | |
| |||
0 commit comments