Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/single-kernel-tenancy-posture-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/rest": patch
"@objectstack/core": patch
---

fix(rest,core): an organization-less or ex-member API key on a walled single-kernel deployment now answers 401 where it answered 200

Under a wall-enforcing tenancy posture (`isolated`), an API key stamped with an
organization its owner is no longer a member of **read and wrote that
organization's rows** on the wiring the open core actually builds. Not a silent
empty set — a GET that returned the other organization's records, and a POST
that landed a row read back from the store carrying that organization's id and
the ex-member as its creator. An organization-less key on the same deployment
read `200` with an empty set, which is the silent failure the wall exists to
replace.

The cause was a seam, not a predicate. `RestServer.computeExecCtx` derived the
effective tenancy posture from a per-request kernel, and on the single-kernel
wiring there is no per-request kernel — so the posture was `undefined` on every
request, and both posture-conditional API-key refusals are gated on it:
`organization_required` in `api-key.ts` and `organization_membership_ended` in
`resolve-authz-context.ts`. Neither ever ran. The Layer 0 wall itself was
active the whole time; it compares against the caller's active organization,
and an API key's tenant is `sys_api_key.active_organization_id` copied verbatim
— the holder's own stored claim. Enforcing the wall is what let the ex-member
through, because the one fact that would expose the ended membership was not an
input to the layer that could act on it.

The single-kernel branch now derives the posture from a provider `rest-api-plugin`
wires to the lone local kernel's `tenancy` service, in the same shape as the
auth-service provider beside it. A host that registers no `tenancy` service is
unchanged and still admits: there is no wall on such a deployment, so there is
nothing for an organization-less key to be walled out of. A `tenancy` service
that was registered and **failed to build** is an outage and answers `503`, not
an admission — a posture that could not be read is not a posture that is absent.

Refusals are now also said out loud on the server side, at `warn`, where each
one is decided: the key's row id (never the credential or its hash), the
principal, the organization and the reason. **The wire is unchanged** — both
refusals still answer the generic `401 UNAUTHENTICATED` with no reason in the
body, so a holder of someone else's key learns nothing a plain 401 does not
already tell them. The operator, who previously had a key that was neither
revoked nor expired and a 401 that said nothing, now has a line to find.

Behaviour that does not move: a current member's key on the same route still
returns its rows and still writes; a request with no credential still answers
401; and an unknown, revoked or expired key is not a refusal at all, so a key
scanner produces no log volume.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ not on any flag.
## How the flag is set

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

Expand Down Expand Up @@ -103,7 +103,7 @@ that silently does not happen.
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |

### 2. Write pipeline and data integrity

Expand Down Expand Up @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 sites**.
|:--|:---|:---|:---|:---|
| 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` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
| 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` |
| 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` |
| 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` |
| 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` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down Expand Up @@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
| "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) |
| "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` |
| "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` |
| "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` |
| "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` |

---

Expand Down
77 changes: 71 additions & 6 deletions packages/core/src/security/api-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,21 @@ export function isExpired(value: unknown, nowMs: number): boolean {
/** The principal resolved from a valid `sys_api_key`. */
export interface ApiKeyPrincipal {
userId: string;
/**
* [#15256 / 2A] The `sys_api_key` ROW id — a non-secret handle an operator
* can look the credential up by. Carried so the posture-conditional refusal
* log in `resolve-authz-context.ts` can name WHICH key was refused without
* naming the credential.
*
* ⛔ Never the raw key and never its hash: the raw key is returned exactly
* once by {@link generateApiKey} and only `sha256(raw)` is ever stored, and
* neither may enter a log line (see this module's SECURITY header). The row
* id is not derived from either.
*
* Optional because a row is only required to identify its owner; a store
* that answers without an `id` still yields a usable principal.
*/
keyId?: string;
/**
* The organization this key authenticates INTO — read from the row's
* `active_organization_id` and adopted by `resolveAuthzContext` as the
Expand Down Expand Up @@ -164,7 +179,24 @@ export type ApiKeyRefusalReason = 'organization_required' | 'organization_member
export type ApiKeyAdmission =
| { outcome: 'none' }
| { outcome: 'admitted'; principal: ApiKeyPrincipal }
| { outcome: 'refused'; reason: ApiKeyRefusalReason; message: string };
| {
outcome: 'refused';
reason: ApiKeyRefusalReason;
message: string;
/**
* [#15256 / 2A] The refused key's `sys_api_key` row id — same non-secret
* handle as {@link ApiKeyPrincipal.keyId}, carried on this arm too so the
* refusal log can name the credential the operator must go look at. ⛔
* Never the raw key or its hash. The WIRE answer is unchanged (a generic
* `401 UNAUTHENTICATED`, no reason and no id), so nothing here reaches a
* caller holding someone else's key.
*/
keyId?: string;
/** The owner this refused key authenticates as — for the same log line. */
userId?: string;
/** The organization the refusal is about, when the key names one. */
organizationId?: string;
};

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

// [#15256 / 2A] The row's own id — a non-secret handle for the refusal log.
// ⛔ Never `row.key` (the at-rest hash) and never the inbound `apiKey`.
const keyId = typeof row.id === 'string' && row.id ? row.id : undefined;

// [#8287] Posture-conditional refusal for a key that carries no organization.
//
// ⛔ Never backfilled — inferring the org from the owner's CURRENT membership
Expand All @@ -298,16 +334,45 @@ export async function resolveApiKeyAdmission(
// ⚠️ An ABSENT posture means "the caller could not tell us which posture is in
// force", and the answer to that is to admit — i.e. today's behaviour. Not
// fail-closed, deliberately, and this is the one place in this module where
// that is the right call: refusing on an unknown posture would break every
// org-less key on every `single` deployment whose transport has not been
// wired, to enforce a wall that may not exist. Fail-closed belongs on
// questions about THIS credential; this is a question about the deployment.
// that is the right call. Fail-closed belongs on questions about THIS
// credential; this is a question about the DEPLOYMENT, and refusing on an
// unknown one would break working automation to enforce a wall that may not
// exist at all.
//
// [#15256 — maintainer ruling 2026-09-04, decision 3A] ⭐ The behaviour is
// unchanged and its justification is rewritten, because the premise the
// justification rested on was measured FALSE. It read:
//
// "refusing on an unknown posture would break every org-less key on
// every `single` deployment whose transport has not been wired"
//
// The transport it called not-yet-wired was `@objectstack/rest`'s
// single-kernel branch — i.e. every deployment the open core builds, not a
// residual case. So the sentence described the shipped wiring as an
// exception, and the exception was the rule: on that wiring an org-less key
// answered `200 + total 0` and an ex-member's stamped key read AND wrote
// another organization's rows (objectstack#15163; cloud#1982 with the real
// `@objectstack/organizations`). That branch now derives the posture
// (`rest-server.ts`, wired by `rest-api-plugin.ts`), and a REST-level pin
// holds it derived.
//
// The only legitimate case left — the one this admission now exists for — is
// a HOST THAT REGISTERS NO `tenancy` SERVICE: an embedder composing the
// kernel without `plugin-auth`, or any host that never asks for tenancy at
// all. There is no wall on such a deployment, so there is nothing for an
// org-less key to be walled out of. ⛔ Note what is NOT in that set: a
// `tenancy` service that was registered and FAILED to build. That is an
// outage, it is classified apart at every transport seam
// (`isServiceNotRegisteredError`, #13906 decision 1 option A), and it never
// reaches here as an absent posture.
if (!tenantId && tenancyPosture) {
const posture = tenancyPosture;
if (postureEnforcesWall(posture) && !postureUsesUnionScope(posture)) {
return {
outcome: 'refused',
reason: 'organization_required',
keyId,
userId,
message:
'This API key carries no organization and cannot be used under the `isolated` tenancy '
+ 'posture, where every organization-scoped read is walled to an active organization. '
Expand All @@ -318,7 +383,7 @@ export async function resolveApiKeyAdmission(

return {
outcome: 'admitted',
principal: { userId, tenantId, scopes: parseScopes(row.scopes) },
principal: { userId, keyId, tenantId, scopes: parseScopes(row.scopes) },
};
}

Expand Down
74 changes: 74 additions & 0 deletions packages/core/src/security/resolve-authz-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ export interface ResolvedAuthzContext {
* and a refused credential's standard member is `UNAUTHENTICATED`. This is a
* diagnostic discriminator for the message, deliberately lowercase so it can
* never be mistaken for one.
*
* ⚠️ [#14273 A1] This field has ZERO consumers outside test assertions and is
* REMOVED by that card, in its own PR. The operator exit it was meant to be
* is now {@link warnApiKeyRefusal}'s server-side `warn` line (#15256 / 2A),
* which is why removing it costs nothing. ⛔ Not removed here.
*/
authRefusal?: { reason: ApiKeyRefusalReason; message: string };
}
Expand Down Expand Up @@ -153,6 +158,58 @@ function safeJsonParse<T>(s: string, fallback: T): T {
try { return JSON.parse(s) as T; } catch { return fallback; }
}

/**
* [#15256 — maintainer ruling 2026-09-04, decision 2A] Say a posture-conditional
* API-key refusal OUT LOUD, on the SERVER side, where the refusal is decided.
*
* ## Why the operator needs this and the caller must not get it
*
* Both refusals surface by leaving `userId` unset, so every transport answers
* the generic anonymous `401 UNAUTHENTICATED` — byte-identical to sending no
* credential at all. That is deliberate on the wire: a holder of someone else's
* key must learn nothing about it, so ⛔ the response body is NOT changed by
* this log and no `reason`, key id, principal or organization ever reaches a
* caller. It is also why the operator was left with nothing: a key they can see
* is neither revoked nor expired, and a 401 that says only "unauthenticated".
*
* `ResolvedAuthzContext.authRefusal` was that exit and never got a consumer
* (zero readers outside two test assertions); #14273's A1 ruling REMOVES the
* field in its own PR. ⛔ Not removed here — cross-referenced only. This log
* line is the operator exit that field never delivered.
*
* ## What may appear here
*
* The `sys_api_key` ROW id, the owner, the organization, the reason. ⛔ Never
* the raw key and ⛔ never its at-rest hash — see `api-key.ts`'s SECURITY
* header; the row id is derived from neither.
*
* ## Volume
*
* Bounded by real credentials, not by traffic: an unknown, revoked, expired or
* absent key resolves to `outcome: 'none'` and is never a refusal, so a key
* scanner produces no lines here. One line per refused request, deliberately —
* a rate limiter would hide exactly the burst (an automation still running on a
* key whose membership ended) that the operator most needs to see.
*
* `console.warn` and not an injected logger: this resolver is deliberately
* kernel-agnostic and takes no host wiring, and a refusal that is only loud on
* hosts which happened to wire a sink is not loud.
*/
function warnApiKeyRefusal(details: {
reason: ApiKeyRefusalReason;
keyId?: string;
userId?: string;
organizationId?: string;
}): void {
const { reason, keyId, userId, organizationId } = details;
console.warn(
`[security] API key refused (${reason}): `
+ `key=${keyId ?? '<unknown>'} principal=${userId ?? '<unknown>'} `
+ `organization=${organizationId ?? '<none>'}. `
+ 'The caller received the generic 401 UNAUTHENTICATED — this reason is server-side only.',
);
}

async function tryFind(
ql: any,
object: string,
Expand Down Expand Up @@ -275,6 +332,15 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
// replaced (an API key already outranks a session), and a refusal that
// quietly becomes a session login is not a refusal.
if (admission.outcome === 'refused') {
// [#15256 / 2A] The `organization_required` decision point. One line, here,
// where the admission verdict is read — ⛔ not a second copy inside
// `api-key.ts`'s admission path, which would log the same refusal twice.
warnApiKeyRefusal({
reason: admission.reason,
keyId: admission.keyId,
userId: admission.userId,
organizationId: admission.organizationId,
});
ctx.authRefusal = { reason: admission.reason, message: admission.message };
return ctx;
}
Expand Down Expand Up @@ -342,6 +408,14 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
if (keyPrincipal?.tenantId && input.tenancyPosture) {
const posture = input.tenancyPosture;
if (postureEnforcesWall(posture) && !grants.accessible_org_ids.includes(keyPrincipal.tenantId)) {
// [#15256 / 2A] The `organization_membership_ended` decision point — AFTER
// grants, because the membership set is what decides it. One line, here.
warnApiKeyRefusal({
reason: 'organization_membership_ended',
keyId: keyPrincipal.keyId,
userId: keyPrincipal.userId,
organizationId: keyPrincipal.tenantId,
});
return {
positions: [],
permissions: [],
Expand Down
Loading
Loading