Skip to content

Commit 67903f2

Browse files
committed
fix(core): the position rows an organization-less resolution collects bindings from answer to the same rule
With no tenant the sys_position read is installation-wide, so every organization's copy of a held position name fed its bindings in. Each row now answers grantAppliesInTenant, a no-op when a tenant is given. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
1 parent b96b10f commit 67903f2

3 files changed

Lines changed: 118 additions & 12 deletions

File tree

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1903,6 +1903,65 @@ describe('[#20515] with no active organization, only global grants apply', () =>
19031903
});
19041904
});
19051905

1906+
/**
1907+
* §6a — position-bound sets. Under a walled posture the catalog holds one
1908+
* copy of each built-in position PER ORGANIZATION (`everyone`, `org_member`,
1909+
* …), and an organization binds its own sets to its own copies. With no
1910+
* tenant the `sys_position` read is installation-wide, so every
1911+
* organization's copy of a name the caller holds used to feed its bindings in.
1912+
* This double ignores the context's tenant exactly as that read does.
1913+
*/
1914+
describe('§6a: a position row scoped to an organization binds nothing with no tenant', () => {
1915+
const catalog = () => ({
1916+
sys_position: [
1917+
{ id: 'pos_member_alpha', name: 'org_member', organization_id: ALPHA },
1918+
{ id: 'pos_member_beta', name: 'org_member', organization_id: BETA },
1919+
{ id: 'pos_everyone_alpha', name: 'everyone', organization_id: ALPHA },
1920+
{ id: 'pos_everyone_global', name: 'everyone', organization_id: null },
1921+
],
1922+
sys_position_permission_set: [
1923+
{ position_id: 'pos_member_alpha', permission_set_id: 'ps_alpha_members' },
1924+
{ position_id: 'pos_everyone_alpha', permission_set_id: 'ps_alpha_everyone' },
1925+
{ position_id: 'pos_everyone_global', permission_set_id: 'ps_global_everyone' },
1926+
],
1927+
sys_permission_set: [
1928+
{ id: 'ps_alpha_members', name: 'alpha_member_tools', system_permissions: ['manage_metadata'] },
1929+
{ id: 'ps_alpha_everyone', name: 'alpha_everyone_extra' },
1930+
{ id: 'ps_global_everyone', name: 'global_everyone_default' },
1931+
],
1932+
});
1933+
/** Removed from alpha, still an `org_member` of beta — the role name alpha bound its set to. */
1934+
const exMember = () => makeQl({
1935+
sys_user: [{ id: 'u_ex' }],
1936+
sys_member: [{ user_id: 'u_ex', organization_id: BETA, role: 'member' }],
1937+
sys_user_position: [],
1938+
sys_user_permission_set: [],
1939+
...catalog(),
1940+
});
1941+
1942+
it('no tenant: neither alpha\'s org_member binding nor alpha\'s everyone binding applies; the global everyone binding does', async () => {
1943+
const grants = await resolveUserAuthzGrants(exMember(), 'u_ex', {});
1944+
expect(grants.positions).toContain('org_member');
1945+
expect([...grants.permissions].sort()).toEqual(['global_everyone_default']);
1946+
expect(grants.systemPermissions).not.toContain('manage_metadata');
1947+
});
1948+
1949+
it('in beta: alpha\'s bindings still do not apply; in alpha (a current member there): they do', async () => {
1950+
const inBeta = await resolveUserAuthzGrants(exMember(), 'u_ex', { tenantId: BETA });
1951+
expect([...inBeta.permissions].sort()).toEqual(['global_everyone_default']);
1952+
const alphaMember = makeQl({
1953+
sys_user: [{ id: 'u_in' }],
1954+
sys_member: [{ user_id: 'u_in', organization_id: ALPHA, role: 'member' }],
1955+
sys_user_position: [],
1956+
sys_user_permission_set: [],
1957+
...catalog(),
1958+
});
1959+
const inAlpha = await resolveUserAuthzGrants(alphaMember, 'u_in', { tenantId: ALPHA });
1960+
expect([...inAlpha.permissions].sort()).toEqual(['alpha_everyone_extra', 'alpha_member_tools', 'global_everyone_default']);
1961+
expect(inAlpha.systemPermissions).toContain('manage_metadata');
1962+
});
1963+
});
1964+
19061965
describe('through the session arm: the removed member whose claim is dropped', () => {
19071966
/**
19081967
* `u_ex` was removed from `org_alpha` and is still a member of `org_beta`;

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

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -622,12 +622,12 @@ export interface ResolveUserAuthzGrantsOptions {
622622
}
623623

624624
/**
625-
* The ONE organization rule for a stored grant row — `sys_user_position` (§4)
626-
* and `sys_user_permission_set` (§6) both ask it, so the two cannot disagree:
627-
* a row with no `organization_id` is GLOBAL and applies in every resolution; a
628-
* row scoped to an organization applies only when that organization is the
629-
* ACTIVE tenant. With no active tenant, an organization-scoped row does not
630-
* apply at all.
625+
* The ONE organization rule for a stored grant row — `sys_user_position` (§4),
626+
* `sys_user_permission_set` (§6) and the `sys_position` rows whose bindings
627+
* §6a collects all ask it, so none of them can disagree: a row with no
628+
* `organization_id` is GLOBAL and applies in every resolution; a row scoped to
629+
* an organization applies only when that organization is the ACTIVE tenant.
630+
* With no active tenant, an organization-scoped row does not apply at all.
631631
*
632632
* The spelling both sites carried before, `org && tenantId && org !== tenantId`
633633
* as the SKIP condition, read "no tenant" as "every organization": it was false
@@ -643,9 +643,10 @@ export interface ResolveUserAuthzGrantsOptions {
643643
* that has none gets the global grants and nothing else.
644644
*
645645
* ⚠️ Not applied to §3's `sys_member` role projection, which reads the user's
646-
* OWN memberships rather than a grant row, nor to §6a's `sys_position` read,
647-
* which is scoped by threading the organization into the driver's tenant scope
648-
* rather than by filtering rows here.
646+
* OWN current memberships rather than a grant row. With no active organization
647+
* that projection still names every membership's role; what a role name can
648+
* CONFER arrives through §6a's per-organization position rows and §6's
649+
* organization-scoped grants, and both answer to this rule.
649650
*/
650651
function grantAppliesInTenant(organizationId: unknown, tenantId: string | undefined): boolean {
651652
return !organizationId || organizationId === tenantId;
@@ -948,7 +949,22 @@ export async function resolveUserAuthzGrants(
948949
// organization-less rows stay REACHABLE on purpose — they are not
949950
// reaped, and grants point at them by row id, so dropping them here
950951
// would revoke standing access silently.
951-
const positionRows = await tryFind(ql, 'sys_position', { name: { $in: grants.positions } }, 200, tenantId);
952+
//
953+
// [#20515] The same grant rule as §4 and §6 is then asked of each
954+
// position row ({@link grantAppliesInTenant}). With a tenant it is a
955+
// no-op — the driver's scope already returned only this organization's
956+
// rows and the organization-less ones. With NO tenant the read above is
957+
// installation-wide by design, so without it every organization's
958+
// `everyone` row, and every organization's copy of a name the caller
959+
// holds (the `sys_member` role projection's `org_member`, say), fed its
960+
// bindings into an organization-less resolution: measured on a real
961+
// `SqlDriver` over the shipped per-organization catalog, a member
962+
// removed from an organization kept the `manage_metadata` set that
963+
// organization had bound to its `org_member` position. This is the
964+
// grant rule, not a second tenant wall: it decides which organization's
965+
// bindings APPLY, after the driver decided which rows are visible.
966+
const positionRows = (await tryFind(ql, 'sys_position', { name: { $in: grants.positions } }, 200, tenantId))
967+
.filter((r) => grantAppliesInTenant(r.organization_id, tenantId));
952968
const deactivatedNames = new Set<string>(
953969
positionRows.filter((r) => !isRowActive(r)).map((r) => r.name).filter(Boolean),
954970
);

‎packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
* whole environment (200), and `DELETE /packages/:id` reached the door's own
1818
* organization check (400 `TENANT_SCOPE_REQUIRED`) instead of the gate's 403.
1919
*
20+
* The same held for a set the left organization bound to one of its own
21+
* POSITIONS: with no tenant the position read is installation-wide, so the left
22+
* organization's copy of `org_member` fed its bindings to anyone still an
23+
* `org_member` somewhere.
24+
*
2025
* The rule now: with no active organization only the global grants apply, and
2126
* a grant scoped to an organization applies only while that organization is the
2227
* active tenant (`resolveUserAuthzGrants`, `@objectstack/core`).
@@ -75,8 +80,10 @@ const TABLES: Record<string, any[]> = {
7580
{ user_id: 'u_exmember', organization_id: BETA, role: 'member' },
7681
{ user_id: 'u_global', organization_id: BETA, role: 'member' },
7782
{ user_id: 'u_admin', organization_id: ALPHA, role: 'owner' },
83+
{ user_id: 'u_posex', organization_id: BETA, role: 'member' },
84+
{ user_id: 'u_posmember', organization_id: ALPHA, role: 'member' },
7885
],
79-
sys_user: ['u_member', 'u_exmember', 'u_gone', 'u_global', 'u_admin']
86+
sys_user: ['u_member', 'u_exmember', 'u_gone', 'u_global', 'u_admin', 'u_posex', 'u_posmember']
8087
.map((id) => ({ id, email: `${id}@example.com` })),
8188
sys_user_permission_set: [
8289
{ user_id: 'u_member', permission_set_id: 'ps_meta', organization_id: ALPHA },
@@ -88,10 +95,22 @@ const TABLES: Record<string, any[]> = {
8895
sys_permission_set: [
8996
{ id: 'ps_meta', name: 'alpha_metadata_editors', system_permissions: ['manage_metadata', 'studio.access'] },
9097
{ id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] },
98+
{ id: 'ps_members', name: 'alpha_member_tools', system_permissions: ['manage_metadata'] },
99+
],
100+
// The per-organization copies of the `org_member` built-in a walled catalog
101+
// holds; org_alpha bound a metadata-editing set to ITS copy. This double
102+
// ignores the read's tenant, which is exactly what an organization-less
103+
// resolution's installation-wide `sys_position` read does.
104+
sys_position: [
105+
{ id: 'pos_member_alpha', name: 'org_member', organization_id: ALPHA },
106+
{ id: 'pos_member_beta', name: 'org_member', organization_id: BETA },
107+
],
108+
sys_position_permission_set: [
109+
{ position_id: 'pos_member_alpha', permission_set_id: 'ps_members' },
91110
],
92111
};
93112

94-
type Who = 'member' | 'exmember' | 'gone' | 'global' | 'admin' | 'admin_orgless';
113+
type Who = 'member' | 'exmember' | 'gone' | 'global' | 'admin' | 'admin_orgless' | 'posex' | 'posmember';
95114

96115
/** Every session but the last names `org_alpha`. */
97116
const SESSIONS: Record<Who, { id: string; token: string; userId: string; activeOrganizationId?: string }> = {
@@ -101,6 +120,8 @@ const SESSIONS: Record<Who, { id: string; token: string; userId: string; activeO
101120
global: { id: 'ses_global', token: 'tok_global', userId: 'u_global', activeOrganizationId: ALPHA },
102121
admin: { id: 'ses_admin', token: 'tok_admin', userId: 'u_admin', activeOrganizationId: ALPHA },
103122
admin_orgless: { id: 'ses_admin2', token: 'tok_admin2', userId: 'u_admin' },
123+
posex: { id: 'ses_posex', token: 'tok_posex', userId: 'u_posex', activeOrganizationId: ALPHA },
124+
posmember: { id: 'ses_posmember', token: 'tok_posmember', userId: 'u_posmember', activeOrganizationId: ALPHA },
104125
};
105126

106127
function rig() {
@@ -185,6 +206,9 @@ afterEach(() => { warnSpy.mockRestore(); });
185206
const REMOVED: Array<[Who, string]> = [
186207
['exmember', 'a member removed from org_alpha, still a member of org_beta'],
187208
['gone', 'a member removed from org_alpha, with no membership left anywhere'],
209+
// The same set reached through a POSITION: org_alpha bound it to its own
210+
// copy of `org_member`, and this member is still an `org_member` elsewhere.
211+
['posex', 'a member removed from org_alpha whose org_member binding there carried manage_metadata'],
188212
];
189213

190214
describe('[#20515] a removed member\'s organization-scoped manage_metadata no longer passes the /packages gate', () => {
@@ -225,6 +249,13 @@ describe('[#20515] what the rule leaves unchanged', () => {
225249
expect(r.deleteRequests).toEqual([{ packageId: PKG, organizationId: ALPHA }]);
226250
});
227251

252+
it('CONTROL · the position-bound set: a current org_alpha member with org_alpha active passes: 200', async () => {
253+
const r = rig();
254+
const answer = await r.call('DELETE', 'posmember', `/packages/${PKG}`);
255+
expect(answer.status).toBe(200);
256+
expect(r.deleteRequests).toEqual([{ packageId: PKG, organizationId: ALPHA }]);
257+
});
258+
228259
it('a GLOBAL grant still passes the gate for the removed member — the door\'s own organization check answers, not the gate', async () => {
229260
const r = rig();
230261
const answer = await r.call('DELETE', 'global', `/packages/${PKG}`);

0 commit comments

Comments
 (0)