Skip to content

Commit b1ac1d8

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-ybjm3h
2 parents 1c09db4 + 59d9181 commit b1ac1d8

30 files changed

Lines changed: 1989 additions & 592 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/runtime': patch
5+
'@objectstack/core': patch
6+
---
7+
8+
fix(security): a hook that runs as system and an action body are refused writes to position holdings, permission-set grants, invitations and memberships, as a system flow already is
9+
10+
Clause-②: no (narrowing: app-authored hook and action-script writes to the four identity objects are refused at run time, as #25251 did for flows; the seat runs the contract review before the queue)
11+
Metadata-protocol: none
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What changes is the run-time answer to a hook or action body's elevated write to four platform objects; the save-time refusal and its semantic entry are the spec half of the same card. -->
14+
15+
**BREAKING** (a served-set narrowing) for app-authored automation that writes `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` under system elevation. A census of the hooks and action bodies in this repository and in HotCRM found none that does.
16+
17+
A `runAs: 'system'` flow has been refused these writes since 18.1. Two other kinds of app-authored automation run with the same elevation and were not: a hook that declares `runAs: 'system'`, and an action body (`type: 'script'`), which always runs elevated. Their writes skipped the delegated-administration gate (so a grant carried no `granted_by`) and the invitation door's placement and role-cap checks. Both are now refused the same way:
18+
19+
- An insert, update, delete, transfer, restore or purge of one of the four objects, from a `runAs: 'system'` hook (a sandboxed body or an in-process handler) or from an action body (through `ctx.api` or `ctx.engine`), answers `403 PERMISSION_DENIED`. Nothing is written. A hook's refusal aborts the triggering write under the default `onError: 'abort'`.
20+
- The message names the source and the fix. For position holdings and permission-set grants: set the hook's `runAs` to `'user'`, so the delegated-administration gate judges the user the hook acts for and records them as `granted_by`. An action body has no `runAs`, so its grant moves to a `runAs: 'user'` flow, which an action of type `flow` runs as the invoking user. For invitations and memberships: use the organization endpoints.
21+
22+
Unchanged: reads of the four objects, and writes to every other object, from the same hook or body. A hook under `runAs: 'user'`, which the ordinary gates judge as its user. The platform's own writers (better-auth and its reconciliation, invitation placement, the seeders, any `sudo()`), which carry no app-automation mark. A system flow's refusal, word for word.
23+
24+
**What changes for you.** A hook or action body that grants a position or a permission set under elevation now fails. Declare `runAs: 'user'` on the hook, or move an action body's grant into a `runAs: 'user'` flow. Invitations and memberships change through `POST /api/v1/auth/organization/invite-member`, `cancel-invitation`, `update-member-role` and `remove-member`.
25+
26+
`@objectstack/core` exports the mark: `APP_AUTOMATION_ELEVATION`, `markAppAutomationElevation`, `appAutomationElevationOf` and `carryAppAutomationElevation`.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
fix(runtime): the automation run doors answer another flow's run as an unknown one
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** Every `/automation/:name/runs/:runId/*` door read the run by `runId` alone, so the `:name` segment was accepted and ignored. `POST /automation/OTHER_FLOW/runs/RUNID/resume` completed another flow's paused run, `GET /automation/OTHER_FLOW/runs/RUNID` and `…/screen` served it, and `…/cancel` and `…/restore-suspension` acted on it.
12+
- **Now.** A flow name that does not own the run, whether it exists or not, is answered exactly as the door answers a run nobody holds:
13+
- `POST …/resume`: `404 RUN_NOT_FOUND`, `No suspended run 'RUNID'`, the engine's own unknown-run answer.
14+
- `GET …/runs/:runId`: `404 RUN_NOT_FOUND`, `Execution not found`. An unknown run now carries the same code (it was the status-derived `RESOURCE_NOT_FOUND`), so the two doors onto one run agree it is not there.
15+
- `GET …/screen`: `404 No pending screen for run`, unchanged.
16+
- `POST …/cancel`: `200 { cancelled: false }` with the not-found notice.
17+
- `POST …/restore-suspension`: `404`, `refusal: 'RUN_NOT_FOUND'`, `No run 'RUNID' is known`.
18+
- **Nothing is consumed.** The check runs after the doors' existing gates and before the verb, so a mismatched resume, cancel or restore leaves the run as it was. Who may act is unchanged.
19+
- **Owner.** The run's `flowName` from `getRun`, else the suspended-run list. A service that can name neither keeps the previous answer.
20+
- **Ledger.** `RUN_NOT_FOUND` is now registered under `@objectstack/runtime` too, since the door stamps it before the engine is asked.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth)!: admin create-user refuses a password outside the deployment's length range, as set-user-password does
6+
7+
`POST /api/v1/auth/admin/create-user` saved an explicit password of any length up to the maximum: better-auth's own `createUser` checks only `maxPasswordLength`, never `minPasswordLength`. An administrator could create a user with a 1-character password and `mustChangePassword: false`, and the user signed in with it, while `POST /api/v1/auth/admin/set-user-password` and the user's own change-password refuse the same value. Both admin doors now resolve a password through one check of the deployment's `minPasswordLength` / `maxPasswordLength` (the `auth.password_min_length` / `password_max_length` settings, ADR-0069):
8+
9+
- **An explicit password outside the range** is refused with `400 INVALID_REQUEST`, "Password must be at least N characters" (or "at most N"), the answer set-user-password already gave. No new error code. The refusal comes before anything is written: no account, no membership, no permission-set grant, no audit row.
10+
- **A generated password** (`generatePassword: true` with no password typed) is 16 characters, raised to the minimum when the minimum is above 16 and capped at the maximum when the maximum is below 16. Before, it was always 16, so on a deployment with a minimum above 16, set-user-password refused the password it had just generated, and create-user saved it below the minimum.
11+
12+
A password inside the range is applied exactly as before.
13+
14+
Clause-②: no (narrowing) — admin create-user refuses one request shape it used to run: an explicit `password` shorter than the deployment's `minPasswordLength` (one longer than `maxPasswordLength` was already refused by better-auth, now with set-user-password's code). Nothing is widened.
15+
16+
Metadata-protocol: none (an admin REST door's handling of a request body; nothing a metadata author writes changes)
17+
18+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is how the admin create-user door handles a request body's explicit password: a value outside the deployment's configured length range is refused, as the set-user-password door already refused it. The remedy is a request edit (send a password inside the range, or `generatePassword: true`), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
19+
20+
**BREAKING** for a caller that created users with passwords shorter than the deployment's minimum, or that matched the error code of a too-long one.
21+
22+
- **Before.** `POST /api/v1/auth/admin/create-user` with `password: 'a'` answered `200` and saved the password. A password longer than `maxPasswordLength` answered `400 PASSWORD_TOO_LONG`.
23+
- **Now.** The short one answers `400 INVALID_REQUEST`, "Password must be at least 8 characters" (the deployment's minimum), and creates no user. The too-long one answers `400 INVALID_REQUEST`, "Password must be at most 128 characters" (the deployment's maximum), set-user-password's answer.
24+
- **Fix.** Send a password inside the deployment's `minPasswordLength` / `maxPasswordLength` range, or send `generatePassword: true` and hand the user the returned temporary password. A client that matched `PASSWORD_TOO_LONG` on this door matches `INVALID_REQUEST` instead.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +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-authz-middleware.ts#registerAuthzMiddleware` |
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` |
100+
| 1b | **App-authored automation's system context is REFUSED governed writes to the identity-assignment objects** — a flow run's, a `runAs: 'system'` hook's and an action body's; 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 either a `flowRunId` (a `runAs: 'system'` flow's data nodes) or the app-automation elevation mark (`@objectstack/core`; set only by a hook's `runAs: 'system'` derivation and by the action body's execution-context builder), 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 with neither (the seeders, better-auth and its reconciliation, invitation placement, any `sudo()`, which never sets the mark) passes on to the short-circuit unchanged, reads are not judged, and a `runAs: 'user'` flow or hook 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#elevationSource` |
101101
| 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-authz-middleware.ts#registerAuthzMiddleware` |
102102
| 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` |
103103
| 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` |

‎content/docs/protocol/kernel/realtime-protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ They are camelCase, and the record body travels **inside `payload`**, never at t
432432
and `payload`, so a client written from the old fences reads `msg.subscription_id` and `msg.data`
433433
and gets `undefined` on both. `changes` is real but sits **one level down**, on the `DataEvent`
434434
inside `payload`, and it carries the write's own patch — a flat `{ field: newValue }` map
435-
(`publishDataEvent`, `packages/objectql/src/engine.ts`), not the `old` / `new` pairs this page
435+
(`publishDataEvent`, `packages/objectql/src/engine-realtime-publish.ts`), not the `old` / `new` pairs this page
436436
used to show; read the post-state from `payload.after`. `reason` is declared nowhere in the
437437
protocol at all — see **Subscribe to Query Results** below.
438438
</Callout>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25653] The app-automation elevation mark at its home: how it is set, read
5+
* and carried. Its two producers and its one reader are driven end to end in
6+
* runtime (`app-automation-identity-write-refusal.integration.test.ts`).
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { ExecutionContextSchema } from '@objectstack/spec/kernel';
11+
12+
import {
13+
APP_AUTOMATION_ELEVATION,
14+
appAutomationElevationOf,
15+
carryAppAutomationElevation,
16+
markAppAutomationElevation,
17+
} from './app-automation-elevation.js';
18+
19+
describe('the app-automation elevation mark', () => {
20+
it('is the registry symbol, so an ESM and a CJS copy of this package agree on it', () => {
21+
expect(APP_AUTOMATION_ELEVATION).toBe(Symbol.for('objectstack.core.appAutomationElevation'));
22+
});
23+
24+
it('marks a fresh copy and leaves the given context unchanged', () => {
25+
const context = { isSystem: true, userId: 'u1' };
26+
const marked = markAppAutomationElevation(context, 'hook');
27+
expect(marked).not.toBe(context);
28+
expect(appAutomationElevationOf(marked)).toBe('hook');
29+
expect(appAutomationElevationOf(context)).toBe(undefined);
30+
expect(marked).toMatchObject({ isSystem: true, userId: 'u1' });
31+
});
32+
33+
it('rides a spread, as every sudo()-shaped derivation spreads its caller', () => {
34+
const marked = markAppAutomationElevation({ isSystem: true }, 'action');
35+
expect(appAutomationElevationOf({ ...marked, transaction: {} })).toBe('action');
36+
});
37+
38+
it('reads nothing from a context that carries none, a value outside the two sources, or a non-object', () => {
39+
expect(appAutomationElevationOf({ isSystem: true })).toBe(undefined);
40+
expect(appAutomationElevationOf({ [APP_AUTOMATION_ELEVATION]: 'flow' })).toBe(undefined);
41+
expect(appAutomationElevationOf(null)).toBe(undefined);
42+
expect(appAutomationElevationOf('hook')).toBe(undefined);
43+
});
44+
45+
it('cannot be spelled by a JSON payload', () => {
46+
const parsed = JSON.parse(JSON.stringify({ isSystem: true, 'objectstack.core.appAutomationElevation': 'hook' }));
47+
expect(appAutomationElevationOf(parsed)).toBe(undefined);
48+
});
49+
50+
it('is dropped by the schema parse, and carried across it', () => {
51+
const marked = markAppAutomationElevation({ isSystem: true, userId: 'u1' }, 'action');
52+
const parsed = ExecutionContextSchema.parse(marked);
53+
expect(appAutomationElevationOf(parsed)).toBe(undefined);
54+
55+
const carried = carryAppAutomationElevation(marked, parsed);
56+
expect(appAutomationElevationOf(carried)).toBe('action');
57+
expect(carried).toMatchObject({ isSystem: true, userId: 'u1' });
58+
59+
const unmarked = ExecutionContextSchema.parse({ isSystem: true });
60+
expect(carryAppAutomationElevation({ isSystem: true }, unmarked)).toBe(unmarked);
61+
});
62+
});

0 commit comments

Comments
 (0)