Repository navigation
Commit 81bd9fa
fix(plugin-security)!: a position assignment or permission-set grant scoped to an organization must name a member of it (#22275)
Fixes #22226
Clause-②: no (narrowing)
Executes the maintainer's ruling A on objectstack-ai/cloud#1765
(`6053780796`) as #22226 states it: a non-system insert or update of
`sys_user_position` whose `user_id` has no `sys_member` row in the row's
`organization_id` is refused, for every caller, platform administrators
included, with the row's existing `400 VALIDATION_FAILED` envelope
(`fields[].code: reference_not_found`), no new error code; system writes
stand down. The sibling junction `sys_user_permission_set` measured as
the same class and takes the same predicate. Not taken: B (narrowing the
picker) and C (accepting the row).
## What changes
-
**`packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts`**
(new, beside `position-catalog-refusal.ts`):
`registerGrantHolderMembershipRefusal` binds a `beforeInsert` and a
`beforeUpdate` engine hook on `sys_user_position` and
`sys_user_permission_set`.
- **Insert** (one row or a batch): judges the organization the row is
STORED with. That is the row's own non-empty `organization_id`, or else
the caller's active organization, which the driver writes into the empty
slot after the hooks run. `organizationTheInsertStores` mirrors that
stamp: `buildDriverOptions` in `@objectstack/objectql` and
`injectTenantOnInsert` in `@objectstack/driver-sql`.
- **Update** (by id and by predicate): the engine hands each matched
row's stored image to the hook as `previous`. The post-image pair
(`user_id`, `organization_id`) is judged only when it differs from the
stored pair. An edit that leaves both alone, such as end-dating the row
of a holder who has since left, is not judged.
- **Membership read:** `sys_member` by (`user_id`, `organization_id`)
under `{ isSystem: true, tenantId }`, with `tenantId` set to the row's
own organization. It joins the writer's transaction. A
placeholder-shaped identifier is compared literally.
- **Stand-downs:** every `isSystem` write; a row stored with no
organization (a global grant names no organization to be a member of); a
`user_id` that is not an id (the engine answers `required` itself); and
a composition that registers no `sys_member` object.
- **Failure:** a membership read that throws propagates and the write is
refused. A security refusal that cannot read its input does not admit
the write.
- **Order:** priority 40. That is after the authority/standing guards
(10, 20) and the grant-name derivation (30).
`sys_user_position.position` is judged by the catalog refusal
middleware, ahead of every hook. So on both tables the value a row names
is judged before its holder, and a caller the CRUD check or the
delegated-admin gate refuses never sees a membership verdict.
- **`security-plugin.ts`**: one import plus one registration line,
placed beside the catalog refusal's wiring. No other region is touched.
- **`.changeset/22226-grant-holder-must-be-organization-member.md`**:
`minor`, declared breaking, ADR-0087 `not-required
(no-migration-prescription)`.
- **`content/docs/permissions/system-context.mdx`**: row 23d plus
regenerated counts. The hooks read `isSystem` to stand down, and
`check:system-context-census` requires a row for each such read site.
- **Sibling suites** (`position-catalog-refusal.test.ts`,
`grant-permission-set-name.test.ts`): their harnesses now provision
`sys_member`, and the holders of their organization-scoped grants become
members.
## Why hooks rather than a branch inside `createPositionCatalogRefusal`
(H1)
H1 is confirmed on `73a0a6bf`: `createPositionCatalogRefusal` is the
middleware on `sys_user_position`, wired once at
`security-plugin.ts:4386`. It stands down on `isSystem` and answers the
`400` envelope. A membership branch there would see only the payload. A
predicate update needs every matched row's stored organization, and the
engine hands exactly that to a `beforeUpdate` hook. The sibling table
needs the same rule too, so the refusal is a module of its own with one
wiring line.
## Before / after, per write case
Measured on a real `ObjectQL` engine over SQLite with the real
`SecurityPlugin`. The posture is `isolated` unless marked `single`. The
holder belongs to another organization only.
| Write | Before (`73a0a6bf`, pins red) | After |
|:--|:--|:--|
| platform administrator inserts `sys_user_position` (stamped
organization) | stored | 400 `VALIDATION_FAILED`, `reference_not_found`
at `user_id`; row absent |
| same insert, holder is a member (positive control) | stored | stored,
`organization_id` = the writer's organization |
| insert naming the writer's organization explicitly | stored | refused,
same envelope |
| batch insert, one row a non-member | stored | refused whole; nothing
stored |
| update by id moving `user_id` to a non-member | landed | refused;
stored row unchanged |
| predicate update moving `user_id` to a non-member | landed | refused;
no matched row changes |
| update leaving holder and organization alone, on a non-member's stored
row | landed | lands (not judged) |
| system write of a non-member | stored | stored (stands down) |
| caller the CRUD check refuses | 403 | 403, member or not |
| platform administrator grants `sys_user_permission_set` (stamped
organization) | stored | refused, same envelope; row absent |
| update by id of that grant to a non-member | landed | refused |
| `single`, writer with no active organization (row stored with no
organization) | stored | stored (not judged) |
| `single`, writer's active organization holds the user | stored |
stored |
| `single`, organization-scoped row naming a non-member | stored |
refused |
**`single` posture (H2).** Under the default `auto` membership policy,
plugin-auth's reconciler binds every created user to the default
organization (`reconcile-membership.ts`, ADR-0093 D7), and the one-time
backfill binds users who predate it. The platform administrator is bound
as owner by `ensureDefaultOrganization`. So on a stock
single-organization deployment, every organization-scoped row names a
member. Users created under `invite-only` and not yet invited are
refused, which is the predicate as ruled.
## Tests
At `74f8180344` (merge of `origin/main` `3513ac7781`). Code is
byte-identical at head `cfaac2bf9c`, whose last commit touches only the
census page.
- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2`: 180 files passed, 3796 tests passed, 45 skipped.
- `src/grant-holder-membership-refusal.test.ts`: 21 tests. 8 were red on
the base before the fix (every negative pin: the write landed). All 21
are green after it.
- `pnpm --filter @objectstack/plugin-security typecheck` at
`cfaac2bf9c`: exit 0, including `check:test-typecheck: OK`.
- Downstream consumers, measured at `2fbc0ba585`:
- `@objectstack/plugin-auth`: 128 files, 2637 passed.
- `@objectstack/organizations`: 11 files, 147 passed. Both suites alias
`plugin-security` to source.
- `@objectstack/dogfood`: the 39 test files that touch these two tables,
369 passed against a rebuilt `plugin-security` dist.
## Ablation
Each leg is a mutation of the committed module through
`scripts/ablation-replace.mjs`. The anchor hits 1 and then 0, the marker
shows 1 on disk, and the blob changes. Each leg is followed by a restore
to the HEAD blob `37dea505`, with `git diff HEAD` empty. The suite
imports the module from source, so no build sits in the path.
- **Leg 1: the refusal's throw disabled.** Exactly the 8 negative pins
turn red; 13 stay green.
- **Leg 2: the insert-stamp mirror disabled** (only a row's own
organization is judged). The 5 pins that rely on the stamped
organization turn red: platform-admin insert, batch, permission-set
insert, `single` predicate, and fail-closed read. The
explicit-organization pin stays green, as predicted.
## Gates
`node scripts/pm/dispatch-gates.mjs` derived 96 families at
`cfaac2bf9c`. All 96 were run, and the reconciliation via `--ran`
reports `96 run, 0 NOT-MEASURED (a DERIVED zero)`.
- `check-changeset-no-major` and `check-adr-0087-registration` are both
green.
- `check:dual-build-cjs-loads` is green after building the packages
whose `dist/` was missing.
- `check:changeset-gate-self-tests` failed once on the container's
commit-signing service (a 503 inside its throwaway repositories), then
passed on rerun.
Lint was a proven narrowing, not a repo-wide run. `eslint
--no-inline-config --format json` over the 5 touched `.ts` files reports
5 files, 0 errors, 0 warnings. All 5 are in the population of
`eslint.config.mjs` (`files: **/*.{ts,…}`, and `--print-config`
resolves). The config enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict on any
untouched file.
## Acceptance notes
- `content/docs/permissions/system-context.mdx` is outside the claim's
enumerated file surface. `check:system-context-census` requires it,
because the stand-down for system writes is two new `isSystem` read
sites.
- The hooks are not unbound in `SecurityPlugin.destroy()`. Doing so
would be a second edit region in `security-plugin.ts`. Instead,
registration first unbinds its own `packageId`, so a re-run `start()`
replaces the binding rather than doubling it. That registration
lifecycle matches the engine middlewares this plugin registers.
- Membership is read fail-closed. The sibling catalog refusal fails
open; the sibling grant-name hook fails closed. This refusal follows the
grant-name hook because it is a security refusal.
- A row stored with no organization is outside this ruling's predicate.
An adjacent class, re-scoping an existing organization-scoped grant, is
reported to the seat for triage and is not changed here.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent fbcbcf1 commit 81bd9fa
7 files changed
Lines changed: 881 additions & 11 deletions
File tree
- .changeset
- content/docs/permissions
- packages/plugins/plugin-security/src
Lines changed: 22 additions & 0 deletions
| 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 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
| 13 | + | |
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
| |||
128 | 128 | | |
129 | 129 | | |
130 | 130 | | |
| 131 | + | |
131 | 132 | | |
132 | 133 | | |
133 | 134 | | |
| |||
139 | 140 | | |
140 | 141 | | |
141 | 142 | | |
142 | | - | |
| 143 | + | |
143 | 144 | | |
144 | 145 | | |
145 | 146 | | |
| |||
282 | 283 | | |
283 | 284 | | |
284 | 285 | | |
285 | | - | |
| 286 | + | |
286 | 287 | | |
287 | 288 | | |
288 | 289 | | |
| |||
356 | 357 | | |
357 | 358 | | |
358 | 359 | | |
359 | | - | |
| 360 | + | |
360 | 361 | | |
361 | 362 | | |
362 | 363 | | |
363 | | - | |
364 | | - | |
| 364 | + | |
| 365 | + | |
365 | 366 | | |
366 | 367 | | |
367 | | - | |
368 | | - | |
| 368 | + | |
| 369 | + | |
369 | 370 | | |
370 | 371 | | |
371 | 372 | | |
| |||
429 | 430 | | |
430 | 431 | | |
431 | 432 | | |
432 | | - | |
| 433 | + | |
433 | 434 | | |
434 | 435 | | |
435 | 436 | | |
| |||
0 commit comments