Repository navigation
Commit 45f428d
Fixes #20477
Clause-②: no
## What was wrong (H0, measured before any edit)
The runtime dispatcher's nine organization-scoped `/packages` doors take
the caller's organization from one source,
`HttpDispatcher.resolveActiveOrganizationId`. That source returned the
auth session's `activeOrganizationId` as stored. `resolveAuthzContext`
vets that claim onto the execution context as `tenantId`, and under a
wall-enforcing posture it drops a claim that no membership backs
(maintainer ruling B on #15409, implemented by PR #15794). The
dispatcher never read the vetted value.
Measured on unmodified `origin/main` source (`851af0c27`), with real
identity resolution (`dispatch()` → `resolveRequestScope` →
`resolveExecutionContext` → `resolveAuthzContext`) under an `isolated`
posture. The subject is a member removed from `org_alpha` whose session
still names it. They hold the same permission set as a current member,
so RBAC cannot separate the arms.
| Door | Transport | What it did with `org_alpha`'s rows |
|---|---|---|
| `GET /packages/:id/commits` (read) | `dispatch()`, and the plugin's
explicit mount over a real socket | 200, served `org_alpha`'s commit
`cmt_alpha` |
| `POST /packages/:id/discard-drafts` (write) | both | 200, deleted
`org_alpha`'s draft |
| `DELETE /packages/:id` (write) | both | 200, deleted `org_alpha`'s row
|
All nine doors handed the protocol `org_alpha` on both transports: 20 of
20 subject cases red, 56 of 56 controls green. So reach is served, and
the p0 grade stands. Every effect stayed inside the test's own in-memory
stack.
## The fix
The fix is in the one source, not at the nine sites.
`resolveActiveOrganizationId` now returns
`context.executionContext.tenantId` through `metaCallerOrganizationId`
from `@objectstack/rest`. That is the helper `RestServer` and the
dispatcher's `/meta` doors already share (#20408).
- `@objectstack/rest` is already a dependency of `@objectstack/runtime`
(`domains/meta.ts` imports from it), so no dependency edge is added.
- There is no second vetting path: `packages/core` is untouched.
- `domains/packages.ts` is unchanged. The `domain-handler-registry.ts`
edit is only the dep's contract comment.
## Hypotheses
- **H1 (every caller has the vetted context): holds.**
- The dep's only caller is `domains/packages.ts`, at 9 sites (`git grep`
at HEAD).
- Both HTTP entries reach the domain through `dispatch()`: the
`createHonoApp` catch-all and `createDispatcherPlugin`'s explicit
mounts. `dispatch()` runs `resolveRequestScope`, which writes
`executionContext` before any domain handler. Only the declared liveness
route (`/health`) skips it, and that route reads no organization.
- The domain's first statement is the anonymous-deny floor. It refuses a
context with no resolved principal before any of the nine sites is
reached.
- So no call site runs without a resolved context.
- **H2 (controls unchanged): holds.**
- A current member reaches their own organization on every door, on both
transports.
- The ex-member, switched to an organization they belong to, reaches
that one and never the one they left.
- An anonymous caller gets `401 ANONYMOUS_DENY` before any protocol
call.
- A control asserts the resolver really dropped the ex-member's claim:
its `Session organization claim dropped` line names `org_alpha`. So the
green subject cannot come from a rig that never presented the stale
claim.
- **H3 (ablation): holds, in the direction predicted.**
- Starting from the committed fix, `scripts/ablation-replace.mjs` put
the original raw-session body back, verbatim from `BASE` (anchor 1 → 0,
blob `d6ad749b8ced` → `0414fe725ad2`).
- Predicted before the run: exactly the 20 left-organization cases red
and the 56 controls green, plus the migrated seed-apply §0 control red.
- Measured: 21 failed and 65 passed of 86, and the red set is exactly
those 21 names.
- The tool proved the restore: the blob after restore equals the blob at
HEAD (`d6ad749b8ced`), and `git diff HEAD` is empty.
## The pins
`packages/runtime/src/domains/packages-vetted-org-source.test.ts` has 76
cases. It covers each of the nine doors × both transports × four
callers: a current member, the ex-member switched to a real membership,
an anonymous caller, and the ex-member with the stale claim. It adds the
claim-drop control and the uninstall refusal.
- The subject cases assert two things: the protocol is handed no
organization, and the left organization's partition is neither read (no
`alpha` in the answer) nor written (its partition is byte-identical
afterwards).
- The ex-member's uninstall gets the protocol's refusal, `400
TENANT_SCOPE_REQUIRED`, and nothing is deleted. The protocol double
copies that refusal from `metadata-protocol`'s `deletePackage`.
## One existing test migrated
`packages-seed-apply-org-scope.test.ts` passed its organization in
through a stubbed `getSession`, which is the raw source this PR retires.
After the fix, every measurement in that file had silently moved to its
one-rung branch, and only its §0 control went red. The organization now
lands on the execution context's `tenantId`, where `dispatch()` puts it,
and the dead session stub is removed.
## Behaviour notes
- **API-key callers.** The doors now use the organization the key is
bound to. The raw read found no session for a key, because API keys
resolve in `@objectstack/core`, not in better-auth. So these doors used
to get no organization for a key caller. This matches `RestServer` and
the landed `/meta` doors. It is reasoned from source, not measured with
a key.
- **The ex-member after the fix** is exactly a session with no active
organization, so the doors reach the env-wide package state. The landed
`/meta` sibling does the same: its pin lands the ex-member's `PUT`
env-wide on both transports.
## Verification
Run on the final head `1e0553b290`, after merging `origin/main` at
`3062e5001`.
- **Build:** `pnpm --workspace-concurrency=2 --filter
'@objectstack/runtime^...' --filter @objectstack/runtime build` exited
0.
- **Tests:** the `@objectstack/runtime` `local` project ran 285 files:
4160 passed, 1 skipped. `test:repo` ran 3 files: 575 passed.
- **Typecheck:** exited 0, and `check:test-typecheck` is OK. `tsc
--listFiles` confirms both touched test files are in the test program.
- **Lint:** `pnpm lint` exited 0.
- **Citations:** `node scripts/check-issue-citations.mjs --base
origin/main` reports that every added citation resolves.
- **Derived gates:** `dispatch-gates --commands` derived 61 commands,
and 60 of them ran with exit 0. The `--ran` reconciliation reads 61
derived, 60 run, 1 NOT-MEASURED, 0 UNRUN.
- The NOT-MEASURED one is `pnpm check:dual-build-cjs-loads`, which
exited 3 with `PREREQUISITE NOT MET` because the whole workspace's
`dist/` is not built in this worktree. It is not a pass. This diff
touches no manifest, export or build config.
**Declared narrowing — verification ran UNLOCKED.**
`scripts/pm/os-verify-lock.sh`
could not take the shared verify lock on this host: no usable `flock`.
The shared
verify lock is declared Linux-only (`flock` is util-linux, and a stock
macOS does
not ship it), so the command below was run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held
for this
run, nor for any sibling agent in this container while it ran.
pnpm --filter @objectstack/runtime exec vitest run --project local
--maxWorkers=2
Every build and test command listed above ran under the same
declaration, and each printed this block with its own command.
## Acceptance notes
- **Out of scope, found while measuring, reported for the seat to file
(class a).** `DELETE /packages/:id` runs `registry.uninstallPackage(id)`
before the protocol's org-scoped persistence call.
- The protocol refuses any caller with no active organization (`400
TENANT_SCOPE_REQUIRED`, "Refusing to uninstall"). By then the package
has already been removed from the live registry.
- Measured through `dispatch()` with a real `SchemaRegistry` and the
real `ObjectStackProtocolImplementation`: `GET /packages/:id` answered
200 before the refused DELETE and 404 after it, while the stored rows
were kept.
- It is not fixed here: it is a different defect class from this card,
in code this diff does not touch.
- **ADR-0123 boundary, noted only.** ADR-0123 D2 refuses a caller with
no organization when they write tenant-scoped data through the security
middleware. The package verbs run as protocol calls, so that caller's
publish-drafts, discard-drafts, revert, rollback and adopt-orphans reach
the env-wide package state.
- That is the existing behaviour for every session with no active
organization, and the landed `/meta` sibling behaves the same way.
- Whether ADR-0123's write refusal should also cover metadata-package
verbs is a question for the maintainer. It is not a finding here.
---
_Generated by [Claude
Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_
---------
Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
1 parent fc0db22 commit 45f428d
5 files changed
Lines changed: 479 additions & 56 deletions
File tree
- .changeset
- packages/runtime/src
- domains
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
320 | 320 | | |
321 | 321 | | |
322 | 322 | | |
323 | | - | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
324 | 330 | | |
325 | 331 | | |
326 | 332 | | |
| |||
Lines changed: 12 additions & 13 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
50 | 50 | | |
51 | 51 | | |
52 | 52 | | |
53 | | - | |
| 53 | + | |
54 | 54 | | |
55 | 55 | | |
56 | 56 | | |
| |||
217 | 217 | | |
218 | 218 | | |
219 | 219 | | |
220 | | - | |
221 | | - | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
222 | 227 | | |
223 | 228 | | |
224 | 229 | | |
225 | 230 | | |
226 | 231 | | |
| 232 | + | |
227 | 233 | | |
228 | 234 | | |
229 | 235 | | |
230 | 236 | | |
231 | | - | |
| 237 | + | |
232 | 238 | | |
233 | 239 | | |
234 | 240 | | |
| |||
297 | 303 | | |
298 | 304 | | |
299 | 305 | | |
300 | | - | |
301 | | - | |
302 | | - | |
303 | | - | |
304 | | - | |
305 | | - | |
306 | | - | |
307 | 306 | | |
308 | 307 | | |
309 | 308 | | |
| |||
312 | 311 | | |
313 | 312 | | |
314 | 313 | | |
315 | | - | |
| 314 | + | |
316 | 315 | | |
317 | 316 | | |
318 | 317 | | |
| |||
388 | 387 | | |
389 | 388 | | |
390 | 389 | | |
391 | | - | |
| 390 | + | |
392 | 391 | | |
393 | 392 | | |
394 | 393 | | |
| |||
0 commit comments