Skip to content

Commit 0262092

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22792-explain-asker-gate
2 parents f64673b + 0253192 commit 0262092

109 files changed

Lines changed: 2041 additions & 1888 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
A permission-set grant names its set by name: `sys_user_permission_set.permission_set` is the grant's one reference, and `permission_set_id` is no longer written or required
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A data-door contract change on a platform object's rows, not on any metadata schema: no key of any spec schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. Stored grants written before the name column are named by the existing one-time backfill at boot; the id column and its data are dropped by a later ADR-0131 D10 stage, not here. The package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered). -->
10+
11+
**BREAKING** accept-set narrowing on `sys_user_permission_set`, shipped as `minor` under the repo's launch-window convention for breaking changes. ADR-0131 D10's first step: every grant names its set, then the id column goes.
12+
13+
- **Before.** A grant was written with `permission_set_id`, a required lookup to a `sys_permission_set` row, and the platform derived `permission_set` (read-only) from that row. A write carrying only the id was accepted and named; a supplied name had to agree with the id's row.
14+
- **Now.** `permission_set` is required and writable: it holds the permission set's machine name, and the set is the security catalog's definition of that name (ADR-0131 D3). `permission_set_id` is optional, and nothing writes it or derives anything from it. A grant that names no set is refused `400 VALIDATION_FAILED` (`required` at `permission_set`). A non-system write naming a set the security catalog does not hold is refused `400 VALIDATION_FAILED` (`reference_not_found` at `permission_set`), the same envelope `sys_user_position.position` answers with; a deactivated set is still a set and is accepted. System writes are not judged against the catalog.
15+
- **The platform's own grant writers** (the platform-admin promotion and the organization-admin reconcile) write the name alone and read no `sys_permission_set` row to do it, so a database with no such rows still gets its first platform administrator and its organization administrators. A row id is read only to revoke, or not duplicate, a grant written before the name column existed, which names nothing until the one-time backfill names it. Uninstalling a package revokes the grants that name its sets.
16+
- **Unchanged.** Grants written before this release keep their id and are named by the existing backfill; the resolver and every grant reader already read the name.
17+
18+
**The remedy.** A caller that wrote `{ user_id, permission_set_id: <row id> }` writes `{ user_id, permission_set: '<set name>' }`: the `name` of that set, as the metadata door (`GET /api/v1/meta/permission`) lists it.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The TSDoc on `RecordRelatedListProps.add` names its canonical example by name: "Assigned Users" on a permission set keys `sys_user_permission_set` on `permission_set` with `relationshipValueField: 'name'`, since a grant names its permission set (ADR-0131 D4). Text only; no schema changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/verify': patch
4+
'@objectstack/platform-objects': patch
5+
---
6+
7+
Grants name their permission set, with no `sys_permission_set` row read (ADR-0131 D4)
8+
9+
- `@objectstack/plugin-auth`: the self-registration grant names the declared set alone. Admission asks the security catalog and the activation ledger whether the set grants, and the settlement writes `{ user_id, permission_set }` with no row read, so a deployment with no `sys_permission_set` row for the set still grants it. The last-administrator guard reads which set a grant holds from its name only; writing `permission_set_id` moves no standing.
10+
- `@objectstack/verify`: the RLS probe persona's grant names the registered probe set alone; it no longer needs, or refuses for want of, a boot-seeded `sys_permission_set` row.
11+
- `@objectstack/platform-objects`: Setup's user page grants a permission set by name: the Add picker stores the set's machine name (`valueField: 'name'`, `linkField: 'permission_set'`), and the list shows the name column. The picker still lists `sys_permission_set` rows; listing the security catalog itself is a console change.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/runtime': minor
3+
'@objectstack/core': minor
4+
---
5+
6+
feat(runtime,core): inbound automation hooks work on a kernel with no raw HTTP app — the dispatcher admits `POST /automation/hooks/:flowName/:hookId` with no session, through to the trigger's own signature check
7+
8+
Clause-②: yes
9+
10+
On a self-hosted server, `@objectstack/trigger-api` serves a flow's inbound hook by mounting `POST /api/v1/automation/hooks/:flowName/:hookId` on the raw Hono app, where a third-party sender (a payment provider, a code host) posts a signed event with no session. A hosted tenant kernel has no raw app, so on that shape the path belonged to the dispatcher's `/automation` domain, whose anonymous floor refused every sender `401 UNAUTHENTICATED`.
11+
12+
**What widens: one route passes the anonymous floor and the ADR-0069 gate with no session.** `@objectstack/core`'s auth-gate allow-list gains its first parameterised row: exactly `POST /automation/hooks/:flowName/:hookId` in the dispatcher's spelling (four segments, optionally under one `/environments/:id` scope), and nothing else. `/automation/hooks`, `/automation/hooks/x`, `/automation/hooks/x/y/z`, every other method on the route and every other `/automation` path stay gated exactly as before.
13+
14+
- `@objectstack/core`: `isAuthGateAllowlisted(path, method?)` and `evaluateAuthGate(user, path, method?)` take an optional method. The existing rows are exempt for every method, as before, so a caller that passes no method gets the answer it always got. The new row is exempt for `POST` only, and only when a method is passed. `shouldDenyAnonymous` now passes its `method` to the allow-list beside its `path`. New export: `matchInboundHookRoute(path, method)`, the one reading of the route that the row matches through, returning the two path segments raw.
15+
- `@objectstack/runtime`: the `/automation` domain's anonymous floor now asks the allow-list with its path and method. On the very next statement it hands the admitted route to the request kernel's `trigger-api` service (`ITriggerApiService.handleInboundHook`), ahead of every other `/automation` gate and service probe. The request goes with its body unread and the two path parameters decoded, and the service's `Response` comes back as it is. The service verifies the HMAC signature, so a correctly signed post is accepted and a wrongly signed or unsigned one is refused by that service, with its own answer. The membership gate skips the route for `POST` only, so a signed-in caller is judged exactly as an anonymous one, as on the self-hosted mount.
16+
- No `trigger-api` service registered, or one without `handleInboundHook`: `501 NOT_IMPLEMENTED`, naming which, never `ROUTE_NOT_FOUND`. A service that throws, or a request whose body a transport already read: a sanitised `500 INTERNAL_ERROR`, logged.
17+
18+
Nothing changes on a self-hosted server: the trigger's raw-app mount still serves the path there and never reaches the dispatcher, and the dispatcher mounts nothing that answers it.

‎content/docs/api/error-catalog.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -285,14 +285,14 @@ object is rejected with `400 VALIDATION_FAILED`, and the specifics ride in
285285

286286
```json
287287
{
288-
"error": "Permission Set: no sys_permission_set record has id \"ps_missing\"",
288+
"error": "User: no sys_user record has id \"usr_missing\"",
289289
"code": "VALIDATION_FAILED",
290290
"fields": [{
291-
"field": "permission_set_id",
291+
"field": "user_id",
292292
"code": "reference_not_found",
293-
"label": "Permission Set",
294-
"constraint": { "target": "sys_permission_set" },
295-
"value": "ps_missing"
293+
"label": "User",
294+
"constraint": { "target": "sys_user" },
295+
"value": "usr_missing"
296296
}]
297297
}
298298
```

‎content/docs/permissions/permission-sets.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,10 @@ A direct grant is a data row; a position's sets are declared on the position's
219219
definition:
220220

221221
```typescript
222-
// Grant a set to a single user (direct grant)
222+
// Grant a set to a single user (direct grant) — by the set's name
223223
await objectql.object('sys_user_permission_set').insert({
224224
user_id: 'user123',
225-
permission_set_id: psId,
225+
permission_set: 'crm_sales_user',
226226
});
227227

228228
// A position names the sets it distributes (everyone holding it gets them)

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
1010
service self-write, a migration.
1111

1212
This page is **the authority** for what that flag actually does. It exists
13-
because the flag is not one concept: it is a single boolean read at **122
13+
because the flag is not one concept: it is a single boolean read at **123
1414
distinct sites across 20 packages**, and knowing three of those behaviours gives
1515
no hint that the other hundred-and-four exist. Every documented app-side bug
1616
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -129,7 +129,7 @@ that silently does not happen.
129129
| 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. One exception: a `formula` value, which the `computed` strip takes from every caller, so strict still refuses it | `packages/objectql/src/engine.ts#insert`, `packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS` |
130130
| 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` |
131131
| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no position in the security catalog is not refused | plugin-security | Get: a system writer can store an assignment naming no position — the seed loader writes `stack.data` from `AppPlugin.start()` under a system context, as invitation acceptance and the platform's own bootstraps do. 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 the security catalog holds no position of that name: the environment registry (ADR-0131 D3), one per deployment, which takes no organization, so a name only a `sys_position` row carries — any organization's — is refused like one nobody carries. It is the same stand-down as the engine's referential-integrity check for a lookup column | `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` |
132-
| 23c | **Grant-name check relaxed for an unresolvable id** — a `sys_user_permission_set` write that supplies `permission_set` beside a `permission_set_id` naming no set the writer's context reaches keeps the supplied name | plugin-security | Get: a system writer can store a grant naming a set whose row does not exist yet — seed replay and boot provisioning may write the grant before the set row, the ordering the engine's referential-integrity check also stands down for. Lose: such a row's name is unchecked until a backfill names it. Everything else is judged for every caller, system included: the name is derived from the id on every write that carries one, and a supplied name that names any other set is refused `400 VALIDATION_FAILED` / `invalid_value`; a non-system caller's name beside an unresolvable id is refused the same way, read as `{ ...context, isSystem: true }` through the hook's `ctx.api.sudo()`, never a bare `{ isSystem: true }` (ADR-0131 D4) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#settle` |
132+
| 23c | **Grant-name catalog check skipped** — a `sys_user_permission_set` write whose `permission_set` names no permission set in the security catalog is not refused | plugin-security | Get: a system writer can store a grant naming a set not yet registered — seed replay and boot provisioning may write the grant before the definition it names is registered. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `permission_set`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no set by that name; a grant that names no set at all is refused `required` for every caller. The grant names its set by name alone, and nothing derives the name from `permission_set_id` (ADR-0131 D4, D10) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onInsert`, `#onUpdate` |
133133
| 23d | **Grant-holder membership check skipped** — a `sys_user_position` or `sys_user_permission_set` write naming a user with no `sys_member` row in the row's organization is not refused | plugin-security | Get: a system writer can store such a grant — invitation acceptance writes the membership and its placement together, and seed replay, the organization-admin reconcile and the platform bootstraps write grants as the system. Lose: such a row takes effect once its user is admitted to the organization. A non-system insert, or a non-system update that moves `user_id` or `organization_id`, is refused `400 VALIDATION_FAILED` / `reference_not_found` at `user_id` instead, for every caller, a platform administrator included; the membership is read as `{ isSystem: true, tenantId }` with `tenantId` the row's own organization, never another one. A row stored with no organization is a global grant and is not judged | `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert`, `#onUpdate` |
134134
| 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` |
135135
| 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` |
@@ -142,7 +142,7 @@ that silently does not happen.
142142

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

145-
The largest single consumer — **17 of the 122 sites**.
145+
The largest single consumer — **17 of the 123 sites**.
146146

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

288-
- **Shipped semantics.** `isSystem` is a published contract with 122 read sites
288+
- **Shipped semantics.** `isSystem` is a published contract with 123 read sites
289289
in 20 packages. Splitting it is a breaking contract change across all of them.
290290
(The ruling was taken when the census read 80 sites in 18 packages; the count
291291
has grown, which strengthens rather than weakens the argument.)
@@ -359,16 +359,16 @@ still holds equal to the census on every pull request:
359359
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
360360
| — parsed as a declaration | 28 | ✅ |
361361
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
362-
| — parsed as a property **read** | 128 | ✅ |
362+
| — parsed as a property **read** | 129 | ✅ |
363363
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
364364
| — the remainder: text inside comments and string literals | 358 | — |
365365
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
366-
| Of those reads: reads of `ExecutionContext.isSystem` | **122** | ✅ |
367-
| — behaviour-bearing (rows 1–61 above) | 119 | ✅ |
366+
| Of those reads: reads of `ExecutionContext.isSystem` | **123** | ✅ |
367+
| — behaviour-bearing (rows 1–61 above) | 120 | ✅ |
368368
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
369369
| Packages containing at least one elevation read | **20** | ✅ |
370370
| Files containing at least one elevation read | 57 | ✅ |
371-
| — the distinct symbols those reads live in — what this page anchors | 104 | ✅ |
371+
| — the distinct symbols those reads live in — what this page anchors | 105 | ✅ |
372372
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |
373373

374374
The six rows marked — are a **dated decomposition, not a live claim**: they were

‎packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,14 +245,14 @@ const recordOf = (a: Answer) => a.body?.data ?? a.body?.record ?? a.body;
245245
interface Grants {
246246
/** `GET /data/sys_permission_set?name=…` — the projection the admin surface grants from. */
247247
sets: Answer;
248-
/** `GET /data/sys_user_permission_set?permission_set_id=…` — the user grant made before the uninstall. */
248+
/** `GET /data/sys_user_permission_set?permission_set=…` — the user grant made before the uninstall, by the set's name (ADR-0131 D4). */
249249
bindings: Answer;
250250
}
251251

252-
async function readGrants(live: LiveStart, token: string, setId: string): Promise<Grants> {
252+
async function readGrants(live: LiveStart, token: string, _setId: string): Promise<Grants> {
253253
return {
254254
sets: await http(live, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, token),
255-
bindings: await http(live, 'GET', `/api/v1/data/sys_user_permission_set?permission_set_id=${encodeURIComponent(setId)}`, token),
255+
bindings: await http(live, 'GET', `/api/v1/data/sys_user_permission_set?permission_set=${PERMISSION_SET}`, token),
256256
};
257257
}
258258

@@ -299,7 +299,7 @@ async function grantThenUninstall(live: LiveStart, session: Session, run: Run):
299299
run.setId = setId;
300300
run.granted = await http(live, 'POST', '/api/v1/data/sys_user_permission_set', session.token, {
301301
user_id: session.userId,
302-
permission_set_id: setId,
302+
permission_set: PERMISSION_SET,
303303
});
304304
run.before = await readGrants(live, session.token, setId);
305305
run.objectBefore = await http(live, 'GET', `/api/v1/data/${TASK}`, session.token);

‎packages/core/src/security/anonymous-deny.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ describe('shouldDenyAnonymous — the shared HTTP anonymous-deny decision (#2567
7272
expect(shouldDenyAnonymous({ method: 'OPTIONS', path: '' })).toBe(false);
7373
});
7474

75+
// [#22773] The allow-list's parameterised row (the inbound automation hook)
76+
// is honoured here only with the method beside the path, and only for POST:
77+
// the `/automation` floor passes both, and a seam that passes neither is
78+
// answered exactly as before.
79+
it('honours the inbound-hook row for POST on the exact route only, and only with the method', () => {
80+
expect(shouldDenyAnonymous({ path: '/automation/hooks/f/h', method: 'POST' })).toBe(false);
81+
expect(shouldDenyAnonymous({ path: '/environments/env_1/automation/hooks/f/h', method: 'post' })).toBe(false);
82+
for (const method of ['GET', 'PUT', 'DELETE', 'HEAD', undefined, null]) {
83+
expect(shouldDenyAnonymous({ path: '/automation/hooks/f/h', method }), String(method)).toBe(true);
84+
}
85+
for (const path of ['/automation/hooks', '/automation/hooks/x', '/automation/hooks/x/y/z', '/automation/f/trigger', '/automation']) {
86+
expect(shouldDenyAnonymous({ path, method: 'POST' }), path).toBe(true);
87+
}
88+
// ⭐ The pathless guard still decides first: a method alone exempts nothing.
89+
expect(shouldDenyAnonymous({ method: 'POST' })).toBe(true);
90+
expect(shouldDenyAnonymous({ path: '', method: 'POST' })).toBe(true);
91+
});
92+
7593
it('exposes a stable 401 body + status for seams to return', () => {
7694
expect(ANONYMOUS_DENY_STATUS).toBe(401);
7795
// [#9487] `code` carries the machine code — the documented key every other

0 commit comments

Comments
 (0)