From 58bce113da3c2d662fd474fec3faf6ccf4d0dcf0 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:28:19 +0800 Subject: [PATCH 1/4] wip(rest,core): single-kernel tenancy posture provider + refusal warn lines --- packages/core/src/security/api-key.ts | 77 ++++++++++++++++-- .../src/security/resolve-authz-context.ts | 74 +++++++++++++++++ packages/rest/src/rest-api-plugin.ts | 70 +++++++++++++++- packages/rest/src/rest-server.ts | 81 ++++++++++++++++++- 4 files changed, 292 insertions(+), 10 deletions(-) diff --git a/packages/core/src/security/api-key.ts b/packages/core/src/security/api-key.ts index e54987a730..086a521aea 100644 --- a/packages/core/src/security/api-key.ts +++ b/packages/core/src/security/api-key.ts @@ -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 @@ -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. @@ -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 @@ -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. ' @@ -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) }, }; } diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 239f5d2053..f4e2af46cd 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -117,6 +117,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 }; } @@ -152,6 +157,58 @@ function safeJsonParse(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 ?? ''} principal=${userId ?? ''} ` + + `organization=${organizationId ?? ''}. ` + + 'The caller received the generic 401 UNAUTHENTICATED — this reason is server-side only.', + ); +} + async function tryFind( ql: any, object: string, @@ -274,6 +331,15 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise => { + const kernel = typeof ctx.getKernel === 'function' ? ctx.getKernel() : undefined; + if (kernel && typeof kernel.getServiceAsync === 'function') { + try { + return await kernel.getServiceAsync('tenancy'); + } catch (err) { + if (isServiceNotRegisteredError(err)) return undefined; + throw err; + } + } + // The sync leg, for a `KernelBase`-shaped host (`LiteKernel`) + // with no `getServiceAsync` — identical reasoning to + // `objectQLProvider`'s: such a host supports no service + // factories, so "not registered" is the only fault its accessor + // can report and absorbing it is the same classification rather + // than a second collapse. + try { + return ctx.getService('tenancy'); + } catch { return undefined; } + }; + // ObjectQL resolver — single-kernel fallback so resolveExecCtx // can run sys_member / sys_user_permission_set lookups when // there is no kernelManager wired (e.g. `pnpm dev:crm`). @@ -453,7 +521,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { // `RouteManager` in the first place. let restServer: RestServer | undefined; try { - restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider, requestEnvResolver, metadataServiceProvider); + restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider, requestEnvResolver, metadataServiceProvider, tenancyServiceProvider); restServer.registerRoutes(); ctx.logger.info('REST API successfully registered'); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 3381a87e8e..9a724d08e8 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1163,6 +1163,23 @@ export class RestServer { private defaultEnvironmentIdProvider?: () => string | undefined; private authServiceProvider?: (environmentId?: string) => Promise; private objectQLProvider?: (environmentId?: string) => Promise; + /** + * [#15256 — maintainer ruling 2026-09-04, decision 1A] The lone local + * kernel's `tenancy` service, on the SINGLE-KERNEL wiring — the seam that + * made {@link computeExecCtx}'s posture `undefined` on every deployment the + * open core builds, so both posture-conditional API-key refusals were + * gated off and an ex-member's org-stamped key read AND wrote another + * organization's rows (measured twice: objectstack#15163 on the framework, + * cloud#1982 with the real `@objectstack/organizations`). + * + * Wired by `rest-api-plugin` in the same SHAPE as + * {@link authServiceProvider} — a provider closure over the lone kernel — + * but with `objectQLProvider`'s CLASSIFICATION, because decision 1 option A + * governs what its faults mean: only the branded not-registered rejection + * may resolve quietly, and every other rejection must stay loud. See the + * posture block in `computeExecCtx`. + */ + private tenancyServiceProvider?: (environmentId?: string) => Promise; private emailServiceProvider?: (environmentId?: string) => Promise; private sharingServiceProvider?: (environmentId?: string) => Promise; private reportsServiceProvider?: (environmentId?: string) => Promise; @@ -1222,6 +1239,12 @@ export class RestServer { securityServiceProvider?: (environmentId?: string) => Promise, requestEnvResolver?: RestRequestEnvResolver, metadataServiceProvider?: (environmentId?: string) => Promise, + /** + * [#15256] Appended LAST on purpose: 135 files construct a + * `RestServer`, and inserting the parameter beside its sibling + * providers would silently re-bind every positional argument after it. + */ + tenancyServiceProvider?: (environmentId?: string) => Promise, ) { this.protocol = protocol; this.config = this.normalizeConfig(config); @@ -1243,6 +1266,7 @@ export class RestServer { this.securityServiceProvider = securityServiceProvider; this.requestEnvResolver = requestEnvResolver; this.metadataServiceProvider = metadataServiceProvider; + this.tenancyServiceProvider = tenancyServiceProvider; } /** @@ -2385,9 +2409,33 @@ export class RestServer { // discipline. Without that guard the single-kernel provider path // (where `kernel` is `undefined`) would raise a `TypeError` from the // dereference and every embedder on that wiring would take the loud - // answer. That path carries no posture at all; its half of this - // ruling (decision 1 option B′) is refused at BOOT instead — see - // `rest-api-plugin.ts`. + // answer. + // + // [#15256 — maintainer ruling 2026-09-04, decision 1A] ⭐ SUPERSEDED + // TEXT, quoted so the correction is legible from this file alone. + // The paragraph above used to end: + // + // "That path carries no posture at all; its half of this + // ruling (decision 1 option B′) is refused at BOOT instead — + // see `rest-api-plugin.ts`." + // + // Both halves of that sentence were wrong on this tree. B′ (the + // boot refusal) was WITHDRAWN on 2026-09-04 and `rest-api-plugin.ts` + // never carried it — a p0 seam documented as covered when it was + // not. And "that path carries no posture at all" is no longer true: + // the single-kernel branch below DERIVES the posture, from a + // provider `rest-api-plugin` wires to the lone local kernel's + // `tenancy` service — the same way this method already obtains + // `authService`. Measured consequence of the absence, on this exact + // wiring under a healthy `isolated` posture, with an API key stamped + // with an organization its owner had left: + // + // | wiring | before | after | + // |:--|:--|:--| + // | ex-member's org-stamped key | **GET 200 / POST 201, row lands in the other org** | **401 / 401** | + // | organization-less key | **GET 200 total 0 (silent) / POST 403** | **401** | + // | CURRENT member's key (control) | 200 / 201 | 200 / 201 — unchanged | + // | no credential (control) | 401 | 401 — unchanged | // // ⚠️ The ASYNC ACCESSOR's presence is part of the wiring fact, for // the same reason the shipped `objectQLProvider` splits on it: a @@ -2414,6 +2462,33 @@ export class RestServer { // quiet `undefined`, no posture-conditional refusal. tenancyPosture = undefined; } + } else if (this.tenancyServiceProvider) { + // [#15256 / 1A] The SINGLE-KERNEL branch — the wiring every + // deployment the open core builds actually runs, and the one + // that carried no posture at all. Reached only when no `kernel` + // was bound above, exactly as `authServiceProvider` is: the + // kernelManager branches already read the per-environment + // kernel's own `tenancy` service, and asking twice would let a + // provider bound to the LOCAL kernel answer for a request that + // resolved to another environment. + // + // Same classification as the branch above, and it is the whole + // reason this is not `seamOrUndefined`: decision 1 option A + // governs BOTH halves of this seam, so "never registered" stays + // quiet (the supported no-tenancy composition) and every other + // rejection is the outage it is. The provider re-raises + // unbranded rejections for precisely that reason — see + // `rest-api-plugin.ts`. + try { + tenancyPosture = effectiveTenancyPosture( + await this.tenancyServiceProvider(environmentId) as any, + ); + } catch (err) { + if (!isServiceNotRegisteredError(err)) { + throw new AuthzStoreUnavailableError('tenancy', err); + } + tenancyPosture = undefined; + } } const authz = await resolveAuthzContext({ ql, headers, getSession, tenancyPosture }); // [#6216] The anonymous contract IS the shared assembler's default From a95f9f78b5274201c0b4303800e2b5f34eb1405a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:36:43 +0800 Subject: [PATCH 2/4] fix(rest,core): pins + ablation for the single-kernel posture seam; state the corrected comment as prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...cctx-authz-input-seam-reachability.test.ts | 224 ++++++++- packages/rest/src/rest-server.ts | 36 +- ...gle-kernel-isolated-api-key-matrix.test.ts | 443 ++++++++++++++++++ 3 files changed, 676 insertions(+), 27 deletions(-) create mode 100644 packages/rest/src/single-kernel-isolated-api-key-matrix.test.ts diff --git a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts index ec60cd0775..0d628851bd 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -179,6 +179,41 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol', // without this guard the dereference would raise an unbranded `TypeError` // and turn that host shape into a 503. expect(body).toMatch(/let tenancyPosture;\s*\n\s*if \(kernel && typeof kernel\.getServiceAsync === 'function'\) \{/); + // [#15256 / 1A] …and the OTHER wiring now has a leg of its own, with the + // same classification. Before this card the `if` above was the whole + // block, so on the single-kernel wiring `tenancyPosture` stayed the + // declaration's `undefined` and no refusal could fire. + expect(body).toMatch(/\} else if \(this\.tenancyServiceProvider\) \{/); + expect(body).toMatch(/tenancyPosture = effectiveTenancyPosture\(\s*\n?\s*await this\.tenancyServiceProvider\(environmentId\)/); + }); + + it('[#15256 / 1A] the withdrawn B-prime BOOT refusal is no longer cited as this seam\'s remedy', () => { + // The cloud reading (cloud#1982) found `rest-server.ts` pointing at a remedy + // that had been pulled: it said the single-kernel half was refused at + // startup by `rest-api-plugin.ts`, which never carried such a refusal — a p0 + // seam documenting itself as covered when it was not. + // + // ⚠️ The forbidden phrase appears exactly ONCE in this repository, in the + // regex below. That is deliberate: a pin must name what it forbids, but a + // second copy anywhere — including a "superseded text, quoted" comment in + // the file being corrected — answers the greps that hunt for the withdrawn + // remedy, and one such copy already caused this seam to be re-read as + // unrepaired after it had been fixed. + expect(body).not.toMatch(/is refused at BOOT instead/); + // Positive control for the reading: the file IS the one that carried it, + // and the surrounding paragraph is still here. + expect(body).toMatch(/The WIRING fact is taken from `kernel`'s PRESENCE/); + // And the plugin really has no such refusal, so nothing may point at one. + // + // ⚠️ Matched on what a refusal would be SPELLED as, never on the words + // "boot refusal": that phrase is how the ⛔ notes in both files say the + // remedy stays withdrawn, and a pin that reddens on a correct note teaches + // authors to delete the note. + const plugin = readFileSync(resolve(HERE, 'rest-api-plugin.ts'), 'utf8'); + expect(plugin).not.toMatch(/Refusing to start|refuseBoot|refuseAtBoot/); + // Positive control for THAT reading (the cloud comment's own): the symbol + // this seam does wire is present, twice — the closure and the argument. + expect(plugin.match(/tenancyServiceProvider/g) ?? []).toHaveLength(2); }); it('REPAIRED [decision 2 B]: the auth-gate seam fails closed in the measured window only', () => { @@ -228,6 +263,8 @@ interface Wiring { defaultEnvironmentIdProvider?: any; authServiceProvider?: any; objectQLProvider?: any; + /** [#15256 / 1A] The single-kernel posture seam this card wired. */ + tenancyServiceProvider?: any; } function serverWith(w: Wiring): RestServer { @@ -245,6 +282,10 @@ function serverWith(w: Wiring): RestServer { undefined, undefined, undefined, + // metadataServiceProvider — unused here, named so the seam below is not + // silently bound to the wrong positional slot. + undefined, + w.tenancyServiceProvider, ); } @@ -396,8 +437,13 @@ function viaKernelManager(kernel: ObjectKernel): Wiring { * Single-kernel provider-path wiring, byte-faithful to `rest-api-plugin.ts` * (#14250 shape): the auth provider absorbs, the objectql provider absorbs * ONLY the branded not-registered rejection. No kernelManager. + * + * [#15256 / 1A] Plus the TENANCY provider the card added — same classification + * as the objectql one, for the same reason (decision 1 option A governs both + * halves of the posture seam). `omitTenancy` is the ABLATION handle: it removes + * exactly the one thing this card wired and nothing else. */ -function viaProviders(kernel: ObjectKernel): Wiring { +function viaProviders(kernel: ObjectKernel, opts: { omitTenancy?: boolean } = {}): Wiring { return { authServiceProvider: async () => { try { return kernel.getService('auth'); } catch { return undefined; } @@ -410,6 +456,16 @@ function viaProviders(kernel: ObjectKernel): Wiring { throw err; } }, + ...(opts.omitTenancy ? {} : { + tenancyServiceProvider: async () => { + try { + return await kernel.getServiceAsync('tenancy'); + } catch (err) { + if (isServiceNotRegisteredError(err)) return undefined; + throw err; + } + }, + }), }; } @@ -552,30 +608,176 @@ describe('[#13906] §2 — the Layer 0 ex-member refusal, and what a failed post // §3 — SEAM 1 (tenancy posture), single-kernel provider path: the card's // sharper claim. `kernel` is a LOCAL that only kernelManager branches assign // (§0 pins that mechanically), so on the shipped single-kernel wiring the -// posture probe dereferences `undefined` and the posture is ALWAYS absent — +// posture probe used to dereference nothing and the posture was ALWAYS absent — // not an edge case on failure, the NORMAL state of that wiring. +// +// [#15256 — maintainer ruling 2026-09-04, decision 1A] ⭐ REPAIRED, and the +// pins are re-aimed IN PLACE with their superseded text quoted beside them +// (this file's standing convention). The single-kernel branch now derives the +// posture from a provider `rest-api-plugin` wires to the lone local kernel's +// `tenancy` service — the option the 2026-09-02 ruling declined, re-opened on +// the #15163 / cloud#1982 measurement. ⛔ B′ (a boot refusal) stays withdrawn. // --------------------------------------------------------------------------- -describe('[#13906] §3 — single-kernel provider path: the posture is never even asked for', () => { - it('⚠️ MEASURED: the SAME deployment facts answer 401 via kernelManager and 200 via the provider wiring — and the healthy tenancy service is NEVER INVOKED on the provider path', async () => { +describe('[#15256] §3 — single-kernel provider path: the posture is derived, and both wirings answer alike', () => { + it('REPAIRED [decision 1A]: the SAME deployment facts now answer 401 on BOTH shipped wirings — and the healthy tenancy service IS invoked on the provider path', async () => { // ONE real kernel: healthy, wall-enforcing tenancy (a RECORDING factory), // same engine fixture, same ex-member key. The only variable is which of - // the two shipped wiring shapes the host used. + // the two shipped wiring shapes the host used — and that variable no + // longer changes the answer, which is the whole point of the repair. const recA = { calls: 0 }; const viaManager = kernelWith({ ql: qlWith({ memberships: MEMBER_ROWS }), tenancy: { recording: recA } }); const refused = await drive(mount(serverWith(viaKernelManager(viaManager))), { 'x-api-key': RAW_EXMEMBER_KEY }); expect(refused.status).toBe(ANONYMOUS_DENY_STATUS); expect(recA.calls).toBeGreaterThan(0); // the wall consulted the posture + // SUPERSEDED PINS, quoted — the leak this card was filed for: + // expect(served.status).toBe(200); + // expect(recB.calls).toBe(0); + // The tenancy service was healthy and registered, and was never asked; + // both posture-conditional refusals were therefore skipped and an + // ex-member's org-stamped key was SERVED with full grants. const recB = { calls: 0 }; const viaProvider = kernelWith({ ql: qlWith({ memberships: MEMBER_ROWS }), tenancy: { recording: recB } }); - const served = await drive(mount(serverWith(viaProviders(viaProvider))), { 'x-api-key': RAW_EXMEMBER_KEY }); + const nowRefused = await drive(mount(serverWith(viaProviders(viaProvider))), { 'x-api-key': RAW_EXMEMBER_KEY }); + expect(nowRefused.status).toBe(ANONYMOUS_DENY_STATUS); + expect(nowRefused.body?.error?.code).toBe(ANONYMOUS_DENY_CODE); + expect(recB.calls).toBeGreaterThan(0); + }); + + it('ABLATION: remove the provider and the ex-member key is served again — 200, the exact row this card repaired', async () => { + // The one thing removed is the provider this card wired; every other fact + // — the healthy `isolated` tenancy service, the engine fixture, the key, + // the route — is byte-identical to the leg above. A pin that cannot go red + // has measured nothing, and this is the leg that shows it can. + const rec = { calls: 0 }; + const kernel = kernelWith({ ql: qlWith({ memberships: MEMBER_ROWS }), tenancy: { recording: rec } }); + const served = await drive( + mount(serverWith(viaProviders(kernel, { omitTenancy: true }))), + { 'x-api-key': RAW_EXMEMBER_KEY }, + ); expect(served.status).toBe(200); - // The tenancy service is healthy and registered — and was never asked. - // "Failed" is not even required on this path: the posture is undefined - // BEFORE any failure can occur, which is why the card calls it the - // normal state rather than a failure edge case. - expect(recB.calls).toBe(0); + expect(served.body?.success).toBe(true); + expect(rec.calls).toBe(0); + }); + + it('NARROWNESS CONTROL: a CURRENT member is still served on the provider wiring — the repair refuses the ex-member, not the wiring', async () => { + const kernel = kernelWith({ ql: qlWith({ memberships: MEMBER_ROWS }), tenancy: 'healthy-isolated' }); + const served = await drive(mount(serverWith(viaProviders(kernel))), { 'x-api-key': RAW_MEMBER_KEY }); + expect(served.status).toBe(200); + expect(served.body?.success).toBe(true); + }); + + it('UNCHANGED: with NO tenancy service registered the provider path still serves — the supported no-tenancy composition', async () => { + // The other half of decision 1 option A, at this seam: "never registered" + // is branded and stays quiet. A repair that made this a 503 (or a 401) + // would have broken every embedder composing a kernel without tenancy. + const kernel = kernelWith({ ql: qlWith({ memberships: MEMBER_ROWS }), tenancy: 'unregistered' }); + const served = await drive(mount(serverWith(viaProviders(kernel))), { 'x-api-key': RAW_EXMEMBER_KEY }); + expect(served.status).toBe(200); + expect(served.body?.success).toBe(true); + }); + + it('REGISTERED AND FAILING on the provider path is a 503 outage — decision 1 option A, both halves of the seam', async () => { + const kernel = kernelWith({ ql: qlWith({ memberships: MEMBER_ROWS }), tenancy: 'factory-throws' }); + const captured = await drive(mount(serverWith(viaProviders(kernel))), { 'x-api-key': RAW_EXMEMBER_KEY }); + expect(captured.status).toBe(503); + expect(captured.body?.success).not.toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// §3b — [#15256 ruling, item 4a] EVERY `computeExecCtx` branch supplies a +// posture when a `tenancy` service is registered. +// +// The card's mechanism was not "one branch is wrong", it was "one branch was +// never asked". So the pin is over the BRANCH SET rather than over the branch +// that leaked: enumerate every wiring shape that can resolve an auth service, +// drive each one, and require that the registered tenancy service was actually +// CONSULTED and that the refusal it enables actually fired. A new branch that +// forgets the posture reds here even if it never touches the two above. +// --------------------------------------------------------------------------- + +describe('[#15256] §3b — every computeExecCtx branch derives the posture', () => { + /** + * The three branches, in `computeExecCtx`'s own order: + * + * 1. scoped kernelManager — `environmentId && environmentId !== 'platform' + * && this.kernelManager`, driven by `req.params.environmentId`. + * 2. default-environment kernelManager — reached when (1) resolved no auth + * service, via `defaultEnvironmentIdProvider`. + * 3. single-kernel providers — no kernelManager at all. THE CARD'S BRANCH. + */ + const BRANCHES: Array<{ + name: string; + wiring: (kernel: ObjectKernel) => Wiring; + params?: Record; + }> = [ + { + name: '1 · scoped kernelManager (params.environmentId)', + wiring: (k) => ({ kernelManager: { getOrCreate: async () => k } }), + params: { environmentId: 'env1' }, + }, + { + name: '2 · default-environment kernelManager', + wiring: (k) => viaKernelManager(k), + }, + { + name: '3 · single-kernel providers', + wiring: (k) => viaProviders(k), + }, + ]; + + /** Same driver as `drive`, with `params` so branch 1 is reachable. */ + async function driveWithParams( + routes: Map, + headers: Record, + params: Record, + ): Promise { + const handler = routes.get(`GET:${PKGS}`); + if (!handler) throw new Error(`no handler for GET ${PKGS}`); + const captured: Captured = { status: 0, body: undefined }; + const res: any = { + json(data: any) { captured.body = data; }, + send() {}, + status(code: number) { captured.status = code; return res; }, + header() { return res; }, + }; + await handler({ params, query: {}, body: undefined, headers, method: 'GET', path: PKGS } as any, res); + return captured; + } + + it.each(BRANCHES)('branch $name consults the registered tenancy service and the refusal fires', async (branch) => { + const rec = { calls: 0 }; + const kernel = kernelWith({ ql: qlWith({ memberships: MEMBER_ROWS }), tenancy: { recording: rec } }); + const routes = mount(serverWith(branch.wiring(kernel))); + + // ANTI-VACUITY CONTROL first: this branch really does authenticate. Without + // it, a wiring that resolved no auth service at all would "pass" the + // refusal assertion below for the wrong reason — 401 because nobody was + // authenticated, not 401 because the posture refused. + const member = await driveWithParams(routes, { 'x-api-key': RAW_MEMBER_KEY }, branch.params ?? {}); + expect(member.status).toBe(200); + expect(member.body?.success).toBe(true); + + const exMember = await driveWithParams(routes, { 'x-api-key': RAW_EXMEMBER_KEY }, branch.params ?? {}); + expect(exMember.status).toBe(ANONYMOUS_DENY_STATUS); + expect(exMember.body?.error?.code).toBe(ANONYMOUS_DENY_CODE); + + // The posture was DERIVED, not defaulted: the registered service was asked. + expect(rec.calls).toBeGreaterThan(0); + }); + + it('the branch set is COMPLETE — every `authService = ` assignment in computeExecCtx is one of the three above', async () => { + // Mechanical completeness, so the enumeration above cannot silently fall + // behind the source. `computeExecCtx` resolves an auth service in exactly + // three places; if a fourth appears, this reds and the table needs a row. + const body = computeExecCtxBody(SOURCE); + const assignments = body.split('\n').filter((l) => /^\s*(let )?authService = /.test(l)); + expect(assignments).toHaveLength(3); + // …and the third is the single-kernel provider, the one that carried no + // posture until this card. + expect(assignments[2]).toMatch(/this\.authServiceProvider!\(environmentId\)/); }); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 9a724d08e8..87db8036c9 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2411,24 +2411,28 @@ export class RestServer { // dereference and every embedder on that wiring would take the loud // answer. // - // [#15256 — maintainer ruling 2026-09-04, decision 1A] ⭐ SUPERSEDED - // TEXT, quoted so the correction is legible from this file alone. - // The paragraph above used to end: + // [#15256 — maintainer ruling 2026-09-04, decision 1A] ⭐ CORRECTED. + // This paragraph used to end by asserting that the single-kernel + // path carried no posture at all, and that its half of the #13906 + // ruling (option B′) was handled by a startup refusal over in + // `rest-api-plugin.ts`. Both halves of that were FALSE on this tree: + // B′ was WITHDRAWN on 2026-09-04, `rest-api-plugin.ts` never carried + // such a refusal, and so this file documented a p0 seam as covered + // when nothing covered it. ⛔ Do not reintroduce a startup refusal + // here in any form. // - // "That path carries no posture at all; its half of this - // ruling (decision 1 option B′) is refused at BOOT instead — - // see `rest-api-plugin.ts`." + // That sentence is PARAPHRASED above rather than quoted, on purpose: + // a verbatim copy goes on answering the greps that look for the + // withdrawn remedy, and it already caused this seam to be re-read as + // unrepaired once. The pin that forbids the phrase returning lives in + // `execctx-authz-input-seam-reachability.test.ts`. // - // Both halves of that sentence were wrong on this tree. B′ (the - // boot refusal) was WITHDRAWN on 2026-09-04 and `rest-api-plugin.ts` - // never carried it — a p0 seam documented as covered when it was - // not. And "that path carries no posture at all" is no longer true: - // the single-kernel branch below DERIVES the posture, from a - // provider `rest-api-plugin` wires to the lone local kernel's - // `tenancy` service — the same way this method already obtains - // `authService`. Measured consequence of the absence, on this exact - // wiring under a healthy `isolated` posture, with an API key stamped - // with an organization its owner had left: + // What is true now: the single-kernel branch below DERIVES the + // posture, from a provider `rest-api-plugin` wires to the lone local + // kernel's `tenancy` service — the same way this method already + // obtains `authService`. Measured consequence of its absence, on this + // exact wiring under a healthy `isolated` posture, with an API key + // stamped with an organization its owner had left: // // | wiring | before | after | // |:--|:--|:--| diff --git a/packages/rest/src/single-kernel-isolated-api-key-matrix.test.ts b/packages/rest/src/single-kernel-isolated-api-key-matrix.test.ts new file mode 100644 index 0000000000..c2c4ea9df2 --- /dev/null +++ b/packages/rest/src/single-kernel-isolated-api-key-matrix.test.ts @@ -0,0 +1,443 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15256 — maintainer ruling 2026-09-04, decisions 1A + 2A] The #15163 matrix, + * at REST level, on the SINGLE-KERNEL wiring under a live `isolated` posture. + * + * ## What was measured, twice, on two repositories + * + * Under `isolated`, on the provider wiring the open core actually builds, an + * API key stamped with an organization its owner is no longer a member of + * **read and wrote that organization's rows**: + * + * | credential | before | after (this card) | + * |:--|:--|:--| + * | no credential | 401 · 401 | 401 · 401 — unchanged | + * | CURRENT member (positive control) | 200 total 2 · 201 | 200 total 2 · 201 — unchanged | + * | **ex-member, key stamped `org_alpha`** | **200 total 2 · 201, row LANDS in `org_alpha`** | **401 · 401** | + * | organization-less key | **200 total 0 (silent) · 403** | **401** | + * + * objectstack#15163 measured it on the framework; cloud#1982 reproduced it on + * `apps/objectos-ee` with the REAL cloud-private `@objectstack/organizations` + * mounted, reading the written row back out of the sqlite file — the enterprise + * plugin adds no request-time refusal, so the blast radius was every walled + * deployment. + * + * ## Why the fixture is shaped the way it is + * + * The three facts that make this a measurement rather than a shape assertion, + * each carried over from the two readings: + * + * 1. **Data must be shown to REACH.** A probe that cannot serve a healthy + * member has measured nothing, so every arm below runs the member key on + * the same route and requires rows back. + * 2. **The write is read back FROM THE STORE**, never from the response body. + * `store()` is the fixture's table; the assertions count rows in it. + * 3. **Layer 0 is modelled as the hard equality it is** — `organization_id = + * context.tenantId`, `tenant-layer.ts`'s `isolated` branch, which is + * exactly what admits an ex-member whose key names the organization. The + * fixture seeds a SECOND organization the member must never see, so a wall + * that silently stopped applying would redden here rather than pass. + * + * ⛔ `resolveExecCtx` is NOT stubbed: the whole subject is what that method + * derives, so the real `computeExecCtx` → `resolveAuthzContext` chain runs. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { hashApiKey, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE } from '@objectstack/core'; +import { RestServer } from './rest-server.js'; + +const DATA_COLLECTION = '/api/v1/data/:object'; +const OBJECT = 'sys_business_unit'; + +const RAW_MEMBER_KEY = 'osk_15256_member'; +const RAW_EXMEMBER_KEY = 'osk_15256_exmember'; +const RAW_ORGLESS_KEY = 'osk_15256_orgless'; + +// --------------------------------------------------------------------------- +// The store — the fixture's table, read directly by the write assertions +// --------------------------------------------------------------------------- + +interface BusinessUnitRow { + id: string; + organization_id: string | undefined; + created_by: string | undefined; + name: string; +} + +const SEED: BusinessUnitRow[] = [ + { id: 'bu_a1', organization_id: 'org_alpha', created_by: undefined, name: 'alpha unit 1' }, + { id: 'bu_a2', organization_id: 'org_alpha', created_by: undefined, name: 'alpha unit 2' }, + // The other organization, seeded so "the wall is live" is a control rather + // than an assumption: a member of org_alpha must never see these two. + { id: 'bu_b1', organization_id: 'org_beta', created_by: undefined, name: 'beta unit 1' }, + { id: 'bu_b2', organization_id: 'org_beta', created_by: undefined, name: 'beta unit 2' }, +]; + +/** + * The fixture's ONE hand-written where-matcher: equality plus `$in` — the two + * shapes the shared resolver actually issues — refusing every other shape + * loudly, so a combinator it does not implement can never read as a field that + * happened not to match. + */ +function matchesWhere(row: any, where: any): boolean { + for (const [field, cond] of Object.entries(where ?? {})) { + if (field.startsWith('$')) { + throw new Error(`fixture where-matcher: unsupported combinator '${field}'`); + } + if (cond !== null && typeof cond === 'object') { + const ops = Object.keys(cond as object); + if (ops.length !== 1 || ops[0] !== '$in' || !Array.isArray((cond as any).$in)) { + throw new Error(`fixture where-matcher: unsupported operator shape on '${field}'`); + } + if (!(cond as any).$in.includes(row[field])) return false; + continue; + } + if (row[field] !== cond) return false; + } + return true; +} + +/** + * The permission store, in the SHIPPED aggregation shapes. `u_exmember`'s key + * is stamped `org_alpha` while its only current `sys_member` row is for + * `org_beta` — the credential outlived the membership that backed it, which is + * the whole scenario. + */ +function makeQl() { + const tables: Record = { + sys_api_key: [ + { id: 'key_member', key: hashApiKey(RAW_MEMBER_KEY), user_id: 'u_member', active_organization_id: 'org_alpha', revoked: false }, + { id: 'key_exmember', key: hashApiKey(RAW_EXMEMBER_KEY), user_id: 'u_exmember', active_organization_id: 'org_alpha', revoked: false }, + { id: 'key_orgless', key: hashApiKey(RAW_ORGLESS_KEY), user_id: 'u_orgless', revoked: false }, + ], + sys_member: [ + { user_id: 'u_member', organization_id: 'org_alpha' }, + { user_id: 'u_exmember', organization_id: 'org_beta' }, + ], + sys_user: [ + { id: 'u_member', email: 'u_member@example.com' }, + { id: 'u_exmember', email: 'u_exmember@example.com' }, + { id: 'u_orgless', email: 'u_orgless@example.com' }, + ], + // RBAC opened SYMMETRICALLY for all three principals through one + // permission set — the cloud reading's discipline. If the three + // principals held different capabilities, RBAC could be what separates + // the arms; with one shared grant, only the organization wall can be. + sys_user_permission_set: [ + { user_id: 'u_member', permission_set_id: 'ps_shared' }, + { user_id: 'u_exmember', permission_set_id: 'ps_shared' }, + { user_id: 'u_orgless', permission_set_id: 'ps_shared' }, + ], + sys_permission_set: [ + { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, + ], + }; + return { + find: async (object: string, q: any = {}) => { + const rows = (tables[object] ?? []).filter((row: any) => matchesWhere(row, q?.where)); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + }; +} + +// --------------------------------------------------------------------------- +// The REST harness — real routes, real `computeExecCtx`, no stubbed exec ctx +// --------------------------------------------------------------------------- + +function makeRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn(() => res); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); + return res; +} + +interface Harness { + rest: RestServer; + /** Every row the fixture table holds, in insertion order. */ + store: () => BusinessUnitRow[]; + warnings: () => string[]; +} + +/** + * The single-kernel wiring, byte-faithful to `rest-api-plugin.ts`: no + * kernelManager, an auth provider and an objectql provider over the lone local + * kernel — plus, unless `omitTenancyProvider` ablates it, the tenancy provider + * decision 1A added. + */ +function setup(opts: { omitTenancyProvider?: boolean } = {}): Harness { + const rows: BusinessUnitRow[] = SEED.map((r) => ({ ...r })); + let seq = 0; + const ql = makeQl(); + + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn().mockResolvedValue({}), + // ADR-0105 Layer 0 under `isolated`, as `tenant-layer.ts` computes it: + // a HARD EQUALITY against the caller's active organization. It never + // reads `accessible_org_ids` — that is the `group` union branch — which + // is precisely why a key naming an organization its owner left passes + // it. Modelled, not mocked away. + findData: vi.fn(async (r: any) => { + const tenantId = r?.context?.tenantId; + const visible = rows.filter((row) => row.organization_id === tenantId); + return { value: visible, total: visible.length }; + }), + createData: vi.fn(async (r: any) => { + const row: BusinessUnitRow = { + id: `w${++seq}`, + organization_id: r?.context?.tenantId, + created_by: r?.context?.userId, + name: String(r?.data?.name ?? ''), + }; + rows.push(row); + return row; + }), + }; + + const server: any = { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; + + const authServiceProvider = async () => ({ + // No session path at all: this matrix is about API keys, and a session + // that silently authenticated would make every arm unreadable. + api: { getSession: async () => undefined }, + }); + const objectQLProvider = async () => ql; + const tenancyServiceProvider = async () => ({ posture: 'isolated' }); + + const rest = new RestServer( + server, + protocol, + {} as any, + undefined, // kernelManager — THE single-kernel wiring + undefined, // envRegistry + undefined, // defaultEnvironmentIdProvider + authServiceProvider, + objectQLProvider, + undefined, undefined, undefined, undefined, undefined, undefined, // email…i18n + undefined, undefined, undefined, undefined, undefined, undefined, // analytics…metadata + opts.omitTenancyProvider ? undefined : tenancyServiceProvider, + ); + rest.registerRoutes(); + + return { + rest, + store: () => rows.map((r) => ({ ...r })), + warnings: () => warnSpy.mock.calls.map((c: unknown[]) => c.map(String).join(' ')), + }; +} + +function routeOf(rest: any, method: string, path: string) { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + if (!route) throw new Error(`${method} ${path} route not registered`); + return route; +} + +function keyHeaders(raw?: string): Record { + return raw ? { 'x-api-key': raw } : {}; +} + +async function callGet(rest: any, raw?: string) { + const res = makeRes(); + await routeOf(rest, 'GET', DATA_COLLECTION).handler( + { method: 'GET', path: `/api/v1/data/${OBJECT}`, params: { object: OBJECT }, query: {}, headers: keyHeaders(raw) }, + res, + ); + return res; +} + +async function callPost(rest: any, raw: string | undefined, name: string) { + const res = makeRes(); + await routeOf(rest, 'POST', DATA_COLLECTION).handler( + { + method: 'POST', path: `/api/v1/data/${OBJECT}`, params: { object: OBJECT }, query: {}, + headers: keyHeaders(raw), body: { name }, + }, + res, + ); + return res; +} + +let warnSpy: ReturnType; +let errorSpy: ReturnType; +beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { warnSpy.mockRestore(); errorSpy.mockRestore(); }); + +// --------------------------------------------------------------------------- +// §1 — Instrument controls. Both directions, before any subject arm is read. +// --------------------------------------------------------------------------- + +describe('[#15256] §1 — the probe can serve, and the door can refuse', () => { + it('CONTROL · data REACHES: a CURRENT member reads its own organization and only that one', async () => { + const h = setup(); + const res = await callGet(h.rest, RAW_MEMBER_KEY); + expect(res.statusCode).toBe(200); + expect(res.body.total).toBe(2); + expect(res.body.value.map((r: BusinessUnitRow) => r.id)).toEqual(['bu_a1', 'bu_a2']); + // The wall IS live: org_beta's two rows exist in the store and are not served. + expect(h.store().filter((r) => r.organization_id === 'org_beta')).toHaveLength(2); + expect(res.body.value.map((r: BusinessUnitRow) => r.organization_id)).toEqual(['org_alpha', 'org_alpha']); + }); + + it('CONTROL · writes REACH: a CURRENT member\'s POST lands, read back FROM THE STORE', async () => { + const h = setup(); + const res = await callPost(h.rest, RAW_MEMBER_KEY, 'w-member'); + expect(res.statusCode).toBe(201); + const landed = h.store().filter((r) => r.name === 'w-member'); + expect(landed).toHaveLength(1); + expect(landed[0]).toMatchObject({ organization_id: 'org_alpha', created_by: 'u_member' }); + }); + + it('CONTROL · the door refuses: no credential is 401 on both verbs', async () => { + const h = setup(); + const get = await callGet(h.rest, undefined); + expect(get.statusCode).toBe(ANONYMOUS_DENY_STATUS); + expect(get.body?.error?.code ?? get.body?.code).toBe(ANONYMOUS_DENY_CODE); + const post = await callPost(h.rest, undefined, 'w-anon'); + expect(post.statusCode).toBe(ANONYMOUS_DENY_STATUS); + // Nothing was written: the store still holds only the seed. + expect(h.store()).toHaveLength(SEED.length); + }); +}); + +// --------------------------------------------------------------------------- +// §2 — THE SUBJECT ROW. The ex-member key, on the single-kernel wiring. +// --------------------------------------------------------------------------- + +describe('[#15256] §2 — an ex-member\'s org-stamped key on the single-kernel wiring under `isolated`', () => { + it('REPAIRED: GET is 401 — was 200 carrying the other organization\'s rows', async () => { + const h = setup(); + const res = await callGet(h.rest, RAW_EXMEMBER_KEY); + expect(res.statusCode).toBe(ANONYMOUS_DENY_STATUS); + expect(res.body?.error?.code ?? res.body?.code).toBe(ANONYMOUS_DENY_CODE); + // ⛔ And the wire says nothing else. A holder of someone else's key must + // learn nothing the generic 401 does not already say (decision 2A). + expect(JSON.stringify(res.body)).not.toMatch(/membership|organization_membership_ended|org_alpha|key_exmember/i); + }); + + it('REPAIRED: POST is 401 and NOTHING LANDS — read back from the store', async () => { + const h = setup(); + const res = await callPost(h.rest, RAW_EXMEMBER_KEY, 'w-exmember'); + expect(res.statusCode).toBe(ANONYMOUS_DENY_STATUS); + // The measured defect was a write that LANDED, stamped with the other + // organization. The store is the authority on whether it did. + expect(h.store().filter((r) => r.name === 'w-exmember')).toHaveLength(0); + expect(h.store()).toHaveLength(SEED.length); + }); + + it('[2A] the refusal is said OUT LOUD on the server side — one `warn`, naming key / principal / organization / reason', async () => { + const h = setup(); + await callGet(h.rest, RAW_EXMEMBER_KEY); + const lines = h.warnings().filter((l) => l.includes('API key refused')); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('organization_membership_ended'); + expect(lines[0]).toContain('key=key_exmember'); + expect(lines[0]).toContain('principal=u_exmember'); + expect(lines[0]).toContain('organization=org_alpha'); + // ⛔ NEVER the credential — neither the raw key nor its at-rest hash. + expect(lines[0]).not.toContain(RAW_EXMEMBER_KEY); + expect(lines[0]).not.toContain(hashApiKey(RAW_EXMEMBER_KEY)); + }); +}); + +// --------------------------------------------------------------------------- +// §3 — The organization-less key: the silent-empty row of the same matrix. +// --------------------------------------------------------------------------- + +describe('[#15256] §3 — an organization-less key under `isolated`', () => { + it('REPAIRED: GET is 401 — was 200 with total 0, a silent empty set', async () => { + const h = setup(); + const res = await callGet(h.rest, RAW_ORGLESS_KEY); + expect(res.statusCode).toBe(ANONYMOUS_DENY_STATUS); + expect(res.body?.error?.code ?? res.body?.code).toBe(ANONYMOUS_DENY_CODE); + }); + + it('REPAIRED: POST is 401 and nothing lands', async () => { + const h = setup(); + const res = await callPost(h.rest, RAW_ORGLESS_KEY, 'w-orgless'); + expect(res.statusCode).toBe(ANONYMOUS_DENY_STATUS); + expect(h.store().filter((r) => r.name === 'w-orgless')).toHaveLength(0); + }); + + it('[2A] its refusal is its own line, with its own reason', async () => { + const h = setup(); + await callGet(h.rest, RAW_ORGLESS_KEY); + const lines = h.warnings().filter((l) => l.includes('API key refused')); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('organization_required'); + expect(lines[0]).toContain('key=key_orgless'); + expect(lines[0]).toContain('principal=u_orgless'); + // The key names no organization — that IS the reason, said plainly. + expect(lines[0]).toContain('organization='); + expect(lines[0]).not.toContain(RAW_ORGLESS_KEY); + }); + + it('a REFUSAL is not a key scanner\'s log: an unknown key is silent', async () => { + // Volume control for 2A. `outcome: 'none'` — unknown, revoked, expired + // or absent — is never a refusal, so a prober writes no lines here. + const h = setup(); + const res = await callGet(h.rest, 'osk_not_a_real_key'); + expect(res.statusCode).toBe(ANONYMOUS_DENY_STATUS); + expect(h.warnings().filter((l) => l.includes('API key refused'))).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// §4 — THE ABLATION. Remove the provider; the ex-member row comes back. +// +// One variable changes: the tenancy provider decision 1A wired. The tenancy +// service, the engine, the keys, the routes and the store are byte-identical to +// §2. A pin that cannot go red has measured nothing. +// --------------------------------------------------------------------------- + +describe('[#15256] §4 — ablation: without the provider, the measured leak returns', () => { + it('the ex-member READS the other organization again — GET 200, total 2', async () => { + const h = setup({ omitTenancyProvider: true }); + const res = await callGet(h.rest, RAW_EXMEMBER_KEY); + expect(res.statusCode).toBe(200); + expect(res.body.total).toBe(2); + expect(res.body.value.map((r: BusinessUnitRow) => r.id)).toEqual(['bu_a1', 'bu_a2']); + }); + + it('the ex-member WRITES into it again — POST 201, the row read back from the store carries `org_alpha` / `u_exmember`', async () => { + const h = setup({ omitTenancyProvider: true }); + const res = await callPost(h.rest, RAW_EXMEMBER_KEY, 'w-exmember'); + expect(res.statusCode).toBe(201); + const landed = h.store().filter((r) => r.name === 'w-exmember'); + expect(landed).toHaveLength(1); + expect(landed[0]).toMatchObject({ organization_id: 'org_alpha', created_by: 'u_exmember' }); + }); + + it('the organization-less key goes back to the silent empty set — 200, total 0', async () => { + const h = setup({ omitTenancyProvider: true }); + const res = await callGet(h.rest, RAW_ORGLESS_KEY); + expect(res.statusCode).toBe(200); + expect(res.body.total).toBe(0); + }); + + it('and NOTHING is said about any of it — no refusal line, because no refusal was decided', async () => { + const h = setup({ omitTenancyProvider: true }); + await callGet(h.rest, RAW_EXMEMBER_KEY); + await callGet(h.rest, RAW_ORGLESS_KEY); + expect(h.warnings().filter((l) => l.includes('API key refused'))).toHaveLength(0); + }); + + it('NARROWNESS: the member control is UNCHANGED by the ablation — the provider is what moved, not the fixture', async () => { + const h = setup({ omitTenancyProvider: true }); + const res = await callGet(h.rest, RAW_MEMBER_KEY); + expect(res.statusCode).toBe(200); + expect(res.body.total).toBe(2); + }); +}); From bf99558b4db4ca765e2d7091bda5cabfa33c088a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:43:49 +0800 Subject: [PATCH 3/4] chore(changeset): @objectstack/rest patch + @objectstack/core patch for the single-kernel posture seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../single-kernel-tenancy-posture-provider.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .changeset/single-kernel-tenancy-posture-provider.md diff --git a/.changeset/single-kernel-tenancy-posture-provider.md b/.changeset/single-kernel-tenancy-posture-provider.md new file mode 100644 index 0000000000..684f6b19c1 --- /dev/null +++ b/.changeset/single-kernel-tenancy-posture-provider.md @@ -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. From 4cfedfe01e2fac3aff9525eacf54be1b24cb6624 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:53:42 +0800 Subject: [PATCH 4/4] docs(permissions): re-anchor the system-context census line citations after the rest-server insertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- content/docs/permissions/system-context.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index e33b4b577e..e6803d98d0 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -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: @@ -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 @@ -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` | @@ -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` | ---