diff --git a/.changeset/20515-orgless-grants-global-only.md b/.changeset/20515-orgless-grants-global-only.md new file mode 100644 index 00000000000..f5be029cac0 --- /dev/null +++ b/.changeset/20515-orgless-grants-global-only.md @@ -0,0 +1,17 @@ +--- +"@objectstack/core": minor +"@objectstack/plugin-security": minor +--- + +A grants resolution with no active organization now applies only the global grants. `resolveUserAuthzGrants` applies a grant scoped to an organization only while that organization is the active tenant, and one rule decides it for all three kinds of grant row it reads: position assignments (`sys_user_position`), permission-set grants (`sys_user_permission_set`) and the organization's own position rows whose bound permission sets it collects (`sys_position`). + +**BREAKING** for a principal acting with no active organization. Before, "no organization" read as "every organization": each organization-scoped grant the user held anywhere applied, with no organization boundary left on it. That is the resolution a session falls back to when it names an organization its owner no longer belongs to, so a member removed from an organization kept the capabilities that organization had granted until someone revoked each grant by hand. Such a principal now holds its global grants and nothing scoped to an organization. + +- **Unchanged:** a principal with an active organization resolves exactly as before, and a global grant (no organization) applies everywhere as before. Platform-admin standing is unchanged: it was only ever derived from the unscoped `admin_full_access` grant or the declared administrator list, never from an organization-scoped grant. +- **If a principal relied on it:** act in the organization. Select it as the active organization, or mint the API key from a session that has it active, or grant the permission set globally (no organization) when it is meant to apply everywhere. +- **No "every organization" mode.** No option asks the resolver for every organization's grants, and nothing falls back to that reading. +- **`@objectstack/plugin-security`:** `buildContextForUser(ql, userId, nowMs?, tenantId?)` takes the organization to resolve the user in. The access explainer (`explainAccessForCaller`) resolves the explained user in the caller's organization, and the delegator behind an on-behalf-of principal is resolved in the live principal's organization, so the delegated intersection counts the delegator's grants where the request actually runs. + +Clause-②: yes (narrowing) + + diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index f4787821c08..1b97d24b666 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { resolveAuthzContext, resolveUserAuthzGrants, resolveLocalizationContext } from './resolve-authz-context.js'; +import { hasPlatformAdminStanding, resolveAuthzContext, resolveUserAuthzGrants, resolveLocalizationContext } from './resolve-authz-context.js'; import { POSTURE_RANK } from './posture-ladder.js'; import { hashApiKey } from './api-key.js'; import type { AuthzPosture } from '@objectstack/spec/security'; @@ -1831,3 +1831,237 @@ describe('[#15409] a session organization claim that no membership backs', () => expect(dropLines()).toHaveLength(0); }); }); + +/** + * [#20515] A resolution with NO active organization applies only the GLOBAL + * grants (`organization_id` null). A grant scoped to an organization applies + * only while that organization is the active tenant — §4 (`sys_user_position`) + * and §6 (`sys_user_permission_set`) ask the one predicate, so they cannot + * disagree. + * + * The population this was measured on: a member removed from an organization + * whose session still names it. The session arm drops the claim (#15409 ruling + * B) and re-resolves with no tenant; the old skip condition, + * `org && tenantId && org !== tenantId`, was false for every row once + * `tenantId` was undefined, so a permission set granted SCOPED to the + * organization the member left went on conferring `manage_metadata` with no + * organization boundary at all. The door-level half (403 at + * `DELETE /packages/:id`) is pinned in `packages/runtime` + * (`packages-orgless-grants-capability-gate.test.ts`). + */ +describe('[#20515] with no active organization, only global grants apply', () => { + const ALPHA = 'org_alpha'; + const BETA = 'org_beta'; + + /** One set and one position per scope: global, alpha, beta. */ + const grantRows = (userId: string) => ({ + sys_user_position: [ + { user_id: userId, position: 'global_position', organization_id: null }, + { user_id: userId, position: 'alpha_position', organization_id: ALPHA }, + { user_id: userId, position: 'beta_position', organization_id: BETA }, + ], + sys_user_permission_set: [ + { user_id: userId, permission_set_id: 'ps_global', organization_id: null }, + { user_id: userId, permission_set_id: 'ps_alpha', organization_id: ALPHA }, + { user_id: userId, permission_set_id: 'ps_beta', organization_id: BETA }, + ], + sys_permission_set: [ + { id: 'ps_global', name: 'global_set', system_permissions: ['global_cap'] }, + { id: 'ps_alpha', name: 'alpha_set', system_permissions: ['manage_metadata'] }, + { id: 'ps_beta', name: 'beta_set', system_permissions: ['beta_cap'] }, + ], + }); + + /** What §4 and §6 each let through, read off one resolution. */ + const applied = (g: { positions: string[]; permissions: string[]; systemPermissions: string[] }) => ({ + positions: g.positions.filter((p) => p.endsWith('_position')).sort(), + permissions: [...g.permissions].sort(), + systemPermissions: [...g.systemPermissions].sort(), + }); + + it('no tenant: §4 and §6 both keep the global rows and NOTHING scoped to an organization', async () => { + const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], ...grantRows('u1') }); + const grants = await resolveUserAuthzGrants(ql, 'u1', {}); + expect(applied(grants)).toEqual({ + positions: ['global_position'], + permissions: ['global_set'], + systemPermissions: ['global_cap'], + }); + }); + + it('a tenant: §4 and §6 both keep the global rows plus THAT organization\'s, never another\'s', async () => { + const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], ...grantRows('u1') }); + expect(applied(await resolveUserAuthzGrants(ql, 'u1', { tenantId: ALPHA }))).toEqual({ + positions: ['alpha_position', 'global_position'], + permissions: ['alpha_set', 'global_set'], + systemPermissions: ['global_cap', 'manage_metadata'], + }); + expect(applied(await resolveUserAuthzGrants(ql, 'u1', { tenantId: BETA }))).toEqual({ + positions: ['beta_position', 'global_position'], + permissions: ['beta_set', 'global_set'], + systemPermissions: ['beta_cap', 'global_cap'], + }); + }); + + /** + * §6a — position-bound sets. Under a walled posture the catalog holds one + * copy of each built-in position PER ORGANIZATION (`everyone`, `org_member`, + * …), and an organization binds its own sets to its own copies. With no + * tenant the `sys_position` read is installation-wide, so every + * organization's copy of a name the caller holds used to feed its bindings in. + * This double ignores the context's tenant exactly as that read does. + */ + describe('§6a: a position row scoped to an organization binds nothing with no tenant', () => { + const catalog = () => ({ + sys_position: [ + { id: 'pos_member_alpha', name: 'org_member', organization_id: ALPHA }, + { id: 'pos_member_beta', name: 'org_member', organization_id: BETA }, + { id: 'pos_everyone_alpha', name: 'everyone', organization_id: ALPHA }, + { id: 'pos_everyone_global', name: 'everyone', organization_id: null }, + ], + sys_position_permission_set: [ + { position_id: 'pos_member_alpha', permission_set_id: 'ps_alpha_members' }, + { position_id: 'pos_everyone_alpha', permission_set_id: 'ps_alpha_everyone' }, + { position_id: 'pos_everyone_global', permission_set_id: 'ps_global_everyone' }, + ], + sys_permission_set: [ + { id: 'ps_alpha_members', name: 'alpha_member_tools', system_permissions: ['manage_metadata'] }, + { id: 'ps_alpha_everyone', name: 'alpha_everyone_extra' }, + { id: 'ps_global_everyone', name: 'global_everyone_default' }, + ], + }); + /** Removed from alpha, still an `org_member` of beta — the role name alpha bound its set to. */ + const exMember = () => makeQl({ + sys_user: [{ id: 'u_ex' }], + sys_member: [{ user_id: 'u_ex', organization_id: BETA, role: 'member' }], + sys_user_position: [], + sys_user_permission_set: [], + ...catalog(), + }); + + it('no tenant: neither alpha\'s org_member binding nor alpha\'s everyone binding applies; the global everyone binding does', async () => { + const grants = await resolveUserAuthzGrants(exMember(), 'u_ex', {}); + expect(grants.positions).toContain('org_member'); + expect([...grants.permissions].sort()).toEqual(['global_everyone_default']); + expect(grants.systemPermissions).not.toContain('manage_metadata'); + }); + + it('in beta: alpha\'s bindings still do not apply; in alpha (a current member there): they do', async () => { + const inBeta = await resolveUserAuthzGrants(exMember(), 'u_ex', { tenantId: BETA }); + expect([...inBeta.permissions].sort()).toEqual(['global_everyone_default']); + const alphaMember = makeQl({ + sys_user: [{ id: 'u_in' }], + sys_member: [{ user_id: 'u_in', organization_id: ALPHA, role: 'member' }], + sys_user_position: [], + sys_user_permission_set: [], + ...catalog(), + }); + const inAlpha = await resolveUserAuthzGrants(alphaMember, 'u_in', { tenantId: ALPHA }); + expect([...inAlpha.permissions].sort()).toEqual(['alpha_everyone_extra', 'alpha_member_tools', 'global_everyone_default']); + expect(inAlpha.systemPermissions).toContain('manage_metadata'); + }); + }); + + describe('through the session arm: the removed member whose claim is dropped', () => { + /** + * `u_ex` was removed from `org_alpha` and is still a member of `org_beta`; + * `u_gone` has no membership left anywhere; both still hold the operator- + * authored `manage_metadata` set granted SCOPED to `org_alpha`, which the + * removal did not revoke. `u_member` is the control: a current `org_alpha` + * member holding the same grant. `u_global_ex` is removed the same way but + * holds the set GLOBALLY — a global grant is untouched by this card. + */ + const tables = () => ({ + sys_user: ['u_ex', 'u_gone', 'u_member', 'u_global_ex'].map((id) => ({ id, email: `${id}@x.com` })), + sys_member: [ + { user_id: 'u_ex', organization_id: BETA, role: 'member' }, + { user_id: 'u_member', organization_id: ALPHA, role: 'member' }, + { user_id: 'u_global_ex', organization_id: BETA, role: 'member' }, + ], + sys_user_position: [], + sys_user_permission_set: [ + { user_id: 'u_ex', permission_set_id: 'ps_meta', organization_id: ALPHA }, + { user_id: 'u_gone', permission_set_id: 'ps_meta', organization_id: ALPHA }, + { user_id: 'u_member', permission_set_id: 'ps_meta', organization_id: ALPHA }, + { user_id: 'u_global_ex', permission_set_id: 'ps_meta', organization_id: null }, + ], + sys_permission_set: [{ id: 'ps_meta', name: 'alpha_metadata_editors', system_permissions: ['manage_metadata'] }], + }); + const namingAlpha = (userId: string) => async () => ({ + user: { id: userId, email: `${userId}@x.com` }, + session: { id: `ses_${userId}`, token: 'tok', userId, activeOrganizationId: ALPHA }, + }); + + let warnSpy: ReturnType; + beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); + afterEach(() => { warnSpy.mockRestore(); }); + + for (const userId of ['u_ex', 'u_gone']) { + it(`${userId}: the claim is dropped AND the left organization's grant no longer applies`, async () => { + const ctx = await resolveAuthzContext({ + ql: makeQl(tables()), headers: H(), getSession: namingAlpha(userId), tenancyPosture: 'isolated', + }); + expect(ctx.userId).toBe(userId); + expect(ctx.tenantId).toBeUndefined(); + expect(ctx.systemPermissions).not.toContain('manage_metadata'); + expect(ctx.permissions).not.toContain('alpha_metadata_editors'); + }); + } + + it('CONTROL · a current member with the same grant and that organization active keeps it', async () => { + const ctx = await resolveAuthzContext({ + ql: makeQl(tables()), headers: H(), getSession: namingAlpha('u_member'), tenancyPosture: 'isolated', + }); + expect(ctx.tenantId).toBe(ALPHA); + expect(ctx.systemPermissions).toContain('manage_metadata'); + expect(ctx.permissions).toContain('alpha_metadata_editors'); + }); + + it('a GLOBAL grant is unchanged: the removed member still holds it with no active organization', async () => { + const ctx = await resolveAuthzContext({ + ql: makeQl(tables()), headers: H(), getSession: namingAlpha('u_global_ex'), tenancyPosture: 'isolated', + }); + expect(ctx.tenantId).toBeUndefined(); + expect(ctx.systemPermissions).toContain('manage_metadata'); + }); + }); + + describe('platform-admin standing is unchanged — it was only ever derived from an UNSCOPED grant', () => { + const POSTURE_ENV = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_PLATFORM_OWNER_EMAIL'] as const; + const saved: Record = {}; + beforeEach(() => { + for (const k of POSTURE_ENV) { saved[k] = process.env[k]; delete process.env[k]; } + }); + afterEach(() => { + for (const k of POSTURE_ENV) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + /** The row `bootstrapPlatformAdmin` mints: `admin_full_access`, organization null. */ + const adminTables = (organizationId: string | null) => ({ + sys_user: [{ id: 'u_admin', email: 'admin@x.com' }], + sys_member: [{ user_id: 'u_admin', organization_id: ALPHA, role: 'owner' }], + sys_user_position: [], + sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_admin', organization_id: organizationId }], + sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata'] }], + }); + + it('the bootstrap-shaped UNSCOPED grant: PLATFORM_ADMIN with no tenant and with one', async () => { + const ql = makeQl(adminTables(null)); + expect(await hasPlatformAdminStanding(ql, 'u_admin')).toBe(true); + const orgless = await resolveUserAuthzGrants(ql, 'u_admin', {}); + expect(orgless.posture).toBe('PLATFORM_ADMIN'); + expect(orgless.systemPermissions).toContain('manage_metadata'); + expect((await resolveUserAuthzGrants(ql, 'u_admin', { tenantId: ALPHA })).posture).toBe('PLATFORM_ADMIN'); + }); + + it('an ORG-scoped admin_full_access grant confers no platform standing, with or without that tenant', async () => { + const ql = makeQl(adminTables(ALPHA)); + expect(await hasPlatformAdminStanding(ql, 'u_admin')).toBe(false); + expect((await resolveUserAuthzGrants(ql, 'u_admin', {})).posture).not.toBe('PLATFORM_ADMIN'); + expect((await resolveUserAuthzGrants(ql, 'u_admin', { tenantId: ALPHA })).posture).not.toBe('PLATFORM_ADMIN'); + }); + }); +}); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 8434298537a..1f0d2889e62 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -581,7 +581,13 @@ export interface UserAuthzGrants { } export interface ResolveUserAuthzGrantsOptions { - /** Active org/tenant id — scopes org-bound grants (a null-org row is global). */ + /** + * Active org/tenant id — scopes org-bound grants (a null-org row is global). + * Omitted ⇒ NO active organization, so only the global grants apply: an + * organization-scoped position or permission-set row applies only while its + * organization is this tenant ({@link grantAppliesInTenant}). There is no + * "every organization" reading of an omitted tenant. + */ tenantId?: string; /** Clock injection for grant validity windows (tests). */ nowMs?: number; @@ -615,6 +621,37 @@ export interface ResolveUserAuthzGrantsOptions { bypassGrantsCache?: boolean; } +/** + * The ONE organization rule for a stored grant row — `sys_user_position` (§4), + * `sys_user_permission_set` (§6) and the `sys_position` rows whose bindings + * §6a collects all ask it, so none of them can disagree: a row with no + * `organization_id` is GLOBAL and applies in every resolution; a row scoped to + * an organization applies only when that organization is the ACTIVE tenant. + * With no active tenant, an organization-scoped row does not apply at all. + * + * The spelling both sites carried before, `org && tenantId && org !== tenantId` + * as the SKIP condition, read "no tenant" as "every organization": it was false + * for every row once `tenantId` was undefined, so a resolution with no active + * organization kept every organization-scoped grant the user held anywhere. + * That is the resolution the session arm of {@link resolveAuthzContext} falls + * back to when it drops a claim its membership no longer backs, so a member + * removed from an organization kept the capabilities that organization had + * granted — with no organization boundary left on them at all. + * + * ⛔ There is no "every organization" mode: no option selects one and nothing + * falls back to one. A caller that has an organization passes it; a caller + * that has none gets the global grants and nothing else. + * + * ⚠️ Not applied to §3's `sys_member` role projection, which reads the user's + * OWN current memberships rather than a grant row. With no active organization + * that projection still names every membership's role; what a role name can + * CONFER arrives through §6a's per-organization position rows and §6's + * organization-scoped grants, and both answer to this rule. + */ +function grantAppliesInTenant(organizationId: unknown, tenantId: string | undefined): boolean { + return !organizationId || organizationId === tenantId; +} + /** * resolveUserAuthzGrants — the userId-driven core of {@link resolveAuthzContext}. * @@ -795,11 +832,11 @@ export async function resolveUserAuthzGrants( // 4. [ADR-0057 D4] Platform-owned RBAC role assignments (sys_user_position) — the // source of truth for custom roles, decoupled from sys_member.role. - // `organization_id = null` = global (cross-tenant); else match active org. + // `organization_id = null` = global (cross-tenant); else match active org — + // so with no active org, only the global rows ({@link grantAppliesInTenant}). // (read in Leg 1 above) for (const ur of userPositionRows) { - const org = ur.organization_id ?? null; - if (org && tenantId && org !== tenantId) continue; + if (!grantAppliesInTenant(ur.organization_id, tenantId)) continue; if (!isGrantActive(ur, nowMs)) continue; const r = ur.position; if (typeof r === 'string' && r && !grants.positions.includes(r)) grants.positions.push(r); @@ -817,17 +854,15 @@ export async function resolveUserAuthzGrants( grants.org_user_ids = Array.from(ids); } - // 6. Permission sets — user-scoped grants (null org = global, else active org). + // 6. Permission sets — user-scoped grants (null org = global, else active org; + // with no active org, only the global rows — {@link grantAppliesInTenant}). // Rows outside their validity window are dropped BEFORE any derivation, so // an expired admin_full_access grant cannot yield platform_admin either. // (read in Leg 1 above) const upsRows = upsRowsAll.filter((r) => isGrantActive(r, nowMs)); const psIds = new Set( upsRows - .filter((r) => { - const org = (r.organization_id ?? r.organizationId) ?? null; - return !(org && tenantId && org !== tenantId); - }) + .filter((r) => grantAppliesInTenant(r.organization_id ?? r.organizationId, tenantId)) .map((r) => r.permission_set_id ?? r.permissionSetId) .filter(Boolean), ); @@ -914,7 +949,22 @@ export async function resolveUserAuthzGrants( // organization-less rows stay REACHABLE on purpose — they are not // reaped, and grants point at them by row id, so dropping them here // would revoke standing access silently. - const positionRows = await tryFind(ql, 'sys_position', { name: { $in: grants.positions } }, 200, tenantId); + // + // [#20515] The same grant rule as §4 and §6 is then asked of each + // position row ({@link grantAppliesInTenant}). With a tenant it is a + // no-op — the driver's scope already returned only this organization's + // rows and the organization-less ones. With NO tenant the read above is + // installation-wide by design, so without it every organization's + // `everyone` row, and every organization's copy of a name the caller + // holds (the `sys_member` role projection's `org_member`, say), fed its + // bindings into an organization-less resolution: measured on a real + // `SqlDriver` over the shipped per-organization catalog, a member + // removed from an organization kept the `manage_metadata` set that + // organization had bound to its `org_member` position. This is the + // grant rule, not a second tenant wall: it decides which organization's + // bindings APPLY, after the driver decided which rows are visible. + const positionRows = (await tryFind(ql, 'sys_position', { name: { $in: grants.positions } }, 200, tenantId)) + .filter((r) => grantAppliesInTenant(r.organization_id, tenantId)); const deactivatedNames = new Set( positionRows.filter((r) => !isRowActive(r)).map((r) => r.name).filter(Boolean), ); diff --git a/packages/core/src/security/resolve-user-grants-cache.test.ts b/packages/core/src/security/resolve-user-grants-cache.test.ts index 3951bc4a810..7b03fe7f263 100644 --- a/packages/core/src/security/resolve-user-grants-cache.test.ts +++ b/packages/core/src/security/resolve-user-grants-cache.test.ts @@ -543,3 +543,43 @@ describe('pin 9 — two nodes over one database', () => { expect(converged.permissions).not.toContain('admin_full_access'); }); }); + +// ── [#20515] the no-tenant rule holds with the cache ON ───────────────────── + +describe('[#20515] with the cache on, a no-tenant resolution never serves an organization\'s grants', () => { + /** One grant scoped to `org_a`, one global. */ + function scopedTables(): Record { + return { + sys_user: [{ id: 'u1', email: 'u1@x.com' }], + sys_member: [], + sys_user_position: [], + sys_user_permission_set: [ + { user_id: 'u1', permission_set_id: 'ps_org', organization_id: 'org_a' }, + { user_id: 'u1', permission_set_id: 'ps_global', organization_id: null }, + ], + sys_permission_set: [ + { id: 'ps_org', name: 'org_a_editors', system_permissions: ['manage_metadata'] }, + { id: 'ps_global', name: 'everyone_readers' }, + ], + sys_position: [], + sys_position_permission_set: [], + }; + } + + it('an org_a resolution cached first is NOT what a no-tenant resolution gets — and the reverse', async () => { + cacheOn(3_600_000); + const ql = makeSeamQl(scopedTables()); + + const inOrg = await resolveUserAuthzGrants(ql, 'u1', { tenantId: 'org_a', nowMs: T }); + expect(inOrg.systemPermissions).toContain('manage_metadata'); + const orgless = await resolveUserAuthzGrants(ql, 'u1', { nowMs: T }); + expect(orgless.permissions).toEqual(['everyone_readers']); + expect(orgless.systemPermissions).not.toContain('manage_metadata'); + + // Both entries are now cached, each under its own tenant — and each hit + // reads nothing and still answers its own tenant's grants. + expect((await resolveExpectingZeroReads(ql, 'u1', { nowMs: T })).systemPermissions).not.toContain('manage_metadata'); + expect((await resolveExpectingZeroReads(ql, 'u1', { tenantId: 'org_a', nowMs: T })).systemPermissions) + .toContain('manage_metadata'); + }); +}); diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index a248ef85021..60c3a6a153e 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect } from 'vitest'; import { resolveUserAuthzGrants, resetPlatformAdminEmailMemo } from '@objectstack/core'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { PermissionEvaluator } from './permission-evaluator'; -import { explainAccess, buildContextForUser, type ExplainEngineDeps } from './explain-engine'; +import { explainAccess, buildContextForUser, resolveDelegatorContext, type ExplainEngineDeps } from './explain-engine'; import { RLS_DENY_FILTER } from './rls-compiler'; import { unresolvedPostureRemedy } from './unresolved-posture'; import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; @@ -770,7 +770,11 @@ describe('buildContextForUser', () => { sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', organization_id: 'org1' }], sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], }); - const ctx = await buildContextForUser(qlScoped, 'u2'); + // [#20515] Explained IN org1: a grant scoped to an organization applies + // only while that organization is the one resolved in, so the set has to + // resolve before "it resolves, and still confers no platform standing" is + // a statement about anything. + const ctx = await buildContextForUser(qlScoped, 'u2', NOW, 'org1'); expect(ctx.hasPlatformAdminGrant).toBe(false); // The name is still resolved into permissions (it grants object CRUD), but it // no longer confers platform_admin posture — the drift this closes. @@ -935,6 +939,8 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => const PARITY_CASES: Array<{ name: string; tables: Rows; + /** [#20515] The organization BOTH sides resolve in; omitted = no active organization. */ + tenantId?: string; expected: { positions: string[]; permissions: string[]; @@ -1009,6 +1015,9 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => }, { name: 'org-scoped admin_full_access does NOT derive platform_admin', + // [#20515] Resolved IN org1, where the scoped set applies — with no active + // organization it would not resolve at all, and the case would pin nothing. + tenantId: 'org1', tables: { sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', organization_id: 'org1' }], sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], @@ -1026,6 +1035,9 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => // [ADR-0095 D3] The org-admin rung comes from the CAPABILITY grant // `auto-org-admin-grant` writes, never from the better-auth role. name: 'organization_admin capability grant derives TENANT_ADMIN', + // [#20515] The auto-grant is scoped to its organization, so it confers the + // rung while that organization is the one resolved in. + tenantId: 'org1', tables: { sys_member: [{ user_id: 'u2', organization_id: 'org1', role: 'admin' }], sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psOrg', organization_id: 'org1' }], @@ -1071,10 +1083,10 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => }, ]; - for (const { name, tables, expected } of PARITY_CASES) { + for (const { name, tables, tenantId, expected } of PARITY_CASES) { it(`agrees with the enforcement resolver — ${name}`, async () => { - const grants = await resolveUserAuthzGrants(makeGrantQl(tables), 'u2', { nowMs: NOW }); - const ctx = await buildContextForUser(makeGrantQl(tables), 'u2', NOW); + const grants = await resolveUserAuthzGrants(makeGrantQl(tables), 'u2', { nowMs: NOW, tenantId }); + const ctx = await buildContextForUser(makeGrantQl(tables), 'u2', NOW, tenantId); // (a) The two agree, field for field, on the whole aggregation surface. expect(ctx.positions).toEqual(grants.positions); @@ -1579,3 +1591,89 @@ describe('[#18253] explain refuses an object that does not exist', () => { expect(d.layers.find((l) => l.layer === 'object_crud')!.verdict).toBe('denies'); }); }); + +// ─── [#20515] the explainer resolves IN an organization, as enforcement does ── +// +// The resolver applies an organization-scoped grant only while that +// organization is the active tenant; with none, only global grants. The +// explainer used to reach every organization's grants by passing NO tenant — +// the very resolution that let a member removed from an organization keep that +// organization's `manage_metadata`. There is no "every organization" option to +// ask for instead: each caller names the organization it resolves in. +describe('[#20515] buildContextForUser and resolveDelegatorContext resolve in an organization', () => { + const ALPHA = 'org_alpha'; + const BETA = 'org_beta'; + const tables = (): Rows => ({ + sys_user: [{ id: 'u_x', email: 'u_x@example.com' }], + sys_member: [{ user_id: 'u_x', organization_id: BETA, role: 'member' }], + sys_user_permission_set: [ + { user_id: 'u_x', permission_set_id: 'ps_alpha', organization_id: ALPHA }, + { user_id: 'u_x', permission_set_id: 'ps_beta', organization_id: BETA }, + { user_id: 'u_x', permission_set_id: 'ps_global' }, + ], + sys_permission_set: [ + { id: 'ps_alpha', name: 'alpha_metadata_editors', system_permissions: ['manage_metadata'] }, + { id: 'ps_beta', name: 'beta_readers' }, + { id: 'ps_global', name: 'global_readers' }, + ], + }); + + it('with no organization it explains what enforcement answers with none: the global grants only', async () => { + const ctx = await buildContextForUser(makeGrantQl(tables()), 'u_x', NOW); + expect([...ctx.permissions].sort()).toEqual(['global_readers']); + expect(ctx.systemPermissions).not.toContain('manage_metadata'); + // …and it is the enforcement resolver's own answer for that state. + const enforced = await resolveUserAuthzGrants(makeGrantQl(tables()), 'u_x', { nowMs: NOW }); + expect(ctx.permissions).toEqual(enforced.permissions); + }); + + it('in an organization it explains that organization\'s grants beside the global ones, never another\'s', async () => { + const inAlpha = await buildContextForUser(makeGrantQl(tables()), 'u_x', NOW, ALPHA); + expect([...inAlpha.permissions].sort()).toEqual(['alpha_metadata_editors', 'global_readers']); + expect(inAlpha.systemPermissions).toContain('manage_metadata'); + const inBeta = await buildContextForUser(makeGrantQl(tables()), 'u_x', NOW, BETA); + expect([...inBeta.permissions].sort()).toEqual(['beta_readers', 'global_readers']); + expect(inBeta.systemPermissions).not.toContain('manage_metadata'); + }); + + /** + * The delegator leg is an ENFORCEMENT input (the D10 intersection), not only + * an explanation: `SecurityPlugin` resolves it through this function on every + * on-behalf-of request. Resolved with no tenant it would hold only the + * delegator's global grants — never what the delegator holds in the + * organization the request runs in. + */ + describe('resolveDelegatorContext: the delegator is resolved in the live principal\'s organization', () => { + const delegatorQl = () => { + const t = tables(); + return { + ...makeGrantQl(t), + async findOne(object: string, opts: EngineFindOneQueryInput) { + assertEngineFindOnePredicate(object, opts); + const id = (opts as any)?.where?.id; + return (t[object] ?? []).find((r) => r.id === id) ?? null; + }, + }; + }; + + it('live principal in org_alpha → the delegator\'s org_alpha grant applies, its org_beta grant does not', async () => { + const res = await resolveDelegatorContext( + delegatorQl(), + { userId: 'agent_1', tenantId: ALPHA, onBehalfOf: { userId: 'u_x' } }, + NOW, + ); + expect(res.kind).toBe('resolved'); + const ctx = (res as { kind: 'resolved'; context: any }).context; + expect([...ctx.permissions].sort()).toEqual(['alpha_metadata_editors', 'global_readers']); + expect(ctx.tenantId).toBe(ALPHA); + }); + + it('live principal with no organization → the delegator\'s global grants only', async () => { + const res = await resolveDelegatorContext(delegatorQl(), { userId: 'agent_1', onBehalfOf: { userId: 'u_x' } }, NOW); + expect(res.kind).toBe('resolved'); + const ctx = (res as { kind: 'resolved'; context: any }).context; + expect([...ctx.permissions].sort()).toEqual(['global_readers']); + expect(ctx.systemPermissions).not.toContain('manage_metadata'); + }); + }); +}); diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index fca6431074a..92716c91af5 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -553,15 +553,32 @@ async function collectGrantProvenance( * ({@link collectGrantProvenance}), and `hasPlatformAdminGrant`, which is now * READ OFF the resolver's own posture verdict instead of being recomputed from * the grant rows. + * + * [#20515] `tenantId` is the organization the user is resolved IN, handed to + * the resolver exactly as enforcement hands it — the resolver applies an + * organization-scoped grant only while its organization is the active tenant, + * and with no tenant only the global grants. There is no "every organization" + * reading: the explainer used to get one by passing no tenant, which is the + * very resolution that kept a removed member's grants. Each caller passes the + * organization it is explaining in — the live principal's for a delegator + * ({@link resolveDelegatorContext}), the caller's own for the explain API. + * Omitted, the context is the user with NO active organization. The returned + * context still carries no `tenantId` of its own; a caller that needs one sets + * it, as {@link resolveDelegatorContext} does. */ -export async function buildContextForUser(ql: any, userId: string, nowMs: number = Date.now()): Promise { +export async function buildContextForUser( + ql: any, + userId: string, + nowMs: number = Date.now(), + tenantId?: string, +): Promise { // [#11971] ⭐ Ruled bypass of the #11633 leg-B grants cache (maintainer // acceptance 2026-08-25): the explainer is the tool an administrator uses to // VERIFY that a revocation took effect. An explainer answering from cache // would explain a state that no longer exists — and would do it at exactly // the moment someone is checking. `explain` therefore takes the force-fresh // path unconditionally, whatever `OS_AUTHZ_GRANTS_CACHE_TTL_MS` says. - const grants = await resolveUserAuthzGrants(ql, userId, { nowMs, bypassGrantsCache: true }); + const grants = await resolveUserAuthzGrants(ql, userId, { tenantId, nowMs, bypassGrantsCache: true }); const { droppedGrants, delegatedPositions } = await collectGrantProvenance(ql, userId, nowMs); return { userId, @@ -614,9 +631,12 @@ export type DelegatorResolution = * in the same org, so `tenantId` / `org_user_ids` carry over — delegator-side * RLS that substitutes them then compiles faithfully instead of collapsing to * the deny sentinel. Since #6352, `buildContextForUser` returns the resolver's - * own `org_user_ids`, which without a known `tenantId` is the degenerate - * `[delegatorId]` seed — the live principal's real org peer set is the better - * answer, so the assignment below overwrites it exactly as before. + * own `org_user_ids`; the live principal's org peer set is the answer the + * delegated request runs under, so the assignment below overwrites it + * exactly as before. The delegator's GRANTS are the one tenant-scoped thing + * resolved rather than copied: since #20515 they are resolved IN the live + * principal's organization, so a delegator grant scoped to that organization + * applies and one scoped to any other does not. * `accessible_org_ids` (ADR-0105 D2) is the exception: it is resolved from * the DELEGATOR's own memberships by `buildContextForUser`, never inherited, * because inheriting it would widen a delegated read past the organizations @@ -654,7 +674,16 @@ export async function resolveDelegatorContext( user = null; } if (!user) return { kind: 'missing', userId: String(oboId) }; - const dctx = await buildContextForUser(ql, oboId, nowMs); + // [#20515] The delegator's GRANTS are resolved in the live principal's + // organization — the organization the delegated request runs in, which this + // function already hands the delegator's context below. Resolved with no + // tenant, the delegator leg would hold only global grants (and before the + // resolver's no-tenant rule, every organization's), never the grants the + // delegator actually holds where the request runs. + const liveTenantId = typeof context?.tenantId === 'string' && context.tenantId !== '' + ? context.tenantId + : undefined; + const dctx = await buildContextForUser(ql, oboId, nowMs, liveTenantId); // Inherit tenant-scoped substitution bags from the live principal (same org). if (context?.tenantId != null) dctx.tenantId = context.tenantId; if (context?.org_user_ids != null) dctx.org_user_ids = context.org_user_ids; diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index e80eff16b33..5c79ea0224a 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -3795,6 +3795,39 @@ describe('explainAccessForCaller (ADR-0090 D6/D12)', () => { const self = await plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: 'u_east_1' }, plain); expect(self.principal.userId).toBe('u_east_1'); }); + + // [#20515] Explaining ANOTHER user resolves that user's grants in the CALLER's + // organization — the one the explain right was checked in. A grant scoped to + // an organization applies only while that organization is the one resolved + // in; with no organization only global grants do, which is what enforcement + // answers for that user with none. + describe('[#20515] the explained user is resolved in the caller\'s organization', () => { + const scopedTarget = async () => { + const b = await boot(); + b.h.tables.sys_user_permission_set.push( + { user_id: 'u_west_1', permission_set_id: 'ps_sub', organization_id: 'org_alpha' }, + ); + b.h.tables.sys_permission_set.push({ id: 'ps_sub', name: 'sub_admin' }); + return b; + }; + const hr = (tenantId?: string) => ({ + userId: 'u_hr', positions: [], permissions: ['hr_admin'], ...(tenantId ? { tenantId } : {}), + }); + + it('caller in org_alpha: the org_alpha-scoped set is part of the explained principal', async () => { + const { plugin } = await scopedTarget(); + const d = await plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: 'u_west_1' }, hr('org_alpha')); + expect(d.principal.permissionSets).toContain('sub_admin'); + }); + + it('caller in another organization, or in none: it is not', async () => { + const { plugin } = await scopedTarget(); + const inBeta = await plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: 'u_west_1' }, hr('org_beta')); + expect(inBeta.principal.permissionSets).not.toContain('sub_admin'); + const orgless = await plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: 'u_west_1' }, hr()); + expect(orgless.principal.permissionSets).not.toContain('sub_admin'); + }); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index af0a956396d..edf38d7a996 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -4681,7 +4681,13 @@ export class SecurityPlugin implements Plugin { } } } - targetContext = await buildContextForUser(this.ql, request.userId); + // [#20515] Resolved in the CALLER's organization — the one the explain + // right above was checked in. Grants scoped to any other organization do + // not apply there, and with no active organization only global grants do. + const callerTenantId = typeof callerContext?.tenantId === 'string' && callerContext.tenantId !== '' + ? callerContext.tenantId + : undefined; + targetContext = await buildContextForUser(this.ql, request.userId, Date.now(), callerTenantId); } // [C2 / ADR-0095] The optional `sharing` service backs the record-grained diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts index fbf56421a1f..1a98d83380f 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts @@ -22,7 +22,10 @@ * * ⭐ WHAT THE ESCALATION BUYS, driven rather than argued (see the arms below): * `manage_sharing` is an ORG-scoped capability (ADR-0111 D6) that an ordinary - * tenant admin may grant. Holding it with no organization resolved is refused + * tenant admin may grant. (Since #20515 a grant scoped to an organization no + * longer applies to a resolution with no organization at all, so the arms below + * hold it through a GLOBAL grant — the one way an org-less caller still holds + * it.) Holding it with no organization resolved is refused * by `assertResolvableAdminScope` precisely because an unscoped answer "would * expose every tenant's rules". The D4 name-read was the bypass: it satisfied * that gate, `adminOrgScope` then returned the UNFILTERED `where`, and @@ -100,10 +103,14 @@ function authzTables(shape: 'name-only' | 'genuine') { ] : []; const userSets: Array> = [ - // The ORG-scoped capability both shapes hold — the precondition, not the - // axis under test. Scoped to `HOME_ORG`, so it can never be mistaken for - // the unscoped grant that confers standing. - { user_id: USER, permission_set_id: PS_SHARING, organization_id: HOME_ORG }, + // The `manage_sharing` capability both shapes hold — the precondition, not + // the axis under test. GLOBAL (no organization): this caller resolves with + // NO organization, and an organization-less resolution applies only global + // grants (#20515), so an org-scoped grant here would simply not be held and + // every arm below would be vacuous. It is still `sharing_admin`, never + // `admin_full_access`, so it cannot be mistaken for the grant that confers + // standing. + { user_id: USER, permission_set_id: PS_SHARING, organization_id: null }, ]; if (shape === 'genuine') { userSets.push({ user_id: USER, permission_set_id: PS_ADMIN, organization_id: null }); diff --git a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts index fe80c87314e..abefc23c939 100644 --- a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts @@ -32,12 +32,23 @@ // PRECONDITION tests below assert each link rather than assuming it. // // The user shape is ordinary, not contrived: a permission-set grant -// (`sys_user_permission_set`) is independent of organization MEMBERSHIP, and -// `resolveUserAuthzGrants` keeps an org-scoped grant when the caller has no -// active org to compare it against (`!(org && tenantId && org !== tenantId)`). -// A multi-org deployment (whose membership reconciler binds nobody — ADR-0093 +// (`sys_user_permission_set`) is independent of organization MEMBERSHIP. A +// multi-org deployment (whose membership reconciler binds nobody — ADR-0093 // D1 `no-target-org`), an `invite-only` deployment, a user removed from their -// organization, or an SSO JIT user pending placement all produce it. +// organization, or an SSO JIT user pending placement all produce a caller with +// no active organization. +// +// [#20515] What such a caller HOLDS changed underneath this file. When #8158 +// was filed, `resolveUserAuthzGrants` kept an ORGANIZATION-scoped grant for a +// caller with no active organization (`!(org && tenantId && org !== tenantId)` +// read "no tenant" as "every organization"), so an org-scoped `manage_sharing` +// reached `adminOrgScope`. It no longer does: with no active organization only +// GLOBAL grants apply. So the two faces are pinned separately below — +// - the EXPOSED persona holds `manage_sharing` through a GLOBAL grant, the one +// way an org-less caller still holds it, and keeps pinning `adminOrgScope` +// (#8158's defence in depth: capability held, no organization to use it in); +// - the ORG-SCOPED persona holds the very grant #8158 was filed with, scoped +// to tenant A, and is now refused at the ADR-0111 D6 capability gate. // // ## Anti-vacuity // @@ -63,6 +74,7 @@ const RULE_B = 'rule_8158_tenant_b'; const PASSWORD = 'Member-Pass-123'; const ORG_LESS_EMAIL = 'orgless-8158@verify.test'; const ORG_BOUND_EMAIL = 'orgbound-8158@verify.test'; +const ORG_SCOPED_ORG_LESS_EMAIL = 'orgscoped-orgless-8158@verify.test'; interface RuleRow { id: string; @@ -76,11 +88,14 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r let ql: any; /** The harness admin: platform authority, and (by harness design) org-less. */ let platform: string; - /** The exposed persona: org-scoped `manage_sharing`, no membership, no active org. */ + /** The exposed persona: GLOBAL `manage_sharing`, no membership, no active org. */ let orgLess: string; - /** The control persona: the SAME grant, plus a membership in tenant A. */ + /** [#20515] The #8158 grant as filed — scoped to tenant A — with no membership and no active org. */ + let orgScopedOrgLess: string; + /** The control persona: the same set scoped to tenant A, plus a membership in tenant A. */ let orgBound: string; let orgLessUserId = ''; + let orgScopedOrgLessUserId = ''; let orgBoundUserId = ''; const ruleRow = (organizationId: string, name: string) => ({ @@ -126,13 +141,23 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // Real sign-ups: better-auth's own path, through every database hook. await stack.signUp(ORG_LESS_EMAIL, PASSWORD); await stack.signUp(ORG_BOUND_EMAIL, PASSWORD); + await stack.signUp(ORG_SCOPED_ORG_LESS_EMAIL, PASSWORD); const uid = async (email: string): Promise => (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; orgLessUserId = await uid(ORG_LESS_EMAIL); orgBoundUserId = await uid(ORG_BOUND_EMAIL); + orgScopedOrgLessUserId = await uid(ORG_SCOPED_ORG_LESS_EMAIL); - // The identical grant for both, scoped to tenant A. - for (const userId of [orgLessUserId, orgBoundUserId]) { + // [#20515] The exposed persona holds the set GLOBALLY (no organization): + // with no active organization only global grants apply, so this is the one + // spelling under which it still carries `manage_sharing` and still reaches + // `adminOrgScope`. + await ql.insert('sys_user_permission_set', { + user_id: orgLessUserId, permission_set_id: psId, organization_id: null, + }, { context: SYS }); + // The #8158 grant as filed — scoped to tenant A — for the control (which + // has tenant A active) and for the org-scoped persona (which has none). + for (const userId of [orgBoundUserId, orgScopedOrgLessUserId]) { await ql.insert('sys_user_permission_set', { user_id: userId, permission_set_id: psId, organization_id: ORG_A, }, { context: SYS }); @@ -148,6 +173,7 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // with the membership state above already in place. orgLess = await stack.signIn(ORG_LESS_EMAIL, PASSWORD); orgBound = await stack.signIn(ORG_BOUND_EMAIL, PASSWORD); + orgScopedOrgLess = await stack.signIn(ORG_SCOPED_ORG_LESS_EMAIL, PASSWORD); }, 180_000); afterAll(async () => { @@ -206,6 +232,8 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // ── the measurement ────────────────────────────────────────────────── it('THE REPORTED CASE: listing is refused 403, not answered with every tenant’s rules', async () => { + // The exposed persona holds `manage_sharing` GLOBALLY (#20515 — see the + // header), so this is `adminOrgScope` refusing, as the next case proves. const res = await stack.apiAs(orgLess, 'GET', RULES); const payload = res.status === 200 ? ((await res.json()) as { data: RuleRow[] }).data.map((r) => `${r.name}@${r.organization_id}`) @@ -274,6 +302,37 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r })).toBeFalsy(); }); + // ── [#20515] the #8158 grant as filed: ORG-scoped, no active organization ─── + + it('PRECONDITION: the org-scoped persona holds no membership, and its SESSION carries no active organization', async () => { + const members = await ql.find('sys_member', { where: { user_id: orgScopedOrgLessUserId }, context: SYS }); + expect(Array.isArray(members) ? members : members?.records ?? []).toHaveLength(0); + const sessions = await ql.find('sys_session', { where: { user_id: orgScopedOrgLessUserId }, context: SYS }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows: any[] = Array.isArray(sessions) ? sessions : sessions?.records ?? []; + expect(rows.length, 'the sign-in really did mint a session row').toBeGreaterThan(0); + for (const s of rows) { + expect(s.active_organization_id ?? s.activeOrganizationId ?? null).toBeFalsy(); + } + }); + + it('an ORG-scoped manage_sharing grant with no active organization is refused 403 at the CAPABILITY gate', async () => { + // With no active organization only global grants apply, so the tenant-A + // grant confers nothing here: the ADR-0111 D6 gate refuses before + // `adminOrgScope` is ever asked — and no tenant's rule is read. + const res = await stack.apiAs(orgScopedOrgLess, 'GET', RULES); + const body = (await res.json()) as { code?: string; error?: string; data?: RuleRow[] }; + expect(res.status, JSON.stringify(body)).toBe(403); + expect(body.code).toBe('PERMISSION_DENIED'); + expect(body.error ?? '').toMatch(/requires the manage_sharing capability/); + expect(body.data).toBeUndefined(); + }); + + it('the org-scoped persona is refused the other tenant’s rule by name as well', async () => { + const res = await stack.apiAs(orgScopedOrgLess, 'GET', `${RULES}/${RULE_B}`); + expect(res.status, await res.text()).toBe(403); + }); + // ── the control: the SAME grant, with an organization, still works ─── it('the org-BOUND holder of the same grant reads its own tenant and NOT the other', async () => { diff --git a/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts new file mode 100644 index 00000000000..7465d3ffb5c --- /dev/null +++ b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts @@ -0,0 +1,297 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20515] A member removed from an organization does not keep a capability + * that organization granted: the `manage_metadata` gate of the `/packages` + * doors refuses them `403`. + * + * ## The defect + * + * A member removed from `org_alpha` keeps a session that still names it. The + * resolver drops that claim (no membership backs it) and re-resolves the grants + * with no active organization. The resolution's organization filters read + * "no tenant" as "every organization", so a permission set granted SCOPED to + * `org_alpha` — operator-authored, and not revoked by the removal — went on + * conferring `manage_metadata` with no organization boundary left on it. The + * gate passed: `PATCH /packages/:id/disable` switched the package off for the + * whole environment (200), and `DELETE /packages/:id` reached the door's own + * organization check (400 `TENANT_SCOPE_REQUIRED`) instead of the gate's 403. + * + * The same held for a set the left organization bound to one of its own + * POSITIONS: with no tenant the position read is installation-wide, so the left + * organization's copy of `org_member` fed its bindings to anyone still an + * `org_member` somewhere. + * + * The rule now: with no active organization only the global grants apply, and + * a grant scoped to an organization applies only while that organization is the + * active tenant (`resolveUserAuthzGrants`, `@objectstack/core`). + * + * ## The rig + * + * The one `packages-uninstall-refuse-before-mutate.test.ts` uses: `dispatch()` + * runs the REAL identity resolution (`resolveRequestScope` → + * `resolveExecutionContext` → `resolveAuthzContext`) under an `isolated` + * posture, over a real `SchemaRegistry` holding the package, and a `protocol` + * double that refuses an organization-less `deletePackage` the way + * `@objectstack/metadata-protocol` does. `@objectstack/core` resolves to its + * source here (this package's vitest alias), so the resolver under test is the + * one in this checkout. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const PKG = 'com.acme.crm'; +const ALPHA = 'org_alpha'; +const BETA = 'org_beta'; +const SESSION_HEADER = 'x-test-session'; + +/** Equality plus `$in`, and a loud refusal of every other shape. */ +function matchesWhere(row: any, where: any): boolean { + return Object.entries(where ?? {}).every(([field, cond]) => { + if (field.startsWith('$')) throw new Error(`fixture where-matcher: unsupported combinator '${field}'`); + if (cond !== null && typeof cond === 'object') { + const ops = Object.keys(cond as object); + if (ops.length !== 1 || ops[0] !== '$in' || !Array.isArray((cond as any).$in)) { + throw new Error(`fixture where-matcher: unsupported operator shape on '${field}'`); + } + return (cond as any).$in.includes(row[field]); + } + return row[field] === cond; + }); +} + +/** + * `u_exmember` was removed from `org_alpha` and is still a member of + * `org_beta`; `u_gone` has no membership left anywhere. Both still hold the + * `org_alpha`-scoped `manage_metadata` set. `u_member` is the control: a + * current `org_alpha` member holding the same grant. `u_global` was removed the + * same way but holds the set GLOBALLY. `u_admin` holds `admin_full_access` + * unscoped — the row the platform-admin bootstrap mints. `u_orgless` signed in, + * never selected an organization and holds no grant at all. + */ +const TABLES: Record = { + sys_api_key: [], + sys_member: [ + { user_id: 'u_member', organization_id: ALPHA, role: 'member' }, + { user_id: 'u_exmember', organization_id: BETA, role: 'member' }, + { user_id: 'u_global', organization_id: BETA, role: 'member' }, + { user_id: 'u_admin', organization_id: ALPHA, role: 'owner' }, + { user_id: 'u_posex', organization_id: BETA, role: 'member' }, + { user_id: 'u_posmember', organization_id: ALPHA, role: 'member' }, + ], + sys_user: ['u_member', 'u_exmember', 'u_gone', 'u_global', 'u_admin', 'u_posex', 'u_posmember', 'u_orgless'] + .map((id) => ({ id, email: `${id}@example.com` })), + sys_user_permission_set: [ + { user_id: 'u_member', permission_set_id: 'ps_meta', organization_id: ALPHA }, + { user_id: 'u_exmember', permission_set_id: 'ps_meta', organization_id: ALPHA }, + { user_id: 'u_gone', permission_set_id: 'ps_meta', organization_id: ALPHA }, + { user_id: 'u_global', permission_set_id: 'ps_meta', organization_id: null }, + { user_id: 'u_admin', permission_set_id: 'ps_admin', organization_id: null }, + ], + sys_permission_set: [ + { id: 'ps_meta', name: 'alpha_metadata_editors', system_permissions: ['manage_metadata', 'studio.access'] }, + { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }, + { id: 'ps_members', name: 'alpha_member_tools', system_permissions: ['manage_metadata'] }, + ], + // The per-organization copies of the `org_member` built-in a walled catalog + // holds; org_alpha bound a metadata-editing set to ITS copy. This double + // ignores the read's tenant, which is exactly what an organization-less + // resolution's installation-wide `sys_position` read does. + sys_position: [ + { id: 'pos_member_alpha', name: 'org_member', organization_id: ALPHA }, + { id: 'pos_member_beta', name: 'org_member', organization_id: BETA }, + ], + sys_position_permission_set: [ + { position_id: 'pos_member_alpha', permission_set_id: 'ps_members' }, + ], +}; + +type Who = 'member' | 'exmember' | 'gone' | 'global' | 'admin' | 'admin_orgless' | 'posex' | 'posmember' | 'orgless'; + +/** Every session names `org_alpha` except `admin_orgless` and `orgless`, which name no organization. */ +const SESSIONS: Record = { + member: { id: 'ses_member', token: 'tok_member', userId: 'u_member', activeOrganizationId: ALPHA }, + exmember: { id: 'ses_exmember', token: 'tok_exmember', userId: 'u_exmember', activeOrganizationId: ALPHA }, + gone: { id: 'ses_gone', token: 'tok_gone', userId: 'u_gone', activeOrganizationId: ALPHA }, + global: { id: 'ses_global', token: 'tok_global', userId: 'u_global', activeOrganizationId: ALPHA }, + admin: { id: 'ses_admin', token: 'tok_admin', userId: 'u_admin', activeOrganizationId: ALPHA }, + admin_orgless: { id: 'ses_admin2', token: 'tok_admin2', userId: 'u_admin' }, + posex: { id: 'ses_posex', token: 'tok_posex', userId: 'u_posex', activeOrganizationId: ALPHA }, + posmember: { id: 'ses_posmember', token: 'tok_posmember', userId: 'u_posmember', activeOrganizationId: ALPHA }, + orgless: { id: 'ses_orgless', token: 'tok_orgless', userId: 'u_orgless' }, +}; + +function rig() { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + (registry as any).logLevel = 'silent'; + registry.installPackage({ id: PKG, namespace: 'crm', name: 'CRM', version: '1.0.0', type: 'app', scope: 'project' } as any); + registry.registerObject({ name: 'crm_lead', fields: { title: { type: 'text' } } } as any, PKG, 'crm', 'own'); + + const deleteRequests: any[] = []; + const protocol = { + deletePackage: async (req: any) => { + deleteRequests.push({ ...req }); + if (!req?.organizationId && req?.allTenants !== true) { + throw Object.assign(new Error('Refusing to uninstall with no organization scope.'), { + code: 'TENANT_SCOPE_REQUIRED', status: 400, + }); + } + return { success: true, deletedCount: 0, failedCount: 0, deleted: [], failed: [], cleanups: [] }; + }, + }; + const services: Record = { + objectql: { + registry, + find: async (object: string, q: any = {}) => { + const found = (TABLES[object] ?? []).filter((row: any) => matchesWhere(row, q?.where)); + return typeof q?.limit === 'number' ? found.slice(0, q.limit) : found; + }, + }, + auth: { + api: { + getSession: async ({ headers }: any) => { + const who = (typeof headers?.get === 'function' ? headers.get(SESSION_HEADER) : headers?.[SESSION_HEADER]) as Who | undefined; + const row = who ? SESSIONS[who] : undefined; + if (!row) return undefined; + return { user: { id: row.userId, email: `${row.userId}@example.com` }, session: { ...row } }; + }, + }, + }, + tenancy: { posture: 'isolated' }, + protocol, + }; + const get = (n: string) => services[n] ?? null; + const dispatcher = new HttpDispatcher({ context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) } as any); + + return { + registry, + deleteRequests, + call: async (method: string, who: Who, path: string) => { + const res = await dispatcher.dispatch(method, path, undefined, {}, { + request: { headers: { [SESSION_HEADER]: who } }, + } as any); + const body = JSON.parse(JSON.stringify(res.response?.body ?? null)); + return { status: res.response?.status ?? 0, code: body?.error?.code, httpStatus: body?.error?.httpStatus, body }; + }, + }; +} + +/** Whether the registry now holds the package switched off. */ +function switchedOff(r: ReturnType): boolean { + const pkg: any = r.registry.getPackage(PKG); + return pkg?.enabled === false || pkg?.status === 'disabled'; +} + +// An allowed disable or uninstall writes a REAL state file under the ObjectStack +// home, so the home is a temp dir for the whole file. +const envSnapshot = { OS_HOME: process.env.OS_HOME }; +let home: string; +beforeAll(() => { + home = mkdtempSync(join(tmpdir(), 'os-20515-')); + process.env.OS_HOME = home; +}); +afterAll(() => { + if (envSnapshot.OS_HOME === undefined) delete process.env.OS_HOME; + else process.env.OS_HOME = envSnapshot.OS_HOME; + rmSync(home, { recursive: true, force: true }); +}); + +let warnSpy: ReturnType; +beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); +afterEach(() => { warnSpy.mockRestore(); }); + +const REMOVED: Array<[Who, string]> = [ + ['exmember', 'a member removed from org_alpha, still a member of org_beta'], + ['gone', 'a member removed from org_alpha, with no membership left anywhere'], + // The same set reached through a POSITION: org_alpha bound it to its own + // copy of `org_member`, and this member is still an `org_member` elsewhere. + ['posex', 'a member removed from org_alpha whose org_member binding there carried manage_metadata'], +]; + +describe('[#20515] a removed member\'s organization-scoped manage_metadata no longer passes the /packages gate', () => { + for (const [who, label] of REMOVED) { + it(`${label}: DELETE /packages/:id is refused 403 by the capability gate, and deletePackage is never asked`, async () => { + const r = rig(); + const answer = await r.call('DELETE', who, `/packages/${PKG}`); + expect({ status: answer.status, code: answer.code, httpStatus: answer.httpStatus }) + .toEqual({ status: 403, code: 'PERMISSION_DENIED', httpStatus: 403 }); + expect(r.deleteRequests).toEqual([]); + expect(r.registry.getPackage(PKG)).toBeDefined(); + }); + + it(`${label}: PATCH /packages/:id/disable is refused 403, and the package stays enabled for everyone`, async () => { + const r = rig(); + const answer = await r.call('PATCH', who, `/packages/${PKG}/disable`); + expect({ status: answer.status, code: answer.code, httpStatus: answer.httpStatus }) + .toEqual({ status: 403, code: 'PERMISSION_DENIED', httpStatus: 403 }); + expect(switchedOff(r)).toBe(false); + }); + } + + it('the rig really drops the removed member\'s claim — the refusal is not a session that never named the organization', async () => { + const r = rig(); + await r.call('DELETE', 'exmember', `/packages/${PKG}`); + const dropped = warnSpy.mock.calls.map((args: unknown[]) => String(args[0])) + .filter((line: string) => line.includes('Session organization claim dropped')); + expect(dropped.length).toBeGreaterThan(0); + expect(dropped[0]).toContain(`organization=${ALPHA}`); + }); +}); + +describe('[#20515] what the rule leaves unchanged', () => { + it('CONTROL · a current member with the same grant and org_alpha active passes: DELETE /packages/:id answers 200', async () => { + const r = rig(); + const answer = await r.call('DELETE', 'member', `/packages/${PKG}`); + expect(answer.status).toBe(200); + expect(r.deleteRequests).toEqual([{ packageId: PKG, organizationId: ALPHA }]); + }); + + it('CONTROL · the same current member passes PATCH /packages/:id/disable: 200, and the package is switched off', async () => { + const r = rig(); + expect((await r.call('PATCH', 'member', `/packages/${PKG}/disable`)).status).toBe(200); + expect(switchedOff(r)).toBe(true); + }); + + it('CONTROL · the position-bound set: a current org_alpha member with org_alpha active passes: 200', async () => { + const r = rig(); + const answer = await r.call('DELETE', 'posmember', `/packages/${PKG}`); + expect(answer.status).toBe(200); + expect(r.deleteRequests).toEqual([{ packageId: PKG, organizationId: ALPHA }]); + }); + + it('a GLOBAL grant still passes the gate for the removed member — the door\'s own organization check answers, not the gate', async () => { + const r = rig(); + const answer = await r.call('DELETE', 'global', `/packages/${PKG}`); + expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'TENANT_SCOPE_REQUIRED' }); + // …and the disable door, which asks no organization, lands — the + // positive control that makes `switchedOff` false above mean something. + const d = rig(); + expect((await d.call('PATCH', 'global', `/packages/${PKG}/disable`)).status).toBe(200); + expect(switchedOff(d)).toBe(true); + }); + + it('platform-admin standing (unscoped admin_full_access) passes with org_alpha active, and with no organization at all', async () => { + expect((await rig().call('DELETE', 'admin', `/packages/${PKG}`)).status).toBe(200); + expect((await rig().call('PATCH', 'admin', `/packages/${PKG}/disable`)).status).toBe(200); + const orgless = await rig().call('DELETE', 'admin_orgless', `/packages/${PKG}`); + expect({ status: orgless.status, code: orgless.code }).toEqual({ status: 400, code: 'TENANT_SCOPE_REQUIRED' }); + expect((await rig().call('PATCH', 'admin_orgless', `/packages/${PKG}/disable`)).status).toBe(200); + }); + + it('a caller who never selected an organization and holds no grant is refused 403 on both doors, as before', async () => { + const r = rig(); + const del = await r.call('DELETE', 'orgless', `/packages/${PKG}`); + expect({ status: del.status, code: del.code, httpStatus: del.httpStatus }) + .toEqual({ status: 403, code: 'PERMISSION_DENIED', httpStatus: 403 }); + const dis = await r.call('PATCH', 'orgless', `/packages/${PKG}/disable`); + expect({ status: dis.status, code: dis.code, httpStatus: dis.httpStatus }) + .toEqual({ status: 403, code: 'PERMISSION_DENIED', httpStatus: 403 }); + expect(r.deleteRequests).toEqual([]); + expect(switchedOff(r)).toBe(false); + }); +});