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
19 changes: 19 additions & 0 deletions .changeset/25251-flow-system-identity-write-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/plugin-security': minor
---

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

Clause-②: no (narrowing)
Metadata-protocol: semantic

<!-- 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. -->

**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.

- **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.
- **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.
- **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.
- **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`.

The package's exports are unchanged.
1 change: 1 addition & 0 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ that silently does not happen.
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 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` |
| 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` |
| 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` |
| 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.ts#getReadFilter` |
| 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.ts#resolveProjectionFieldMask` |
Expand Down
17 changes: 14 additions & 3 deletions packages/plugins/plugin-security/src/delegated-admin-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@
*
* System/boot writes carry `isSystem` and short-circuit the security
* middleware before this gate (seeders, publish materializer, better-auth
* reconciliation are unaffected).
* reconciliation are unaffected). One system context stops short of that
* bypass: a flow run's (`isSystem` with a `flowRunId`), whose writes to these
* tables, `sys_invitation` included, are refused outright
* (`flow-system-identity-write-refusal.ts`) — a `runAs: 'user'` run is judged
* here instead, as the user it acts for.
*/

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

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

Expand Down
Loading
Loading