Skip to content

Commit 9e50def

Browse files
os-zhuangclaude
andcommitted
fix(runtime): platform-scoped (null-org) permission grants resolve under an active org
resolveExecutionContext queried sys_user_permission_set with `organization_id = tenantId`, which DROPPED a platform-scoped (organization_id IS NULL) grant the moment a user had an active org. A platform admin who also owns an org therefore silently lost `admin_full_access` (and its systemPermissions) — and with ADR-0066 that locked them out of a requiredPermissions-gated control-plane object (sys_license). Mirror the role-binding logic (null org = global, cross-org): fetch the user's grants and keep null-org + active-org ones, dropping only grants scoped to a DIFFERENT org. +2 regression tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7ca86a8 commit 9e50def

2 files changed

Lines changed: 79 additions & 9 deletions

File tree

packages/runtime/src/security/resolve-execution-context.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,64 @@ describe('resolveExecutionContext — localization (timezone + locale)', () => {
263263
expect(ctx.locale).toBeUndefined();
264264
});
265265
});
266+
267+
268+
// ---------------------------------------------------------------------------
269+
// ADR-0066 — platform-scoped (null-org) permission-set grants are GLOBAL and
270+
// must resolve even when the caller has an active org. Regression for the gap
271+
// where `organization_id = tenantId` dropped a platform admin's null-org
272+
// admin_full_access grant (and its systemPermissions) the moment they owned an
273+
// org — which then locked them out of a requiredPermissions-gated object.
274+
// ---------------------------------------------------------------------------
275+
describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-0066)', () => {
276+
const RAW = 'osk_admin';
277+
function makeAuthQl(extraGrants = []) {
278+
const tables = {
279+
sys_api_key: [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', organization_id: 'orgA', expires_at: FUTURE }],
280+
sys_member: [{ user_id: 'u1', organization_id: 'orgA', role: 'owner' }],
281+
sys_user_permission_set: [
282+
{ id: 'ups_global', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null },
283+
...extraGrants,
284+
],
285+
sys_permission_set: [
286+
{ id: 'ps_admin', name: 'admin_full_access', system_permissions: '["manage_platform_settings","manage_users"]', object_permissions: '{}' },
287+
{ id: 'ps_other', name: 'other_org_set', system_permissions: '["should_not_appear"]', object_permissions: '{}' },
288+
],
289+
sys_role: [],
290+
sys_role_permission_set: [],
291+
sys_user_role: [],
292+
};
293+
return {
294+
async find(object, opts) {
295+
const rows = tables[object] ?? [];
296+
const where = opts?.where ?? {};
297+
return rows.filter((row) => {
298+
for (const [k, v] of Object.entries(where)) {
299+
if (v !== null && typeof v === 'object') {
300+
if (Array.isArray(v.$in) && !v.$in.includes(row[k])) return false;
301+
continue;
302+
}
303+
if ((v ?? null) !== (row[k] ?? null)) return false;
304+
}
305+
return true;
306+
});
307+
},
308+
};
309+
}
310+
const opts = (ql) => ({ getService: async () => undefined, getQl: async () => ql, request: { headers: { 'x-api-key': RAW } } });
311+
312+
it('resolves a null-org admin grant + its systemPermissions even with an active org', async () => {
313+
const ctx = await resolveExecutionContext(opts(makeAuthQl()));
314+
expect(ctx.tenantId).toBe('orgA');
315+
expect(ctx.permissions).toContain('admin_full_access');
316+
expect(ctx.systemPermissions).toContain('manage_platform_settings');
317+
});
318+
319+
it('still drops a grant scoped to a DIFFERENT org', async () => {
320+
const ql = makeAuthQl([{ id: 'ups_otherorg', user_id: 'u1', permission_set_id: 'ps_other', organization_id: 'orgB' }]);
321+
const ctx = await resolveExecutionContext(opts(ql));
322+
expect(ctx.permissions).toContain('admin_full_access'); // global grant kept
323+
expect(ctx.permissions).not.toContain('other_org_set'); // foreign-org grant dropped
324+
expect(ctx.systemPermissions).not.toContain('should_not_appear');
325+
});
326+
});

packages/runtime/src/security/resolve-execution-context.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -287,16 +287,25 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise<Exe
287287
}
288288

289289
// Resolve user-scoped permission sets.
290-
const upsRows = await tryFind(
291-
ql,
292-
'sys_user_permission_set',
293-
tenantId
294-
? { user_id: userId, organization_id: tenantId }
295-
: { user_id: userId },
296-
100,
297-
);
290+
//
291+
// [ADR-0066] A platform-scoped grant (`organization_id IS NULL`) is GLOBAL and
292+
// must apply regardless of the caller's active org. Querying with
293+
// `organization_id = tenantId` dropped null-org grants the moment a user had
294+
// an active org — so a platform admin who also owns an org silently lost
295+
// `admin_full_access` (and its `systemPermissions`), which then locked them
296+
// out of a `requiredPermissions`-gated control-plane object (e.g. sys_license).
297+
// Mirror the role-binding logic above (null org = global, cross-org): fetch the
298+
// user's grants and keep null-org + active-org ones, dropping only grants
299+
// scoped to a DIFFERENT org.
300+
const upsRows = await tryFind(ql, 'sys_user_permission_set', { user_id: userId }, 100);
298301
const psIds = new Set<string>(
299-
upsRows.map((r) => r.permission_set_id ?? r.permissionSetId).filter(Boolean),
302+
upsRows
303+
.filter((r) => {
304+
const org = (r.organization_id ?? r.organizationId) ?? null;
305+
return !(org && tenantId && org !== tenantId);
306+
})
307+
.map((r) => r.permission_set_id ?? r.permissionSetId)
308+
.filter(Boolean),
300309
);
301310

302311
// Resolve role-bound permission sets.

0 commit comments

Comments
 (0)