From d8e6287dad6d247beb7eeb965a36d9a7d3940bce Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:06:57 +0800 Subject: [PATCH 1/4] test(runtime): pin the /packages doors to the vetted organization (reach measurement, fix pending) Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- .../packages-vetted-org-source.test.ts | 406 ++++++++++++++++++ 1 file changed, 406 insertions(+) create mode 100644 packages/runtime/src/domains/packages-vetted-org-source.test.ts diff --git a/packages/runtime/src/domains/packages-vetted-org-source.test.ts b/packages/runtime/src/domains/packages-vetted-org-source.test.ts new file mode 100644 index 00000000000..3b57525cd93 --- /dev/null +++ b/packages/runtime/src/domains/packages-vetted-org-source.test.ts @@ -0,0 +1,406 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20477] The dispatcher's `/packages` doors scope a caller to the VETTED + * active organization on the request's execution context, never to the + * session's `activeOrganizationId` as stored. + * + * ## The defect + * + * Nine `/packages` doors take their organization from + * `deps.resolveActiveOrganizationId`, and that source read + * `session.activeOrganizationId` off the auth service unchanged. The value the + * identity step VETS is a different one: `resolveAuthzContext` + * (`@objectstack/core`) writes the caller's organization onto the execution + * context as `tenantId`, and under a wall-enforcing posture it DROPS a session + * claim naming an organization its owner no longer belongs to (maintainer + * ruling B on #15409, implemented by PR #15794). The dispatcher's doors never + * saw the drop, so a member removed from an organization kept that + * organization's package partition for the rest of the session: they read its + * commit history, export and duplicate source, and they published, discarded, + * reverted, rolled back, adopted and uninstalled inside it. + * + * ## The rig + * + * The REAL identity resolution on every request: `dispatch()` runs + * `resolveRequestScope` → `resolveExecutionContext` → `resolveAuthzContext` + * under an `isolated` posture. Only the caller's stored session claim and + * their `sys_member` rows differ between arms, and every caller holds the ONE + * shared permission set, so RBAC cannot separate any two arms: only the + * organization can. + * + * Both HTTP entries into the `/packages` domain drive it: + * + * - `dispatch()` itself, the delegate of `createHonoApp`'s `${prefix}/*` + * catch-all; + * - `createDispatcherPlugin`'s explicit package mounts, over a real socket on + * `HonoServerPlugin`. + * + * `RestServer` is not a third: its package registrar mounts only + * `POST /packages/publish`, and the dispatcher's domain is the family's one + * implementation. + * + * ## The protocol double + * + * It keeps each organization's package state in its own partition, plus an + * env-wide one, and answers every verb from the partition the request's + * `organizationId` names (env-wide when none). Every partition's names carry + * its organization, so a read of organization A's rows is visible in the + * answer and a write to them is visible in the store. `deletePackage` mirrors + * the real protocol's refusal of an uninstall that names no organization + * (`400 TENANT_SCOPE_REQUIRED`, `metadata-protocol` `deletePackage`). + */ + +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { LiteKernel, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_STATUS } from '@objectstack/core'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import type { IHttpServer } from '@objectstack/spec/contracts'; +import { HttpDispatcher } from '../http-dispatcher.js'; +import { createDispatcherPlugin } from '../dispatcher-plugin.js'; + +const PREFIX = '/api/v1'; +const PKG = 'com.acme.crm'; +const ALPHA = 'org_alpha'; +const BETA = 'org_beta'; +/** The env-wide partition's key: what a request carrying no organization reaches. */ +const ENV_WIDE = 'env_wide'; +const SESSION_HEADER = 'x-test-session'; + +// ── The package store: one partition per organization, and the env-wide one ── + +interface Partition { + drafts: string[]; + commits: string[]; + rows: string[]; + orphans: string[]; + dashboards: Array<{ name: string; label: string }>; +} + +function partition(tag: string): Partition { + return { + drafts: [`${tag}_draft`], + commits: [`cmt_${tag}`], + rows: [`${tag}_row`], + orphans: [`${tag}_orphan`], + dashboards: [{ name: `${tag}_board`, label: `${tag} board` }], + }; +} + +function seedStore(): Record { + return { [ALPHA]: partition('alpha'), [BETA]: partition('beta'), [ENV_WIDE]: partition('shared') }; +} + +interface ProtocolCall { verb: string; organizationId: unknown } + +/** Mutable per-test state the protocol double reads — reset before every test. */ +const state: { store: Record; calls: ProtocolCall[] } = { store: seedStore(), calls: [] }; + +/** A refusal whose text never echoes request input, so a leak check on the answer reads only partition data. */ +function notFound(): Error { + return Object.assign(new Error('No such commit in this scope.'), { code: 'NOT_FOUND', status: 404 }); +} + +function protocolDouble() { + const scopeOf = (req: any): string => (typeof req?.organizationId === 'string' ? req.organizationId : ENV_WIDE); + const record = (verb: string, req: any) => { + state.calls.push({ verb, organizationId: req?.organizationId }); + return state.store[scopeOf(req)] ?? partition('unseeded'); + }; + const takeCommit = (p: Partition, commitId: unknown) => { + const at = p.commits.indexOf(String(commitId)); + if (at < 0) throw notFound(); + p.commits.splice(at, 1); + }; + return { + publishPackageDrafts: async (req: any) => { + const drafts = record('publishPackageDrafts', req).drafts.splice(0); + return { success: true, publishedCount: drafts.length, failedCount: 0, published: [], failed: [], drafts }; + }, + discardPackageDrafts: async (req: any) => { + const discarded = record('discardPackageDrafts', req).drafts.splice(0); + return { success: true, discardedCount: discarded.length, discarded }; + }, + listCommits: async (req: any) => + record('listCommits', req).commits.map((id) => ({ id, packageId: req.packageId })), + revertCommit: async (req: any) => { + takeCommit(record('revertCommit', req), req.commitId); + return { success: true }; + }, + rollbackToPackageCommit: async (req: any) => { + takeCommit(record('rollbackToPackageCommit', req), req.commitId); + return { success: true }; + }, + reassignOrphanedMetadata: async (req: any) => { + const reassigned = record('reassignOrphanedMetadata', req).orphans.splice(0); + return { success: true, reassignedCount: reassigned.length, reassigned }; + }, + duplicatePackage: async (req: any) => ({ success: true, copied: [...record('duplicatePackage', req).rows] }), + deletePackage: async (req: any) => { + state.calls.push({ verb: 'deletePackage', organizationId: req?.organizationId }); + if (!req?.organizationId && req?.allTenants !== true) { + throw Object.assign(new Error('Refusing to uninstall with no organization scope.'), { + code: 'TENANT_SCOPE_REQUIRED', status: 400, + }); + } + // An org-scoped uninstall reaches the org's rows AND the env-wide ones (#7705). + const deleted = [...state.store[scopeOf(req)].rows.splice(0), ...state.store[ENV_WIDE].rows.splice(0)]; + return { success: true, deletedCount: deleted.length, failedCount: 0, deleted }; + }, + getMetaItems: async (req: any) => { + const p = record('getMetaItems', req); + if (req?.type !== 'dashboard') return { items: [] }; + const items = scopeOf(req) === ENV_WIDE ? p.dashboards : [...state.store[ENV_WIDE].dashboards, ...p.dashboards]; + return { items: items.map((d) => ({ ...d })) }; + }, + }; +} + +// ── Identity: sessions and the permission store, in the shipped shapes ──────── + +/** + * `u_exmember`'s stale session names `org_alpha` while their only current + * `sys_member` row is `org_beta`. `u_member` is a current `org_alpha` member. + * Both hold ONE shared permission set carrying both package capabilities. + */ +function makeQl() { + const tables: Record = { + sys_api_key: [], + sys_member: [ + { user_id: 'u_member', organization_id: ALPHA, role: 'member' }, + { user_id: 'u_exmember', organization_id: BETA, role: 'member' }, + ], + sys_user: [ + { id: 'u_member', email: 'u_member@example.com' }, + { id: 'u_exmember', email: 'u_exmember@example.com' }, + ], + sys_user_permission_set: [ + { user_id: 'u_member', permission_set_id: 'ps_shared' }, + { user_id: 'u_exmember', permission_set_id: 'ps_shared' }, + ], + sys_permission_set: [ + { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, + ], + }; + /** Equality plus `$in`, and a loud refusal of every other shape. */ + const matches = (row: any, where: any): boolean => Object.entries(where ?? {}).every(([field, cond]) => { + 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}'`); + } + return (cond as any).$in.includes(row[field]); + } + return row[field] === cond; + }); + const pkg = { manifest: { id: PKG, name: 'CRM', version: '1.0.0', scope: 'project' } }; + return { + find: async (object: string, q: any = {}) => { + const rows = (tables[object] ?? []).filter((row: any) => matches(row, q?.where)); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + // The `/packages` domain's registry probe, answered for one writable base. + registry: { + getAllPackages: () => [pkg], + getPackage: (id: string) => (id === PKG ? pkg : undefined), + // `false` keeps the uninstall off the persisted disable-state file; the + // subject here is the organization the protocol call carries. + uninstallPackage: () => false, + }, + }; +} + +interface SessionRow { id: string; token: string; userId: string; activeOrganizationId: string } + +const SESSIONS: Record = { + sid_member: { id: 'ses_member', token: 'tok_member', userId: 'u_member', activeOrganizationId: ALPHA }, + // ⭐ THE SUBJECT: the claim outlived the membership that backed it. + sid_exmember: { id: 'ses_exmember', token: 'tok_exmember', userId: 'u_exmember', activeOrganizationId: ALPHA }, + // The same person, switched to an organization they ARE in. + sid_exmember_beta: { id: 'ses_exmember_beta', token: 'tok_exmember_beta', userId: 'u_exmember', activeOrganizationId: BETA }, +}; + +function authService() { + return { + api: { + getSession: async ({ headers }: any) => { + const sid: string | undefined = typeof headers?.get === 'function' + ? headers.get(SESSION_HEADER) ?? undefined + : headers?.[SESSION_HEADER]; + const row = sid ? SESSIONS[sid] : undefined; + if (!row) return undefined; + return { user: { id: row.userId, email: `${row.userId}@example.com` }, session: { ...row } }; + }, + }, + }; +} + +const TENANCY = { posture: 'isolated' }; + +function services(): Record { + return { protocol: protocolDouble(), auth: authService(), objectql: makeQl(), tenancy: TENANCY }; +} + +// ── The two HTTP entries into the domain ────────────────────────────────────── + +type Who = 'member' | 'exmember' | 'exmember_beta' | 'anonymous'; +const SID: Record = { + member: 'sid_member', exmember: 'sid_exmember', exmember_beta: 'sid_exmember_beta', anonymous: undefined, +}; + +interface Answer { status: number; code?: string; body: any } +type Call = (method: string, who: Who, path: string, body?: unknown) => Promise; + +/** `dispatch()` — the delegate of `createHonoApp`'s catch-all. */ +function dispatchEntry(): Call { + const svc = services(); + const get = (n: string) => svc[n] ?? null; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + return async (method, who, path, body) => { + const sid = SID[who]; + const res = await dispatcher.dispatch(method, path, body, {}, { + request: { headers: sid ? { [SESSION_HEADER]: sid } : {} }, + } as any); + const b: any = res.response?.body; + return { status: res.response?.status ?? 0, code: b?.error?.code, body: b }; + }; +} + +/** `createDispatcherPlugin`'s explicit package mounts, over a real socket. */ +function servicesPlugin(): Plugin { + return { + name: 'com.objectstack.test.packages-vetted-org-source', + version: '1.0.0', + init: async (ctx: PluginContext) => { + for (const [name, svc] of Object.entries(services())) ctx.registerService(name, svc); + }, + }; +} + +let socketKernel: LiteKernel | undefined; +let socketBase = ''; + +const socketEntry: Call = async (method, who, path, body) => { + const sid = SID[who]; + const res = await fetch(`${socketBase}${PREFIX}${path}`, { + method, + headers: { 'content-type': 'application/json', ...(sid ? { [SESSION_HEADER]: sid } : {}) }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + let b: any; + try { b = await res.json(); } catch { b = undefined; } + return { status: res.status, code: b?.error?.code, body: b }; +}; + +const TRANSPORTS: Array<[string, () => Call]> = [ + ['dispatch()', dispatchEntry], + ['the plugin mount (socket)', () => socketEntry], +]; + +beforeAll(async () => { + socketKernel = new LiteKernel(); + socketKernel.use(new HonoServerPlugin({ port: 0, cors: false })); + socketKernel.use(servicesPlugin()); + socketKernel.use(createDispatcherPlugin({ prefix: PREFIX, securityHeaders: false })); + await socketKernel.bootstrap(); + socketBase = `http://127.0.0.1:${socketKernel.getService('http.server').getPort!()}`; +}, 60_000); + +afterAll(async () => { + if (!socketKernel) return; + await Promise.race([socketKernel.shutdown(), new Promise((r) => setTimeout(r, 10_000))]); +}, 30_000); + +let warnSpy: ReturnType; +beforeEach(() => { + state.store = seedStore(); + state.calls = []; + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +afterEach(() => { warnSpy.mockRestore(); }); + +// ── The nine doors that read the organization source ────────────────────────── + +interface Door { name: string; method: string; path: string; body?: unknown; verb: string } + +const DOORS: Door[] = [ + { name: 'POST /packages/:id/publish-drafts', method: 'POST', path: `/packages/${PKG}/publish-drafts`, body: {}, verb: 'publishPackageDrafts' }, + { name: 'POST /packages/:id/discard-drafts', method: 'POST', path: `/packages/${PKG}/discard-drafts`, body: {}, verb: 'discardPackageDrafts' }, + { name: 'GET /packages/:id/commits', method: 'GET', path: `/packages/${PKG}/commits`, verb: 'listCommits' }, + { name: 'POST /packages/:id/commits/:commitId/revert', method: 'POST', path: `/packages/${PKG}/commits/cmt_alpha/revert`, body: {}, verb: 'revertCommit' }, + { name: 'POST /packages/:id/rollback', method: 'POST', path: `/packages/${PKG}/rollback`, body: { commitId: 'cmt_alpha' }, verb: 'rollbackToPackageCommit' }, + { name: 'POST /packages/:id/adopt-orphans', method: 'POST', path: `/packages/${PKG}/adopt-orphans`, body: {}, verb: 'reassignOrphanedMetadata' }, + { name: 'POST /packages/:id/duplicate', method: 'POST', path: `/packages/${PKG}/duplicate`, body: { targetPackageId: 'com.acme.crm_copy' }, verb: 'duplicatePackage' }, + { name: 'DELETE /packages/:id', method: 'DELETE', path: `/packages/${PKG}`, verb: 'deletePackage' }, + { name: 'GET /packages/:id/export', method: 'GET', path: `/packages/${PKG}/export`, verb: 'getMetaItems' }, +]; + +/** Did this request read or write organization `tag`'s partition? */ +function touched(orgId: string, tag: string, answer: Answer): { wrote: boolean; read: boolean } { + return { + wrote: JSON.stringify(state.store[orgId]) !== JSON.stringify(partition(tag)), + read: JSON.stringify(answer.body ?? null).includes(tag), + }; +} + +const organizationsCarried = () => [...new Set(state.calls.map((c) => c.organizationId))]; + +// ── Controls: the rig separates the organizations, and the resolver drops the claim ── + +describe('[#20477] controls: the rig can tell the organizations apart', () => { + for (const [transport, entry] of TRANSPORTS) { + for (const door of DOORS) { + it(`${transport} · ${door.name}: a CURRENT member reaches their own organization's partition`, async () => { + const answer = await entry()(door.method, 'member', door.path, door.body); + expect(state.calls.map((c) => c.verb)).toContain(door.verb); + expect(organizationsCarried()).toEqual([ALPHA]); + const { wrote, read } = touched(ALPHA, 'alpha', answer); + expect(wrote || read, `${door.name} -> ${answer.status} ${JSON.stringify(answer.body)}`).toBe(true); + }); + + it(`${transport} · ${door.name}: the ex-member, switched to an organization they ARE in, reaches that one and never the left one`, async () => { + const answer = await entry()(door.method, 'exmember_beta', door.path, door.body); + expect(organizationsCarried()).toEqual([BETA]); + expect(touched(ALPHA, 'alpha', answer)).toEqual({ wrote: false, read: false }); + }); + + it(`${transport} · ${door.name}: an anonymous caller is refused before any protocol call, as before`, async () => { + const answer = await entry()(door.method, 'anonymous', door.path, door.body); + expect({ status: answer.status, code: answer.code }).toEqual({ status: ANONYMOUS_DENY_STATUS, code: ANONYMOUS_DENY_CODE }); + expect(state.calls).toEqual([]); + }); + } + + it(`${transport}: the resolver really DROPS the ex-member's claim — the subject below is not a rig that never presented it`, async () => { + await entry()('GET', 'exmember', `/packages/${PKG}/commits`); + const dropped = warnSpy.mock.calls.map((args: unknown[]) => String(args[0])) + .filter((line: string) => line.includes('Session organization claim dropped')); + expect(dropped.length).toBeGreaterThan(0); + expect(dropped[0]).toContain(`organization=${ALPHA}`); + }); + } +}); + +// ── The subject: a claim the resolver dropped scopes nothing ────────────────── + +describe('[#20477] a session claim the resolver DROPPED reaches no organization on any /packages door', () => { + for (const [transport, entry] of TRANSPORTS) { + for (const door of DOORS) { + it(`${transport} · ${door.name}: the protocol is handed no organization, and the left organization's rows are neither read nor written`, async () => { + const answer = await entry()(door.method, 'exmember', door.path, door.body); + expect(state.calls.map((c) => c.verb)).toContain(door.verb); + expect(organizationsCarried()).toEqual([undefined]); + expect(touched(ALPHA, 'alpha', answer), `${door.name} -> ${answer.status} ${JSON.stringify(answer.body)}`) + .toEqual({ wrote: false, read: false }); + }); + } + + it(`${transport} · DELETE /packages/:id: the org-less uninstall gets the protocol's ruled refusal, and nothing is deleted`, async () => { + const answer = await entry()('DELETE', 'exmember', `/packages/${PKG}`); + expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'TENANT_SCOPE_REQUIRED' }); + expect(state.store).toEqual(seedStore()); + }); + } +}); From 65534cfc7adbe135b5547de80a711ea1c82bb8e4 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:11:55 +0800 Subject: [PATCH 2/4] 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 --- .../runtime/src/domain-handler-registry.ts | 8 +- packages/runtime/src/http-dispatcher.ts | 81 +++++++++---------- 2 files changed, 46 insertions(+), 43 deletions(-) diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts index 130fb34ae87..a81f6e9ddd1 100644 --- a/packages/runtime/src/domain-handler-registry.ts +++ b/packages/runtime/src/domain-handler-registry.ts @@ -320,7 +320,13 @@ export interface DomainHandlerDeps { * surface relies on field-anchored 422s). */ errorFromThrown(e: any, fallbackStatus?: number): { status: number; body: any }; - /** Active organization id from the request session (undefined if anonymous / no auth). */ + /** + * The caller's VETTED active organization — the execution context's + * `tenantId`, never the session's stored `activeOrganizationId`, which the + * identity step drops under a walled posture when no membership backs it + * (#20477). `undefined` for anonymous, no active organization, or a dropped + * claim. + */ resolveActiveOrganizationId(context: HttpProtocolContext): Promise; /** * Fire a kernel-context event on the request's resolved kernel (no-op diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 5a14864eab4..d6ad749b8ce 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -15,6 +15,9 @@ import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; import type { PrimaryDatasourceVerdict } from '@objectstack/objectql'; import { readServiceSelfInfo, readChannelRoute, isSubscribableChannel, DispatcherErrorCode, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; import type { ServiceInfo } from '@objectstack/spec/api'; +// [#20477] The caller's VETTED organization, read by the one helper `RestServer` +// and the dispatcher's `/meta` doors share — imported, never restated. +import { metaCallerOrganizationId } from '@objectstack/rest'; import { apiErrorResponse } from './error-envelope.js'; import { resolveRuntimeVersion } from './runtime-version.js'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -2181,51 +2184,45 @@ export class HttpDispatcher { } /** - * Resolve `resolveActiveOrganizationId` — the ACTIVE ORGANIZATION id on the - * request's auth session, i.e. `session.activeOrganizationId`, normalising - * plain-object headers into a `Headers` instance first because the auth - * API's `getSession` requires one. Returns `undefined` for anonymous calls, - * when auth is not wired up, when the session names no active organization, - * and on any failure reaching the auth service — the caller cannot - * distinguish those four, by design. + * Resolve `resolveActiveOrganizationId` — the caller's VETTED active + * organization: the `tenantId` the identity step wrote onto + * `context.executionContext` (`resolveRequestScope` → + * `resolveExecutionContext` → `resolveAuthzContext`), ⛔ never the auth + * session's `activeOrganizationId` as stored. * - * ⚠️ Until this was corrected the block here described `resolveCallerUserId` - * ("the calling user id from the request session"), a sibling deleted with - * the multi-tenant `/cloud` control plane. TSDoc binds a block by POSITION, - * so deleting the declaration under a docblock does not delete the docblock - * — it silently re-points it at the next declaration down, which then ships - * a description of a method that no longer exists. A rename sweep cannot - * catch that, because a sweep reads the docblock OF the method it is - * changing and this one read as if it already belonged there. Keep the - * first line naming the method it documents, so the next re-point is - * visible on sight rather than plausible. + * [#20477] Those are two different values. `resolveAuthzContext` + * (`@objectstack/core`) VETS the session claim: under a wall-enforcing + * tenancy posture, a claim naming an organization its owner no longer + * belongs to is DROPPED, and the request resolves with no active + * organization (maintainer ruling B on #15409, PR #15794). This source used + * to call the auth service's `getSession` and return the claim unread, so + * every door that asked it — the nine `/packages` doors — kept a removed + * member inside the organization they had left for the rest of the + * session: its commit history and export were served to them, and their + * publish-drafts, discard-drafts, revert, rollback, adopt-orphans, + * duplicate and uninstall ran inside it. Reading the vetted value HERE + * fixes every caller at once and adds no second vetting path. It is the + * value `RestServer` scopes a caller by (`ctx.tenantId`), read through the + * one helper the dispatcher's `/meta` doors already use (#20408), so no + * transport or domain can answer a different organization for one caller. + * + * An admitted API key's organization is its binding, so a key caller is + * answered that organization here; the session read found no session for a + * key and answered `undefined`. + * + * Returns `undefined` for an anonymous caller, a caller with no active + * organization, and one whose claim was dropped — one fact to every + * consumer. Every caller runs after `dispatch()`'s identity step: the + * `/packages` domain's anonymous-deny floor refuses a context carrying no + * resolved principal before any of them is reached. + * + * Keep the first line naming the method it documents: TSDoc binds a block + * by POSITION, so deleting a declaration from under its docblock silently + * re-points the block at the next declaration down (this one once described + * `resolveCallerUserId`, a sibling deleted with the `/cloud` control plane). */ private async resolveActiveOrganizationId(context: HttpProtocolContext): Promise { - try { - // [#4127 FINDING, batch 5] - // Third `.api` reader on the auth service; see the note above. - const authService = await this.resolveService(this.requestKernel(context), CoreServiceName.enum.auth); - const rawHeaders = context.request?.headers; - let headers: any = rawHeaders; - if (rawHeaders && typeof rawHeaders === 'object' && typeof (rawHeaders as any).get !== 'function') { - try { - const h = new Headers(); - for (const [k, v] of Object.entries(rawHeaders as Record)) { - if (v == null) continue; - h.set(k, Array.isArray(v) ? v.join(', ') : String(v)); - } - headers = h; - } catch { - headers = rawHeaders; - } - } - const apiObj = authService?.auth?.api ?? authService?.api; - const sessionData = await apiObj?.getSession?.call(apiObj, { headers }); - const oid = sessionData?.session?.activeOrganizationId; - return typeof oid === 'string' && oid.length > 0 ? oid : undefined; - } catch { - return undefined; - } + return metaCallerOrganizationId(context.executionContext); } /** Thin delegate — body extracted to `./domains/ui.ts` (D11③ PR-3). */ From 48462a10eab67cc7990960dbeea946edd4ba7790 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:13:32 +0800 Subject: [PATCH 3/4] test(runtime): the seed-apply org-scope file hands its organization in where dispatch() now reads it Its organization arrived through a stubbed `getSession`, the raw source this card retires; after the fix every measurement in the file had moved to the one-rung branch and only its section-0 control caught it. The organization now lands on the execution context's `tenantId`, and the dead session stub is gone. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- .../packages-seed-apply-org-scope.test.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts b/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts index d50ef72dc31..f2cbf9404d4 100644 --- a/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts +++ b/packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts @@ -50,7 +50,7 @@ * would double-insert"). So the second call presents a `publishPackageDrafts` * that reports the published seed without a `seedApplied` field — the exact * population this fallback documents itself as existing for. It also records - * the request it received, which is §0's positive control that the session's + * the request it received, which is §0's positive control that the caller's * organization really reached this request. * * ## Sections, and which are evidence vs. which are the bound @@ -217,18 +217,24 @@ function makeEngine() { return engine; } -/** An authenticated package admin — the route's anonymous-deny + capability floor. */ -const PKG_ADMIN = (): any => ({ +/** + * An authenticated package admin — the route's anonymous-deny + capability + * floor. `organizationId` lands where `dispatch()`'s identity step puts the + * caller's VETTED organization, the execution context's `tenantId`: the one + * source the route reads (#20477), never the session claim as stored. + */ +const PKG_ADMIN = (organizationId?: string): any => ({ request: { headers: {} }, environmentId: 'env_1', executionContext: { userId: 'u_pkg_admin', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + ...(organizationId ? { tenantId: organizationId } : {}), }, }); interface DriveOptions { - /** Session's active organization; `undefined` drives the one-rung branch. */ + /** The caller's vetted active organization; `undefined` drives the one-rung branch. */ activeOrganizationId?: string; /** Injection: the read-back throws this instead of answering. */ readBackError?: () => Error; @@ -297,13 +303,6 @@ async function publishThenRead(opts: DriveOptions = {}) { fields: { name: { type: 'text' }, status: { type: 'select' } }, }), }, - auth: { - api: { - getSession: async () => (opts.activeOrganizationId - ? { session: { activeOrganizationId: opts.activeOrganizationId } } - : { session: {} }), - }, - }, }; const kernel: any = { getServiceAsync: async (name: string) => services[name] ?? null, @@ -312,7 +311,7 @@ async function publishThenRead(opts: DriveOptions = {}) { }; const result = await new HttpDispatcher(kernel).handlePackages( - `/${PKG}/publish-drafts`, 'POST', {}, {}, PKG_ADMIN(), + `/${PKG}/publish-drafts`, 'POST', {}, {}, PKG_ADMIN(opts.activeOrganizationId), ); expect(result.response?.status).toBe(200); const body: any = (result.response as any)?.body; @@ -388,7 +387,7 @@ describe('#15068 · 0 · the publish-then-read path really runs', () => { expect(served?.item?.records).toHaveLength(2); }); - it('the session organization really reaches this request', async () => { + it('the caller\'s organization really reaches this request', async () => { const { publishRequest } = await publishThenRead({ activeOrganizationId: ORG }); // `applyPublishedSeeds` receives the SAME binding this route handed From 2cc7a7cf8295db73f53a715ac03a5055fbe834ba Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:16:00 +0800 Subject: [PATCH 4/4] chore(changeset): @objectstack/runtime patch for the dispatcher's vetted organization source Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- .changeset/20477-dispatcher-vetted-org-source.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/20477-dispatcher-vetted-org-source.md diff --git a/.changeset/20477-dispatcher-vetted-org-source.md b/.changeset/20477-dispatcher-vetted-org-source.md new file mode 100644 index 00000000000..6e13b7cf9ba --- /dev/null +++ b/.changeset/20477-dispatcher-vetted-org-source.md @@ -0,0 +1,15 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): the dispatcher's `/packages` doors scope a caller to the organization the identity step vetted, not the session's stored claim (#20477) + +Clause-②: no + +The runtime dispatcher's nine organization-scoped `/packages` doors took the caller's organization from the auth session's `activeOrganizationId` as stored. Under a wall-enforcing tenancy posture (`isolated` or `group`), the identity step drops that claim when no membership backs it any more, and the request resolves with no active organization (the maintainer's ruling B on #15409). The doors never saw the drop. So a member removed from an organization kept that organization's packages for the rest of the session: its commit history and whole-package export were served to them, and their publish-drafts, discard-drafts, commit revert, rollback, adopt-orphans, duplicate and uninstall ran inside it. + +The doors now read the vetted organization on the request's execution context, the value `RestServer` scopes by and the dispatcher's `/meta` doors already read. The fix is in the one source the nine doors share (`HttpDispatcher`'s `resolveActiveOrganizationId`), so every door changes together, on both HTTP entries (the `createHonoApp` catch-all and the dispatcher plugin's explicit package routes). + +- **A removed member whose session still names the organization they left:** every door is handed no organization. Reads and writes reach only the env-wide package state, as for any session with no active organization. An uninstall is refused `400 TENANT_SCOPE_REQUIRED` and deletes nothing. The left organization's rows are neither read nor written. +- **A caller authenticated by an API key:** the doors now use the organization the key is bound to. The session read found no session for a key, so these doors used to get no organization for it. +- **Unchanged:** a current member reaches their own organization exactly as before, a member who switched to an organization they belong to reaches that one, and an anonymous caller is refused `401` before any package operation. Single-posture deployments are unchanged, because no claim is dropped there.