Skip to content

Commit 65534cf

Browse files
hotlongclaude
andcommitted
fix(runtime): the dispatcher's organization source reads the vetted execution context, not the raw session claim
`resolveActiveOrganizationId` returned `session.activeOrganizationId` as stored, so the nine `/packages` doors that ask it kept a member removed from an organization inside it under a walled posture. It now returns the `tenantId` `resolveAuthzContext` vetted onto the execution context (ruling B on #15409), through the helper RestServer and the `/meta` doors share. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
1 parent d8e6287 commit 65534cf

2 files changed

Lines changed: 46 additions & 43 deletions

File tree

‎packages/runtime/src/domain-handler-registry.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,13 @@ export interface DomainHandlerDeps {
320320
* surface relies on field-anchored 422s).
321321
*/
322322
errorFromThrown(e: any, fallbackStatus?: number): { status: number; body: any };
323-
/** Active organization id from the request session (undefined if anonymous / no auth). */
323+
/**
324+
* The caller's VETTED active organization — the execution context's
325+
* `tenantId`, never the session's stored `activeOrganizationId`, which the
326+
* identity step drops under a walled posture when no membership backs it
327+
* (#20477). `undefined` for anonymous, no active organization, or a dropped
328+
* claim.
329+
*/
324330
resolveActiveOrganizationId(context: HttpProtocolContext): Promise<string | undefined>;
325331
/**
326332
* Fire a kernel-context event on the request's resolved kernel (no-op

‎packages/runtime/src/http-dispatcher.ts‎

Lines changed: 39 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts';
1515
import type { PrimaryDatasourceVerdict } from '@objectstack/objectql';
1616
import { readServiceSelfInfo, readChannelRoute, isSubscribableChannel, DispatcherErrorCode, resolveDiscoveryEnvironment } from '@objectstack/spec/api';
1717
import type { ServiceInfo } from '@objectstack/spec/api';
18+
// [#20477] The caller's VETTED organization, read by the one helper `RestServer`
19+
// and the dispatcher's `/meta` doors share — imported, never restated.
20+
import { metaCallerOrganizationId } from '@objectstack/rest';
1821
import { apiErrorResponse } from './error-envelope.js';
1922
import { resolveRuntimeVersion } from './runtime-version.js';
2023
import type { ExecutionContext } from '@objectstack/spec/kernel';
@@ -2181,51 +2184,45 @@ export class HttpDispatcher {
21812184
}
21822185

21832186
/**
2184-
* Resolve `resolveActiveOrganizationId` — the ACTIVE ORGANIZATION id on the
2185-
* request's auth session, i.e. `session.activeOrganizationId`, normalising
2186-
* plain-object headers into a `Headers` instance first because the auth
2187-
* API's `getSession` requires one. Returns `undefined` for anonymous calls,
2188-
* when auth is not wired up, when the session names no active organization,
2189-
* and on any failure reaching the auth service — the caller cannot
2190-
* distinguish those four, by design.
2187+
* Resolve `resolveActiveOrganizationId` — the caller's VETTED active
2188+
* organization: the `tenantId` the identity step wrote onto
2189+
* `context.executionContext` (`resolveRequestScope` →
2190+
* `resolveExecutionContext` → `resolveAuthzContext`), ⛔ never the auth
2191+
* session's `activeOrganizationId` as stored.
21912192
*
2192-
* ⚠️ Until this was corrected the block here described `resolveCallerUserId`
2193-
* ("the calling user id from the request session"), a sibling deleted with
2194-
* the multi-tenant `/cloud` control plane. TSDoc binds a block by POSITION,
2195-
* so deleting the declaration under a docblock does not delete the docblock
2196-
* — it silently re-points it at the next declaration down, which then ships
2197-
* a description of a method that no longer exists. A rename sweep cannot
2198-
* catch that, because a sweep reads the docblock OF the method it is
2199-
* changing and this one read as if it already belonged there. Keep the
2200-
* first line naming the method it documents, so the next re-point is
2201-
* visible on sight rather than plausible.
2193+
* [#20477] Those are two different values. `resolveAuthzContext`
2194+
* (`@objectstack/core`) VETS the session claim: under a wall-enforcing
2195+
* tenancy posture, a claim naming an organization its owner no longer
2196+
* belongs to is DROPPED, and the request resolves with no active
2197+
* organization (maintainer ruling B on #15409, PR #15794). This source used
2198+
* to call the auth service's `getSession` and return the claim unread, so
2199+
* every door that asked it — the nine `/packages` doors — kept a removed
2200+
* member inside the organization they had left for the rest of the
2201+
* session: its commit history and export were served to them, and their
2202+
* publish-drafts, discard-drafts, revert, rollback, adopt-orphans,
2203+
* duplicate and uninstall ran inside it. Reading the vetted value HERE
2204+
* fixes every caller at once and adds no second vetting path. It is the
2205+
* value `RestServer` scopes a caller by (`ctx.tenantId`), read through the
2206+
* one helper the dispatcher's `/meta` doors already use (#20408), so no
2207+
* transport or domain can answer a different organization for one caller.
2208+
*
2209+
* An admitted API key's organization is its binding, so a key caller is
2210+
* answered that organization here; the session read found no session for a
2211+
* key and answered `undefined`.
2212+
*
2213+
* Returns `undefined` for an anonymous caller, a caller with no active
2214+
* organization, and one whose claim was dropped — one fact to every
2215+
* consumer. Every caller runs after `dispatch()`'s identity step: the
2216+
* `/packages` domain's anonymous-deny floor refuses a context carrying no
2217+
* resolved principal before any of them is reached.
2218+
*
2219+
* Keep the first line naming the method it documents: TSDoc binds a block
2220+
* by POSITION, so deleting a declaration from under its docblock silently
2221+
* re-points the block at the next declaration down (this one once described
2222+
* `resolveCallerUserId`, a sibling deleted with the `/cloud` control plane).
22022223
*/
22032224
private async resolveActiveOrganizationId(context: HttpProtocolContext): Promise<string | undefined> {
2204-
try {
2205-
// [#4127 FINDING, batch 5]
2206-
// Third `.api` reader on the auth service; see the note above.
2207-
const authService = await this.resolveService(this.requestKernel(context), CoreServiceName.enum.auth);
2208-
const rawHeaders = context.request?.headers;
2209-
let headers: any = rawHeaders;
2210-
if (rawHeaders && typeof rawHeaders === 'object' && typeof (rawHeaders as any).get !== 'function') {
2211-
try {
2212-
const h = new Headers();
2213-
for (const [k, v] of Object.entries(rawHeaders as Record<string, any>)) {
2214-
if (v == null) continue;
2215-
h.set(k, Array.isArray(v) ? v.join(', ') : String(v));
2216-
}
2217-
headers = h;
2218-
} catch {
2219-
headers = rawHeaders;
2220-
}
2221-
}
2222-
const apiObj = authService?.auth?.api ?? authService?.api;
2223-
const sessionData = await apiObj?.getSession?.call(apiObj, { headers });
2224-
const oid = sessionData?.session?.activeOrganizationId;
2225-
return typeof oid === 'string' && oid.length > 0 ? oid : undefined;
2226-
} catch {
2227-
return undefined;
2228-
}
2225+
return metaCallerOrganizationId(context.executionContext);
22292226
}
22302227

22312228
/** Thin delegate — body extracted to `./domains/ui.ts` (D11③ PR-3). */

0 commit comments

Comments
 (0)