|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The environment-membership gate judges the principal the request resolved, |
| 5 | + * whichever credential admitted it. |
| 6 | + * |
| 7 | + * A multi-tenant host: a stub resolver places every request in one |
| 8 | + * environment, on a tenant kernel whose `sys_environment_member` holds a row |
| 9 | + * only for {@link MEMBER}. The gate's answer is read off the dispatch result |
| 10 | + * (`403 PROJECT_MEMBERSHIP_REQUIRED`, naming the user it judged) and off the |
| 11 | + * membership reads it made. |
| 12 | + * |
| 13 | + * - CONTROL: a cookie session for a non-member is refused, as before. |
| 14 | + * - A principal an OAuth access token admitted on `/mcp` is judged: refused |
| 15 | + * when it is not a member, and admitted when it is. |
| 16 | + * - A token for a non-member sent beside a member's cookie is judged as the |
| 17 | + * token's principal, the one the request resolved. |
| 18 | + * - A principal an API key admitted is judged as the key's owner, on `/mcp` |
| 19 | + * and on a scoped data route alike. |
| 20 | + */ |
| 21 | + |
| 22 | +import { describe, it, expect, vi } from 'vitest'; |
| 23 | +// Paid at collection: the dispatcher reaches it through a dynamic import. |
| 24 | +import '@objectstack/metadata'; |
| 25 | +import { hashApiKey } from '@objectstack/core'; |
| 26 | +import { HttpDispatcher } from './http-dispatcher.js'; |
| 27 | +import type { HttpProtocolContext, KernelResolver } from './http-dispatcher.js'; |
| 28 | + |
| 29 | +const ENV = 'env-tenant'; |
| 30 | +const MEMBER = 'user-member'; |
| 31 | +const OUTSIDER = 'user-outsider'; |
| 32 | +/** JWT-shaped: three dot-separated segments, which is what the token door reads. */ |
| 33 | +const TOKEN = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4In0.c2ln'; |
| 34 | +const API_KEY = 'osk_probe0123456789abcdefghijklmnopqrstuvwxyz'; |
| 35 | +const MCP_CALL = { jsonrpc: '2.0', id: 1, method: 'tools/list' }; |
| 36 | + |
| 37 | +function kernelWith(services: Record<string, unknown>): any { |
| 38 | + return { |
| 39 | + getState: () => 'running', |
| 40 | + getService: (n: string) => services[n] ?? null, |
| 41 | + getServiceAsync: async (n: string) => services[n] ?? null, |
| 42 | + context: { getService: (n: string) => services[n] ?? null }, |
| 43 | + }; |
| 44 | +} |
| 45 | + |
| 46 | +/** |
| 47 | + * The tenant host. `cookieUser` is who the cookie session reads as (none when |
| 48 | + * absent); `tokenUser` is who a verified OAuth access token names. |
| 49 | + */ |
| 50 | +function tenantHost(opts: { cookieUser?: string; tokenUser?: string; keyOwner?: string }) { |
| 51 | + const find = vi.fn(async (object: string, query: { where?: Record<string, unknown> }) => { |
| 52 | + const where = query?.where ?? {}; |
| 53 | + if (object === 'sys_environment_member') { |
| 54 | + return where.environment_id === ENV && where.user_id === MEMBER |
| 55 | + ? [{ id: 'm1', environment_id: ENV, user_id: MEMBER }] |
| 56 | + : []; |
| 57 | + } |
| 58 | + if (object === 'sys_api_key' && opts.keyOwner && where.key === hashApiKey(API_KEY)) { |
| 59 | + return [{ id: 'key-1', key: hashApiKey(API_KEY), user_id: opts.keyOwner }]; |
| 60 | + } |
| 61 | + if (object === 'sys_user' && typeof where.id === 'string') return [{ id: where.id }]; |
| 62 | + return []; |
| 63 | + }); |
| 64 | + const objectql = { |
| 65 | + find, |
| 66 | + getObjects: vi.fn().mockReturnValue({}), |
| 67 | + registry: { |
| 68 | + getObject: vi.fn((name: string) => (name === 'sys_environment_member' ? { name } : null)), |
| 69 | + getRegisteredTypes: vi.fn().mockReturnValue([]), |
| 70 | + }, |
| 71 | + }; |
| 72 | + const auth = { |
| 73 | + getApi: async () => ({ |
| 74 | + getSession: async () => (opts.cookieUser |
| 75 | + ? { user: { id: opts.cookieUser }, session: { userId: opts.cookieUser } } |
| 76 | + : null), |
| 77 | + }), |
| 78 | + verifyMcpAccessToken: vi.fn(async () => (opts.tokenUser |
| 79 | + ? { userId: opts.tokenUser, clientId: 'client-1', scopes: ['data:read'] } |
| 80 | + : null)), |
| 81 | + }; |
| 82 | + const tenant = kernelWith({ objectql, auth }); |
| 83 | + const resolver: KernelResolver = { |
| 84 | + resolveKernel: async (c: HttpProtocolContext) => { |
| 85 | + c.environmentId = ENV; |
| 86 | + return tenant; |
| 87 | + }, |
| 88 | + }; |
| 89 | + const dispatcher = new HttpDispatcher(kernelWith({}), undefined, { |
| 90 | + kernelResolver: resolver, |
| 91 | + enforceProjectMembership: true, |
| 92 | + }); |
| 93 | + const membershipReads = () => find.mock.calls |
| 94 | + .filter((call) => call[0] === 'sys_environment_member') |
| 95 | + .map((call) => (call[1] as { where: { user_id: string } }).where.user_id); |
| 96 | + return { dispatcher, membershipReads }; |
| 97 | +} |
| 98 | + |
| 99 | +const mcp = (headers: Record<string, string>): HttpProtocolContext => ({ |
| 100 | + request: new Request('http://tenant.example/api/v1/mcp', { method: 'POST', headers }), |
| 101 | +}); |
| 102 | + |
| 103 | +function membershipRefusal(result: { response?: { status: number; body?: any } }) { |
| 104 | + const error = result.response?.body?.error; |
| 105 | + return result.response?.status === 403 && error?.code === 'PROJECT_MEMBERSHIP_REQUIRED' |
| 106 | + ? { userId: error?.details?.userId as string | undefined } |
| 107 | + : undefined; |
| 108 | +} |
| 109 | + |
| 110 | +describe('the environment-membership gate judges the resolved principal', () => { |
| 111 | + it('CONTROL: a cookie session for a non-member is refused', async () => { |
| 112 | + const { dispatcher, membershipReads } = tenantHost({ cookieUser: OUTSIDER }); |
| 113 | + const result = await dispatcher.dispatch('POST', '/mcp', MCP_CALL, {}, mcp({})); |
| 114 | + expect(membershipRefusal(result)).toEqual({ userId: OUTSIDER }); |
| 115 | + expect(membershipReads()).toEqual([OUTSIDER]); |
| 116 | + }); |
| 117 | + |
| 118 | + it('a principal an OAuth access token admitted, not a member: refused, naming the token\'s user', async () => { |
| 119 | + const { dispatcher, membershipReads } = tenantHost({ tokenUser: OUTSIDER }); |
| 120 | + const result = await dispatcher.dispatch('POST', '/mcp', MCP_CALL, {}, mcp({ authorization: `Bearer ${TOKEN}` })); |
| 121 | + expect(membershipRefusal(result), JSON.stringify(result.response?.body)).toEqual({ userId: OUTSIDER }); |
| 122 | + expect(membershipReads()).toEqual([OUTSIDER]); |
| 123 | + }); |
| 124 | + |
| 125 | + it('a token for a non-member beside a member\'s cookie: judged as the token\'s principal, refused', async () => { |
| 126 | + const { dispatcher, membershipReads } = tenantHost({ cookieUser: MEMBER, tokenUser: OUTSIDER }); |
| 127 | + const result = await dispatcher.dispatch('POST', '/mcp', MCP_CALL, {}, mcp({ authorization: `Bearer ${TOKEN}` })); |
| 128 | + expect(membershipRefusal(result), JSON.stringify(result.response?.body)).toEqual({ userId: OUTSIDER }); |
| 129 | + expect(membershipReads()).toEqual([OUTSIDER]); |
| 130 | + }); |
| 131 | + |
| 132 | + it('a principal an API key admitted, not a member: refused, naming the key\'s owner', async () => { |
| 133 | + const { dispatcher, membershipReads } = tenantHost({ keyOwner: OUTSIDER }); |
| 134 | + const result = await dispatcher.dispatch('POST', '/mcp', MCP_CALL, {}, mcp({ 'x-api-key': API_KEY })); |
| 135 | + expect(membershipRefusal(result), JSON.stringify(result.response?.body)).toEqual({ userId: OUTSIDER }); |
| 136 | + expect(membershipReads()).toEqual([OUTSIDER]); |
| 137 | + }); |
| 138 | + |
| 139 | + it('a principal an API key admitted on a scoped non-MCP route, not a member: refused, naming the key\'s owner', async () => { |
| 140 | + const { dispatcher, membershipReads } = tenantHost({ keyOwner: OUTSIDER }); |
| 141 | + const context: HttpProtocolContext = { |
| 142 | + request: new Request('http://tenant.example/api/v1/data/task', { headers: { 'x-api-key': API_KEY } }), |
| 143 | + }; |
| 144 | + const result = await dispatcher.dispatch('GET', '/data/task', undefined, {}, context); |
| 145 | + expect(membershipRefusal(result), JSON.stringify(result.response?.body)).toEqual({ userId: OUTSIDER }); |
| 146 | + expect(membershipReads()).toEqual([OUTSIDER]); |
| 147 | + }); |
| 148 | + |
| 149 | + it('CONTROL: a principal an OAuth access token admitted, a member: passes the gate', async () => { |
| 150 | + const { dispatcher, membershipReads } = tenantHost({ tokenUser: MEMBER }); |
| 151 | + const result = await dispatcher.dispatch('POST', '/mcp', MCP_CALL, {}, mcp({ authorization: `Bearer ${TOKEN}` })); |
| 152 | + expect(membershipRefusal(result)).toBeUndefined(); |
| 153 | + expect(membershipReads()).toEqual([MEMBER]); |
| 154 | + }); |
| 155 | + |
| 156 | + it('CONTROL: a principal an API key admitted, a member: passes the gate', async () => { |
| 157 | + const { dispatcher, membershipReads } = tenantHost({ keyOwner: MEMBER }); |
| 158 | + const result = await dispatcher.dispatch('POST', '/mcp', MCP_CALL, {}, mcp({ 'x-api-key': API_KEY })); |
| 159 | + expect(membershipRefusal(result)).toBeUndefined(); |
| 160 | + expect(membershipReads()).toEqual([MEMBER]); |
| 161 | + }); |
| 162 | + |
| 163 | + it('CONTROL: no credential at all is not judged here', async () => { |
| 164 | + const { dispatcher, membershipReads } = tenantHost({}); |
| 165 | + const result = await dispatcher.dispatch('POST', '/mcp', MCP_CALL, {}, mcp({})); |
| 166 | + expect(membershipRefusal(result)).toBeUndefined(); |
| 167 | + expect(membershipReads()).toEqual([]); |
| 168 | + }); |
| 169 | +}); |
0 commit comments