|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#20935] `getQueryableFields` — the query-side twin of `getReadableFields`. |
| 5 | + * |
| 6 | + * The first block is an EQUIVALENCE, not a table of expected lists: for every |
| 7 | + * field of the object it drives the REAL registered middleware with a query |
| 8 | + * naming only that field — as a filter, as a sort key, as a group key and as an |
| 9 | + * aggregate input — and requires "the middleware admitted it" to equal "the |
| 10 | + * field is in `getQueryableFields`". The published answer and the engine's two |
| 11 | + * query guards come from one derivation; this is what keeps them one. |
| 12 | + * |
| 13 | + * The second block pins the answers the contract names, above all the one that |
| 14 | + * makes this method necessary: a field the caller is served MASKED is in the |
| 15 | + * read projection and NOT in the query one. |
| 16 | + * |
| 17 | + * Harness mirrors `get-writable-fields.test.ts`. |
| 18 | + */ |
| 19 | + |
| 20 | +import { describe, it, expect, vi } from 'vitest'; |
| 21 | +import type { PermissionSet } from '@objectstack/spec/security'; |
| 22 | +import { SecurityPlugin } from './security-plugin.js'; |
| 23 | + |
| 24 | +const CRUD = { allowRead: true, allowCreate: true, allowEdit: true }; |
| 25 | + |
| 26 | +/** |
| 27 | + * The baseline every authenticated caller resolves: reads the object, and two |
| 28 | + * fields not at all — one of them declares a masking rule, which never widens |
| 29 | + * an explicit deny. |
| 30 | + */ |
| 31 | +const MEMBER_SET = { |
| 32 | + name: 'member_default', |
| 33 | + label: 'Reader', |
| 34 | + objects: { ledger: CRUD }, |
| 35 | + fields: { |
| 36 | + 'ledger.secret': { readable: false, editable: false }, |
| 37 | + 'ledger.denied_masked': { readable: false, editable: false }, |
| 38 | + }, |
| 39 | +} as unknown as PermissionSet; |
| 40 | + |
| 41 | +/** The same grant plus the capability that lifts `gated_masked`'s rule. */ |
| 42 | +const UNMASK_SET = { |
| 43 | + ...(MEMBER_SET as object), |
| 44 | + label: 'Reader who may see the gated field unmasked', |
| 45 | + systemPermissions: ['view_gated'], |
| 46 | +} as unknown as PermissionSet; |
| 47 | + |
| 48 | +/** An agent's own set, holding the capability: the D10 case turns on the intersection alone. */ |
| 49 | +const AGENT_SET = { |
| 50 | + name: 'agent_reader', |
| 51 | + label: 'Agent', |
| 52 | + objects: { ledger: CRUD }, |
| 53 | + systemPermissions: ['view_gated'], |
| 54 | +} as unknown as PermissionSet; |
| 55 | + |
| 56 | +const SCHEMAS: Record<string, unknown> = { |
| 57 | + ledger: { |
| 58 | + name: 'ledger', |
| 59 | + fields: { |
| 60 | + title: { type: 'text', label: 'Title' }, |
| 61 | + // Masked unless the caller holds `view_gated` (the rule's unmask gate). |
| 62 | + gated_masked: { type: 'text', label: 'Gated', maskingRule: { keepHead: 1, keepTail: 1 }, requiredPermissions: ['view_gated'] }, |
| 63 | + // A rule with no gate: masked for every non-system caller. |
| 64 | + always_masked: { type: 'text', label: 'Always', maskingRule: 'name' }, |
| 65 | + secret: { type: 'text', label: 'Secret' }, |
| 66 | + denied_masked: { type: 'text', label: 'Denied', maskingRule: 'name' }, |
| 67 | + }, |
| 68 | + }, |
| 69 | +}; |
| 70 | +/** The field universe the plugin resolves: the schema's fields plus `id`. */ |
| 71 | +const FIELDS = ['id', 'title', 'gated_masked', 'always_masked', 'secret', 'denied_masked']; |
| 72 | + |
| 73 | +const MEMBER_CTX = { userId: 'u_member', tenantId: 'org-1', positions: [], permissions: [], posture: 'MEMBER' }; |
| 74 | +const LIVE_DELEGATOR = 'u_boss'; |
| 75 | +const AGENT_CTX = { userId: 'u_agent', tenantId: 'org-1', positions: [], permissions: ['agent_reader'], posture: 'MEMBER' }; |
| 76 | +const DELEGATED_AGENT_CTX = { ...AGENT_CTX, onBehalfOf: { userId: LIVE_DELEGATOR } }; |
| 77 | + |
| 78 | +async function boot(sets: PermissionSet[], opts: { noBaseline?: boolean } = {}) { |
| 79 | + const middlewares: Array<(opCtx: any, next: () => Promise<void>) => Promise<void>> = []; |
| 80 | + const services: Record<string, unknown> = { |
| 81 | + manifest: { register: vi.fn() }, |
| 82 | + objectql: { |
| 83 | + registerMiddleware: (mw: any) => middlewares.push(mw), |
| 84 | + getSchema: (name: string) => SCHEMAS[name], |
| 85 | + findOne: vi.fn(async (_object: string, query: any) => |
| 86 | + (query?.where?.id === LIVE_DELEGATOR ? { id: LIVE_DELEGATOR, email: 'boss@example.test' } : null)), |
| 87 | + }, |
| 88 | + metadata: { |
| 89 | + get: async (_type: string, name: string) => SCHEMAS[name], |
| 90 | + list: async () => sets, |
| 91 | + }, |
| 92 | + }; |
| 93 | + const registerService = vi.fn(); |
| 94 | + const ctx: Record<string, unknown> = { |
| 95 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, |
| 96 | + registerService, |
| 97 | + getService: (name: string) => { |
| 98 | + if (!(name in services)) throw new Error(`service not registered: ${name}`); |
| 99 | + return services[name]; |
| 100 | + }, |
| 101 | + }; |
| 102 | + const plugin = new SecurityPlugin( |
| 103 | + opts.noBaseline |
| 104 | + ? { defaultPermissionSets: [], fallbackPermissionSet: null } |
| 105 | + : { fallbackPermissionSet: 'member_default' }, |
| 106 | + ); |
| 107 | + await plugin.init(ctx as any); |
| 108 | + await plugin.start(ctx as any); |
| 109 | + if (middlewares.length === 0) throw new Error('SecurityPlugin registered no middleware'); |
| 110 | + return { plugin, middleware: middlewares[0], registerService }; |
| 111 | +} |
| 112 | + |
| 113 | +/** Each way a query can name one field: the four positions the engine's two query guards judge. */ |
| 114 | +const PROBES: ReadonlyArray<readonly [string, 'find' | 'aggregate', (field: string) => Record<string, unknown>]> = [ |
| 115 | + ['a filter', 'find', (field) => ({ where: { [field]: 'v' } })], |
| 116 | + ['a sort key', 'find', (field) => ({ where: {}, orderBy: [{ field, order: 'asc' }] })], |
| 117 | + ['a group key', 'aggregate', (field) => ({ groupBy: [field], aggregations: [{ function: 'count', alias: 'n' }] })], |
| 118 | + ['an aggregate input', 'aggregate', (field) => ({ aggregations: [{ function: 'max', field, alias: 'm' }] })], |
| 119 | +]; |
| 120 | + |
| 121 | +async function middlewareAdmits( |
| 122 | + middleware: (opCtx: any, next: () => Promise<void>) => Promise<void>, |
| 123 | + operation: 'find' | 'aggregate', |
| 124 | + context: Record<string, unknown>, |
| 125 | + ast: Record<string, unknown>, |
| 126 | +): Promise<{ admitted: true } | { admitted: false; code?: unknown; status?: unknown }> { |
| 127 | + try { |
| 128 | + await middleware({ object: 'ledger', operation, context: { ...context }, options: {}, ast }, async () => {}); |
| 129 | + return { admitted: true }; |
| 130 | + } catch (e) { |
| 131 | + const err = e as { code?: unknown; status?: unknown; statusCode?: unknown }; |
| 132 | + return { admitted: false, code: err.code, status: err.status ?? err.statusCode }; |
| 133 | + } |
| 134 | +} |
| 135 | + |
| 136 | +describe('[#20935] getQueryableFields agrees with the middleware\'s query guards, field for field', () => { |
| 137 | + const CASES: Array<{ label: string; sets: PermissionSet[]; context: Record<string, unknown>; queryable: string[] }> = [ |
| 138 | + { label: 'a member: two fields served masked, two hidden', sets: [MEMBER_SET], context: MEMBER_CTX, queryable: ['id', 'title'] }, |
| 139 | + { label: 'a member holding the capability that lifts one rule', sets: [UNMASK_SET], context: MEMBER_CTX, queryable: ['id', 'title', 'gated_masked'] }, |
| 140 | + { label: 'an agent holding that capability, acting for nobody', sets: [AGENT_SET, MEMBER_SET], context: AGENT_CTX, queryable: ['id', 'title', 'gated_masked'] }, |
| 141 | + { label: 'the same agent acting for a delegator who does not hold it', sets: [AGENT_SET, MEMBER_SET], context: DELEGATED_AGENT_CTX, queryable: ['id', 'title'] }, |
| 142 | + ]; |
| 143 | + |
| 144 | + for (const c of CASES) { |
| 145 | + it(c.label, async () => { |
| 146 | + const { plugin, middleware } = await boot(c.sets); |
| 147 | + const queryable = await plugin.getQueryableFields('ledger', c.context); |
| 148 | + // The expected list keeps each case honest about what it exercises; the |
| 149 | + // equivalence below is the pin. |
| 150 | + expect(queryable).toEqual(c.queryable); |
| 151 | + for (const field of FIELDS) { |
| 152 | + for (const [position, operation, ast] of PROBES) { |
| 153 | + const verdict = await middlewareAdmits(middleware, operation, c.context, ast(field)); |
| 154 | + if (queryable!.includes(field)) { |
| 155 | + expect(verdict, `${field} as ${position}`).toEqual({ admitted: true }); |
| 156 | + } else { |
| 157 | + expect(verdict, `${field} as ${position}`).toMatchObject({ admitted: false, code: 'PERMISSION_DENIED', status: 403 }); |
| 158 | + } |
| 159 | + } |
| 160 | + } |
| 161 | + }); |
| 162 | + } |
| 163 | +}); |
| 164 | + |
| 165 | +describe('[#20935] getQueryableFields — the answers the contract names', () => { |
| 166 | + it('a field the caller is served MASKED is readable and NOT queryable; the difference is exactly the masked fields', async () => { |
| 167 | + const { plugin } = await boot([MEMBER_SET]); |
| 168 | + const readable = await plugin.getReadableFields('ledger', MEMBER_CTX); |
| 169 | + const queryable = await plugin.getQueryableFields('ledger', MEMBER_CTX); |
| 170 | + expect(readable).toEqual(['id', 'title', 'gated_masked', 'always_masked']); |
| 171 | + expect(queryable).toEqual(['id', 'title']); |
| 172 | + expect(queryable!.every((f) => readable!.includes(f))).toBe(true); |
| 173 | + expect(readable!.filter((f) => !queryable!.includes(f))).toEqual(['gated_masked', 'always_masked']); |
| 174 | + }); |
| 175 | + |
| 176 | + it('a system context bypasses: the full field set', async () => { |
| 177 | + const { plugin } = await boot([MEMBER_SET]); |
| 178 | + expect(await plugin.getQueryableFields('ledger', { isSystem: true })).toEqual(FIELDS); |
| 179 | + }); |
| 180 | + |
| 181 | + it('no permission sets resolved: the full field set, as the middleware skips both guards', async () => { |
| 182 | + const { plugin } = await boot([], { noBaseline: true }); |
| 183 | + expect(await plugin.getQueryableFields('ledger', MEMBER_CTX)).toEqual(FIELDS); |
| 184 | + }); |
| 185 | + |
| 186 | + it('an unresolvable object is no answer (undefined), not an empty one', async () => { |
| 187 | + const { plugin } = await boot([MEMBER_SET]); |
| 188 | + expect(await plugin.getQueryableFields('no_such_object', MEMBER_CTX)).toBeUndefined(); |
| 189 | + }); |
| 190 | + |
| 191 | + it('a delegator that does not exist fails closed: []', async () => { |
| 192 | + const { plugin } = await boot([AGENT_SET, MEMBER_SET]); |
| 193 | + expect(await plugin.getQueryableFields('ledger', { ...AGENT_CTX, onBehalfOf: { userId: 'u_ghost' } })).toEqual([]); |
| 194 | + }); |
| 195 | + |
| 196 | + it('is exposed on the registered "security" service', async () => { |
| 197 | + const { registerService } = await boot([MEMBER_SET]); |
| 198 | + const svc = registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1]; |
| 199 | + expect(typeof svc?.getQueryableFields).toBe('function'); |
| 200 | + expect(await svc.getQueryableFields('ledger', MEMBER_CTX)).toEqual(['id', 'title']); |
| 201 | + }); |
| 202 | +}); |
0 commit comments