|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#24366] The storage family's ADR-0069 gate and the route judge the SAME |
| 5 | + * credential. |
| 6 | + * |
| 7 | + * ## The rule |
| 8 | + * |
| 9 | + * A session the authentication policy HOLDS (an expired password, enforced MFA |
| 10 | + * not yet enrolled) is gated out of every protected resource until it |
| 11 | + * remediates, and every route of the storage family is one. The family door |
| 12 | + * fronts routes that serve different credentials — the upload routes and the |
| 13 | + * unclaimed-file download identify their caller by the session, the |
| 14 | + * parent-governed download serves the one composition's principal — so the |
| 15 | + * gate judges every credential a route can serve: a held session is refused |
| 16 | + * `403` with its own posture whatever else the request carries, and a held |
| 17 | + * key's owner is refused through the shared key-admission seam. |
| 18 | + * |
| 19 | + * ## What is pinned |
| 20 | + * |
| 21 | + * On the download routes (an unclaimed file and a parent-governed file) and an |
| 22 | + * upload route (the start route): a held session is refused `403` with the |
| 23 | + * gate's code whatever else the request carries, and storage is never touched. |
| 24 | + * |
| 25 | + * ## Controls |
| 26 | + * |
| 27 | + * - an un-held session is SERVED (the gate is not firing for everyone); |
| 28 | + * - a held owner's key alone is refused as today — through the key arm, with |
| 29 | + * the owner's posture. |
| 30 | + * |
| 31 | + * The composition is `mountStorageRoutes` over a real `ObjectKernel`, the same |
| 32 | + * one the plugin's own mount calls, with an engine that knows the key and the |
| 33 | + * files. |
| 34 | + */ |
| 35 | + |
| 36 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 37 | +import { promises as fs } from 'node:fs'; |
| 38 | +import { join } from 'node:path'; |
| 39 | +import { tmpdir } from 'node:os'; |
| 40 | +import { ObjectKernel, hashApiKey } from '@objectstack/core'; |
| 41 | +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/objectql'; |
| 42 | +import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; |
| 43 | +import { LocalStorageAdapter } from './local-storage-adapter.js'; |
| 44 | +import { mountStorageRoutes } from './mount-storage-routes.js'; |
| 45 | + |
| 46 | +const BASE = '/api/v1/storage'; |
| 47 | +const ORG = 'org_alpha'; |
| 48 | +const FUTURE = '2999-01-01T00:00:00Z'; |
| 49 | + |
| 50 | +/** A held user whose session carries the posture; an un-held user; a separate key owner. */ |
| 51 | +const HELD_USER = 'u_held'; |
| 52 | +const OK_USER = 'u_ok'; |
| 53 | +const KEY_OWNER = 'u_key_owner'; |
| 54 | +const MFA_OWNER = 'u_mfa_owner'; |
| 55 | + |
| 56 | +/** An admitted key owned by someone NOT the held session. */ |
| 57 | +const OTHER_KEY = 'osk_family_gate_other_owner'; |
| 58 | +/** A key whose own owner is held (the key arm's control). */ |
| 59 | +const HELD_OWNER_KEY = 'osk_family_gate_held_owner'; |
| 60 | + |
| 61 | +const PASSWORD_EXPIRED = { code: 'PASSWORD_EXPIRED', message: 'Your password has expired. Please change it to continue.' }; |
| 62 | +const MFA_REQUIRED = { |
| 63 | + code: 'MFA_REQUIRED', |
| 64 | + message: 'Multi-factor authentication is required. Please set up an authenticator app to continue.', |
| 65 | +}; |
| 66 | +const POSTURE_CODES = ['MFA_REQUIRED', 'PASSWORD_EXPIRED', 'PASSWORD_RESET_REQUIRED']; |
| 67 | + |
| 68 | +/** An unclaimed file (no attachments scope, no field owner): a signed-in caller is all it needs. */ |
| 69 | +const UNCLAIMED_FILE = 'file_family_gate_unclaimed'; |
| 70 | +/** A parent-governed file: access derives from the record its field owns. */ |
| 71 | +const GOVERNED_FILE = 'file_family_gate_governed'; |
| 72 | +const GOVERNED_RECORD = 'rec_family_gate'; |
| 73 | + |
| 74 | +type Gate = { code: string; message: string }; |
| 75 | + |
| 76 | +function services(sessionReads: { n: number }) { |
| 77 | + const tables: Record<string, Array<Record<string, unknown>>> = { |
| 78 | + sys_api_key: [ |
| 79 | + { id: 'k_other', key: hashApiKey(OTHER_KEY), revoked: false, user_id: KEY_OWNER, active_organization_id: ORG, expires_at: FUTURE }, |
| 80 | + { id: 'k_mfa', key: hashApiKey(HELD_OWNER_KEY), revoked: false, user_id: MFA_OWNER, active_organization_id: ORG, expires_at: FUTURE }, |
| 81 | + ], |
| 82 | + sys_member: [ |
| 83 | + { user_id: HELD_USER, organization_id: ORG, role: 'member' }, |
| 84 | + { user_id: OK_USER, organization_id: ORG, role: 'member' }, |
| 85 | + { user_id: KEY_OWNER, organization_id: ORG, role: 'member' }, |
| 86 | + { user_id: MFA_OWNER, organization_id: ORG, role: 'member' }, |
| 87 | + ], |
| 88 | + sys_file: [ |
| 89 | + { |
| 90 | + id: UNCLAIMED_FILE, key: 'files/unclaimed.txt', name: 'unclaimed.txt', mime_type: 'text/plain', size: 3, |
| 91 | + scope: 'user', status: 'committed', acl: 'private', owner_id: 'u_uploader', |
| 92 | + }, |
| 93 | + { |
| 94 | + id: GOVERNED_FILE, key: 'files/governed.pdf', name: 'governed.pdf', mime_type: 'application/pdf', size: 3, |
| 95 | + scope: 'record', status: 'committed', acl: 'private', owner_id: 'u_uploader', |
| 96 | + ref_object: 'contract', ref_id: GOVERNED_RECORD, ref_field: 'attachment', |
| 97 | + }, |
| 98 | + ], |
| 99 | + }; |
| 100 | + const matches = (row: Record<string, unknown>, where: Record<string, unknown> | undefined) => |
| 101 | + Object.entries(where ?? {}).every(([k, v]) => { |
| 102 | + if (k.startsWith('$')) throw new Error(`fixture: unsupported combinator ${k}`); |
| 103 | + if (v !== null && typeof v === 'object') { |
| 104 | + const list = (v as { $in?: unknown[] }).$in; |
| 105 | + if (!Array.isArray(list)) throw new Error(`fixture: unsupported operator on ${k}`); |
| 106 | + return list.includes(row[k]); |
| 107 | + } |
| 108 | + return (v ?? null) === (row[k] ?? null); |
| 109 | + }); |
| 110 | + const objectql = { |
| 111 | + async find(object: string, q: any = {}) { |
| 112 | + // The owner record is readable by every principal that resolves one, so |
| 113 | + // only the gate decides a held case. |
| 114 | + if (object === 'contract') return q?.context?.userId ? [{ id: GOVERNED_RECORD }] : []; |
| 115 | + const rows = (tables[object] ?? []).filter((row) => matches(row, q?.where)); |
| 116 | + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; |
| 117 | + }, |
| 118 | + async findOne(object: string, q: any = {}) { |
| 119 | + assertEngineFindOnePredicate(object, q); |
| 120 | + return (tables[object] ?? []).find((row) => matches(row, q?.where)) ?? null; |
| 121 | + }, |
| 122 | + async insert(_object: string, row: any) { return row; }, |
| 123 | + async update(_object: string, patch: any, q: any = {}) { |
| 124 | + assertEngineUpdateDispatch(patch, q); |
| 125 | + return patch; |
| 126 | + }, |
| 127 | + }; |
| 128 | + const sessions: Record<string, Gate | undefined> = { [HELD_USER]: PASSWORD_EXPIRED, [OK_USER]: undefined }; |
| 129 | + return { |
| 130 | + objectql, |
| 131 | + tenancy: { posture: 'isolated' }, |
| 132 | + auth: { |
| 133 | + isAuthGateActive: () => true, |
| 134 | + api: { |
| 135 | + getSession: async ({ headers }: { headers: Headers }) => { |
| 136 | + sessionReads.n++; |
| 137 | + const userId = headers.get('x-test-user'); |
| 138 | + if (!userId) return undefined; |
| 139 | + const authGate = sessions[userId]; |
| 140 | + return { |
| 141 | + user: { id: userId, ...(authGate ? { authGate } : {}) }, |
| 142 | + session: { id: `s_${userId}`, userId, token: `b_${userId}`, activeOrganizationId: ORG }, |
| 143 | + }; |
| 144 | + }, |
| 145 | + }, |
| 146 | + // The key owner is clean; MFA_OWNER holds an account-scope posture. |
| 147 | + resolveUserAuthGates: async (userId: string) => (userId === MFA_OWNER ? [MFA_REQUIRED] : []), |
| 148 | + }, |
| 149 | + } as Record<string, unknown>; |
| 150 | +} |
| 151 | + |
| 152 | +let rootDirs: string[] = []; |
| 153 | +afterEach(async () => { |
| 154 | + for (const dir of rootDirs) await fs.rm(dir, { recursive: true, force: true }); |
| 155 | + rootDirs = []; |
| 156 | +}); |
| 157 | + |
| 158 | +async function mount() { |
| 159 | + const rootDir = join(tmpdir(), `os-24366-${Date.now()}-${Math.random().toString(36).slice(2)}`); |
| 160 | + await fs.mkdir(rootDir, { recursive: true }); |
| 161 | + rootDirs.push(rootDir); |
| 162 | + const adapter = new LocalStorageAdapter({ rootDir, signingSecret: 'test-secret-24366' }); |
| 163 | + const calls: string[] = []; |
| 164 | + const storage = new Proxy(adapter, { |
| 165 | + get(target, prop, receiver) { |
| 166 | + const value = Reflect.get(target, prop, receiver); |
| 167 | + return typeof value === 'function' |
| 168 | + ? (...args: unknown[]) => { calls.push(String(prop)); return value.apply(target, args); } |
| 169 | + : value; |
| 170 | + }, |
| 171 | + }); |
| 172 | + const sessionReads = { n: 0 }; |
| 173 | + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as never); |
| 174 | + kernel.registerService('storage', storage); |
| 175 | + kernel.registerService('tenancy', (services(sessionReads) as any).tenancy); |
| 176 | + kernel.registerService('auth', (services(sessionReads) as any).auth); |
| 177 | + kernel.registerService('objectql', (services(sessionReads) as any).objectql); |
| 178 | + const routes = new Map<string, RouteHandler>(); |
| 179 | + const http = { |
| 180 | + get: (path: string, handler: RouteHandler) => { routes.set(`GET ${path}`, handler); }, |
| 181 | + post: (path: string, handler: RouteHandler) => { routes.set(`POST ${path}`, handler); }, |
| 182 | + put: (path: string, handler: RouteHandler) => { routes.set(`PUT ${path}`, handler); }, |
| 183 | + delete: () => {}, patch: () => {}, use: () => {}, |
| 184 | + listen: async () => {}, close: async () => {}, |
| 185 | + }; |
| 186 | + mountStorageRoutes(http as any, kernel, { basePath: BASE, logger: { info: () => {}, warn: () => {} } }); |
| 187 | + calls.length = 0; |
| 188 | + return { routes, calls, sessionReads }; |
| 189 | +} |
| 190 | + |
| 191 | +async function drive(routes: Map<string, RouteHandler>, key: string, headers: Record<string, string>, body?: unknown) { |
| 192 | + const handler = routes.get(key); |
| 193 | + if (!handler) throw new Error(`no handler for ${key}`); |
| 194 | + const method = key.slice(0, key.indexOf(' ')); |
| 195 | + const path = key.slice(key.indexOf(' ') + 1); |
| 196 | + const params: Record<string, string> = {}; |
| 197 | + const fileId = path.includes('files/') ? (path.includes(':fileId') ? headers['x-test-file'] ?? UNCLAIMED_FILE : '') : ''; |
| 198 | + for (const seg of path.split('/')) if (seg.startsWith(':')) params[seg.slice(1)] = seg === ':fileId' ? fileId : 'x'; |
| 199 | + const captured: { status: number; body: any } = { status: 200, body: undefined }; |
| 200 | + const res: any = { |
| 201 | + json(data: unknown) { captured.body = data; return res; }, |
| 202 | + send() { return res; }, |
| 203 | + status(code: number) { captured.status = code; return res; }, |
| 204 | + header() { return res; }, |
| 205 | + }; |
| 206 | + await handler( |
| 207 | + { params, query: {}, body: method === 'GET' ? undefined : body, headers, method, path } as unknown as IHttpRequest, |
| 208 | + res as IHttpResponse, |
| 209 | + ); |
| 210 | + return { status: captured.status, code: captured.body?.error?.code as string | undefined, body: captured.body }; |
| 211 | +} |
| 212 | + |
| 213 | +const PRESIGNED = `POST ${BASE}/upload/presigned`; |
| 214 | +const DL_URL = `GET ${BASE}/files/:fileId/url`; |
| 215 | +const UPLOAD_BODY = { filename: 'a.txt', mimeType: 'text/plain', size: 3 }; |
| 216 | + |
| 217 | +/** The three route kinds the card names, driven on a concrete file where they read one. */ |
| 218 | +const ROUTES: Array<{ name: string; route: string; file?: string; body?: unknown }> = [ |
| 219 | + { name: 'upload start', route: PRESIGNED, body: UPLOAD_BODY }, |
| 220 | + { name: 'download — unclaimed file', route: DL_URL, file: UNCLAIMED_FILE }, |
| 221 | + { name: 'download — parent-governed file', route: DL_URL, file: GOVERNED_FILE }, |
| 222 | +]; |
| 223 | + |
| 224 | +describe('[#24366] the storage family gate and the route judge the same credential', () => { |
| 225 | + for (const { name, route, file, body } of ROUTES) { |
| 226 | + const held = { 'x-test-user': HELD_USER, ...(file ? { 'x-test-file': file } : {}) }; |
| 227 | + |
| 228 | + it(`${name} · a held session is refused 403 with its posture, storage never touched`, async () => { |
| 229 | + const { routes, calls } = await mount(); |
| 230 | + const answer = await drive(routes, route, held, body); |
| 231 | + expect(answer.status).toBe(403); |
| 232 | + expect(answer.code).toBe(PASSWORD_EXPIRED.code); |
| 233 | + expect(answer.body?.error?.message).toBe(PASSWORD_EXPIRED.message); |
| 234 | + expect(calls).toEqual([]); |
| 235 | + }); |
| 236 | + |
| 237 | + it(`${name} · a held session is refused 403 whatever else the request carries (an admitted key beside it)`, async () => { |
| 238 | + const { routes, calls } = await mount(); |
| 239 | + const answer = await drive(routes, route, { ...held, 'x-api-key': OTHER_KEY }, body); |
| 240 | + expect(answer.status).toBe(403); |
| 241 | + expect(answer.code).toBe(PASSWORD_EXPIRED.code); |
| 242 | + expect(answer.body?.error?.message).toBe(PASSWORD_EXPIRED.message); |
| 243 | + expect(calls).toEqual([]); |
| 244 | + }); |
| 245 | + } |
| 246 | + |
| 247 | + it('CONTROL · an un-held session is served the upload start route', async () => { |
| 248 | + const { routes, calls } = await mount(); |
| 249 | + const answer = await drive(routes, PRESIGNED, { 'x-test-user': OK_USER }, UPLOAD_BODY); |
| 250 | + expect(answer.status).toBe(200); |
| 251 | + expect(answer.body?.success).toBe(true); |
| 252 | + expect(calls.length).toBeGreaterThan(0); |
| 253 | + }); |
| 254 | + |
| 255 | + it('CONTROL · an un-held session is served the unclaimed download', async () => { |
| 256 | + const { routes } = await mount(); |
| 257 | + const answer = await drive(routes, DL_URL, { 'x-test-user': OK_USER, 'x-test-file': UNCLAIMED_FILE }); |
| 258 | + expect(answer.status).toBe(200); |
| 259 | + expect(answer.body?.data?.url).toBeTruthy(); |
| 260 | + }); |
| 261 | + |
| 262 | + it('CONTROL · a held owner\'s key alone is refused as today — the key arm, with the owner\'s posture', async () => { |
| 263 | + const { routes, calls } = await mount(); |
| 264 | + const answer = await drive(routes, PRESIGNED, { 'x-api-key': HELD_OWNER_KEY }, UPLOAD_BODY); |
| 265 | + expect(answer.status).toBe(403); |
| 266 | + expect(answer.code).toBe(MFA_REQUIRED.code); |
| 267 | + expect(answer.body?.error?.message).toBe(MFA_REQUIRED.message); |
| 268 | + expect(calls).toEqual([]); |
| 269 | + }); |
| 270 | + |
| 271 | + it('CONTROL · an un-held owner\'s key alone is NOT refused by the gate (no posture code)', async () => { |
| 272 | + const { routes } = await mount(); |
| 273 | + const answer = await drive(routes, PRESIGNED, { 'x-api-key': OTHER_KEY }, UPLOAD_BODY); |
| 274 | + expect(POSTURE_CODES).not.toContain(answer.code); |
| 275 | + }); |
| 276 | +}); |
0 commit comments