Skip to content

Commit bf99558

Browse files
hotlongclaude
andcommitted
chore(changeset): @objectstack/rest patch + @objectstack/core patch for the single-kernel posture seam
Sized as the maintainer ruled (item 6): a fail-closed bug fix, no accept-set change, no new public surface, Clause-2 no. Names the observable change — an organization-less or ex-member API key on a walled single-kernel deployment now answers 401 where it answered 200. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent a95f9f7 commit bf99558

1 file changed

Lines changed: 48 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/rest": patch
3+
"@objectstack/core": patch
4+
---
5+
6+
fix(rest,core): an organization-less or ex-member API key on a walled single-kernel deployment now answers 401 where it answered 200
7+
8+
Under a wall-enforcing tenancy posture (`isolated`), an API key stamped with an
9+
organization its owner is no longer a member of **read and wrote that
10+
organization's rows** on the wiring the open core actually builds. Not a silent
11+
empty set — a GET that returned the other organization's records, and a POST
12+
that landed a row read back from the store carrying that organization's id and
13+
the ex-member as its creator. An organization-less key on the same deployment
14+
read `200` with an empty set, which is the silent failure the wall exists to
15+
replace.
16+
17+
The cause was a seam, not a predicate. `RestServer.computeExecCtx` derived the
18+
effective tenancy posture from a per-request kernel, and on the single-kernel
19+
wiring there is no per-request kernel — so the posture was `undefined` on every
20+
request, and both posture-conditional API-key refusals are gated on it:
21+
`organization_required` in `api-key.ts` and `organization_membership_ended` in
22+
`resolve-authz-context.ts`. Neither ever ran. The Layer 0 wall itself was
23+
active the whole time; it compares against the caller's active organization,
24+
and an API key's tenant is `sys_api_key.active_organization_id` copied verbatim
25+
— the holder's own stored claim. Enforcing the wall is what let the ex-member
26+
through, because the one fact that would expose the ended membership was not an
27+
input to the layer that could act on it.
28+
29+
The single-kernel branch now derives the posture from a provider `rest-api-plugin`
30+
wires to the lone local kernel's `tenancy` service, in the same shape as the
31+
auth-service provider beside it. A host that registers no `tenancy` service is
32+
unchanged and still admits: there is no wall on such a deployment, so there is
33+
nothing for an organization-less key to be walled out of. A `tenancy` service
34+
that was registered and **failed to build** is an outage and answers `503`, not
35+
an admission — a posture that could not be read is not a posture that is absent.
36+
37+
Refusals are now also said out loud on the server side, at `warn`, where each
38+
one is decided: the key's row id (never the credential or its hash), the
39+
principal, the organization and the reason. **The wire is unchanged** — both
40+
refusals still answer the generic `401 UNAUTHENTICATED` with no reason in the
41+
body, so a holder of someone else's key learns nothing a plain 401 does not
42+
already tell them. The operator, who previously had a key that was neither
43+
revoked nor expired and a 401 that said nothing, now has a line to find.
44+
45+
Behaviour that does not move: a current member's key on the same route still
46+
returns its rows and still writes; a request with no credential still answers
47+
401; and an unknown, revoked or expired key is not a refusal at all, so a key
48+
scanner produces no log volume.

0 commit comments

Comments
 (0)