Skip to content

Commit 0c71c41

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24766-branding-runtime-config
2 parents 73adb74 + 644e4b2 commit 0c71c41

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)