|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#3545] Object metadata unresolvable → the SECURITY POSTURE must fail CLOSED. |
| 5 | + * |
| 6 | + * #3545 assessed the fail-open on unresolvable metadata in the API-exposure gate |
| 7 | + * (`checkApiExposure` / REST `enforceApiAccess`) and accepted it, on the premise |
| 8 | + * that the gate is a SURFACE-AREA control while the real authorization boundary — |
| 9 | + * auth + the ObjectQL security middleware (CRUD / FLS / RLS) — enforces |
| 10 | + * unconditionally on the data call regardless of the gate's answer. |
| 11 | + * |
| 12 | + * The middleware does run unconditionally. But two of its INPUTS were read from |
| 13 | + * the same object metadata and defaulted PERMISSIVELY when it could not be |
| 14 | + * resolved, so the same trigger reached one layer deeper than the assessment |
| 15 | + * looked: |
| 16 | + * |
| 17 | + * • `access.default: 'private'` → `isPrivate` defaulted to `false`. A private |
| 18 | + * object is deliberately NOT covered by a plain (non-superuser) `'*'` |
| 19 | + * wildcard grant (ADR-0066 D2, `resolveObjectPermission`); read as public it |
| 20 | + * IS covered — a grant the author never wrote. |
| 21 | + * • `requiredPermissions` → defaulted to `[]`, which skips the ADR-0066 D3 |
| 22 | + * capability AND-gate entirely (`if (required.length > 0)`). |
| 23 | + * |
| 24 | + * These tests pin BOTH directions: the control (metadata resolvable → denied, |
| 25 | + * the ADR-0066 behaviour) and the regression (metadata unresolvable → still |
| 26 | + * denied, rather than silently promoted to public + uncontracted). |
| 27 | + */ |
| 28 | + |
| 29 | +import { describe, it, expect, vi } from 'vitest'; |
| 30 | +import { SecurityPlugin } from './security-plugin.js'; |
| 31 | +import { PermissionEvaluator } from './permission-evaluator.js'; |
| 32 | +import { explainAccess, type ExplainEngineDeps } from './explain-engine.js'; |
| 33 | +import type { PermissionSet } from '@objectstack/spec/security'; |
| 34 | + |
| 35 | +/** Plain member: blanket wildcard grant, NO superuser bits, NO capabilities. */ |
| 36 | +const memberSet: PermissionSet = { |
| 37 | + name: 'member_default', |
| 38 | + label: 'Member', |
| 39 | + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, |
| 40 | +} as any; |
| 41 | + |
| 42 | +/** |
| 43 | + * Middleware harness. `resolvable: false` makes the OBJECT metadata |
| 44 | + * unresolvable — `ql.getSchema()` returns undefined and `metadata.get('object', |
| 45 | + * …)` throws — while permission-set resolution keeps working. That isolates the |
| 46 | + * axis under test: "the object's own posture can't be read", NOT "the permission |
| 47 | + * subsystem is down" (which already fails closed, see security-plugin.ts). |
| 48 | + */ |
| 49 | +const makeHarness = (opts: { schemaExtra?: Record<string, any>; resolvable: boolean }) => { |
| 50 | + const fields: Record<string, any> = {}; |
| 51 | + for (const f of ['id', 'organization_id', 'owner_id', 'name']) fields[f] = { name: f }; |
| 52 | + const baseSchema: any = { name: 'task', fields, ...(opts.schemaExtra ?? {}) }; |
| 53 | + |
| 54 | + let middleware: any; |
| 55 | + const ql = { |
| 56 | + registerMiddleware: (mw: any) => { |
| 57 | + if (!middleware) middleware = mw; |
| 58 | + }, |
| 59 | + getSchema: () => (opts.resolvable ? baseSchema : undefined), |
| 60 | + findOne: vi.fn(async () => null), |
| 61 | + }; |
| 62 | + const metadata = { |
| 63 | + get: async (type: string, name: string) => { |
| 64 | + if (!opts.resolvable && type === 'object' && name === 'task') { |
| 65 | + throw new Error('metadata store unavailable'); |
| 66 | + } |
| 67 | + return baseSchema; |
| 68 | + }, |
| 69 | + // Permission-set resolution stays healthy on BOTH paths. |
| 70 | + list: async () => [memberSet], |
| 71 | + }; |
| 72 | + const services: Record<string, any> = { |
| 73 | + manifest: { register: vi.fn() }, |
| 74 | + objectql: ql, |
| 75 | + metadata, |
| 76 | + }; |
| 77 | + const ctx: any = { |
| 78 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, |
| 79 | + registerService: vi.fn(), |
| 80 | + getService: (name: string) => { |
| 81 | + if (!(name in services)) throw new Error(`service not registered: ${name}`); |
| 82 | + return services[name]; |
| 83 | + }, |
| 84 | + }; |
| 85 | + return { |
| 86 | + ctx, |
| 87 | + logger: ctx.logger, |
| 88 | + run: async (opCtx: any) => { |
| 89 | + await middleware(opCtx, async () => {}); |
| 90 | + return opCtx; |
| 91 | + }, |
| 92 | + }; |
| 93 | +}; |
| 94 | + |
| 95 | +const boot = async (opts: { schemaExtra?: Record<string, any>; resolvable: boolean }) => { |
| 96 | + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); |
| 97 | + const harness = makeHarness(opts); |
| 98 | + await plugin.init(harness.ctx); |
| 99 | + await plugin.start(harness.ctx); |
| 100 | + return harness; |
| 101 | +}; |
| 102 | + |
| 103 | +/** Authenticated member — resolves to a non-empty permission-set list. */ |
| 104 | +const memberRead = (): any => ({ |
| 105 | + object: 'task', |
| 106 | + operation: 'find', |
| 107 | + ast: { where: undefined }, |
| 108 | + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, |
| 109 | +}); |
| 110 | + |
| 111 | +describe('[#3545] unresolvable object metadata — security posture fails closed', () => { |
| 112 | + describe('private posture (ADR-0066 D2)', () => { |
| 113 | + it('control: metadata RESOLVABLE → a plain wildcard does not reach a private object', async () => { |
| 114 | + const h = await boot({ schemaExtra: { access: { default: 'private' } }, resolvable: true }); |
| 115 | + await expect(h.run(memberRead())).rejects.toMatchObject({ name: 'PermissionDeniedError' }); |
| 116 | + }); |
| 117 | + |
| 118 | + it('metadata UNRESOLVABLE → still denied (posture must not default to public)', async () => { |
| 119 | + const h = await boot({ schemaExtra: { access: { default: 'private' } }, resolvable: false }); |
| 120 | + await expect(h.run(memberRead())).rejects.toMatchObject({ name: 'PermissionDeniedError' }); |
| 121 | + }); |
| 122 | + }); |
| 123 | + |
| 124 | + describe('requiredPermissions capability contract (ADR-0066 D3)', () => { |
| 125 | + it('control: metadata RESOLVABLE → a member lacking the capability is denied', async () => { |
| 126 | + const h = await boot({ |
| 127 | + schemaExtra: { requiredPermissions: ['manage_platform_settings'] }, |
| 128 | + resolvable: true, |
| 129 | + }); |
| 130 | + await expect(h.run(memberRead())).rejects.toMatchObject({ name: 'PermissionDeniedError' }); |
| 131 | + }); |
| 132 | + |
| 133 | + it('metadata UNRESOLVABLE → still denied (the capability AND-gate must not be skipped)', async () => { |
| 134 | + const h = await boot({ |
| 135 | + schemaExtra: { requiredPermissions: ['manage_platform_settings'] }, |
| 136 | + resolvable: false, |
| 137 | + }); |
| 138 | + await expect(h.run(memberRead())).rejects.toMatchObject({ name: 'PermissionDeniedError' }); |
| 139 | + }); |
| 140 | + }); |
| 141 | + |
| 142 | + describe('blast radius', () => { |
| 143 | + it('a PUBLIC, uncontracted object is unaffected when its metadata IS resolvable', async () => { |
| 144 | + const h = await boot({ resolvable: true }); |
| 145 | + await expect(h.run(memberRead())).resolves.toBeDefined(); |
| 146 | + }); |
| 147 | + |
| 148 | + it('an anonymous request is unaffected — it short-circuits before the posture read', async () => { |
| 149 | + const h = await boot({ resolvable: false }); |
| 150 | + const anon: any = { |
| 151 | + object: 'task', |
| 152 | + operation: 'find', |
| 153 | + ast: { where: undefined }, |
| 154 | + context: { positions: [], permissions: [] }, // no userId |
| 155 | + }; |
| 156 | + await expect(h.run(anon)).resolves.toBeDefined(); |
| 157 | + }); |
| 158 | + |
| 159 | + it('a system/boot operation is unaffected — isSystem short-circuits the middleware', async () => { |
| 160 | + const h = await boot({ resolvable: false }); |
| 161 | + const sys: any = { |
| 162 | + object: 'task', |
| 163 | + operation: 'find', |
| 164 | + ast: { where: undefined }, |
| 165 | + context: { isSystem: true, userId: 'usr_system' }, |
| 166 | + }; |
| 167 | + await expect(h.run(sys)).resolves.toBeDefined(); |
| 168 | + }); |
| 169 | + |
| 170 | + it('logs the unresolvable posture so a persistent outage is observable', async () => { |
| 171 | + const h = await boot({ resolvable: false }); |
| 172 | + await expect(h.run(memberRead())).rejects.toMatchObject({ name: 'PermissionDeniedError' }); |
| 173 | + expect(h.logger.error).toHaveBeenCalled(); |
| 174 | + }); |
| 175 | + }); |
| 176 | + |
| 177 | + // The "why am I denied?" surface must agree with the enforcement path — |
| 178 | + // explain reporting `allowed` where the middleware throws is the same |
| 179 | + // declared-≠-enforced drift the engine exists to expose. |
| 180 | + describe('explain parity', () => { |
| 181 | + const explainDeps = (unresolved: boolean): ExplainEngineDeps => |
| 182 | + ({ |
| 183 | + ql: { getSchema: () => ({ name: 'task' }) }, |
| 184 | + resolveSets: async () => [memberSet], |
| 185 | + evaluator: new PermissionEvaluator(), |
| 186 | + getObjectSecurityMeta: async () => ({ |
| 187 | + isPrivate: false, |
| 188 | + requiredPermissions: { all: [], read: [], create: [], update: [], delete: [] }, |
| 189 | + fieldRequiredPermissions: {}, |
| 190 | + unresolved, |
| 191 | + }), |
| 192 | + requiredCaps: (meta: any, op: string) => { |
| 193 | + const bucket = op === 'find' ? 'read' : op === 'insert' ? 'create' : op; |
| 194 | + return [...(meta.all ?? []), ...(meta[bucket] ?? [])]; |
| 195 | + }, |
| 196 | + computeRlsFilter: async () => null, |
| 197 | + getFieldMask: () => ({}), |
| 198 | + fallbackPermissionSet: 'member_default', |
| 199 | + }) as any; |
| 200 | + |
| 201 | + const ctx = { userId: 'u1', positions: ['everyone'], permissions: [] }; |
| 202 | + |
| 203 | + it('control: a resolvable posture still explains as granted', async () => { |
| 204 | + const d = await explainAccess(explainDeps(false), { object: 'task', operation: 'read', context: ctx }); |
| 205 | + expect(d.allowed).toBe(true); |
| 206 | + expect(d.layers.find((l) => l.layer === 'object_crud')!.verdict).toBe('grants'); |
| 207 | + }); |
| 208 | + |
| 209 | + it('an unresolvable posture explains as DENIED, naming the real cause', async () => { |
| 210 | + const d = await explainAccess(explainDeps(true), { object: 'task', operation: 'read', context: ctx }); |
| 211 | + expect(d.allowed).toBe(false); |
| 212 | + const crud = d.layers.find((l) => l.layer === 'object_crud')!; |
| 213 | + expect(crud.verdict).toBe('denies'); |
| 214 | + expect(crud.detail).toContain('could not be resolved'); |
| 215 | + // Not misattributed to the permission sets — they were never the problem. |
| 216 | + expect(crud.detail).not.toContain('No resolved permission set'); |
| 217 | + }); |
| 218 | + }); |
| 219 | +}); |
0 commit comments