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
10 changes: 10 additions & 0 deletions .changeset/environment-membership-judges-resolved-principal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/runtime': patch
---

fix(runtime): the environment-membership check judges the principal the request resolved

Clause-②: no
Metadata-protocol: none

On a multi-environment host, the dispatcher refuses a caller who is not a member of the environment a scoped route resolves to (`403 PROJECT_MEMBERSHIP_REQUIRED`). That check now judges the principal the request resolved, whichever credential admitted it: a principal admitted by an OAuth access token on `/mcp` or by an API key is judged as that token's user or that key's owner, in the organization its resolution vetted, including when a session cookie for another user is sent beside it. A principal admitted by a session cookie is judged as before. Hosts without environment scoping, and hosts that turn the check off (`api.enforceProjectMembership: false`), are unchanged.
9 changes: 5 additions & 4 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import { serveStoredMetadataRead, serveStoredMetadataReadsThrough } from './stor
import { actorUserFromExecutionContext, resolveActorDisplayName } from './security/actor-user.js';
import { actionInvocationBranch, actionInvocationFailure, actionInvocationTarget, recordActionInvocation, withActionSurface } from './action-invocation-audit.js';
import type { HttpProtocolContext } from './http-dispatcher.js';
import { admittingCredentialKind, type AdmittingCredentialKind } from './security/admitting-credential.js';
import {
GLOBAL_ACTION_OBJECT_KEY,
actionHandlerObjectKeys,
Expand Down Expand Up @@ -1619,9 +1620,10 @@ function isIdentityRouteTarget(path: string): boolean {
* envelope carries its `oauthScopes`; such a token is honoured on the MCP
* endpoint alone), else what the door's admission recorded — `api_key` or
* `session`. `undefined` when the door recorded nothing (an anonymous caller,
* whom the door's own floor answers first).
* whom the door's own floor answers first). The one reading is
* {@link admittingCredentialKind}, which the environment-membership gate takes too.
*/
export type ApiForwardCredentialKind = 'session' | 'api_key' | 'oauth';
export type ApiForwardCredentialKind = AdmittingCredentialKind;

/** Which kind of door an api action's `target` is served by, for the forward. */
export type ApiForwardTargetDoor = 'identity' | 'mcp' | 'platform';
Expand Down Expand Up @@ -1710,8 +1712,7 @@ export function apiForwardAgentOf(requestContext: HttpProtocolContext | undefine

/** The kind of credential that admitted this request's caller (see {@link ApiForwardCredentialKind}). */
export function apiForwardCredentialKind(requestContext: HttpProtocolContext | undefined): ApiForwardCredentialKind | undefined {
if (Array.isArray(requestContext?.executionContext?.oauthScopes)) return 'oauth';
return requestContext?.admission?.credential;
return admittingCredentialKind(requestContext);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The environment-membership gate judges the principal the request resolved,
* whichever credential admitted it.
*
* A multi-tenant host: a stub resolver places every request in one
* environment, on a tenant kernel whose `sys_environment_member` holds a row
* only for {@link MEMBER}. The gate's answer is read off the dispatch result
* (`403 PROJECT_MEMBERSHIP_REQUIRED`, naming the user it judged) and off the
* membership reads it made.
*
* - CONTROL: a cookie session for a non-member is refused, as before.
* - A principal an OAuth access token admitted on `/mcp` is judged: refused
* when it is not a member, and admitted when it is.
* - A token for a non-member sent beside a member's cookie is judged as the
* token's principal, the one the request resolved.
* - A principal an API key admitted is judged as the key's owner, on `/mcp`
* and on a scoped data route alike.
*/

import { describe, it, expect, vi } from 'vitest';
// Paid at collection: the dispatcher reaches it through a dynamic import.
import '@objectstack/metadata';
import { hashApiKey } from '@objectstack/core';
import { HttpDispatcher } from './http-dispatcher.js';
import type { HttpProtocolContext, KernelResolver } from './http-dispatcher.js';

const ENV = 'env-tenant';
const MEMBER = 'user-member';
const OUTSIDER = 'user-outsider';
/** JWT-shaped: three dot-separated segments, which is what the token door reads. */
const TOKEN = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4In0.c2ln';
const API_KEY = 'osk_probe0123456789abcdefghijklmnopqrstuvwxyz';
const MCP_CALL = { jsonrpc: '2.0', id: 1, method: 'tools/list' };

function kernelWith(services: Record<string, unknown>): any {
return {
getState: () => 'running',
getService: (n: string) => services[n] ?? null,
getServiceAsync: async (n: string) => services[n] ?? null,
context: { getService: (n: string) => services[n] ?? null },
};
}

/**
* The tenant host. `cookieUser` is who the cookie session reads as (none when
* absent); `tokenUser` is who a verified OAuth access token names.
*/
function tenantHost(opts: { cookieUser?: string; tokenUser?: string; keyOwner?: string }) {
const find = vi.fn(async (object: string, query: { where?: Record<string, unknown> }) => {
const where = query?.where ?? {};
if (object === 'sys_environment_member') {
return where.environment_id === ENV && where.user_id === MEMBER
? [{ id: 'm1', environment_id: ENV, user_id: MEMBER }]
: [];
}
if (object === 'sys_api_key' && opts.keyOwner && where.key === hashApiKey(API_KEY)) {
return [{ id: 'key-1', key: hashApiKey(API_KEY), user_id: opts.keyOwner }];
}
if (object === 'sys_user' && typeof where.id === 'string') return [{ id: where.id }];
return [];
});
const objectql = {
find,
getObjects: vi.fn().mockReturnValue({}),
registry: {
getObject: vi.fn((name: string) => (name === 'sys_environment_member' ? { name } : null)),
getRegisteredTypes: vi.fn().mockReturnValue([]),
},
};
const auth = {
getApi: async () => ({
getSession: async () => (opts.cookieUser
? { user: { id: opts.cookieUser }, session: { userId: opts.cookieUser } }
: null),
}),
verifyMcpAccessToken: vi.fn(async () => (opts.tokenUser
? { userId: opts.tokenUser, clientId: 'client-1', scopes: ['data:read'] }
: null)),
};
const tenant = kernelWith({ objectql, auth });
const resolver: KernelResolver = {
resolveKernel: async (c: HttpProtocolContext) => {
c.environmentId = ENV;
return tenant;
},
};
const dispatcher = new HttpDispatcher(kernelWith({}), undefined, {
kernelResolver: resolver,
enforceProjectMembership: true,
});
const membershipReads = () => find.mock.calls
.filter((call) => call[0] === 'sys_environment_member')
.map((call) => (call[1] as { where: { user_id: string } }).where.user_id);
return { dispatcher, membershipReads };
}

const mcp = (headers: Record<string, string>): HttpProtocolContext => ({
request: new Request('http://tenant.example/api/v1/mcp', { method: 'POST', headers }),
});

function membershipRefusal(result: { response?: { status: number; body?: any } }) {
const error = result.response?.body?.error;
return result.response?.status === 403 && error?.code === 'PROJECT_MEMBERSHIP_REQUIRED'
? { userId: error?.details?.userId as string | undefined }
: undefined;
}

describe('the environment-membership gate judges the resolved principal', () => {
it('CONTROL: a cookie session for a non-member is refused', async () => {
const { dispatcher, membershipReads } = tenantHost({ cookieUser: OUTSIDER });
const result = await dispatcher.dispatch('POST', '/mcp', MCP_CALL, {}, mcp({}));
expect(membershipRefusal(result)).toEqual({ userId: OUTSIDER });
expect(membershipReads()).toEqual([OUTSIDER]);
});

it('a principal an OAuth access token admitted, not a member: refused, naming the token\'s user', async () => {
const { dispatcher, membershipReads } = tenantHost({ tokenUser: OUTSIDER });
const result = await dispatcher.dispatch('POST', '/mcp', MCP_CALL, {}, mcp({ authorization: `Bearer ${TOKEN}` }));
expect(membershipRefusal(result), JSON.stringify(result.response?.body)).toEqual({ userId: OUTSIDER });
expect(membershipReads()).toEqual([OUTSIDER]);
});

it('a token for a non-member beside a member\'s cookie: judged as the token\'s principal, refused', async () => {
const { dispatcher, membershipReads } = tenantHost({ cookieUser: MEMBER, tokenUser: OUTSIDER });
const result = await dispatcher.dispatch('POST', '/mcp', MCP_CALL, {}, mcp({ authorization: `Bearer ${TOKEN}` }));
expect(membershipRefusal(result), JSON.stringify(result.response?.body)).toEqual({ userId: OUTSIDER });
expect(membershipReads()).toEqual([OUTSIDER]);
});

it('a principal an API key admitted, not a member: refused, naming the key\'s owner', async () => {
const { dispatcher, membershipReads } = tenantHost({ keyOwner: OUTSIDER });
const result = await dispatcher.dispatch('POST', '/mcp', MCP_CALL, {}, mcp({ 'x-api-key': API_KEY }));
expect(membershipRefusal(result), JSON.stringify(result.response?.body)).toEqual({ userId: OUTSIDER });
expect(membershipReads()).toEqual([OUTSIDER]);
});

it('a principal an API key admitted on a scoped non-MCP route, not a member: refused, naming the key\'s owner', async () => {
const { dispatcher, membershipReads } = tenantHost({ keyOwner: OUTSIDER });
const context: HttpProtocolContext = {
request: new Request('http://tenant.example/api/v1/data/task', { headers: { 'x-api-key': API_KEY } }),
};
const result = await dispatcher.dispatch('GET', '/data/task', undefined, {}, context);
expect(membershipRefusal(result), JSON.stringify(result.response?.body)).toEqual({ userId: OUTSIDER });
expect(membershipReads()).toEqual([OUTSIDER]);
});

it('CONTROL: a principal an OAuth access token admitted, a member: passes the gate', async () => {
const { dispatcher, membershipReads } = tenantHost({ tokenUser: MEMBER });
const result = await dispatcher.dispatch('POST', '/mcp', MCP_CALL, {}, mcp({ authorization: `Bearer ${TOKEN}` }));
expect(membershipRefusal(result)).toBeUndefined();
expect(membershipReads()).toEqual([MEMBER]);
});

it('CONTROL: a principal an API key admitted, a member: passes the gate', async () => {
const { dispatcher, membershipReads } = tenantHost({ keyOwner: MEMBER });
const result = await dispatcher.dispatch('POST', '/mcp', MCP_CALL, {}, mcp({ 'x-api-key': API_KEY }));
expect(membershipRefusal(result)).toBeUndefined();
expect(membershipReads()).toEqual([MEMBER]);
});

it('CONTROL: no credential at all is not judged here', async () => {
const { dispatcher, membershipReads } = tenantHost({});
const result = await dispatcher.dispatch('POST', '/mcp', MCP_CALL, {}, mcp({}));
expect(membershipRefusal(result)).toBeUndefined();
expect(membershipReads()).toEqual([]);
});
});
18 changes: 18 additions & 0 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { DomainHandlerRegistry, type DomainRoute, type DomainHandlerDeps } from
// only — `actionExecutionDeps` hands the endpoint step the same `deps` object
// every domain already passes to `callData`, without this file calling it.
import type { ActionExecutionDeps } from './action-execution.js';
import { admittingCredentialKind } from './security/admitting-credential.js';
import { createAnalyticsDomain, handleAnalyticsRequest } from './domains/analytics.js';
import { isServiceServeable } from './service-serveable.js';
import { createI18nDomain, handleI18nRequest } from './domains/i18n.js';
Expand Down Expand Up @@ -1673,6 +1674,7 @@ export class HttpDispatcher {
// and single-tenant setups run without auth.
let userId: string | undefined;
let activeOrganizationId: string | undefined;
let sessionUnread = false;
try {
// [#4127 batch 4] This read was `authService?.api?.getSession?.(…)`
// with NO `getApi()` fallback — the only one of the three `.api`
Expand All @@ -1698,6 +1700,22 @@ export class HttpDispatcher {
userId = sessionData?.user?.id ?? sessionData?.session?.userId;
activeOrganizationId = sessionData?.session?.activeOrganizationId;
} catch {
sessionUnread = true;
}

// The gate judges the principal this request RESOLVED
// (`resolveRequestScope`), whichever credential admitted it. A
// principal an API key or an OAuth access token admitted (with or
// without a cookie beside it) is the one judged, in the organization
// its resolution vetted; which credential that was is the one reading
// the api-action forward takes too (`admittingCredentialKind`). A
// session principal is judged by the session read above, as before.
const resolved = context.executionContext;
const admittedBy = admittingCredentialKind(context);
if ((admittedBy === 'api_key' || admittedBy === 'oauth') && resolved?.userId) {
userId = resolved.userId;
activeOrganizationId = resolved.tenantId;
} else if (sessionUnread) {
// Auth resolution failed — do not block the request on RBAC.
return null;
}
Expand Down
25 changes: 25 additions & 0 deletions packages/runtime/src/security/admitting-credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Which kind of credential admitted a request's caller: the one reading the
* dispatcher's environment-membership gate and the api-action forward both
* take, so the two cannot disagree.
*
* - `oauth` when the principal was resolved from an OAuth access token (the
* envelope carries its `oauthScopes`; such a token is honoured on the MCP
* endpoint alone, and an in-process forward of it carries them too);
* - else what the identity step's admission recorded: `api_key` or `session`;
* - `undefined` when it recorded nothing (an anonymous caller).
*/
export type AdmittingCredentialKind = 'session' | 'api_key' | 'oauth';

/** The two request facts {@link admittingCredentialKind} reads. */
export interface AdmittingCredentialFacts {
executionContext?: { oauthScopes?: unknown };
admission?: { credential: 'api_key' | 'session' };
}

export function admittingCredentialKind(request: AdmittingCredentialFacts | undefined): AdmittingCredentialKind | undefined {
if (Array.isArray(request?.executionContext?.oauthScopes)) return 'oauth';
return request?.admission?.credential;
}
Loading