From 54d5d112586de9f6274ec9d5bf7a06e76c50a13d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:22:43 +0000 Subject: [PATCH 1/2] fix(core): the authz prelude's grant read projects the columns it decides on The prelude read sys_user_permission_set with no projection, so the engine computed every formula field of the grant table in every request a grant holder made. A record title that reads one hop through user_id would add a sys_user read and a round trip to each such request. The read now names permission_set, organization_id, valid_from and valid_until - every column the resolver reads off a grant row - through the module-private tryFind. Pinned: the resolved envelope is identical over projected and full rows for every grant state, and the prelude's query-count pin gains a grant-holder case that stays at 8 reads in 4 round trips under a one-hop title. Claude-Session: https://claude.ai/code/session_01BQSBMNgcScNf9TzF8W6YpU Co-authored-by: Claude --- ...lve-authz-context.grant-projection.test.ts | 211 ++++++++++++++++++ .../src/security/resolve-authz-context.ts | 24 +- .../src/auth-prelude-query-count.pin.test.ts | 65 +++++- 3 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/security/resolve-authz-context.grant-projection.test.ts diff --git a/packages/core/src/security/resolve-authz-context.grant-projection.test.ts b/packages/core/src/security/resolve-authz-context.grant-projection.test.ts new file mode 100644 index 00000000000..562cb72527b --- /dev/null +++ b/packages/core/src/security/resolve-authz-context.grant-projection.test.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The prelude's `sys_user_permission_set` read names the columns the resolver + * decides on — the set, the organization and the validity window — so the + * engine computes no formula field of the grant table for it. A formula a read + * does not name is never computed, and one that reads through a lookup (a + * record title naming the person) would otherwise cost a read and a round trip + * in every request a grant holder makes. + * + * What is pinned: a projection is only right if it drops nothing the resolver + * reads. The resolved envelope is therefore resolved twice per fixture, on the + * recording double the batch-equivalence suite drives — + * + * - over FULL rows, every stored column plus a computed title, which is what + * the read returned when it named no columns; and + * - over PROJECTED rows, the named columns only and no `id`, which is what a + * driver answers a projection with (driver-sql: exactly the named columns); + * + * and the two envelopes must be identical, whole. Each grant state the + * resolver discriminates is a fixture of its own: an active grant, an expired + * one, one not yet valid, one owned by the active organization, one owned by + * another, a deployment-wide one (`organization_id` null) and an + * `admin_full_access` grant owned by the Default Organization, which anchors + * platform standing. Dropping any one of the four columns from the read turns + * at least one fixture red. The batch-equivalence matrix runs through the same + * comparison. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec'; +import { resetPlatformAdminEmailMemo } from './platform-admin.js'; +import { resolveUserAuthzGrants } from './resolve-authz-context.js'; +import { FIXTURES, T0, makeRecordingQl } from './__tests__/resolve-authz-context.batch-equivalence.testkit.js'; +import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js'; + +const ORG_A = 'org_a'; +const ORG_B = 'org_b'; +const DEFAULT_ORG = 'org_default'; +const DAY = 86_400_000; +const past = new Date(T0 - DAY).toISOString(); +const future = new Date(T0 + DAY).toISOString(); + +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; +let ambient: Record = {}; +beforeAll(() => { + ambient = { [OWNER_ENV]: process.env[OWNER_ENV], [POSTURE_ENV]: process.env[POSTURE_ENV] }; + delete process.env[OWNER_ENV]; + delete process.env[POSTURE_ENV]; + resetPlatformAdminEmailMemo(); +}); +afterAll(() => { + for (const [k, v] of Object.entries(ambient)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + resetPlatformAdminEmailMemo(); +}); + +/** The definitions the catalog holds; each grant fixture names one. */ +const definitions = { + permissions: [ + { name: ADMIN_FULL_ACCESS, systemPermissions: ['manage_users'] }, + { name: 'tools', systemPermissions: ['cap_tools'], tabPermissions: { crm: 'visible' } }, + { name: 'reports', systemPermissions: ['cap_reports'] }, + ], + positions: [] as Array>, +}; + +/** A grant row as stored: every column the object declares, and the title a full read computes. */ +const grantRow = (id: string, cols: Record) => ({ + id, + user_id: 'u1', + permission_set: null, + organization_id: null, + granted_by: 'u_granter', + valid_from: null, + valid_until: null, + reason: 'pinned', + created_at: past, + ...cols, + display_title: `Ursula → ${String(cols.permission_set)}`, +}); + +interface GrantFixture { + name: string; + grants: Array>; + tenantId?: string; +} + +const GRANT_FIXTURES: GrantFixture[] = [ + { name: 'an active grant owned by the active organization', tenantId: ORG_A, + grants: [grantRow('g_active', { permission_set: 'tools', organization_id: ORG_A })] }, + { name: 'an active grant owned by another organization', tenantId: ORG_A, + grants: [grantRow('g_other', { permission_set: 'tools', organization_id: ORG_B })] }, + { name: 'an expired grant', tenantId: ORG_A, + grants: [grantRow('g_expired', { permission_set: 'tools', organization_id: ORG_A, valid_until: past })] }, + { name: 'a grant not yet valid', tenantId: ORG_A, + grants: [grantRow('g_pending', { permission_set: 'tools', organization_id: ORG_A, valid_from: future })] }, + { name: 'a grant inside its window', tenantId: ORG_A, + grants: [grantRow('g_window', { permission_set: 'tools', organization_id: ORG_A, valid_from: past, valid_until: future })] }, + { name: 'a deployment-wide grant (organization_id null), in an organization', tenantId: ORG_A, + grants: [grantRow('g_global', { permission_set: 'reports', organization_id: null })] }, + { name: 'a deployment-wide grant (organization_id null), with no organization active', + grants: [grantRow('g_global', { permission_set: 'reports', organization_id: null })] }, + { name: 'an admin_full_access grant owned by the Default Organization, acting in another', tenantId: ORG_A, + grants: [grantRow('g_admin', { permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG })] }, + { name: 'an admin_full_access grant owned by the Default Organization, with none active', + grants: [grantRow('g_admin', { permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG })] }, + { name: 'an expired admin_full_access grant', tenantId: ORG_A, + grants: [grantRow('g_admin_lapsed', { permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG, valid_until: past })] }, + { name: 'an admin_full_access grant owned by another organization', tenantId: ORG_B, + grants: [grantRow('g_admin_b', { permission_set: ADMIN_FULL_ACCESS, organization_id: ORG_B })] }, + { name: 'every state at once', tenantId: ORG_A, + grants: [ + grantRow('g_active', { permission_set: 'tools', organization_id: ORG_A }), + grantRow('g_expired', { permission_set: 'reports', organization_id: ORG_A, valid_until: past }), + grantRow('g_pending', { permission_set: 'reports', organization_id: ORG_A, valid_from: future }), + grantRow('g_global', { permission_set: 'reports', organization_id: null }), + grantRow('g_admin', { permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG }), + ] }, +]; + +const tablesFor = (grants: Array>): Record => ({ + sys_user: [{ id: 'u1', email: 'u1@example.com', name: 'Ursula' }], + sys_member: [ + { user_id: 'u1', organization_id: ORG_A, role: 'member' }, + { user_id: 'u1', organization_id: ORG_B, role: 'member' }, + ], + sys_user_position: [], + sys_user_permission_set: grants, + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: ORG_A, slug: 'a' }, { id: ORG_B, slug: 'b' }], + sys_metadata_activation: [], +}); + +/** The `fields` each read carried, by object, in issue order. */ +type Projections = Array<{ object: string; fields: unknown }>; + +/** + * The recording double, answering a projection the way a driver does — the + * named columns and nothing else, `id` included only when named — or, with + * `projecting: false`, ignoring it and answering every column as a read that + * named none did. + */ +function engineOver(tables: Record, projecting: boolean, catalog?: typeof definitions) { + const ql: any = makeRecordingQl(tables); + if (catalog) bindStaticSecurityCatalog(ql, catalog); + const projections: Projections = []; + const find = ql.find.bind(ql); + ql.find = async (object: string, opts: any) => { + projections.push({ object, fields: opts?.fields }); + const rows: Array> = await find(object, opts); + if (!projecting || !Array.isArray(opts?.fields)) return rows; + return rows.map((row) => Object.fromEntries(opts.fields.filter((f: string) => f in row).map((f: string) => [f, row[f]]))); + }; + return { ql, projections }; +} + +async function resolveBoth( + userId: string, + tables: Record, + opts: Record, + catalog?: typeof definitions, +) { + const full = engineOver(tables, false, catalog); + const projected = engineOver(tables, true, catalog); + return { + full: await resolveUserAuthzGrants(full.ql, userId, opts as never), + projected: await resolveUserAuthzGrants(projected.ql, userId, opts as never), + projections: projected.projections, + }; +} + +describe('the prelude\'s grant read names the columns the resolver decides on', () => { + it('the grant read names exactly permission_set, organization_id, valid_from and valid_until — no other read names any', async () => { + const { projections } = await resolveBoth('u1', tablesFor(GRANT_FIXTURES.at(-1)!.grants), { tenantId: ORG_A, nowMs: T0 }, definitions); + const grantReads = projections.filter((p) => p.object === 'sys_user_permission_set'); + expect(grantReads).toHaveLength(1); + expect([...(grantReads[0]!.fields as string[])].sort()).toEqual(['organization_id', 'permission_set', 'valid_from', 'valid_until']); + // No other read in the prelude changes. + expect(projections.filter((p) => p.object !== 'sys_user_permission_set' && p.fields !== undefined)).toEqual([]); + }); + + describe.each(GRANT_FIXTURES)('$name', ({ grants, tenantId }) => { + it('resolves the identical envelope from the projected rows as from the full rows', async () => { + const opts = { ...(tenantId ? { tenantId } : {}), nowMs: T0 }; + const { full, projected } = await resolveBoth('u1', tablesFor(grants), opts, definitions); + expect(projected).toEqual(full); + }); + }); + + it('the fixtures discriminate: they resolve distinct envelopes, platform standing among them', async () => { + const seen = new Set(); + let platformAdmin = 0; + for (const { grants, tenantId } of GRANT_FIXTURES) { + const { projected } = await resolveBoth('u1', tablesFor(grants), { ...(tenantId ? { tenantId } : {}), nowMs: T0 }, definitions); + seen.add(JSON.stringify(projected)); + if (projected.posture === 'PLATFORM_ADMIN') platformAdmin += 1; + } + expect(seen.size).toBeGreaterThanOrEqual(5); + expect(platformAdmin).toBe(3); + }); + + describe.each(FIXTURES)('batch-equivalence fixture $name', (fixture) => { + it('resolves the identical envelope from the projected rows as from the full rows', async () => { + const { full, projected } = await resolveBoth(fixture.userId, fixture.tables, fixture.opts as Record); + expect(projected).toEqual(full); + }); + }); +}); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index d05eb6b42b4..6d3b1baf715 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -361,11 +361,18 @@ async function tryFind( * what a `single`-posture deployment wants everywhere. */ organizationId?: string, + /** + * The columns to read. Omitted reads every column — and computes every + * formula field the object declares, a formula that reads through a lookup + * paying its own read for the hop. Named, the engine computes only the + * formulas named (none, for a list of stored columns). + */ + fields?: readonly string[], ): Promise { if (!ql || typeof ql.find !== 'function') return []; try { const context = organizationId ? { isSystem: true, tenantId: organizationId } : { isSystem: true }; - let rows = await ql.find(object, { where, limit, context } as any); + let rows = await ql.find(object, { where, limit, context, ...(fields ? { fields: [...fields] } : {}) } as any); if (rows && (rows as any).value) rows = (rows as any).value; return Array.isArray(rows) ? rows : []; } catch (err) { @@ -870,6 +877,19 @@ function grantAppliesInTenant(organizationId: string | null, tenantId: string | return organizationId !== null && organizationId === tenantId; } +/** + * The columns of a `sys_user_permission_set` grant row this resolver decides + * on, and the only ones its grant read asks for: the set it names + * ({@link grantSetNameOf}), its organization ({@link grantOrganizationOf}) and + * its validity window (`isGrantActive`, `nextGrantValidityBoundary`). The read + * runs in every request's prelude, and a read naming no columns computes every + * formula field the object declares — its record title included, which may + * read through a lookup at the cost of a read and a round trip — for values + * nothing here reads. A helper below that learns to read another column of a + * grant row adds it here; the read returns nothing it does not name. + */ +const GRANT_ROW_COLUMNS = ['permission_set', 'organization_id', 'valid_from', 'valid_until'] as const; + /** * [ADR-0131 D4] The permission set a stored `sys_user_permission_set` grant * names: its `permission_set`, or `undefined` for a grant that names none — @@ -1463,7 +1483,7 @@ export async function resolveUserAuthzGrants( tryFind(ql, 'sys_member', { user_id: userId }, 200), tryFind(ql, 'sys_user_position', { user_id: userId }, 200), tenantId ? tryFind(ql, 'sys_member', { organization_id: tenantId }, 1000) : Promise.resolve([] as any[]), - tryFind(ql, 'sys_user_permission_set', { user_id: userId }, 100), + tryFind(ql, 'sys_user_permission_set', { user_id: userId }, 100, undefined, GRANT_ROW_COLUMNS), ]); // Resolve the caller's unique email for `current_user.email` RLS owner diff --git a/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts b/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts index 1ac6f5c35d4..5cad4acbf59 100644 --- a/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts @@ -41,6 +41,14 @@ * 5. **A keyring write is seen on the very next signing** — the key the * header was signed with is retired through the engine, and the next * `/get-session` signs with a new one. + * 6. **A principal holding a direct grant pays the same prelude** — the + * same 8 reads in 4 round trips, whatever display formula + * `sys_user_permission_set` declares. The resolver's grant read projects + * the columns it decides on, so a formula it does not name is never + * computed there: the case declares a title that reads ONE hop through + * `user_id`, which a read that computes it pays for with a `sys_user` read + * and a round trip. Case 1 cannot see this — its principal holds no grant + * row, so no formula is ever computed for it. * * The counts are exact on purpose: a change that issues a read the prelude did * not issue turns 1 or 2 red. A change that REMOVES one turns them red too — @@ -118,6 +126,24 @@ function probeDriver(driver: Record) { }; } +/** + * A `sys_user_permission_set` title that reads one hop through `user_id` — + * the person's name, or their email — which a read computing it pays for with + * a batched `sys_user` read. + */ +const ONE_HOP_GRANT_TITLE = + "(isBlank(trim(record.user_id.name)) ? record.user_id.email : record.user_id.name) + ' → ' + record.permission_set"; + +/** `sys_user_permission_set` as declared, with its `display_title` reading {@link ONE_HOP_GRANT_TITLE}. */ +function withOneHopGrantTitle(object: any): unknown { + const title = object.fields.display_title; + expect(title?.type, 'the declared grant table carries a display_title formula').toBe('formula'); + return { + ...object, + fields: { ...object.fields, display_title: { ...title, expression: { dialect: 'cel', source: ONE_HOP_GRANT_TITLE } } }, + }; +} + /** The grant tables, as `SecurityPlugin` declares them. */ async function grantTables(): Promise { let manifest: { objects?: unknown[] } = {}; @@ -149,15 +175,24 @@ const kidOf = (jwt: string | null): string | undefined => const tally = (objects: string[]): Record => objects.reduce>((acc, o) => ({ ...acc, [o]: (acc[o] ?? 0) + 1 }), {}); +interface RigOptions { + /** Grant the signed-in user one permission set in the active organization. */ + directGrant?: boolean; + /** Declare `sys_user_permission_set.display_title` as {@link ONE_HOP_GRANT_TITLE}. */ + oneHopGrantTitle?: boolean; +} + /** A signed-in user whose session has an active organization, on a warmed engine. */ -async function signedInRig() { +async function signedInRig(options: RigOptions = {}) { vi.spyOn(console, 'warn').mockImplementation(() => {}); const engine = new ObjectQL(); engines.push(engine); const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); engine.registerDriver(driver, true); await engine.init(); - for (const object of [...authIdentityObjects, SysMetadataActivation, ...(await grantTables())]) { + const grants = (await grantTables()).map((o: any) => + options.oneHopGrantTitle && o.name === 'sys_user_permission_set' ? withOneHopGrantTitle(o) : o); + for (const object of [...authIdentityObjects, SysMetadataActivation, ...grants]) { engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); } await engine.syncSchemas(); @@ -178,6 +213,11 @@ async function signedInRig() { await engine.insert('sys_organization', { id: 'org_prelude', name: 'Prelude', slug: 'prelude', created_at: now }, SYS); await engine.insert('sys_member', { id: 'mem_prelude', organization_id: 'org_prelude', user_id: session.user.id, role: 'owner', created_at: now }, SYS); await engine.update('sys_session', { id: session.session.id, active_organization_id: 'org_prelude' }, SYS); + if (options.directGrant) { + await engine.insert('sys_user_permission_set', { + id: 'ups_prelude', user_id: session.user.id, permission_set: 'prelude_editor', organization_id: 'org_prelude', created_at: now, + }, SYS); + } const api: any = await manager.getApi(); const headers = new Headers({ cookie }); @@ -231,6 +271,27 @@ describe('[#24061] one authenticated request\'s prelude, counted on a real engin expect(run.legs).toBe(4); }, 60_000); + describe.each([ + ['as declared', false], + ['with a title that reads one hop through user_id', true], + ])('a principal holding a direct grant, its title %s', (_title, oneHopGrantTitle) => { + it('the shared prelude: the same 8 reads in 4 round trips', async () => { + const { engine, probe, prelude, userId } = await signedInRig({ directGrant: true, oneHopGrantTitle }); + // The grant is real, and its title formula is computed on a read that does + // not project it — so the hop, where declared, is live on this engine. + const [held] = (await engine.find('sys_user_permission_set', { where: { user_id: userId }, ...SYS })) as any[]; + expect(held?.permission_set).toBe('prelude_editor'); + expect(held?.display_title).toBe(oneHopGrantTitle ? 'Prelude → prelude_editor' : `${userId} → prelude_editor`); + + const run = await probe.measure(prelude); + + expect((run.result as any).userId).toBe(userId); + expect((run.result as any).tenantId).toBe('org_prelude'); + expect(tally(run.objects)).toEqual(PRELUDE_READS); + expect(run.legs).toBe(4); + }, 60_000); + }); + it('the HTTP /get-session still carries set-auth-jwt, and reads the keyring once', async () => { const { engine, manager, probe, cookie, userId } = await signedInRig(); const getSession = () => manager.handleRequest(new Request(`${AUTH}/get-session`, { headers: { cookie } })); From 9b8a2b5f995be7d9c5b8ae5626bf3f9acfe60a53 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:25:47 +0000 Subject: [PATCH 2/2] chore(changeset): patch @objectstack/core for the prelude's grant read projection Claude-Session: https://claude.ai/code/session_01BQSBMNgcScNf9TzF8W6YpU Co-authored-by: Claude --- .changeset/24896-prelude-grant-read-projection.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/24896-prelude-grant-read-projection.md diff --git a/.changeset/24896-prelude-grant-read-projection.md b/.changeset/24896-prelude-grant-read-projection.md new file mode 100644 index 00000000000..efe454f8502 --- /dev/null +++ b/.changeset/24896-prelude-grant-read-projection.md @@ -0,0 +1,9 @@ +--- +'@objectstack/core': patch +--- + +fix(core): the authorization prelude's grant read names the columns it decides on, so no formula on `sys_user_permission_set` is computed in every request a grant holder makes + +Clause-②: no (a read's projection: every resolved authz answer is unchanged and pinned so; no accept set, served shape or export moves) + +`resolveAuthzContext` reads the principal's `sys_user_permission_set` grants on every authenticated request. That read named no columns, so the engine computed every formula field the grant table declares for it, the record title included, though the resolver reads none of them. A title that reads through a lookup (the person's name, one hop through `user_id`) would have cost each request by a principal holding a direct grant one more `sys_user` read and one more round trip: 9 reads in 5 round trips instead of 8 in 4. The read now names exactly the four columns the resolver decides on: `permission_set`, `organization_id`, `valid_from` and `valid_until`. The resolved authorization context is unchanged for every grant state (active, expired, not yet valid, organization-owned, deployment-wide, and the Default Organization's `admin_full_access` grant), and a grant holder's prelude stays at 8 reads in 4 round trips whatever display formula the grant table declares.