Skip to content

Commit 644e4b2

Browse files
fix(core): the authz prelude's grant read projects the columns it decides on (#24941)
Fixes #24896 Clause-②: no (a read's projection: every resolved authz answer is unchanged and pinned so; no accept set, served shape or export moves) ## What changed The authorization prelude (`resolveUserAuthzGrants` in `packages/core/src/security/resolve-authz-context.ts`) read `sys_user_permission_set` with no projection, so the engine computed every formula field of the grant table on every request a grant holder made. That read now names exactly the four columns the resolver decides on, through the module-private `tryFind`, which gains an optional `fields` argument. No new export, no error code, and no other read in the prelude changes. ### The columns, measured before the edit (base `74fcec1edb`) | column | read by | via | |---|---|---| | `permission_set` | `grantSetNameOf` | `anchorReadsDefaultOrganization`, `mayApply`, the ledger names, `anchorsPlatformStanding`, the 6b set loop | | `organization_id` (helper also accepts `organizationId`) | `grantOrganizationOf` | `grantAppliesInTenant`, `grantAnchorsPlatformStanding`, `mayApply`, `applyingGrants` | | `valid_from` (also `validFrom`) | `isGrantActive`, `nextGrantValidityBoundary` | the `upsRows` filter; the grants-cache boundary over `upsRowsAll` | | `valid_until` (also `validUntil`) | `isGrantActive`, `nextGrantValidityBoundary` | same | Nothing in the prelude reads a grant row's `id`, `user_id` (it is only the filter), `granted_by`, `reason` or `display_title`. The camelCase spellings are not declared fields of the object, so the engine never returns them under those keys; only the declared names are projected. ### What a projection does in objectql `planFormulaProjection` (`packages/objectql/src/engine.ts`) evaluates only the formula fields a non-empty `fields` list names. A list of stored columns yields an empty plan, so no formula is computed and the hop a formula would read is never issued. (#22300's widening applies only when a formula field is IN the projection.) No system column has to be named: driver-sql answers exactly the named columns, `id` included only when named, and the resolver reads no `id`. ### The counts, on the rig of `auth-prelude-query-count.pin.test.ts` (a principal with one organization-owned direct grant) | core | `display_title` | reads | round trips | |---|---|---|---| | `main` | as declared (own columns) | 8 | 4 | | `main` | PR #24885's one-hop formula (applied in the test fixture only) | 9 (`sys_user` 3) | 5 | | this PR | PR #24885's one-hop formula | 8 | 4 | ## Pins - **Equivalence** (`packages/core/src/security/resolve-authz-context.grant-projection.test.ts`, new). Each fixture is resolved twice on the batch-equivalence recording double: over full stored rows plus a computed title, as the unprojected read answered, and over projected rows (named columns only, no `id`), as a driver answers a projection. The whole envelope must be identical. Fixtures: an active grant in the active organization, one owned by another organization, an expired one, one not yet valid, one inside its window, a deployment-wide one (`organization_id` null) with and without an active organization, an `admin_full_access` grant owned by the Default Organization (acting elsewhere, and with none active), an expired `admin_full_access` grant, another organization's `admin_full_access`, and all states at once. The batch-equivalence `FIXTURES` matrix runs through the same comparison. A further case pins that the grant read names exactly the four columns and that no other prelude read names any. - **Count** (`packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts`). The rig gains an optional direct grant, and a variant of the grant table whose `display_title` reads one hop through `user_id`, with the same expression as PR #24885. A new grant-holder case runs under both titles and holds 8 reads in 4 round trips. The case first proves the hop is live on that engine: an unprojected read computes `Prelude → prelude_editor` from the user's name. ## Verification (head `41b479266`, after merging `origin/main` at `71696121a`) - **Ablation:** projection reverted (`--hold` with a dist marker, preflight proved the marker in core's `dist/`). The count pin went red only under the one-hop title (`sys_user` 3, expected 2). The equivalence pin's projection case went red. Restore: `ablation-replace` reports blob == HEAD (`6d3b1baf7150`) and an empty `git diff HEAD`. Rebuild and `ablation-dist-preflight --absent` report the marker absent from all 14 built files and the tree clean. - **Column sweep:** each of the four columns dropped from the projection in turn. Envelope-equivalence fixtures went red every time: `permission_set` 11 failed, `organization_id` 11, `valid_from` 3, `valid_until` 6. Every leg restored blob == HEAD. - **Suites (exit 0 each):** - `@objectstack/core` test: 108 files / 2623 tests. - `@objectstack/core` `test:repo`: 6 / 82. - `@objectstack/plugin-security` test: 196 files / 4075 passed, 45 skipped. - `@objectstack/plugin-auth` test: 171 files / 3283 passed, 10 skipped. - **Typecheck:** `@objectstack/core` and `@objectstack/plugin-auth` both exit 0. Both test layers compile with no new debt. - **Build:** the full build (`turbo run build`, docs excluded) exits 0. - **Gates:** `dispatch-gates.mjs --commands` derived 70 families, all run with exit 0, and `--ran` reconciles: 70 derived, 70 run, 0 NOT-MEASURED, all exit codes recorded. `check:dual-build-cjs-loads` first exited 3 (no `dist` yet). `check:lean-entry-closure` first exited 1, because it read `spec`'s `dist` while the full build was rewriting it. Both exit 0 when re-run on the finished build. - **Pin sweep:** - The batch-equivalence and request-rounds goldens record `object`, `where`, `limit` and `isSystem`, never `fields`, so none moves. - No pin anywhere asserts the grant read's options exactly. - The one pin that moves is the query-count pin, which gains its grant-holder case. ## Cross-lane `packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts` is in `domain:services`' package. The claim declares this edit as cross-lane, and the seat declares it to that lane before the queue. ## Acceptance notes - `sys_member` and `sys_user_position`, the prelude's other grant reads, are also unprojected. Each declares an own-column `display_title` formula (`sys_member`: user and role; `sys_user_position`: user and position), so today they cost one CEL evaluation per row and no read. They would carry the same read cost if either title gained a hop. Measured and named only; not built (out of scope per the claim). - The grants cache default (`OS_AUTHZ_GRANTS_CACHE_TTL_MS=0`) is untouched. - PR #24885 can merge `main` after this lands: its formula then adds no read or round trip to the prelude. --- _Generated by [Claude Code](https://claude.ai/code/session_01BQSBMNgcScNf9TzF8W6YpU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent feb09db commit 644e4b2

4 files changed

Lines changed: 305 additions & 4 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no (a read's projection: every resolved authz answer is unchanged and pinned so; no accept set, served shape or export moves)
8+
9+
`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.
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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+
});

‎packages/core/src/security/resolve-authz-context.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,11 +361,18 @@ async function tryFind(
361361
* what a `single`-posture deployment wants everywhere.
362362
*/
363363
organizationId?: string,
364+
/**
365+
* The columns to read. Omitted reads every column — and computes every
366+
* formula field the object declares, a formula that reads through a lookup
367+
* paying its own read for the hop. Named, the engine computes only the
368+
* formulas named (none, for a list of stored columns).
369+
*/
370+
fields?: readonly string[],
364371
): Promise<any[]> {
365372
if (!ql || typeof ql.find !== 'function') return [];
366373
try {
367374
const context = organizationId ? { isSystem: true, tenantId: organizationId } : { isSystem: true };
368-
let rows = await ql.find(object, { where, limit, context } as any);
375+
let rows = await ql.find(object, { where, limit, context, ...(fields ? { fields: [...fields] } : {}) } as any);
369376
if (rows && (rows as any).value) rows = (rows as any).value;
370377
return Array.isArray(rows) ? rows : [];
371378
} catch (err) {
@@ -870,6 +877,19 @@ function grantAppliesInTenant(organizationId: string | null, tenantId: string |
870877
return organizationId !== null && organizationId === tenantId;
871878
}
872879

880+
/**
881+
* The columns of a `sys_user_permission_set` grant row this resolver decides
882+
* on, and the only ones its grant read asks for: the set it names
883+
* ({@link grantSetNameOf}), its organization ({@link grantOrganizationOf}) and
884+
* its validity window (`isGrantActive`, `nextGrantValidityBoundary`). The read
885+
* runs in every request's prelude, and a read naming no columns computes every
886+
* formula field the object declares — its record title included, which may
887+
* read through a lookup at the cost of a read and a round trip — for values
888+
* nothing here reads. A helper below that learns to read another column of a
889+
* grant row adds it here; the read returns nothing it does not name.
890+
*/
891+
const GRANT_ROW_COLUMNS = ['permission_set', 'organization_id', 'valid_from', 'valid_until'] as const;
892+
873893
/**
874894
* [ADR-0131 D4] The permission set a stored `sys_user_permission_set` grant
875895
* names: its `permission_set`, or `undefined` for a grant that names none —
@@ -1463,7 +1483,7 @@ export async function resolveUserAuthzGrants(
14631483
tryFind(ql, 'sys_member', { user_id: userId }, 200),
14641484
tryFind(ql, 'sys_user_position', { user_id: userId }, 200),
14651485
tenantId ? tryFind(ql, 'sys_member', { organization_id: tenantId }, 1000) : Promise.resolve([] as any[]),
1466-
tryFind(ql, 'sys_user_permission_set', { user_id: userId }, 100),
1486+
tryFind(ql, 'sys_user_permission_set', { user_id: userId }, 100, undefined, GRANT_ROW_COLUMNS),
14671487
]);
14681488

14691489
// Resolve the caller's unique email for `current_user.email` RLS owner

0 commit comments

Comments
 (0)