Repository navigation
Commit 251a7dd
Fixes #21666
Clause-②: no (narrowing)
## What changes
On a walled posture, the insert stamp in `@objectstack/organizations`
(Middleware A) used to **overwrite** a supplied `organization_id` with
the caller's active organization in every user context. The overwrite is
`packages/plugins/organizations/src/organizations-plugin.ts:334` at
`72f3c74d60`: `data.organization_id = opCtx.context.tenantId;` inside
`if (isUserContext)`.
The stamp now **fills only an absent or empty value**, for every
non-system context (ADR-0105 D5). A supplied value is left as sent and
meets the Layer 0 write wall in `plugin-security` (step 3.7, ADR-0095
D1). That is the wall the PATCH already meets, so the create now gets
the PATCH's answer. This follows triage ruling 5975961814: "the
governed, loud side wins" and "⛔ No silent replacement on any posture."
No `plugin-security` code changes. The wall was already symmetric (see
Zone 2.2 below). No `packages/spec`, `packages/objectql` or
`packages/metadata-protocol` edit.
## Measured on a real walled boot (before → after)
Harness: `@objectstack/verify` `bootStack(app, { multiTenant: true,
hostRoot })`, run from a scratch host app that declares the real
`@objectstack/organizations`. The stack is the real `SecurityPlugin`,
the real REST routes and `sqlite-wasm`. Requests go over HTTP to
`/api/v1/data/...`. Orgs: **A** is the caller's active organization,
**B** is another tenant ("Tenant North D2"), and **C** is a sister
organization the caller also holds. Objects: `sys_user_permission_set`
and `sys_business_unit` (platform objects that declare their own
`organization_id`), `qa_ledger` (a public app object with the injected
`organization_id`) and `qa_vault` (a private app object, where a
platform admin is posture-exempt).
### `isolated`
| caller | object | create naming B | PATCH to B | `createMany` [B] |
|---|---|---|---|---|
| platform admin | `sys_user_permission_set` | 201, stored A → **403
PERMISSION_DENIED** | 403 PERMISSION_DENIED (both) | 403 (both) |
| platform admin | `sys_business_unit` | 201, stored A → **403
PERMISSION_DENIED** | 403 (both) | 403 (both) |
| platform admin | `qa_ledger` | 201, stored A → **403
PERMISSION_DENIED** | 403 (both) | 403 (both) |
| platform admin | `qa_vault` (exempt) | 201, stored A + `droppedFields`
readonly (unchanged) | 200, stored A + `droppedFields` (both) | 201,
stored A + `droppedFields` (both) |
| member | `qa_ledger` | 201, stored A → **403 PERMISSION_DENIED** | 403
(both) | 403 (both) |
| member | `qa_vault` | 201, stored A → **403 PERMISSION_DENIED** | 403
(both) | 403 (both) |
A create naming no organization, or naming A, answers 201 and is stored
in A, before and after, in every row above.
### `group`
| caller | object | create naming B | create naming C | PATCH to C |
|---|---|---|---|---|
| platform admin | `sys_user_permission_set` | 201 A → **403** | 201 A →
**201 C** | 200 C (both) |
| platform admin | `sys_business_unit` | 201 A → **403** | 201 A → **201
C** | 200 C (both) |
| platform admin | `qa_ledger` | 201 A → **403** | 201 A + dropped
(unchanged) | 200 A + dropped (both) |
| platform admin | `qa_vault` | 201 A + dropped (unchanged, exempt) |
201 A + dropped (unchanged) | 200 A + dropped (both) |
| member | `qa_ledger` | 201 A → **403** | 201 A + dropped (unchanged) |
200 A + dropped (both) |
| member | `qa_vault` | 201 A → **403** | 201 A + dropped (unchanged) |
200 A + dropped (both) |
PATCH to B and `createMany` [B] were 403 in every row, before and after.
### `single` (the control)
`objectstack serve` mounts the runtime only under a walled posture, so
the production `single` shape has no Middleware A. Every cell there is
byte-identical before and after. For example, `sys_user_permission_set`
create B answers 201 stored B, PATCH B answers 200 stored B, and
`createMany` [B] answers 201 stored B. As an extra non-production cell,
I also mounted the runtime under `single` by hand. The create naming B
moved from 201 stored A to 201 stored B, which now matches that boot's
PATCH and `createMany`.
### Other single-row doors (same middleware)
- **Import** (`POST /data/:object/import`, `isolated`; rows [B, none]).
The batch is refused by the wall and degrades to per-row `createData`.
Before, both rows reported `ok` and were stored in A. After, the B row
reports `PERMISSION_DENIED` and the none row reports `ok` in A. Measured
as admin on `sys_business_unit`, as admin on `qa_ledger`, and as member
on `qa_ledger`.
- **Clone** (`POST /data/:object/:id/clone`, `group`, source row in C).
For `sys_business_unit` it was 201 A and is now 201 C. For
`sys_user_permission_set` it was 201 A and is now **409**: the copy
would duplicate its source's `(user, set, organization)` key in C. For
`qa_ledger`, the clone strips the injected column, so it is 201 A both
before and after.
- The `create` operation of `POST /batch` writes row by row through the
same path. I read this in code; I did not measure it.
### Zone 2.2: insert vs update on the wall
There is no asymmetry. Both verbs reach `computeWriteTenantCheckFilter`
→ `computeLayeredRlsFilter`, and they share the platform-admin exemption
(only on posture-permitting objects: `private`, platform-global,
better-auth-managed). They throw the same `PermissionDeniedError`,
`code: PERMISSION_DENIED` with status 403. The message names the verb:
"the insert would place …" and "the update would place …". So
`security-plugin.ts` is not edited. Its step 3.7 comment already said
the stamp "only fills a MISSING value, never overwrites a supplied one",
and that sentence is now true.
## Census: who relied on the overwrite (triage's stop condition)
| writer | context | sets `organization_id` itself? | reliant? |
|---|---|---|---|
| per-org seed replay (`seed-loader` `SEED_OPTIONS`) | system | yes |
no: skips Middleware A |
| default-org bootstrap (`ensureDefaultOrganization`,
`claimOrgSeedOwnership`) | system | yes | no |
| orphan claim (`claimOrphanOrgRows`) | system, update | yes | no:
insert-only middleware |
| sharing, approvals, audit, auto-org-admin grant, invitation placement,
email, settings audit, better-auth adapter | system | yes | no |
| storage `metadata-store` (`sys_file`, `sys_upload_session`) | caller |
`= context.tenantId` | no: always equal |
| messaging, outboxes, `sys-metadata-repository`, `database-loader` | no
`tenantId` on the context | yes | no: the middleware no-ops |
| REST import runner (`core/import-runner`) | caller | from the user's
file | user input, not a platform writer; see Import above |
| REST clone (`metadata-protocol` `cloneData`) | caller | copied from
the source when the object declares the column | see Acceptance notes |
| flow `create_record` (runAs user) | caller | flow-authored fields |
user-authored input, same as REST |
No non-system writer sets an `organization_id` and relies on the
overwrite to correct it, so this is not a stop. `seed-loader.ts`
(claimed by #21665) was read only.
## Pins (real runtime: this package's Middleware A + real
`SecurityPlugin` + `ObjectQL` + `SqliteWasmDriver`)
New file
`packages/plugins/organizations/src/create-explicit-organization-wall.test.ts`,
14 cases:
- **Another tenant's organization.** A create naming it is refused with
the PATCH's code and status. Covered for a member and for a platform
admin, on a declared-column object and on an injected-column object.
- **Array insert.** An array insert naming it gets the single-row
answer.
- **No organization.** A create naming none is stamped with the active
organization **before the hooks run** (asserted at the `beforeInsert`
payload) and stored there.
- **Own active organization.** A create naming it is admitted.
- **#2937.** A member's forged `organization_id` is refused, and no row
lands in either tenant.
- **System context.** An explicit cross-organization value is kept (the
seed-replay path).
- **`group`.** A sister organization is admitted on create, as on the
PATCH. An organization outside the membership set is refused with the
PATCH's code.
`organizations-plugin.test.ts`: the old "OVERWRITES a forged
organization_id" unit is now "leaves a supplied organization_id
untouched". I added an empty-string fill unit.
## Ablations (predicted direction stated before each run; both through
`scripts/ablation-replace.mjs`, restore proven by blob == HEAD and `git
diff HEAD` empty)
1. **Restore the overwrite.** I predicted red on exactly the 9 wall-file
cells where a create names an organization and expects a refusal or a
non-active placement (6 refusals, 2 array/single parity cells, the group
sister cell), plus the one unit "leaves … untouched". Observed: **10
failed, 113 passed (123)**, exactly those cells. The stamped,
own-organization and system cells stayed green.
2. **Drop the fill for an absent value.** I predicted red on exactly the
2 "stamped before the hooks run" cells and the 2 fill units (absent,
empty). Observed: **4 failed, 119 passed (123)**, exactly those. The
failure reads `the beforeInsert chain sees the stamp: expected [
undefined ] to deeply equal [ 'org_alpha' ]`. The stored-row half alone
could not catch this ablation. The SQL driver fills the same value from
`DriverOptions.tenantId`, measured: `createMany` [none], which
Middleware A never touches, lands in A. That is why the pin asserts the
payload the hooks see.
## Verification (all at `904a8e25c4`)
- ① `pnpm --workspace-concurrency=2 --filter
'@objectstack/organizations^...' build`: exit 0, 29 projects.
- ② `pnpm --filter @objectstack/organizations test`: 9 files, **123
passed**. `typecheck`: exit 0 (tsc and the test layer, 0 errors). `pnpm
--filter @objectstack/plugin-security test`: 164 files, **3527 passed**,
45 skipped.
- ③ `dispatch-gates --commands` (no paths) derived **105** families.
**104 exit 0.** **NOT MEASURED: `check:dual-build-cjs-loads`**, which
exits 3 (PREREQUISITE NOT MET: 32 packages have no `dist/`, and it needs
a full build; CI owns it). `check:skill-examples` first exited 3 for
lack of a client build. I built `@objectstack/client` and `client-react`
and it then exited 0. `--ran` reconciliation: 105 derived, 104 run, 1
NOT MEASURED (derived from the recorded exit 3), 0 unrun.
- ESLint, narrowed to the 4 touched lintable files (`--no-inline-config
--format json`): 4 files, 0 errors, 0 warnings. All 4 are inside the
population of the `files` globs in `eslint.config.mjs` (`--print-config`
resolves for each). The other touched files (`.md`, `.json`, `.yaml`)
match no lint glob. The config enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move a verdict on an
untouched file.
- Dogfood walled-posture files: `rls-multitenant` skips by design
(`@objectstack/dogfood` does not declare the runtime), and
`enterprise-organizations.test.ts` passes 13. **NOT MEASURED:
`attachments-permission-matrix`**: `@objectstack/service-storage` is
unbuilt, and its multi-org block is `skipIf` there anyway. The walled
HTTP measurement above is this card's dogfood.
- The derivation flagged the tree as behind `origin/main` by 3 commits
(#21664, #21674, #21677). None touches `organizations`,
`plugin-security`, `objectql` or the files here. The only gate file
among them is `scripts/cross-package-test-inputs.mjs`.
## Docs and skills
- `content/docs/permissions/system-context.mdx` row 61 said "a forged
`organization_id` is overwritten on the non-elevated path". This PR made
that false, and the row now says the wall refuses it, as it refuses the
update.
- `content/docs/deployment/tenancy-modes.mdx` ("Filling in an absent
`organization_id` … validating a supplied one") was false before and is
true now, so it is untouched.
- `skills/**`: no sentence about the insert stamp or about
`organization_id` on create, so nothing is false there.
## Package and lockfile
- `@objectstack/organizations` gains three devDependencies: `objectql`,
`plugin-security`, `driver-sqlite-wasm`. Each is aliased to source in
`vitest.config.ts`, as `check:test-source-alias` requires.
- `pnpm-lock.yaml` carries only the organizations importer hunk. `pnpm
install` also flipped an unrelated `esbuild` peer suffix in two other
importers, and that churn was dropped. `pnpm install --frozen-lockfile`
passes.
## Changeset
`.changeset/21666-create-explicit-organization-meets-wall.md`:
`@objectstack/organizations` `minor`, `fix(organizations)!`, a
`**BREAKING.**` marker, and `Clause-②: no (narrowing)`. The accept set
narrows: a create, or an import row, naming another tenant's
organization answered 201 and is now refused. The ADR-0087 disposition
is `not-required (no-migration-prescription)`, and
`check:adr-0087-registration` is green on it. The one-line fix: omit
`organization_id` on create or name your active organization, and a
platform operator moves a row with a system-context write.
`@objectstack/plugin-security` is unchanged, so it has no entry.
## Acceptance notes
- **Out-of-scope finding (class a), not fixed here.** On a walled
posture, a create that sends **no** `organization_id` to an app object
answers 201 with `droppedFields: [{ fields: ['organization_id'], reason:
'readonly' }]`, naming a field the caller never sent. Middleware A's
fill lands in the payload before `ObjectQL.insert` snapshots "what the
caller sent", so the static-readonly strip reports the platform's own
stamp as a caller write. Controls: `single` without the runtime reports
nothing, and `createMany` [none] on `isolated` reports nothing. The seam
is in `packages/objectql`, outside this card's surface, and this PR
leaves it unchanged. It goes to the seat to file.
- **Clone under `group`.** The clone door copies an `organization_id`
that the object declares itself. A clone of a sister-organization row
therefore now lands beside its source (or answers 409 on a unique key)
instead of being re-homed into the active organization. The wall admits
it, and so does a PATCH. Whether the clone door should strip a declared
`organization_id` is a `metadata-protocol` question for the seat. This
card neither answers nor edits it.
- **Observation.** During the scratch import, `driver-sql` logged
`DATABASE_ERROR … no such table: _objectstack_sequences`. The import
still completed, the log is unrelated to this diff, and I have not filed
it.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent a4fd82a commit 251a7dd
8 files changed
Lines changed: 445 additions & 30 deletions
File tree
- .changeset
- content/docs/permissions
- packages/plugins/organizations
- src
Lines changed: 28 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 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
182 | 182 | | |
183 | 183 | | |
184 | 184 | | |
185 | | - | |
| 185 | + | |
186 | 186 | | |
187 | 187 | | |
188 | 188 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
| 29 | + | |
29 | 30 | | |
| 31 | + | |
| 32 | + | |
30 | 33 | | |
31 | 34 | | |
32 | 35 | | |
| |||
0 commit comments