|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The prelude's `sys_user_permission_set` read names the columns the resolver |
| 5 | + * decides on — the set, the organization and the validity window — so the |
| 6 | + * engine computes no formula field of the grant table for it. A formula a read |
| 7 | + * does not name is never computed, and one that reads through a lookup (a |
| 8 | + * record title naming the person) would otherwise cost a read and a round trip |
| 9 | + * in every request a grant holder makes. |
| 10 | + * |
| 11 | + * What is pinned: a projection is only right if it drops nothing the resolver |
| 12 | + * reads. The resolved envelope is therefore resolved twice per fixture, on the |
| 13 | + * recording double the batch-equivalence suite drives — |
| 14 | + * |
| 15 | + * - over FULL rows, every stored column plus a computed title, which is what |
| 16 | + * the read returned when it named no columns; and |
| 17 | + * - over PROJECTED rows, the named columns only and no `id`, which is what a |
| 18 | + * driver answers a projection with (driver-sql: exactly the named columns); |
| 19 | + * |
| 20 | + * and the two envelopes must be identical, whole. Each grant state the |
| 21 | + * resolver discriminates is a fixture of its own: an active grant, an expired |
| 22 | + * one, one not yet valid, one owned by the active organization, one owned by |
| 23 | + * another, a deployment-wide one (`organization_id` null) and an |
| 24 | + * `admin_full_access` grant owned by the Default Organization, which anchors |
| 25 | + * platform standing. Dropping any one of the four columns from the read turns |
| 26 | + * at least one fixture red. The batch-equivalence matrix runs through the same |
| 27 | + * comparison. |
| 28 | + */ |
| 29 | + |
| 30 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 31 | +import { ADMIN_FULL_ACCESS } from '@objectstack/spec'; |
| 32 | +import { resetPlatformAdminEmailMemo } from './platform-admin.js'; |
| 33 | +import { resolveUserAuthzGrants } from './resolve-authz-context.js'; |
| 34 | +import { FIXTURES, T0, makeRecordingQl } from './__tests__/resolve-authz-context.batch-equivalence.testkit.js'; |
| 35 | +import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js'; |
| 36 | + |
| 37 | +const ORG_A = 'org_a'; |
| 38 | +const ORG_B = 'org_b'; |
| 39 | +const DEFAULT_ORG = 'org_default'; |
| 40 | +const DAY = 86_400_000; |
| 41 | +const past = new Date(T0 - DAY).toISOString(); |
| 42 | +const future = new Date(T0 + DAY).toISOString(); |
| 43 | + |
| 44 | +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; |
| 45 | +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; |
| 46 | +let ambient: Record<string, string | undefined> = {}; |
| 47 | +beforeAll(() => { |
| 48 | + ambient = { [OWNER_ENV]: process.env[OWNER_ENV], [POSTURE_ENV]: process.env[POSTURE_ENV] }; |
| 49 | + delete process.env[OWNER_ENV]; |
| 50 | + delete process.env[POSTURE_ENV]; |
| 51 | + resetPlatformAdminEmailMemo(); |
| 52 | +}); |
| 53 | +afterAll(() => { |
| 54 | + for (const [k, v] of Object.entries(ambient)) { |
| 55 | + if (v === undefined) delete process.env[k]; |
| 56 | + else process.env[k] = v; |
| 57 | + } |
| 58 | + resetPlatformAdminEmailMemo(); |
| 59 | +}); |
| 60 | + |
| 61 | +/** The definitions the catalog holds; each grant fixture names one. */ |
| 62 | +const definitions = { |
| 63 | + permissions: [ |
| 64 | + { name: ADMIN_FULL_ACCESS, systemPermissions: ['manage_users'] }, |
| 65 | + { name: 'tools', systemPermissions: ['cap_tools'], tabPermissions: { crm: 'visible' } }, |
| 66 | + { name: 'reports', systemPermissions: ['cap_reports'] }, |
| 67 | + ], |
| 68 | + positions: [] as Array<Record<string, unknown>>, |
| 69 | +}; |
| 70 | + |
| 71 | +/** A grant row as stored: every column the object declares, and the title a full read computes. */ |
| 72 | +const grantRow = (id: string, cols: Record<string, unknown>) => ({ |
| 73 | + id, |
| 74 | + user_id: 'u1', |
| 75 | + permission_set: null, |
| 76 | + organization_id: null, |
| 77 | + granted_by: 'u_granter', |
| 78 | + valid_from: null, |
| 79 | + valid_until: null, |
| 80 | + reason: 'pinned', |
| 81 | + created_at: past, |
| 82 | + ...cols, |
| 83 | + display_title: `Ursula → ${String(cols.permission_set)}`, |
| 84 | +}); |
| 85 | + |
| 86 | +interface GrantFixture { |
| 87 | + name: string; |
| 88 | + grants: Array<Record<string, unknown>>; |
| 89 | + tenantId?: string; |
| 90 | +} |
| 91 | + |
| 92 | +const GRANT_FIXTURES: GrantFixture[] = [ |
| 93 | + { name: 'an active grant owned by the active organization', tenantId: ORG_A, |
| 94 | + grants: [grantRow('g_active', { permission_set: 'tools', organization_id: ORG_A })] }, |
| 95 | + { name: 'an active grant owned by another organization', tenantId: ORG_A, |
| 96 | + grants: [grantRow('g_other', { permission_set: 'tools', organization_id: ORG_B })] }, |
| 97 | + { name: 'an expired grant', tenantId: ORG_A, |
| 98 | + grants: [grantRow('g_expired', { permission_set: 'tools', organization_id: ORG_A, valid_until: past })] }, |
| 99 | + { name: 'a grant not yet valid', tenantId: ORG_A, |
| 100 | + grants: [grantRow('g_pending', { permission_set: 'tools', organization_id: ORG_A, valid_from: future })] }, |
| 101 | + { name: 'a grant inside its window', tenantId: ORG_A, |
| 102 | + grants: [grantRow('g_window', { permission_set: 'tools', organization_id: ORG_A, valid_from: past, valid_until: future })] }, |
| 103 | + { name: 'a deployment-wide grant (organization_id null), in an organization', tenantId: ORG_A, |
| 104 | + grants: [grantRow('g_global', { permission_set: 'reports', organization_id: null })] }, |
| 105 | + { name: 'a deployment-wide grant (organization_id null), with no organization active', |
| 106 | + grants: [grantRow('g_global', { permission_set: 'reports', organization_id: null })] }, |
| 107 | + { name: 'an admin_full_access grant owned by the Default Organization, acting in another', tenantId: ORG_A, |
| 108 | + grants: [grantRow('g_admin', { permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG })] }, |
| 109 | + { name: 'an admin_full_access grant owned by the Default Organization, with none active', |
| 110 | + grants: [grantRow('g_admin', { permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG })] }, |
| 111 | + { name: 'an expired admin_full_access grant', tenantId: ORG_A, |
| 112 | + grants: [grantRow('g_admin_lapsed', { permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG, valid_until: past })] }, |
| 113 | + { name: 'an admin_full_access grant owned by another organization', tenantId: ORG_B, |
| 114 | + grants: [grantRow('g_admin_b', { permission_set: ADMIN_FULL_ACCESS, organization_id: ORG_B })] }, |
| 115 | + { name: 'every state at once', tenantId: ORG_A, |
| 116 | + grants: [ |
| 117 | + grantRow('g_active', { permission_set: 'tools', organization_id: ORG_A }), |
| 118 | + grantRow('g_expired', { permission_set: 'reports', organization_id: ORG_A, valid_until: past }), |
| 119 | + grantRow('g_pending', { permission_set: 'reports', organization_id: ORG_A, valid_from: future }), |
| 120 | + grantRow('g_global', { permission_set: 'reports', organization_id: null }), |
| 121 | + grantRow('g_admin', { permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG }), |
| 122 | + ] }, |
| 123 | +]; |
| 124 | + |
| 125 | +const tablesFor = (grants: Array<Record<string, unknown>>): Record<string, unknown[]> => ({ |
| 126 | + sys_user: [{ id: 'u1', email: 'u1@example.com', name: 'Ursula' }], |
| 127 | + sys_member: [ |
| 128 | + { user_id: 'u1', organization_id: ORG_A, role: 'member' }, |
| 129 | + { user_id: 'u1', organization_id: ORG_B, role: 'member' }, |
| 130 | + ], |
| 131 | + sys_user_position: [], |
| 132 | + sys_user_permission_set: grants, |
| 133 | + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: ORG_A, slug: 'a' }, { id: ORG_B, slug: 'b' }], |
| 134 | + sys_metadata_activation: [], |
| 135 | +}); |
| 136 | + |
| 137 | +/** The `fields` each read carried, by object, in issue order. */ |
| 138 | +type Projections = Array<{ object: string; fields: unknown }>; |
| 139 | + |
| 140 | +/** |
| 141 | + * The recording double, answering a projection the way a driver does — the |
| 142 | + * named columns and nothing else, `id` included only when named — or, with |
| 143 | + * `projecting: false`, ignoring it and answering every column as a read that |
| 144 | + * named none did. |
| 145 | + */ |
| 146 | +function engineOver(tables: Record<string, unknown[]>, projecting: boolean, catalog?: typeof definitions) { |
| 147 | + const ql: any = makeRecordingQl(tables); |
| 148 | + if (catalog) bindStaticSecurityCatalog(ql, catalog); |
| 149 | + const projections: Projections = []; |
| 150 | + const find = ql.find.bind(ql); |
| 151 | + ql.find = async (object: string, opts: any) => { |
| 152 | + projections.push({ object, fields: opts?.fields }); |
| 153 | + const rows: Array<Record<string, unknown>> = await find(object, opts); |
| 154 | + if (!projecting || !Array.isArray(opts?.fields)) return rows; |
| 155 | + return rows.map((row) => Object.fromEntries(opts.fields.filter((f: string) => f in row).map((f: string) => [f, row[f]]))); |
| 156 | + }; |
| 157 | + return { ql, projections }; |
| 158 | +} |
| 159 | + |
| 160 | +async function resolveBoth( |
| 161 | + userId: string, |
| 162 | + tables: Record<string, unknown[]>, |
| 163 | + opts: Record<string, unknown>, |
| 164 | + catalog?: typeof definitions, |
| 165 | +) { |
| 166 | + const full = engineOver(tables, false, catalog); |
| 167 | + const projected = engineOver(tables, true, catalog); |
| 168 | + return { |
| 169 | + full: await resolveUserAuthzGrants(full.ql, userId, opts as never), |
| 170 | + projected: await resolveUserAuthzGrants(projected.ql, userId, opts as never), |
| 171 | + projections: projected.projections, |
| 172 | + }; |
| 173 | +} |
| 174 | + |
| 175 | +describe('the prelude\'s grant read names the columns the resolver decides on', () => { |
| 176 | + it('the grant read names exactly permission_set, organization_id, valid_from and valid_until — no other read names any', async () => { |
| 177 | + const { projections } = await resolveBoth('u1', tablesFor(GRANT_FIXTURES.at(-1)!.grants), { tenantId: ORG_A, nowMs: T0 }, definitions); |
| 178 | + const grantReads = projections.filter((p) => p.object === 'sys_user_permission_set'); |
| 179 | + expect(grantReads).toHaveLength(1); |
| 180 | + expect([...(grantReads[0]!.fields as string[])].sort()).toEqual(['organization_id', 'permission_set', 'valid_from', 'valid_until']); |
| 181 | + // No other read in the prelude changes. |
| 182 | + expect(projections.filter((p) => p.object !== 'sys_user_permission_set' && p.fields !== undefined)).toEqual([]); |
| 183 | + }); |
| 184 | + |
| 185 | + describe.each(GRANT_FIXTURES)('$name', ({ grants, tenantId }) => { |
| 186 | + it('resolves the identical envelope from the projected rows as from the full rows', async () => { |
| 187 | + const opts = { ...(tenantId ? { tenantId } : {}), nowMs: T0 }; |
| 188 | + const { full, projected } = await resolveBoth('u1', tablesFor(grants), opts, definitions); |
| 189 | + expect(projected).toEqual(full); |
| 190 | + }); |
| 191 | + }); |
| 192 | + |
| 193 | + it('the fixtures discriminate: they resolve distinct envelopes, platform standing among them', async () => { |
| 194 | + const seen = new Set<string>(); |
| 195 | + let platformAdmin = 0; |
| 196 | + for (const { grants, tenantId } of GRANT_FIXTURES) { |
| 197 | + const { projected } = await resolveBoth('u1', tablesFor(grants), { ...(tenantId ? { tenantId } : {}), nowMs: T0 }, definitions); |
| 198 | + seen.add(JSON.stringify(projected)); |
| 199 | + if (projected.posture === 'PLATFORM_ADMIN') platformAdmin += 1; |
| 200 | + } |
| 201 | + expect(seen.size).toBeGreaterThanOrEqual(5); |
| 202 | + expect(platformAdmin).toBe(3); |
| 203 | + }); |
| 204 | + |
| 205 | + describe.each(FIXTURES)('batch-equivalence fixture $name', (fixture) => { |
| 206 | + it('resolves the identical envelope from the projected rows as from the full rows', async () => { |
| 207 | + const { full, projected } = await resolveBoth(fixture.userId, fixture.tables, fixture.opts as Record<string, unknown>); |
| 208 | + expect(projected).toEqual(full); |
| 209 | + }); |
| 210 | + }); |
| 211 | +}); |
0 commit comments