Skip to content

Commit a727043

Browse files
hotlongclaude
andauthored
fix(rest,core): the single-kernel wiring derives the tenancy posture — an ex-member's org-stamped API key no longer reads or writes another organization's rows (#15365)
* wip(rest,core): single-kernel tenancy posture provider + refusal warn lines * fix(rest,core): pins + ablation for the single-kernel posture seam; state the corrected comment as prose The dead dispatch left the 1A/2A/3A implementation committed and its pins UNCOMMITTED, so a read of the pushed branch found them absent. This lands them and repairs the one measured contradiction between the two halves. `rest-server.ts` had "corrected" its stale comment by quoting the withdrawn B' claim verbatim under a SUPERSEDED banner. That kept the false sentence answering every grep for it — it had already caused this seam to be re-read as unrepaired after the fix — and it directly contradicted the pin that forbids the phrase (1 failed / 41 passed before this change). The correction is now stated as prose, and the forbidden phrase occurs exactly once in the repository: inside the regex that forbids it. Also narrows that pin's sibling assertion off the words "boot refusal", which are how both files' own no-boot-refusal notes are worded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 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> * docs(permissions): re-anchor the system-context census line citations after the rest-server insertions Pure line rot, produced by `node scripts/check-system-context-census.mjs --fix`: this card's additions to `rest-server.ts` shifted the cited lines by +24 before the posture block and +79 after it. No prose and no row semantics change — only the line numbers the page cites. check-system-context-census: OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 226e724 commit a727043

8 files changed

Lines changed: 1004 additions & 25 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.

content/docs/permissions/system-context.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ not on any flag.
6464
## How the flag is set
6565

6666
`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
67-
cannot set it (`packages/rest/src/rest-server.ts:1524`, `:1553`), and neither
67+
cannot set it (`packages/rest/src/rest-server.ts:1548`, `:1577`), and neither
6868
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
6969
written by internal callers only, as an option on the engine call:
7070

@@ -103,7 +103,7 @@ that silently does not happen.
103103
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
104104
| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` |
105105
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
106-
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1556` |
106+
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1580` |
107107

108108
### 2. Write pipeline and data integrity
109109

@@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 sites**.
158158
|:--|:---|:---|:---|:---|
159159
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` |
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161-
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4888`, `:6302`, `:6550`, `:6981`, `:7174` |
161+
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4967`, `:6381`, `:6629`, `:7060`, `:7253` |
162162
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
163163
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
@@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
199199
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1580` (#3493 / #6640) |
200200
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280``281` |
201201
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` |
202-
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1524`, `:1553`; `domains/actions.ts:404` |
202+
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1548`, `:1577`; `domains/actions.ts:404` |
203203

204204
---
205205

packages/core/src/security/api-key.ts

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,21 @@ export function isExpired(value: unknown, nowMs: number): boolean {
130130
/** The principal resolved from a valid `sys_api_key`. */
131131
export interface ApiKeyPrincipal {
132132
userId: string;
133+
/**
134+
* [#15256 / 2A] The `sys_api_key` ROW id — a non-secret handle an operator
135+
* can look the credential up by. Carried so the posture-conditional refusal
136+
* log in `resolve-authz-context.ts` can name WHICH key was refused without
137+
* naming the credential.
138+
*
139+
* ⛔ Never the raw key and never its hash: the raw key is returned exactly
140+
* once by {@link generateApiKey} and only `sha256(raw)` is ever stored, and
141+
* neither may enter a log line (see this module's SECURITY header). The row
142+
* id is not derived from either.
143+
*
144+
* Optional because a row is only required to identify its owner; a store
145+
* that answers without an `id` still yields a usable principal.
146+
*/
147+
keyId?: string;
133148
/**
134149
* The organization this key authenticates INTO — read from the row's
135150
* `active_organization_id` and adopted by `resolveAuthzContext` as the
@@ -164,7 +179,24 @@ export type ApiKeyRefusalReason = 'organization_required' | 'organization_member
164179
export type ApiKeyAdmission =
165180
| { outcome: 'none' }
166181
| { outcome: 'admitted'; principal: ApiKeyPrincipal }
167-
| { outcome: 'refused'; reason: ApiKeyRefusalReason; message: string };
182+
| {
183+
outcome: 'refused';
184+
reason: ApiKeyRefusalReason;
185+
message: string;
186+
/**
187+
* [#15256 / 2A] The refused key's `sys_api_key` row id — same non-secret
188+
* handle as {@link ApiKeyPrincipal.keyId}, carried on this arm too so the
189+
* refusal log can name the credential the operator must go look at. ⛔
190+
* Never the raw key or its hash. The WIRE answer is unchanged (a generic
191+
* `401 UNAUTHENTICATED`, no reason and no id), so nothing here reaches a
192+
* caller holding someone else's key.
193+
*/
194+
keyId?: string;
195+
/** The owner this refused key authenticates as — for the same log line. */
196+
userId?: string;
197+
/** The organization the refusal is about, when the key names one. */
198+
organizationId?: string;
199+
};
168200

169201
/**
170202
* The shape of the kernel's `tenancy` service this module reads a posture from.
@@ -278,6 +310,10 @@ export async function resolveApiKeyAdmission(
278310
? row.active_organization_id
279311
: undefined;
280312

313+
// [#15256 / 2A] The row's own id — a non-secret handle for the refusal log.
314+
// ⛔ Never `row.key` (the at-rest hash) and never the inbound `apiKey`.
315+
const keyId = typeof row.id === 'string' && row.id ? row.id : undefined;
316+
281317
// [#8287] Posture-conditional refusal for a key that carries no organization.
282318
//
283319
// ⛔ Never backfilled — inferring the org from the owner's CURRENT membership
@@ -298,16 +334,45 @@ export async function resolveApiKeyAdmission(
298334
// ⚠️ An ABSENT posture means "the caller could not tell us which posture is in
299335
// force", and the answer to that is to admit — i.e. today's behaviour. Not
300336
// fail-closed, deliberately, and this is the one place in this module where
301-
// that is the right call: refusing on an unknown posture would break every
302-
// org-less key on every `single` deployment whose transport has not been
303-
// wired, to enforce a wall that may not exist. Fail-closed belongs on
304-
// questions about THIS credential; this is a question about the deployment.
337+
// that is the right call. Fail-closed belongs on questions about THIS
338+
// credential; this is a question about the DEPLOYMENT, and refusing on an
339+
// unknown one would break working automation to enforce a wall that may not
340+
// exist at all.
341+
//
342+
// [#15256 — maintainer ruling 2026-09-04, decision 3A] ⭐ The behaviour is
343+
// unchanged and its justification is rewritten, because the premise the
344+
// justification rested on was measured FALSE. It read:
345+
//
346+
// "refusing on an unknown posture would break every org-less key on
347+
// every `single` deployment whose transport has not been wired"
348+
//
349+
// The transport it called not-yet-wired was `@objectstack/rest`'s
350+
// single-kernel branch — i.e. every deployment the open core builds, not a
351+
// residual case. So the sentence described the shipped wiring as an
352+
// exception, and the exception was the rule: on that wiring an org-less key
353+
// answered `200 + total 0` and an ex-member's stamped key read AND wrote
354+
// another organization's rows (objectstack#15163; cloud#1982 with the real
355+
// `@objectstack/organizations`). That branch now derives the posture
356+
// (`rest-server.ts`, wired by `rest-api-plugin.ts`), and a REST-level pin
357+
// holds it derived.
358+
//
359+
// The only legitimate case left — the one this admission now exists for — is
360+
// a HOST THAT REGISTERS NO `tenancy` SERVICE: an embedder composing the
361+
// kernel without `plugin-auth`, or any host that never asks for tenancy at
362+
// all. There is no wall on such a deployment, so there is nothing for an
363+
// org-less key to be walled out of. ⛔ Note what is NOT in that set: a
364+
// `tenancy` service that was registered and FAILED to build. That is an
365+
// outage, it is classified apart at every transport seam
366+
// (`isServiceNotRegisteredError`, #13906 decision 1 option A), and it never
367+
// reaches here as an absent posture.
305368
if (!tenantId && tenancyPosture) {
306369
const posture = tenancyPosture;
307370
if (postureEnforcesWall(posture) && !postureUsesUnionScope(posture)) {
308371
return {
309372
outcome: 'refused',
310373
reason: 'organization_required',
374+
keyId,
375+
userId,
311376
message:
312377
'This API key carries no organization and cannot be used under the `isolated` tenancy '
313378
+ 'posture, where every organization-scoped read is walled to an active organization. '
@@ -318,7 +383,7 @@ export async function resolveApiKeyAdmission(
318383

319384
return {
320385
outcome: 'admitted',
321-
principal: { userId, tenantId, scopes: parseScopes(row.scopes) },
386+
principal: { userId, keyId, tenantId, scopes: parseScopes(row.scopes) },
322387
};
323388
}
324389

packages/core/src/security/resolve-authz-context.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ export interface ResolvedAuthzContext {
118118
* and a refused credential's standard member is `UNAUTHENTICATED`. This is a
119119
* diagnostic discriminator for the message, deliberately lowercase so it can
120120
* never be mistaken for one.
121+
*
122+
* ⚠️ [#14273 A1] This field has ZERO consumers outside test assertions and is
123+
* REMOVED by that card, in its own PR. The operator exit it was meant to be
124+
* is now {@link warnApiKeyRefusal}'s server-side `warn` line (#15256 / 2A),
125+
* which is why removing it costs nothing. ⛔ Not removed here.
121126
*/
122127
authRefusal?: { reason: ApiKeyRefusalReason; message: string };
123128
}
@@ -153,6 +158,58 @@ function safeJsonParse<T>(s: string, fallback: T): T {
153158
try { return JSON.parse(s) as T; } catch { return fallback; }
154159
}
155160

161+
/**
162+
* [#15256 — maintainer ruling 2026-09-04, decision 2A] Say a posture-conditional
163+
* API-key refusal OUT LOUD, on the SERVER side, where the refusal is decided.
164+
*
165+
* ## Why the operator needs this and the caller must not get it
166+
*
167+
* Both refusals surface by leaving `userId` unset, so every transport answers
168+
* the generic anonymous `401 UNAUTHENTICATED` — byte-identical to sending no
169+
* credential at all. That is deliberate on the wire: a holder of someone else's
170+
* key must learn nothing about it, so ⛔ the response body is NOT changed by
171+
* this log and no `reason`, key id, principal or organization ever reaches a
172+
* caller. It is also why the operator was left with nothing: a key they can see
173+
* is neither revoked nor expired, and a 401 that says only "unauthenticated".
174+
*
175+
* `ResolvedAuthzContext.authRefusal` was that exit and never got a consumer
176+
* (zero readers outside two test assertions); #14273's A1 ruling REMOVES the
177+
* field in its own PR. ⛔ Not removed here — cross-referenced only. This log
178+
* line is the operator exit that field never delivered.
179+
*
180+
* ## What may appear here
181+
*
182+
* The `sys_api_key` ROW id, the owner, the organization, the reason. ⛔ Never
183+
* the raw key and ⛔ never its at-rest hash — see `api-key.ts`'s SECURITY
184+
* header; the row id is derived from neither.
185+
*
186+
* ## Volume
187+
*
188+
* Bounded by real credentials, not by traffic: an unknown, revoked, expired or
189+
* absent key resolves to `outcome: 'none'` and is never a refusal, so a key
190+
* scanner produces no lines here. One line per refused request, deliberately —
191+
* a rate limiter would hide exactly the burst (an automation still running on a
192+
* key whose membership ended) that the operator most needs to see.
193+
*
194+
* `console.warn` and not an injected logger: this resolver is deliberately
195+
* kernel-agnostic and takes no host wiring, and a refusal that is only loud on
196+
* hosts which happened to wire a sink is not loud.
197+
*/
198+
function warnApiKeyRefusal(details: {
199+
reason: ApiKeyRefusalReason;
200+
keyId?: string;
201+
userId?: string;
202+
organizationId?: string;
203+
}): void {
204+
const { reason, keyId, userId, organizationId } = details;
205+
console.warn(
206+
`[security] API key refused (${reason}): `
207+
+ `key=${keyId ?? '<unknown>'} principal=${userId ?? '<unknown>'} `
208+
+ `organization=${organizationId ?? '<none>'}. `
209+
+ 'The caller received the generic 401 UNAUTHENTICATED — this reason is server-side only.',
210+
);
211+
}
212+
156213
async function tryFind(
157214
ql: any,
158215
object: string,
@@ -275,6 +332,15 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
275332
// replaced (an API key already outranks a session), and a refusal that
276333
// quietly becomes a session login is not a refusal.
277334
if (admission.outcome === 'refused') {
335+
// [#15256 / 2A] The `organization_required` decision point. One line, here,
336+
// where the admission verdict is read — ⛔ not a second copy inside
337+
// `api-key.ts`'s admission path, which would log the same refusal twice.
338+
warnApiKeyRefusal({
339+
reason: admission.reason,
340+
keyId: admission.keyId,
341+
userId: admission.userId,
342+
organizationId: admission.organizationId,
343+
});
278344
ctx.authRefusal = { reason: admission.reason, message: admission.message };
279345
return ctx;
280346
}
@@ -342,6 +408,14 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
342408
if (keyPrincipal?.tenantId && input.tenancyPosture) {
343409
const posture = input.tenancyPosture;
344410
if (postureEnforcesWall(posture) && !grants.accessible_org_ids.includes(keyPrincipal.tenantId)) {
411+
// [#15256 / 2A] The `organization_membership_ended` decision point — AFTER
412+
// grants, because the membership set is what decides it. One line, here.
413+
warnApiKeyRefusal({
414+
reason: 'organization_membership_ended',
415+
keyId: keyPrincipal.keyId,
416+
userId: keyPrincipal.userId,
417+
organizationId: keyPrincipal.tenantId,
418+
});
345419
return {
346420
positions: [],
347421
permissions: [],

0 commit comments

Comments
 (0)