Skip to content

Commit 09da7a4

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20351-number-comparand-door
2 parents 47dc7f4 + 45f428d commit 09da7a4

5 files changed

Lines changed: 479 additions & 56 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): the dispatcher's `/packages` doors scope a caller to the organization the identity step vetted, not the session's stored claim (#20477)
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
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).
12+
13+
- **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.
14+
- **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.
15+
- **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.

‎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/domains/packages-seed-apply-org-scope.test.ts‎

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
* would double-insert"). So the second call presents a `publishPackageDrafts`
5151
* that reports the published seed without a `seedApplied` field — the exact
5252
* population this fallback documents itself as existing for. It also records
53-
* the request it received, which is §0's positive control that the session's
53+
* the request it received, which is §0's positive control that the caller's
5454
* organization really reached this request.
5555
*
5656
* ## Sections, and which are evidence vs. which are the bound
@@ -217,18 +217,24 @@ function makeEngine() {
217217
return engine;
218218
}
219219

220-
/** An authenticated package admin — the route's anonymous-deny + capability floor. */
221-
const PKG_ADMIN = (): any => ({
220+
/**
221+
* An authenticated package admin — the route's anonymous-deny + capability
222+
* floor. `organizationId` lands where `dispatch()`'s identity step puts the
223+
* caller's VETTED organization, the execution context's `tenantId`: the one
224+
* source the route reads (#20477), never the session claim as stored.
225+
*/
226+
const PKG_ADMIN = (organizationId?: string): any => ({
222227
request: { headers: {} },
223228
environmentId: 'env_1',
224229
executionContext: {
225230
userId: 'u_pkg_admin',
226231
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
232+
...(organizationId ? { tenantId: organizationId } : {}),
227233
},
228234
});
229235

230236
interface DriveOptions {
231-
/** Session's active organization; `undefined` drives the one-rung branch. */
237+
/** The caller's vetted active organization; `undefined` drives the one-rung branch. */
232238
activeOrganizationId?: string;
233239
/** Injection: the read-back throws this instead of answering. */
234240
readBackError?: () => Error;
@@ -297,13 +303,6 @@ async function publishThenRead(opts: DriveOptions = {}) {
297303
fields: { name: { type: 'text' }, status: { type: 'select' } },
298304
}),
299305
},
300-
auth: {
301-
api: {
302-
getSession: async () => (opts.activeOrganizationId
303-
? { session: { activeOrganizationId: opts.activeOrganizationId } }
304-
: { session: {} }),
305-
},
306-
},
307306
};
308307
const kernel: any = {
309308
getServiceAsync: async (name: string) => services[name] ?? null,
@@ -312,7 +311,7 @@ async function publishThenRead(opts: DriveOptions = {}) {
312311
};
313312

314313
const result = await new HttpDispatcher(kernel).handlePackages(
315-
`/${PKG}/publish-drafts`, 'POST', {}, {}, PKG_ADMIN(),
314+
`/${PKG}/publish-drafts`, 'POST', {}, {}, PKG_ADMIN(opts.activeOrganizationId),
316315
);
317316
expect(result.response?.status).toBe(200);
318317
const body: any = (result.response as any)?.body;
@@ -388,7 +387,7 @@ describe('#15068 · 0 · the publish-then-read path really runs', () => {
388387
expect(served?.item?.records).toHaveLength(2);
389388
});
390389

391-
it('the session organization really reaches this request', async () => {
390+
it('the caller\'s organization really reaches this request', async () => {
392391
const { publishRequest } = await publishThenRead({ activeOrganizationId: ORG });
393392

394393
// `applyPublishedSeeds` receives the SAME binding this route handed

0 commit comments

Comments
 (0)