|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The credential-environment comparison on EVERY route behind `enforceAuth`, |
| 5 | + * driven through the UNSTUBBED identity resolution. |
| 6 | + * |
| 7 | + * ## The fact this file is about |
| 8 | + * |
| 9 | + * `computeExecCtx` validates a caller against an `auth` service it finds in a |
| 10 | + * kernel, and it has a branch that finds one in the DEFAULT environment's |
| 11 | + * kernel when the resolved environment's kernel carries none. A session |
| 12 | + * minted in the default environment then authenticates a request aimed at |
| 13 | + * another environment. The context records which environment actually |
| 14 | + * validated the caller (`__authEnvironmentId`) and which environment the |
| 15 | + * request resolved to (`__environmentId`); `enforceAuth` — the one shared |
| 16 | + * entry point of the data plane — compares the two and refuses a mismatch |
| 17 | + * with the anonymous-deny response, byte for byte, so "valid elsewhere" is |
| 18 | + * indistinguishable from "not authenticated". |
| 19 | + * |
| 20 | + * `ui-view-environment-ownership.test.ts` pins the producer of |
| 21 | + * `__authEnvironmentId` and the UI-view seam; this file pins the rest of the |
| 22 | + * data plane: crud, data-action, batch and sharing, on both the scoped mount |
| 23 | + * (`/environments/:environmentId/...`) and the unscoped one (header-named). |
| 24 | + * |
| 25 | + * Each refusal carries two halves: the status and body, AND the downstream |
| 26 | + * (protocol or sharing service) was never reached. Each has a positive |
| 27 | + * control — the same wiring with the target environment carrying its own |
| 28 | + * `auth` service, which must be served — so the refusal cannot be satisfied |
| 29 | + * by a server that refuses everyone. |
| 30 | + */ |
| 31 | + |
| 32 | +import { describe, it, expect } from 'vitest'; |
| 33 | +import { ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS } from '@objectstack/core'; |
| 34 | +import { RestServer } from './rest-server.js'; |
| 35 | +import type { RestEnvRegistry, RestKernelManager } from './rest-server.js'; |
| 36 | + |
| 37 | +const BASE = '/api/v1'; |
| 38 | +const SCOPED = `${BASE}/environments/:environmentId`; |
| 39 | + |
| 40 | +const ENV_A = 'env_alpha'; // the default environment |
| 41 | +const ENV_B = 'env_beta'; |
| 42 | + |
| 43 | +interface Reached { env: string; method: string } |
| 44 | + |
| 45 | +/** |
| 46 | + * A protocol that serves every call it is asked by recording it — the |
| 47 | + * downstream half of each pin. `getMetaItem` answers no item, so the object |
| 48 | + * carries no exposure policy and `enforceApiAccess` lets the call through. |
| 49 | + */ |
| 50 | +function recordingProtocol(env: string, reached: Reached[]): any { |
| 51 | + const record = (method: string, answer: unknown) => async () => { |
| 52 | + reached.push({ env, method }); |
| 53 | + return answer; |
| 54 | + }; |
| 55 | + return { |
| 56 | + getMetaItem: async () => undefined, |
| 57 | + getMetaItems: async () => ({ items: [] }), |
| 58 | + findData: record('findData', { records: [{ id: `${env}_row` }], total: 1 }), |
| 59 | + getData: record('getData', { record: { id: `${env}_row` } }), |
| 60 | + createData: record('createData', { id: `${env}_new` }), |
| 61 | + cloneData: record('cloneData', { id: `${env}_clone` }), |
| 62 | + createManyData: record('createManyData', [{ id: `${env}_many` }]), |
| 63 | + batchData: record('batchData', { results: [] }), |
| 64 | + }; |
| 65 | +} |
| 66 | + |
| 67 | +/** Every lookup answers EMPTY: the principal is the session's user id, no roles. */ |
| 68 | +function emptyQl(): any { |
| 69 | + const empty = async () => []; |
| 70 | + return new Proxy({}, { get: (_t, k: string) => (k === 'then' ? undefined : empty) }); |
| 71 | +} |
| 72 | + |
| 73 | +function authServiceFor(userId: string): any { |
| 74 | + return { |
| 75 | + api: { |
| 76 | + getSession: async () => ({ user: { id: userId, email: `${userId}@example.test` }, session: { userId } }), |
| 77 | + }, |
| 78 | + }; |
| 79 | +} |
| 80 | + |
| 81 | +/** `authUserId` absent = that environment's kernel carries NO `auth` service. */ |
| 82 | +function wiring(spec: Record<string, { authUserId?: string }>) { |
| 83 | + const reached: Reached[] = []; |
| 84 | + const objectqlFrom: string[] = []; |
| 85 | + const kernelManager: RestKernelManager = { |
| 86 | + async getOrCreate(environmentId: string) { |
| 87 | + const entry = spec[environmentId]; |
| 88 | + return { |
| 89 | + async getServiceAsync<T>(name: string): Promise<T> { |
| 90 | + if (name === 'auth') { |
| 91 | + if (!entry?.authUserId) throw new Error(`no auth service in ${environmentId}`); |
| 92 | + return authServiceFor(entry.authUserId) as T; |
| 93 | + } |
| 94 | + if (name === 'objectql') { objectqlFrom.push(environmentId); return emptyQl() as T; } |
| 95 | + if (name === 'protocol') return recordingProtocol(environmentId, reached) as T; |
| 96 | + // Absent services resolve `undefined` — the registry's |
| 97 | + // quiet absence, not a construction fault. |
| 98 | + return undefined as T; |
| 99 | + }, |
| 100 | + } as any; |
| 101 | + }, |
| 102 | + }; |
| 103 | + return { kernelManager, reached, objectqlFrom }; |
| 104 | +} |
| 105 | + |
| 106 | +const envRegistry: RestEnvRegistry = { |
| 107 | + async resolveByHostname() { return null; }, |
| 108 | + async resolveById(environmentId: string) { |
| 109 | + return environmentId === ENV_A || environmentId === ENV_B ? { driver: environmentId } : null; |
| 110 | + }, |
| 111 | +}; |
| 112 | + |
| 113 | +type Handler = (req: any, res: any) => any; |
| 114 | + |
| 115 | +function recordingHttp() { |
| 116 | + const table = new Map<string, Handler>(); |
| 117 | + const on = (m: string) => (p: string, h: Handler) => { table.set(`${m} ${p}`, h); }; |
| 118 | + return { |
| 119 | + table, |
| 120 | + get: on('GET'), post: on('POST'), put: on('PUT'), delete: on('DELETE'), patch: on('PATCH'), |
| 121 | + use: () => {}, listen: async () => {}, close: async () => {}, |
| 122 | + } as any; |
| 123 | +} |
| 124 | + |
| 125 | +function makeServer(w: ReturnType<typeof wiring>) { |
| 126 | + const http = recordingHttp(); |
| 127 | + const provider = async () => undefined as any; |
| 128 | + const sharingFor = async (environmentId?: string) => ({ |
| 129 | + listShares: async () => { |
| 130 | + w.reached.push({ env: environmentId ?? '(unscoped)', method: 'listShares' }); |
| 131 | + return [{ id: 'share_1' }]; |
| 132 | + }, |
| 133 | + }); |
| 134 | + const args: ConstructorParameters<typeof RestServer> = [ |
| 135 | + /* 1 server */ http, |
| 136 | + /* 2 protocol (control plane) */ recordingProtocol('platform', w.reached), |
| 137 | + /* 3 config */ { api: { enableProjectScoping: true, projectResolution: 'auto' } }, |
| 138 | + /* 4 kernelManager */ w.kernelManager, |
| 139 | + /* 5 envRegistry */ envRegistry, |
| 140 | + /* 6 defaultEnvironmentIdProvider */ () => ENV_A, |
| 141 | + /* 7 authServiceProvider */ undefined, |
| 142 | + /* 8 objectQLProvider */ provider, |
| 143 | + /* 9 emailServiceProvider */ provider, |
| 144 | + /* 10 sharingServiceProvider */ sharingFor, |
| 145 | + /* 11 (retired saved-report slot) */ undefined, |
| 146 | + /* 12 approvalsServiceProvider */ provider, |
| 147 | + /* 13 sharingRulesServiceProvider */ provider, |
| 148 | + /* 14 i18nServiceProvider */ provider, |
| 149 | + /* 15 analyticsServiceProvider */ provider, |
| 150 | + /* 16 settingsServiceProvider */ provider, |
| 151 | + /* 17 serviceExistsProvider */ () => false, |
| 152 | + /* 18 securityServiceProvider */ provider, |
| 153 | + /* 19 requestEnvResolver */ undefined, |
| 154 | + /* 20 metadataServiceProvider */ provider, |
| 155 | + ]; |
| 156 | + const rs: any = new RestServer(...args); |
| 157 | + rs.registerRoutes(); |
| 158 | + return { rs, table: http.table as Map<string, Handler> }; |
| 159 | +} |
| 160 | + |
| 161 | +interface RouteCase { |
| 162 | + family: string; |
| 163 | + method: 'GET' | 'POST'; |
| 164 | + /** Path below the base (scoped or unscoped). */ |
| 165 | + path: string; |
| 166 | + params: Record<string, string>; |
| 167 | + body?: unknown; |
| 168 | + /** The downstream call that serving the request makes. */ |
| 169 | + downstream: string; |
| 170 | +} |
| 171 | + |
| 172 | +const ROUTES: RouteCase[] = [ |
| 173 | + { family: 'crud read', method: 'GET', path: '/data/:object', params: { object: 'account' }, downstream: 'findData' }, |
| 174 | + { family: 'crud write', method: 'POST', path: '/data/:object', params: { object: 'account' }, body: { name: 'x' }, downstream: 'createData' }, |
| 175 | + { family: 'data-action', method: 'POST', path: '/data/:object/:id/clone', params: { object: 'account', id: 'r1' }, body: {}, downstream: 'cloneData' }, |
| 176 | + { family: 'batch', method: 'POST', path: '/data/:object/createMany', params: { object: 'account' }, body: [{ name: 'x' }], downstream: 'createManyData' }, |
| 177 | + { family: 'sharing', method: 'GET', path: '/data/:object/:id/shares', params: { object: 'account', id: 'r1' }, downstream: 'listShares' }, |
| 178 | +]; |
| 179 | + |
| 180 | +async function drive( |
| 181 | + table: Map<string, Handler>, |
| 182 | + route: RouteCase, |
| 183 | + mount: 'scoped' | 'unscoped', |
| 184 | + target: string, |
| 185 | +) { |
| 186 | + const key = `${route.method} ${mount === 'scoped' ? SCOPED : BASE}${route.path}`; |
| 187 | + const handler = table.get(key); |
| 188 | + if (!handler) throw new Error(`route not mounted: ${key}`); |
| 189 | + const concrete = route.path.replace(/:(\w+)/g, (_m, k) => route.params[k] ?? `:${k}`); |
| 190 | + const url = mount === 'scoped' ? `${BASE}/environments/${target}${concrete}` : `${BASE}${concrete}`; |
| 191 | + const req: any = { |
| 192 | + method: route.method, |
| 193 | + path: url, |
| 194 | + url, |
| 195 | + params: { ...route.params, ...(mount === 'scoped' ? { environmentId: target } : {}) }, |
| 196 | + query: {}, |
| 197 | + body: route.body, |
| 198 | + headers: { |
| 199 | + host: 'unbound.example.test', |
| 200 | + cookie: 'session=whatever', |
| 201 | + ...(mount === 'unscoped' ? { 'x-environment-id': target } : {}), |
| 202 | + }, |
| 203 | + }; |
| 204 | + let status = 0; let body: any; let sent = false; |
| 205 | + const res: any = { |
| 206 | + status(c: number) { status = c; return res; }, |
| 207 | + json(b: any) { body = b; sent = true; }, |
| 208 | + send() { sent = true; }, header() { return res; }, setHeader() { return res; }, |
| 209 | + end() { sent = true; }, write() { return true; }, type() { return res; }, |
| 210 | + }; |
| 211 | + await handler(req, res); |
| 212 | + return { status: status || (sent ? 200 : 0), body }; |
| 213 | +} |
| 214 | + |
| 215 | +describe('a default-environment credential aimed at another environment', () => { |
| 216 | + for (const mount of ['scoped', 'unscoped'] as const) { |
| 217 | + for (const route of ROUTES) { |
| 218 | + it(`⭐ is REFUSED on ${route.family} (${route.method} ${route.path}, ${mount}) — and nothing downstream runs`, async () => { |
| 219 | + // B carries no `auth` service, so the identity lookup falls |
| 220 | + // back to A's and authenticates A's session for a request |
| 221 | + // that resolved to B. |
| 222 | + const w = wiring({ [ENV_A]: { authUserId: 'u_alpha' }, [ENV_B]: {} }); |
| 223 | + const { table } = makeServer(w); |
| 224 | + |
| 225 | + const out = await drive(table, route, mount, ENV_B); |
| 226 | + |
| 227 | + expect(out.status).toBe(ANONYMOUS_DENY_STATUS); |
| 228 | + // Byte-identical to the anonymous answer: "valid elsewhere" |
| 229 | + // must not be distinguishable from "not authenticated". |
| 230 | + expect(JSON.stringify(out.body)).toBe(JSON.stringify(ANONYMOUS_DENY_BODY)); |
| 231 | + expect(w.reached).toEqual([]); |
| 232 | + }, 60_000); |
| 233 | + |
| 234 | + it(`POSITIVE CONTROL — the target environment's own credential is served on ${route.family} (${mount})`, async () => { |
| 235 | + const w = wiring({ [ENV_A]: { authUserId: 'u_alpha' }, [ENV_B]: { authUserId: 'u_beta' } }); |
| 236 | + const { table } = makeServer(w); |
| 237 | + |
| 238 | + const out = await drive(table, route, mount, ENV_B); |
| 239 | + |
| 240 | + expect(out.status).toBeGreaterThanOrEqual(200); |
| 241 | + expect(out.status).toBeLessThan(300); |
| 242 | + expect(w.reached.map((r) => r.method)).toEqual([route.downstream]); |
| 243 | + }, 60_000); |
| 244 | + |
| 245 | + it(`POSITIVE CONTROL — the default environment's credential, aimed at the default environment, is served on ${route.family} (${mount})`, async () => { |
| 246 | + const w = wiring({ [ENV_A]: { authUserId: 'u_alpha' }, [ENV_B]: {} }); |
| 247 | + const { table } = makeServer(w); |
| 248 | + |
| 249 | + const out = await drive(table, route, mount, ENV_A); |
| 250 | + |
| 251 | + expect(out.status).toBeGreaterThanOrEqual(200); |
| 252 | + expect(out.status).toBeLessThan(300); |
| 253 | + expect(w.reached.map((r) => r.method)).toEqual([route.downstream]); |
| 254 | + }, 60_000); |
| 255 | + } |
| 256 | + } |
| 257 | +}); |
| 258 | + |
| 259 | +describe('the context records both sides of the comparison', () => { |
| 260 | + it('names the resolved environment beside the one that validated the caller', async () => { |
| 261 | + const w = wiring({ [ENV_A]: { authUserId: 'u_alpha' }, [ENV_B]: {} }); |
| 262 | + const { rs } = makeServer(w); |
| 263 | + const ctx = await rs.resolveExecCtx(ENV_B, { headers: { cookie: 'session=whatever' }, method: 'GET' }); |
| 264 | + |
| 265 | + expect(ctx?.userId).toBe('u_alpha'); |
| 266 | + expect(ctx.__environmentId).toBe(ENV_B); |
| 267 | + expect(ctx.__authEnvironmentId).toBe(ENV_A); |
| 268 | + // The principal was also resolved against A's engine, not B's: the |
| 269 | + // crossing is not only "who" but "whose permission store". |
| 270 | + expect(w.objectqlFrom).toEqual([ENV_A]); |
| 271 | + }, 60_000); |
| 272 | + |
| 273 | + it('names the same environment twice when the target carries its own auth service', async () => { |
| 274 | + const w = wiring({ [ENV_A]: { authUserId: 'u_alpha' }, [ENV_B]: { authUserId: 'u_beta' } }); |
| 275 | + const { rs } = makeServer(w); |
| 276 | + const ctx = await rs.resolveExecCtx(ENV_B, { headers: { cookie: 'session=whatever' }, method: 'GET' }); |
| 277 | + |
| 278 | + expect(ctx?.userId).toBe('u_beta'); |
| 279 | + expect(ctx.__environmentId).toBe(ENV_B); |
| 280 | + expect(ctx.__authEnvironmentId).toBe(ENV_B); |
| 281 | + }, 60_000); |
| 282 | +}); |
0 commit comments