From 514e681cd6124c6d34fb92d5c08a02273f31c50b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:34:14 +0000 Subject: [PATCH 1/9] fix(core): an organization-less grants resolution applies only global grants Sections 4 (sys_user_position) and 6 (sys_user_permission_set) of resolveUserAuthzGrants now ask one predicate, grantAppliesInTenant: a row with no organization is global; a row scoped to an organization applies only while that organization is the active tenant. With no tenant, the old skip condition kept every organization-scoped row. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude --- .../src/security/resolve-authz-context.ts | 52 +++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 8434298537a..490bd8a377a 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,36 @@ export interface ResolveUserAuthzGrantsOptions { bypassGrantsCache?: boolean; } +/** + * The ONE organization rule for a stored grant row — `sys_user_position` (§4) + * and `sys_user_permission_set` (§6) both ask it, so the two cannot 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 memberships rather than a grant row, nor to §6a's `sys_position` read, + * which is scoped by threading the organization into the driver's tenant scope + * rather than by filtering rows here. + */ +function grantAppliesInTenant(organizationId: unknown, tenantId: string | undefined): boolean { + return !organizationId || organizationId === tenantId; +} + /** * resolveUserAuthzGrants — the userId-driven core of {@link resolveAuthzContext}. * @@ -795,11 +831,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 +853,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), ); From a073cf3452bef426537db82349af13a29b66492c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:37:00 +0000 Subject: [PATCH 2/9] test(core): pin the no-tenant grants rule at the resolver, the session arm and the cache Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude --- .../security/resolve-authz-context.test.ts | 177 +++++++++++++++++- .../resolve-user-grants-cache.test.ts | 40 ++++ 2 files changed, 216 insertions(+), 1 deletion(-) diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index f4787821c08..7150ddea811 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,178 @@ 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'], + }); + }); + + 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-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'); + }); +}); From ecf21529108616dba7f826fdffd72b95595706a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:38:07 +0000 Subject: [PATCH 3/9] fix(plugin-security): the explainer and the delegator leg resolve grants in an organization buildContextForUser takes the organization to resolve in. The delegator of an on-behalf-of principal is resolved in the live principal's organization, and the explain API resolves the explained user in the caller's organization, so neither relies on a no-tenant resolution to see organization-scoped grants. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude --- .../plugin-security/src/explain-engine.ts | 41 ++++++++++++++++--- .../plugin-security/src/security-plugin.ts | 8 +++- 2 files changed, 42 insertions(+), 7 deletions(-) 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.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 From 08d063695e38569bec5a46116cd28df4ea81305f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:52:28 +0000 Subject: [PATCH 4/9] test(plugin-security): pin the explainer, the explain API and the delegator leg resolving in an organization Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude --- .../src/explain-engine.test.ts | 108 +++++++++++++++++- .../src/security-plugin.test.ts | 33 ++++++ 2 files changed, 136 insertions(+), 5 deletions(-) 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/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'); + }); + }); }); // --------------------------------------------------------------------------- From b96b10f67109a4f8f9f7c3474c20f01e2d937047 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:55:46 +0000 Subject: [PATCH 5/9] test(runtime): pin the /packages capability gate refusing a removed member's organization-scoped manage_metadata Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude --- ...ges-orgless-grants-capability-gate.test.ts | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts 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..4a68156d75c --- /dev/null +++ b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts @@ -0,0 +1,245 @@ +// 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 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. + */ +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' }, + ], + sys_user: ['u_member', 'u_exmember', 'u_gone', 'u_global', 'u_admin'] + .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'] }, + ], +}; + +type Who = 'member' | 'exmember' | 'gone' | 'global' | 'admin' | 'admin_orgless'; + +/** Every session but the last names `org_alpha`. */ +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' }, +}; + +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'], +]; + +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('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); + 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); + }); +}); From 67903f273edf4012ce5b3af3cc45137abd03e5e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 00:02:07 +0000 Subject: [PATCH 6/9] 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 --- .../security/resolve-authz-context.test.ts | 59 +++++++++++++++++++ .../src/security/resolve-authz-context.ts | 36 +++++++---- ...ges-orgless-grants-capability-gate.test.ts | 35 ++++++++++- 3 files changed, 118 insertions(+), 12 deletions(-) diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index 7150ddea811..1b97d24b666 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -1903,6 +1903,65 @@ describe('[#20515] with no active organization, only global grants apply', () => }); }); + /** + * §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`; diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 490bd8a377a..1f0d2889e62 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -622,12 +622,12 @@ export interface ResolveUserAuthzGrantsOptions { } /** - * The ONE organization rule for a stored grant row — `sys_user_position` (§4) - * and `sys_user_permission_set` (§6) both ask it, so the two cannot 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 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 @@ -643,9 +643,10 @@ export interface ResolveUserAuthzGrantsOptions { * 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 memberships rather than a grant row, nor to §6a's `sys_position` read, - * which is scoped by threading the organization into the driver's tenant scope - * rather than by filtering rows here. + * 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; @@ -948,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/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts index 4a68156d75c..ea5dd6afe07 100644 --- a/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts +++ b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts @@ -17,6 +17,11 @@ * 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`). @@ -75,8 +80,10 @@ const TABLES: Record = { { 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'] + sys_user: ['u_member', 'u_exmember', 'u_gone', 'u_global', 'u_admin', 'u_posex', 'u_posmember'] .map((id) => ({ id, email: `${id}@example.com` })), sys_user_permission_set: [ { user_id: 'u_member', permission_set_id: 'ps_meta', organization_id: ALPHA }, @@ -88,10 +95,22 @@ const TABLES: Record = { 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'; +type Who = 'member' | 'exmember' | 'gone' | 'global' | 'admin' | 'admin_orgless' | 'posex' | 'posmember'; /** Every session but the last names `org_alpha`. */ const SESSIONS: Record = { @@ -101,6 +120,8 @@ const SESSIONS: Record { 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', () => { @@ -225,6 +249,13 @@ describe('[#20515] what the rule leaves unchanged', () => { expect(r.deleteRequests).toEqual([{ packageId: PKG, organizationId: ALPHA }]); }); + 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}`); From eafda39fe68985291085e0cab754b5b7c7c02488 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 00:07:03 +0000 Subject: [PATCH 7/9] chore(changeset): an organization-less grants resolution applies only global grants Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude --- .changeset/20515-orgless-grants-global-only.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/20515-orgless-grants-global-only.md 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) + + From 8b0826f9ee0601808e1e6d5299e904274384e6d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 00:34:31 +0000 Subject: [PATCH 8/9] test(plugin-sharing): the org-less manage_sharing arms hold the capability through a global grant An organization-less resolution no longer applies an organization-scoped grant, so the arms' precondition is now spelled as the one grant an org-less caller still holds. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude --- ...haring-rule-positions-name-authority.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) 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 }); From 6b3414e87a6997e90c4db4dcf7978fc36daa7851 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 01:25:06 +0000 Subject: [PATCH 9/9] test(dogfood,runtime): the org-less sharing admin holds manage_sharing globally; an org-scoped grant with no organization is refused at the capability gate The #8158 dogfood proof's exposed persona held manage_sharing only through an organization-scoped grant, which reached adminOrgScope through the defect this branch fixes. It now holds the set globally and keeps pinning adminOrgScope; a new persona holding the grant as filed (scoped, no active organization) is refused at the capability gate. The runtime door pins gain the control and the platform admin at the disable door, and the no-grant org-less caller. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude --- ...aring-rule-org-less-caller.dogfood.test.ts | 77 ++++++++++++++++--- ...ges-orgless-grants-capability-gate.test.ts | 29 ++++++- 2 files changed, 93 insertions(+), 13 deletions(-) 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 index ea5dd6afe07..7465d3ffb5c 100644 --- a/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts +++ b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts @@ -71,7 +71,8 @@ function matchesWhere(row: any, where: any): boolean { * `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. + * 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: [], @@ -83,7 +84,7 @@ const TABLES: Record = { { 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'] + 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 }, @@ -110,9 +111,9 @@ const TABLES: Record = { ], }; -type Who = 'member' | 'exmember' | 'gone' | 'global' | 'admin' | 'admin_orgless' | 'posex' | 'posmember'; +type Who = 'member' | 'exmember' | 'gone' | 'global' | 'admin' | 'admin_orgless' | 'posex' | 'posmember' | 'orgless'; -/** Every session but the last names `org_alpha`. */ +/** 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 }, @@ -122,6 +123,7 @@ const SESSIONS: Record { 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}`); @@ -269,8 +277,21 @@ describe('[#20515] what the rule leaves unchanged', () => { 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); + }); });