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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/20477-dispatcher-vetted-org-source.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion packages/runtime/src/domain-handler-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>;
/**
* Fire a kernel-context event on the request's resolved kernel (no-op
Expand Down
25 changes: 12 additions & 13 deletions packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading