|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * ADR-0069 at cloud-connection's raw-app plugins: a session the authentication |
| 5 | + * policy HOLDS (an expired password, enforced MFA not yet enrolled) is refused |
| 6 | + * by every door that resolves a session, with REST's answer (`403`, the gate's |
| 7 | + * `code` and `message`), before any door logic runs. |
| 8 | + * |
| 9 | + * ## The two plugins that resolve a session, and where each consults |
| 10 | + * |
| 11 | + * - `CloudConnectionPlugin` reads the session in ONE place |
| 12 | + * (`sessionFromAuthService`, under both resolution modes). Seven of its |
| 13 | + * eight doors call it; `/status` reads no session at all. |
| 14 | + * - `MarketplaceInstallLocalPlugin` reads it in ONE place |
| 15 | + * (`resolveInstallPrincipal`), and all five doors open with the same |
| 16 | + * admission (`admitInstallCaller`). |
| 17 | + * |
| 18 | + * Each consults `evaluateAuthGate` (`@objectstack/core`) once, with no request |
| 19 | + * path, so the shared allow-list can grant no exemption. No route either plugin |
| 20 | + * mounts is allow-listed (asserted below), so there is no exemption to keep. |
| 21 | + * |
| 22 | + * `MarketplaceProxyPlugin` is not here: it resolves no session. It forwards the |
| 23 | + * public catalog with no credential, and every caller gets the same answer. |
| 24 | + * |
| 25 | + * ## The cases, per plugin |
| 26 | + * |
| 27 | + * - HELD: under both postures, every session door answers `403` with the |
| 28 | + * gate's `code` and `message`, and no effect fires (no control-plane call, |
| 29 | + * no credential cleared; no registry, schema, ledger, seed or delete write). |
| 30 | + * - UN-HELD: the same caller and grants, without a posture, are served. |
| 31 | + * - Anonymous is refused `401` as before, and `/status` is untouched. |
| 32 | + */ |
| 33 | + |
| 34 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 35 | +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; |
| 36 | +import { join } from 'node:path'; |
| 37 | +import { tmpdir } from 'node:os'; |
| 38 | +import { isAuthGateAllowlisted } from '@objectstack/core'; |
| 39 | + |
| 40 | +const seedCalls: unknown[] = []; |
| 41 | +vi.mock('@objectstack/runtime', () => ({ |
| 42 | + SeedLoaderService: class { |
| 43 | + async load(request: unknown) { |
| 44 | + seedCalls.push(request); |
| 45 | + return { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; |
| 46 | + } |
| 47 | + async buildDependencyGraph(objectNames: string[]) { |
| 48 | + return { |
| 49 | + nodes: objectNames.map((object) => ({ object, dependsOn: [], references: [] })), |
| 50 | + insertOrder: objectNames, |
| 51 | + circularDependencies: [], |
| 52 | + }; |
| 53 | + } |
| 54 | + }, |
| 55 | + recordSeedOutcome: vi.fn(), |
| 56 | +})); |
| 57 | + |
| 58 | +import { assertEngineDeleteDispatch } from '@objectstack/metadata-core'; |
| 59 | +import { CloudConnectionPlugin } from './cloud-connection-plugin.js'; |
| 60 | +import { ConnectionCredentialStore } from './connection-credential-store.js'; |
| 61 | +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; |
| 62 | +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; |
| 63 | + |
| 64 | +const PASSWORD_EXPIRED = { code: 'PASSWORD_EXPIRED', message: 'You must change your password before continuing.' }; |
| 65 | +const MFA_REQUIRED = { |
| 66 | + code: 'MFA_REQUIRED', |
| 67 | + message: 'Multi-factor authentication is required. Please set up an authenticator app to continue.', |
| 68 | +}; |
| 69 | +const HELD = [ |
| 70 | + { user: 'u_held_pw', gate: PASSWORD_EXPIRED }, |
| 71 | + { user: 'u_held_mfa', gate: MFA_REQUIRED }, |
| 72 | +] as const; |
| 73 | +/** The posture each test user's session carries. The grants are the same for all three. */ |
| 74 | +const POSTURES: Record<string, { code: string; message: string } | undefined> = { |
| 75 | + u_held_pw: PASSWORD_EXPIRED, |
| 76 | + u_held_mfa: MFA_REQUIRED, |
| 77 | + u_member: undefined, |
| 78 | +}; |
| 79 | + |
| 80 | +/** A better-auth session read keyed by the `x-test-user` header, counting its calls. */ |
| 81 | +function sessionAuth() { |
| 82 | + const reads = { count: 0 }; |
| 83 | + return { |
| 84 | + reads, |
| 85 | + api: { |
| 86 | + getSession: async ({ headers }: { headers: Headers }) => { |
| 87 | + reads.count++; |
| 88 | + const uid = headers?.get?.('x-test-user'); |
| 89 | + if (!uid || !(uid in POSTURES)) return null; |
| 90 | + const authGate = POSTURES[uid]; |
| 91 | + return { user: { id: uid, ...(authGate ? { authGate } : {}) }, session: {} }; |
| 92 | + }, |
| 93 | + }, |
| 94 | + }; |
| 95 | +} |
| 96 | + |
| 97 | +type Handler = (c: any) => Promise<any>; |
| 98 | + |
| 99 | +function makeRawApp() { |
| 100 | + const routes = new Map<string, Handler>(); |
| 101 | + return { |
| 102 | + routes, |
| 103 | + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), |
| 104 | + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), |
| 105 | + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), |
| 106 | + }; |
| 107 | +} |
| 108 | + |
| 109 | +function makeC(url: string, user: string | undefined, body: unknown = {}, manifestId?: string) { |
| 110 | + const headers = new Headers(user ? { 'x-test-user': user } : {}); |
| 111 | + return { |
| 112 | + req: { |
| 113 | + url, |
| 114 | + raw: new Request(url, { headers }), |
| 115 | + header: (n: string) => headers.get(n) ?? undefined, |
| 116 | + json: async () => body, |
| 117 | + param: () => manifestId, |
| 118 | + }, |
| 119 | + json: (payload: any, status?: number) => ({ payload, status: status ?? 200 }), |
| 120 | + }; |
| 121 | +} |
| 122 | + |
| 123 | +let dir: string; |
| 124 | +beforeEach(() => { |
| 125 | + dir = mkdtempSync(join(tmpdir(), 'cc-gate-pin-')); |
| 126 | + seedCalls.length = 0; |
| 127 | +}); |
| 128 | +afterEach(() => { |
| 129 | + rmSync(dir, { recursive: true, force: true }); |
| 130 | + vi.unstubAllGlobals(); |
| 131 | + vi.restoreAllMocks(); |
| 132 | +}); |
| 133 | + |
| 134 | +// --------------------------------------------------------------------------- |
| 135 | +// CloudConnectionPlugin |
| 136 | +// --------------------------------------------------------------------------- |
| 137 | + |
| 138 | +const CC = '/api/v1/cloud-connection'; |
| 139 | +const ORIGIN = 'https://env.example.test'; |
| 140 | + |
| 141 | +/** The seven doors that resolve a session, each with what it needs to reach the control plane. */ |
| 142 | +const CC_SESSION_DOORS = [ |
| 143 | + { route: `POST ${CC}/bind/start`, url: `${ORIGIN}${CC}/bind/start`, body: {} }, |
| 144 | + { route: `POST ${CC}/bind/poll`, url: `${ORIGIN}${CC}/bind/poll`, body: { device_code: 'dc_1' } }, |
| 145 | + { route: `POST ${CC}/unbind`, url: `${ORIGIN}${CC}/unbind`, body: {} }, |
| 146 | + { route: `POST ${CC}/install`, url: `${ORIGIN}${CC}/install`, body: { package_id: 'pkg_1' } }, |
| 147 | + { route: `GET ${CC}/installation`, url: `${ORIGIN}${CC}/installation?package_id=pkg_1`, body: {} }, |
| 148 | + { route: `GET ${CC}/installed`, url: `${ORIGIN}${CC}/installed`, body: {} }, |
| 149 | + { route: `GET ${CC}/org-packages`, url: `${ORIGIN}${CC}/org-packages`, body: {} }, |
| 150 | +] as const; |
| 151 | + |
| 152 | +async function mountCloudConnection(mode: 'single' | 'multi') { |
| 153 | + const rawApp = makeRawApp(); |
| 154 | + const hooks = new Map<string, any>(); |
| 155 | + const auth = sessionAuth(); |
| 156 | + const credentialPath = join(dir, 'cloud-connection.json'); |
| 157 | + const store = new ConnectionCredentialStore(credentialPath); |
| 158 | + store.write({ runtimeToken: 'oscc_pin', runtimeId: 'rt_1', environmentId: 'env-1' }); |
| 159 | + const services: Record<string, unknown> = mode === 'single' |
| 160 | + ? { auth } |
| 161 | + : { |
| 162 | + 'env-registry': { resolveByHostname: async () => ({ environmentId: 'env-1' }) }, |
| 163 | + 'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async () => auth }) }, |
| 164 | + }; |
| 165 | + const ctx = { |
| 166 | + hook: (e: string, h: any) => hooks.set(e, h), |
| 167 | + getService: (name: string) => { |
| 168 | + if (name === 'http-server') return { getRawApp: () => rawApp }; |
| 169 | + if (name in services) return services[name]; |
| 170 | + throw new Error(`service ${name} not registered`); |
| 171 | + }, |
| 172 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, |
| 173 | + }; |
| 174 | + const fetchCalls: string[] = []; |
| 175 | + vi.stubGlobal('fetch', vi.fn(async (url: string) => { |
| 176 | + fetchCalls.push(String(url)); |
| 177 | + // One body that every door can read: the device-token grant reads |
| 178 | + // `access_token` at the top level, the others read `data`. |
| 179 | + return new Response(JSON.stringify({ success: true, access_token: 'at_1', data: { installed: false, packages: [], items: [] } }), { |
| 180 | + status: 200, |
| 181 | + headers: { 'content-type': 'application/json' }, |
| 182 | + }); |
| 183 | + })); |
| 184 | + await new CloudConnectionPlugin({ |
| 185 | + controlPlaneUrl: 'http://cloud.test', |
| 186 | + controlPlaneApiKey: 'svc-key', |
| 187 | + credentialPath, |
| 188 | + ...(mode === 'single' ? { singleEnvironment: true, environmentId: 'env-1' } : {}), |
| 189 | + }).start(ctx as any); |
| 190 | + await hooks.get('kernel:ready')?.(); |
| 191 | + return { rawApp, auth, fetchCalls, credentialPath }; |
| 192 | +} |
| 193 | + |
| 194 | +describe('ADR-0069 at CloudConnectionPlugin', () => { |
| 195 | + it('census: eight routes, none allow-listed; seven resolve a session and /status resolves none', async () => { |
| 196 | + const { rawApp, auth } = await mountCloudConnection('single'); |
| 197 | + const keys = [...rawApp.routes.keys()]; |
| 198 | + expect(keys).toHaveLength(8); |
| 199 | + for (const key of keys) expect(isAuthGateAllowlisted(key.split(' ')[1]), key).toBe(false); |
| 200 | + expect(keys.filter((k) => k !== `GET ${CC}/status`).sort()).toEqual(CC_SESSION_DOORS.map((d) => d.route).sort()); |
| 201 | + await rawApp.routes.get(`GET ${CC}/status`)!(makeC(`${ORIGIN}${CC}/status`, 'u_held_pw')); |
| 202 | + expect(auth.reads.count, '/status read a session').toBe(0); |
| 203 | + }); |
| 204 | + |
| 205 | + for (const mode of ['single', 'multi'] as const) { |
| 206 | + for (const { user, gate } of HELD) { |
| 207 | + it(`HELD (${gate.code}, ${mode}-environment): all seven session doors answer 403 with the gate's code, and nothing reaches the control plane`, async () => { |
| 208 | + const { rawApp, fetchCalls, credentialPath } = await mountCloudConnection(mode); |
| 209 | + for (const door of CC_SESSION_DOORS) { |
| 210 | + const res = await rawApp.routes.get(door.route)!(makeC(door.url, user, door.body)); |
| 211 | + expect(res.status, door.route).toBe(403); |
| 212 | + expect(res.payload, door.route).toEqual({ success: false, error: { code: gate.code, message: gate.message } }); |
| 213 | + } |
| 214 | + expect(fetchCalls, 'a held session reached the control plane').toEqual([]); |
| 215 | + expect(new ConnectionCredentialStore(credentialPath).read()?.runtimeToken, 'unbind cleared the credential').toBe('oscc_pin'); |
| 216 | + }); |
| 217 | + } |
| 218 | + } |
| 219 | + |
| 220 | + it('UN-HELD: the same caller without a posture is served by all seven doors', async () => { |
| 221 | + const { rawApp, fetchCalls } = await mountCloudConnection('single'); |
| 222 | + for (const door of CC_SESSION_DOORS) { |
| 223 | + const before = fetchCalls.length; |
| 224 | + const res = await rawApp.routes.get(door.route)!(makeC(door.url, 'u_member', door.body)); |
| 225 | + expect(res.status, `${door.route}: ${JSON.stringify(res.payload)}`).toBe(200); |
| 226 | + expect(fetchCalls.length, `${door.route} did not reach the control plane`).toBeGreaterThan(before); |
| 227 | + } |
| 228 | + }); |
| 229 | + |
| 230 | + it('anonymous: every session door still answers 401 UNAUTHENTICATED', async () => { |
| 231 | + const { rawApp, fetchCalls } = await mountCloudConnection('single'); |
| 232 | + for (const door of CC_SESSION_DOORS) { |
| 233 | + const res = await rawApp.routes.get(door.route)!(makeC(door.url, undefined, door.body)); |
| 234 | + expect(res.status, door.route).toBe(401); |
| 235 | + expect(res.payload?.error?.code, door.route).toBe('UNAUTHENTICATED'); |
| 236 | + } |
| 237 | + expect(fetchCalls).toEqual([]); |
| 238 | + }); |
| 239 | +}); |
| 240 | + |
| 241 | +// --------------------------------------------------------------------------- |
| 242 | +// MarketplaceInstallLocalPlugin |
| 243 | +// --------------------------------------------------------------------------- |
| 244 | + |
| 245 | +const IL = '/api/v1/marketplace/install-local'; |
| 246 | +const APP = { |
| 247 | + id: 'com.acme.gated', |
| 248 | + namespace: 'gated', |
| 249 | + version: '1.0.0', |
| 250 | + objects: [{ name: 'widget', fields: { code: { type: 'text' } } }], |
| 251 | + data: [{ object: 'widget', externalId: 'code', records: [{ code: 'a' }, { code: 'b' }] }], |
| 252 | +}; |
| 253 | +const LEDGER_FILE = 'com.acme.gated.json'; |
| 254 | + |
| 255 | +/** Every test user holds the authoring capability, so a refusal below is the gate's alone. */ |
| 256 | +function grantRows(): Record<string, any[]> { |
| 257 | + return { |
| 258 | + sys_user: Object.keys(POSTURES).map((id) => ({ id, email: `${id}@acme.test` })), |
| 259 | + sys_member: [], |
| 260 | + sys_user_position: [], |
| 261 | + sys_position: [], |
| 262 | + sys_metadata_activation: [], |
| 263 | + sys_position_permission_set: [], |
| 264 | + sys_user_permission_set: Object.keys(POSTURES).map((id, i) => ({ |
| 265 | + id: `ups${i}`, user_id: id, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: null, |
| 266 | + })), |
| 267 | + sys_permission_set: [{ id: 'ps1', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }], |
| 268 | + }; |
| 269 | +} |
| 270 | + |
| 271 | +async function mountInstallLocal(storageDir: string) { |
| 272 | + const register = vi.fn(async () => undefined); |
| 273 | + const syncSchemas = vi.fn(async () => undefined); |
| 274 | + const widgets = [{ id: 'w1', code: 'a' }, { id: 'w2', code: 'b' }]; |
| 275 | + const engineDelete = vi.fn(async (object: string, options?: any) => { |
| 276 | + const dispatch = assertEngineDeleteDispatch(options); |
| 277 | + if (object !== 'widget' || dispatch.kind !== 'by-id') return false; |
| 278 | + const at = widgets.findIndex((w) => w.id === dispatch.id); |
| 279 | + if (at < 0) return false; |
| 280 | + widgets.splice(at, 1); |
| 281 | + return true; |
| 282 | + }); |
| 283 | + const rawApp = makeRawApp(); |
| 284 | + const hooks = new Map<string, any>(); |
| 285 | + const rows = grantRows(); |
| 286 | + const services: Record<string, any> = { |
| 287 | + manifest: { register }, |
| 288 | + auth: sessionAuth(), |
| 289 | + objectql: bindCatalogFromTables({ |
| 290 | + syncSchemas, |
| 291 | + find: async (object: string) => (object === 'widget' ? widgets.map((w) => ({ ...w })) : rows[object] ?? []), |
| 292 | + delete: engineDelete, |
| 293 | + }, rows), |
| 294 | + metadata: { getObject: async () => ({ name: 'widget', fields: {} }) }, |
| 295 | + }; |
| 296 | + const ctx: any = { |
| 297 | + hook: (e: string, h: any) => hooks.set(e, h), |
| 298 | + getService: (name: string) => { |
| 299 | + if (name === 'http-server') return { getRawApp: () => rawApp }; |
| 300 | + const svc = services[name]; |
| 301 | + if (svc === undefined) throw new Error(`no ${name}`); |
| 302 | + return svc; |
| 303 | + }, |
| 304 | + registerService: () => undefined, |
| 305 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, |
| 306 | + }; |
| 307 | + await new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir }).start(ctx); |
| 308 | + await hooks.get('kernel:ready')?.(); |
| 309 | + // `kernel:ready` rehydrates the ledger (registry, schema sync, seed heal), |
| 310 | + // so every counter starts AFTER it: the observation window is the request. |
| 311 | + register.mockClear(); |
| 312 | + syncSchemas.mockClear(); |
| 313 | + engineDelete.mockClear(); |
| 314 | + seedCalls.length = 0; |
| 315 | + return { |
| 316 | + rawApp, |
| 317 | + effects: () => register.mock.calls.length + syncSchemas.mock.calls.length + seedCalls.length + engineDelete.mock.calls.length, |
| 318 | + }; |
| 319 | +} |
| 320 | + |
| 321 | +/** The five doors, addressed with what each needs to act. */ |
| 322 | +const IL_DOORS = [ |
| 323 | + { route: `POST ${IL}`, body: { manifest: APP } }, |
| 324 | + { route: `GET ${IL}`, body: {} }, |
| 325 | + { route: `DELETE ${IL}/:manifestId`, body: {} }, |
| 326 | + { route: `POST ${IL}/:manifestId/reseed-sample-data`, body: {} }, |
| 327 | + { route: `POST ${IL}/:manifestId/purge-sample-data`, body: {} }, |
| 328 | +] as const; |
| 329 | + |
| 330 | +/** Install the app as the un-held operator, so the follow-on doors have a target. */ |
| 331 | +async function seedInstall(storageDir: string) { |
| 332 | + const { rawApp } = await mountInstallLocal(storageDir); |
| 333 | + const res = await rawApp.routes.get(`POST ${IL}`)!(makeC(`http://localhost${IL}`, 'u_member', { manifest: APP })); |
| 334 | + expect(res.payload.success, JSON.stringify(res.payload)).toBe(true); |
| 335 | + expect(existsSync(join(storageDir, LEDGER_FILE))).toBe(true); |
| 336 | +} |
| 337 | + |
| 338 | +describe('ADR-0069 at MarketplaceInstallLocalPlugin', () => { |
| 339 | + it('census: five routes, none allow-listed, all five covered below', async () => { |
| 340 | + const { rawApp } = await mountInstallLocal(dir); |
| 341 | + const keys = [...rawApp.routes.keys()].sort(); |
| 342 | + expect(keys).toEqual(IL_DOORS.map((d) => d.route).sort()); |
| 343 | + for (const key of keys) expect(isAuthGateAllowlisted(key.split(' ')[1]), key).toBe(false); |
| 344 | + }); |
| 345 | + |
| 346 | + for (const { user, gate } of HELD) { |
| 347 | + it(`HELD (${gate.code}): all five doors answer 403 with the gate's code, before any effect and before the ledger is touched`, async () => { |
| 348 | + await seedInstall(dir); |
| 349 | + const ledgerBefore = existsSync(join(dir, LEDGER_FILE)); |
| 350 | + const { rawApp, effects } = await mountInstallLocal(dir); |
| 351 | + for (const door of IL_DOORS) { |
| 352 | + const res = await rawApp.routes.get(door.route)!(makeC(`http://localhost${IL}`, user, door.body, APP.id)); |
| 353 | + expect(res.status, door.route).toBe(403); |
| 354 | + expect(res.payload, door.route).toEqual({ success: false, error: { code: gate.code, message: gate.message } }); |
| 355 | + } |
| 356 | + expect(effects(), 'a held session caused an effect').toBe(0); |
| 357 | + expect(existsSync(join(dir, LEDGER_FILE))).toBe(ledgerBefore); |
| 358 | + }); |
| 359 | + } |
| 360 | + |
| 361 | + it('UN-HELD: the same operator without a posture is served on every door', async () => { |
| 362 | + await seedInstall(dir); |
| 363 | + const { rawApp, effects } = await mountInstallLocal(dir); |
| 364 | + const list = await rawApp.routes.get(`GET ${IL}`)!(makeC(`http://localhost${IL}`, 'u_member')); |
| 365 | + expect(list.status).toBe(200); |
| 366 | + expect(list.payload.success).toBe(true); |
| 367 | + for (const route of [`POST ${IL}/:manifestId/reseed-sample-data`, `POST ${IL}/:manifestId/purge-sample-data`, `DELETE ${IL}/:manifestId`]) { |
| 368 | + const res = await rawApp.routes.get(route)!(makeC(`http://localhost${IL}`, 'u_member', {}, APP.id)); |
| 369 | + expect(res.status, `${route}: ${JSON.stringify(res.payload)}`).toBe(200); |
| 370 | + } |
| 371 | + expect(effects()).toBeGreaterThan(0); |
| 372 | + expect(existsSync(join(dir, LEDGER_FILE)), 'the uninstall removed the ledger entry').toBe(false); |
| 373 | + }); |
| 374 | + |
| 375 | + it('anonymous: every door still answers 401 UNAUTHENTICATED', async () => { |
| 376 | + const { rawApp, effects } = await mountInstallLocal(dir); |
| 377 | + for (const door of IL_DOORS) { |
| 378 | + const res = await rawApp.routes.get(door.route)!(makeC(`http://localhost${IL}`, undefined, door.body, APP.id)); |
| 379 | + expect(res.status, door.route).toBe(401); |
| 380 | + expect(res.payload?.error?.code, door.route).toBe('UNAUTHENTICATED'); |
| 381 | + } |
| 382 | + expect(effects()).toBe(0); |
| 383 | + }); |
| 384 | +}); |
0 commit comments