Skip to content

Commit f6ceddc

Browse files
fix(core,plugin-security)!: a grants resolution with no active organization applies only global grants (#20515) (#20540)
Fixes #20515 Clause-②: yes (narrowing) ## What changed `resolveUserAuthzGrants` (`packages/core/src/security/resolve-authz-context.ts`) now states one rule, once, as the module-private predicate `grantAppliesInTenant`: a grant row with no organization is global and applies everywhere; a row scoped to an organization applies only while that organization is the active tenant. With no active organization, only the global grants apply. There is no "every organization" option and no keep-all fallback. Three sites ask the predicate: - **§4** `sys_user_position` (the triage's `:802`). - **§6** `sys_user_permission_set` (the triage's `:829`). - **§6a** the `sys_position` rows whose bound permission sets the resolver collects. This one is a **declared deviation** from the claim's two sites; see H6 below for the measurement that put it here. With a tenant it is a no-op, because the driver's tenant scope already returned only that organization's rows and the organization-less ones. The §4 and §6 comments, which already stated this rule, are now true. §3 (`sys_member`) is not edited. `@objectstack/plugin-security`: `buildContextForUser(ql, userId, nowMs?, tenantId?)` takes the organization to resolve in (H3): - `resolveDelegatorContext` resolves the on-behalf-of delegator in the live principal's organization. That is an **enforcement** input: the D10 intersection. - `explainAccessForCaller` resolves an explained user in the caller's organization. No second check was added to `requireManageMetadata` or to any other door. The `plugin-sharing` `adminOrgScope` guard is untouched. **Not in this card, per triage:** revoking custom organization-scoped grants when a member is removed. Once this rule holds, those grants no longer apply. ## H0: the defect at the public door, before and after These readings use the #20492 rig (`dispatch()` with real identity resolution, `resolveRequestScope` into `resolveExecutionContext` into `resolveAuthzContext`, under an `isolated` posture). The base is unmodified `397572ed5`, which already includes PR #20514. The "after" column is the committed pin file `packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts`: every cell of it has an arm there, and the file is green at `4e3e4f5e4`. Patch round 1 added the three arms that were probe-only readings at `c9e6463ce`: the control at `PATCH disable`, the platform admin with `org_alpha` active at `PATCH disable`, and the org-less no-grant row. The gate is observable at two doors: - **`PATCH /packages/:id/disable`** is the door where the capability gate's effect is fully visible. It asks no organization, so a caller who passes the gate switches the package off for the whole environment (200). A caller refused by the gate gets 403. - **`DELETE /packages/:id`**: since PR #20514, an org-less caller who passes the gate reaches the door's own organization check (400 `TENANT_SCOPE_REQUIRED`). A caller refused by the gate gets 403. | Arm (every session names `org_alpha`) | DELETE, base | DELETE, after | PATCH disable, base | PATCH disable, after | |---|---|---|---|---| | removed from `org_alpha`, still in `org_beta`, holds an `org_alpha`-scoped `manage_metadata` set (a2) | 400 `TENANT_SCOPE_REQUIRED` (gate passed) | **403 `PERMISSION_DENIED`** | **200 (package disabled)** | **403** | | same, no membership left anywhere (a2b) | 400 (gate passed) | **403** | **200** | **403** | | control: current `org_alpha` member, same grant, `org_alpha` active | 200 | 200 | 200 | 200 | | removed member holding the same set **globally** | 400 | 400 (unchanged) | 200 | 200 (unchanged) | | platform admin (unscoped `admin_full_access`), `org_alpha` active | 200 | 200 | 200 | 200 | | platform admin, no active organization | 400 | 400 (unchanged) | 200 | 200 (unchanged) | | org-less user with no grant | 403 | 403 | 403 | 403 | The base readings come from a throwaway probe at `397572ed5`; the first "after" reading was taken at `514e681cd`, and the committed file reproduces every "after" cell. The committed file adds a third removed-member arm: `org_alpha` bound the set to **its own copy of `org_member`**, and the member is still an `org_member` in `org_beta`. That arm is refused 403 on both doors. With the §6a predicate ablated it passes the gate (see Verification). ## H1: census of the no-tenant callers (source, `packages/**`, at `c9e6463ce`) | Caller | Tenant it passes | Class | What it loses under the rule | Right? | |---|---|---|---|---| | `resolveAuthzContext` first resolution (`resolve-authz-context.ts:428`) | the API key's `active_organization_id`, else the session's `activeOrganizationId` | sometimes none | every organization-scoped §4, §6 and §6a grant, when the key or session names no organization | Yes, per ruling. A request with no active organization acts in none. Most exposed are `group`-posture principals with no active organization: their data wall still spans every member organization, but their organization-scoped grants now need that organization active. | | `resolveAuthzContext` dropped-claim re-resolution (`:548`) | none, by construction | never | the left organization's grants: **the defect**. It also loses every other organization's scoped grants. | Yes. This is the fix. | | `hasPlatformAdminStanding` (`:1184`, `{ nowMs }` only) | none | never | nothing. `PLATFORM_ADMIN` derives only from the unscoped `admin_full_access` user grant or the declared-administrator config (H2). | Yes | | plugin-auth `customSession` (`auth-manager.ts:3987`) | `activeOrganizationId ?? undefined` | sometimes | `positions[]` loses organization-scoped `sys_user_position` names when no organization is active. `isPlatformAdmin` is unchanged. | Yes. Its docblock already scopes the payload to the active organization. | | plugin-auth `isPlatformAdminUserId` (`:7009`), through `hasPlatformAdminStanding` | none | never | nothing | Yes | | plugin-hono-server `makeExecutionContextResolver` (`current-user-endpoints.ts:424`) | `activeOrganizationId ?? undefined` | sometimes | same as `resolveAuthzContext` | Yes | | explainer `buildContextForUser` (`explain-engine.ts:581`) | **was none; now the caller's choice** | H3 | see H3 | changed | | `resolveDelegatorContext` into `buildContextForUser` (`explain-engine.ts:686`), an **enforcement** path | was none (the live tenant was stamped on afterwards); now the live principal's tenant | always, when the live principal has one | **Before:** every organization's delegator grants. **Under the rule with no caller change:** only global grants, a regression for an OAuth agent acting for an organization admin. **Now:** the delegator's grants in the organization the request runs in. | fixed here | | `explainAccessForCaller` into `buildContextForUser` (`security-plugin.ts:4690`) | was none; now the caller's tenant | sometimes | grants the explained user holds in organizations other than the caller's | changed (H3) | | invitation placement `assertIssuable` (`invitation-placement.ts:153`) | the invitation's `organizationId ?? undefined` | always in practice (a better-auth invitation belongs to an organization) | nothing in practice | Yes | | automation `runAs:'user'` (`service-automation/src/plugin.ts:909`) | the triggering run's `tenantId` | sometimes | organization-scoped grants for a run triggered with no organization | Yes. The run matches the user's own direct request in that state. | | transports calling `resolveAuthzContext`: `rest-server.ts:2968`, `runtime/src/security/resolve-execution-context.ts:217`, `sharing-plugin.ts:945`, `marketplace-install-local-plugin.ts:1806`, `service-datasource/admin-routes.ts:480`, `service-settings/settings-service-plugin.ts:296`, `service-storage/storage-service-plugin.ts:1152` | session or API key | sometimes | same as the first row | Yes | | MCP stdio (`mcp/src/plugin.ts:164`), API key only | the key's organization | sometimes (an org-less key, allowed under `single` / `group`) | organization-scoped grants for an org-less key | Yes, per ruling | **No caller needs every organization's grants**, so **no option was added** to `ResolveUserAuthzGrantsOptions` and the grants-cache key is unchanged (H4 moot). `tenantId` already keys cache entries; a new pin checks, with the cache on, that an `org_a` entry and a no-tenant entry never serve each other. `Clause-②` stays `yes (narrowing)`: the `yes` arm is now carried by `buildContextForUser`'s new optional parameter, a public widening of `@objectstack/plugin-security`. ## H2: platform-admin standing is global (measured) This was measured on a real `SqlDriver` (better-sqlite3) with the shipped `bootstrapPlatformAdmin`, under `single`, after the fix: - the minted `admin_full_access` row reads `organization_id: null`; - `hasPlatformAdminStanding` answers `true`; - an org-less resolution answers posture `PLATFORM_ADMIN`, with `manage_metadata` held. `hasPlatformAdminStanding` loses nothing, so it needs no answer of its own. Core pins cover both polarities: the unscoped grant is `PLATFORM_ADMIN` with and without a tenant; an organization-scoped `admin_full_access` confers no standing with or without that tenant. ## H3: the explainer takes a tenant, (b), not the triage's (a) The explainer's own contract decided it. The module header says the report "can never drift from enforcement". `buildContextForUser`'s docblock says it is called "with the exact arguments" enforcement uses. Its parity suite asserts, field by field, that it equals `resolveUserAuthzGrants`. Option (a), an explicit every-organization option, breaks that parity by construction. It would also have kept every organization's grants in an **enforcement** path, because `resolveDelegatorContext` builds the D10 delegator leg through `buildContextForUser`. So `buildContextForUser` takes the organization to resolve in, and each caller names it. Pinned in `explain-engine.test.ts`, `security-plugin.test.ts` and the parity suite, which now runs two cases in `org1` on both sides. **What the explainer shows for the removed member, against enforcement:** | Explained by | Explain shows | Enforcement (the member's own session: claim dropped, no tenant) | Agree? | |---|---|---|---| | a caller with no active organization, or in `org_beta` | global grants only; no `manage_metadata` | refused | yes | | an admin in `org_alpha` (the left organization) | the `org_alpha`-scoped `manage_metadata` set | refused | **no**: explain overstates | | (before this PR) anyone | every organization's grants | passed (the defect) | yes, both wrong | The disagreement in the second row is the #20431 class (explain ≠ enforce). It is reported, not fixed here. The explain API resolves the explained user in the caller's organization, and it does not model the session arm's membership check. Explain-of-another-user's record-level Layer 0 still evaluates with no active organization; that is pre-existing and unchanged. ## H5: section 3, measured Measured on a real `SqlDriver` over the shipped per-organization built-in catalog (`bootstrapBuiltinRoles` for `org_jia` and `org_yi`). The user is a current member of `org_jia` (`admin`) and `org_yi` (`member`), with no organization active. - §3 projects **both** organizations' roles: `positions: [org_admin, org_member, everyone]`. - **The gate that read them was §6a.** At the pre-§6a state, those names pulled every organization's copy of `org_admin`, `org_member` and `everyone`, and their bindings. A member removed from `org_jia` and still in `org_yi` kept `org_jia`'s `org_member`-bound `manage_metadata` set with no tenant. A user with no membership at all picked up `org_jia`'s `everyone` binding. - **After the §6a predicate:** the same resolutions carry no organization's bindings. With `org_jia` active, only `org_jia`'s apply. The verdict on §3 itself: **not the same class once §6a holds**, and not edited. Its rows are the user's own current memberships, not grant rows, and every capability a role name can confer now arrives through organization-scoped rows that answer the rule. What remains is display: `positions[]` with no active organization names every membership's role. ## H6: the smallest fix that satisfies the stated rule Sections 4 and 6 alone did not satisfy the rule. The real-driver measurement in H5 shows the removed member keeping the left organization's `manage_metadata` through §6a after the §4 and §6 fix, so §6a asks the same predicate. - It is written as the **grant rule**, not as a second tenant wall. The driver's `applyTenantScope` stays the one spelling of the wall, as the #10103 comment requires. - With a tenant it filters nothing: the driver already returned only that organization's rows and the organization-less ones. ## Verification (head `4e3e4f5e4`, after merging `origin/main` `288611e3e` with a true merge; the first round's merge was `31d281d3b`) - **Build:** `turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2`: 71/71. - **Tests at `4e3e4f5e4`:** - **`@objectstack/dogfood`, the whole suite:** `vitest run --shard=1/3`, `2/3` and `3/3`, all exit 0. 47 files and 378 passed; 47 files and 321 passed, 1 skipped; 46 files and 451 passed, 1 file and 2 tests skipped. That is 141 files and 1150 tests passed. - `sharing-rule-org-less-caller.dogfood.test.ts` alone: 16 passed. That is the 13 it had, plus 3 for the organization-scoped persona. - `@objectstack/core` `vitest run --project local`: 56 files, 1518 passed. - `@objectstack/plugin-security` `explain-engine`, `security-plugin` and `per-organization-catalog`: 361 passed. - `@objectstack/runtime` the door file (13 pins) plus `packages-uninstall-refuse-before-mutate`: 21 passed. - `@objectstack/plugin-sharing` `sharing-rule-positions-name-authority`: 7 passed. - Typecheck: `@objectstack/runtime` and `@objectstack/dogfood` exit 0; the runtime test-typecheck ledger is unchanged. - **Full suites at `c9e6463ce`'s source, before the first merge** (the two `origin/main` merges since then brought main's own `packages/rest` changes, `rest-server.ts`, `meta-item-read-gate.ts` and four test files, and main's `packages/runtime` test `meta-list-projection-parity.test.ts`; this PR's patch round moved its runtime door-pin file. For those suites, the verdict is the head's `Test Core` runs): | Package | Files | Tests | |---|---|---| | `plugin-security` | 143 | 3052 passed, 16 skipped | | `runtime` local | 287 | 4181 passed | | `rest` local | 221 | 4231 passed | | `plugin-auth` | 115 | 2464 | | `plugin-hono-server` | 27 | 324 | | `service-automation` | 149 | 1837 | | `plugin-sharing` | 37 | 913 | | `plugin-approvals` | 51 | 791 | | `organizations` | 8 | 108 | | `mcp` | 32 | 344 | | `cloud-connection` | 30 | 397 | | `service-datasource` | 34 | 693 | | `service-settings` | 33 | 584 | | `service-storage` | 40 | 627 | | `client` | 50 | 641 | All green. The consumer direction is the downstream importers of `@objectstack/core` named in the H1 census, plus their own consumers `plugin-approvals` and `client`. This table omitted `@objectstack/dogfood` in the first round, and its shard 3/3 was red on `c9e6463ce`. The whole dogfood suite is the first bullet above. - **Typecheck:** `@objectstack/core`, `@objectstack/plugin-security` and `@objectstack/runtime` `typecheck` all exit 0. Each `check:test-typecheck` is OK with its debt ledger unchanged. - **Fixture triage (dogfood, patch round 1):** `sharing-rule-org-less-caller.dogfood.test.ts` (#8158's HTTP proof) gave its exposed org-less persona `manage_sharing` through a grant scoped to `org_8158_a`. That grant reached `adminOrgScope` only through the defect, so shard 3/3 went red on "the refusal names the ORGANIZATION". - The exposed persona now holds the set **globally**. It keeps pinning `adminOrgScope` (#8158's defence in depth) with every assertion unchanged: 403, the "active organization" message, by-name and by-id refused, evaluate / delete / create refused, no cross-tenant read. - A new persona holds the grant **as #8158 filed it**: scoped to `org_8158_a`, no membership, no active organization. It is refused 403 `PERMISSION_DENIED` at the capability gate ("requires the manage_sharing capability"), with no rows returned, and refused by name too. Its session is pinned to carry no active organization. - The control keeps the scoped grant with `org_8158_a` active and still reads only its own tenant. - The new persona's red direction without the fix is the old test's green on `main`: that case measured exactly this persona reaching `adminOrgScope`'s message. - Census of the rest of `packages/qa/dogfood`: - No other fixture writes an organization-scoped `sys_user_permission_set` or `sys_user_position` row. The two other hits, in `membership-actor-attribution`, are reads of the auto-grant row. - `test/armed.ts:215` resolves through the real `resolveAuthzContext` (whatever the session carries), and its users are armed through memberships. - The `authz-conformance.matrix.ts` rows cite §3/§4/§6 as enforcement sites, and none of them states the old no-tenant reading. - The whole suite is green, as above. - **Fixture triage (plugin-sharing, one file, two cases):** `sharing-rule-positions-name-authority.test.ts` gave an org-less caller `manage_sharing` through an organization-scoped grant, which is exactly the defect's behaviour. The grant is re-spelled as **global**, the one way an org-less caller still holds it; it is still `sharing_admin`, never `admin_full_access`. Three explain fixtures were re-judged to resolve in `org1`, where the scoped set applies. - **Ablations**, each through `scripts/ablation-replace.mjs` in WRAP mode, with a script-level `trap` restore on the absolute path. Core resolves from `src` in both the core and runtime suites, and `explain-engine` is imported relatively, so no `dist/` leg applies. Each is labelled with the source state it was measured at. 1. **Re-run at `4e3e4f5e4`** (resolver blob `1f0d2889e626`, which includes §6a). The predicate was put back to the old skip condition. Anchor 1 to 0, blob `1f0d2889e626` to `a03a16f630a3`. - **Red:** 5 core pins (§4/§6 with no tenant; §6a with no tenant; `u_ex`; `u_gone`; cache on) and the 6 removed-member door pins (DELETE and disable, for a2, a2b and the position-bound arm). - **Green, 7 door pins:** both controls, the global grant, the platform admin, the org-less no-grant caller, and the claim-drop proof. - Restored: blob == HEAD, `git diff HEAD` empty. - The first round's run of this ablation was taken before §6a landed (blob `490bd8a377af`) and is superseded. 2. Measured before the first merge; `92716c91af53` is still `explain-engine.ts`'s blob at `4e3e4f5e4`. `buildContextForUser` stopped passing its tenant. Blob `92716c91af53` to `372ec2454029`. **Red:** 6 pins, which are the three re-judged fixtures, explain-in-an-organization, delegator-in-`org_alpha` and the route caller-in-`org_alpha`. Restored and proven the same way. 3. Measured before the first merge; `1f0d2889e626` is still the resolver's blob at `4e3e4f5e4`. The §6a predicate was replaced by a filter that keeps every row. Blob `1f0d2889e626` to `404c23da10ec`. **Red:** both §6a core pins and 4 door pins: the position-bound arm, plus the a2 arm, whose `org_beta` membership also reaches `org_alpha`'s `org_member` binding. Restored and proven the same way. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `4e3e4f5e4` derived 72 commands. That is 68, plus four `@objectstack/spec` families: `check:empty-state`, `check:liveness`, `check:strictness-ledger` and `check:variant-docs`. All 72 were run with exit codes recorded before any pipe, and all ended 0. - `check:type-check-debt` first exited 3 (`PREREQUISITE NOT MET`): ablation 1's restore left core's source newer than its `dist/`. Core was rebuilt and the gate re-run: 0. - `--ran`: `72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN`. - **Lint, a declared narrowing:** `eslint --no-inline-config --format json` over the 10 changed `.ts` files at `4e3e4f5e4` gave 10 files, 0 errors, 0 warnings. - The population is `eslint.config.mjs`'s `**/*.{ts,...}` object minus `NEVER_LINTED` and `packages/spec/**`, and all 10 files are in it. - The config enables no type-aware linting (no `parserOptions.project`, as the config itself states), so this diff cannot move any untouched file's verdict. The full `pnpm lint` is CI's. ## Acceptance notes - **Review nit ①5, not carried:** `explainAccessForCaller` reads `tenantId` where `resolvePermissionSetsForContext` reads `organizationId ?? tenantId`. Patch round 1 does not otherwise touch `security-plugin.ts`, so it is left as reviewed. - **Explain ≠ enforce for a removed member explained from the left organization** (H3, second row). This is the #20431 family, reported and not fixed. The explained user is resolved in the caller's organization, without the membership check the session arm applies. - **§6a no-tenant page cap:** the organization-less `sys_position` read is installation-wide and capped at 200 rows, so with many organizations the organization-less rows can fall outside the page. That was already true before this change; the predicate only decides which of the returned rows apply. - **The position-name fold with no tenant** (`resolvePermissionSetsForContext` requesting position names as permission-set names, loaded through `dbLoaderForContext`) is a separate seam. NOT MEASURED here. - **`group` posture:** a principal with no active organization keeps a data wall spanning every member organization, but it now holds no organization-scoped grant until one is active. That is the ruling; it is named here because it is the most visible population. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 487a784 commit f6ceddc

11 files changed

Lines changed: 907 additions & 37 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/plugin-security": minor
4+
---
5+
6+
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`).
7+
8+
**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.
9+
10+
- **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.
11+
- **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.
12+
- **No "every organization" mode.** No option asks the resolver for every organization's grants, and nothing falls back to that reading.
13+
- **`@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.
14+
15+
Clause-②: yes (narrowing)
16+
17+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes moves: no `packages/spec` schema, key or export changes, and no stored row changes shape, so `objectstack migrate meta` has nothing to rewrite and no conversion entry has anything to convert. What narrows is which already-stored grant rows apply to a resolution that names no organization; the remedy is operational (act in the organization, or grant globally) and is stated above. The other categories are closed on facts: both packages publish (not `unpublished`); no ADR-0087 id covers this (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration alone (not `runtime-interface-only` / `type-surface-only`). -->

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

Lines changed: 235 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4-
import { resolveAuthzContext, resolveUserAuthzGrants, resolveLocalizationContext } from './resolve-authz-context.js';
4+
import { hasPlatformAdminStanding, resolveAuthzContext, resolveUserAuthzGrants, resolveLocalizationContext } from './resolve-authz-context.js';
55
import { POSTURE_RANK } from './posture-ladder.js';
66
import { hashApiKey } from './api-key.js';
77
import type { AuthzPosture } from '@objectstack/spec/security';
@@ -1831,3 +1831,237 @@ describe('[#15409] a session organization claim that no membership backs', () =>
18311831
expect(dropLines()).toHaveLength(0);
18321832
});
18331833
});
1834+
1835+
/**
1836+
* [#20515] A resolution with NO active organization applies only the GLOBAL
1837+
* grants (`organization_id` null). A grant scoped to an organization applies
1838+
* only while that organization is the active tenant — §4 (`sys_user_position`)
1839+
* and §6 (`sys_user_permission_set`) ask the one predicate, so they cannot
1840+
* disagree.
1841+
*
1842+
* The population this was measured on: a member removed from an organization
1843+
* whose session still names it. The session arm drops the claim (#15409 ruling
1844+
* B) and re-resolves with no tenant; the old skip condition,
1845+
* `org && tenantId && org !== tenantId`, was false for every row once
1846+
* `tenantId` was undefined, so a permission set granted SCOPED to the
1847+
* organization the member left went on conferring `manage_metadata` with no
1848+
* organization boundary at all. The door-level half (403 at
1849+
* `DELETE /packages/:id`) is pinned in `packages/runtime`
1850+
* (`packages-orgless-grants-capability-gate.test.ts`).
1851+
*/
1852+
describe('[#20515] with no active organization, only global grants apply', () => {
1853+
const ALPHA = 'org_alpha';
1854+
const BETA = 'org_beta';
1855+
1856+
/** One set and one position per scope: global, alpha, beta. */
1857+
const grantRows = (userId: string) => ({
1858+
sys_user_position: [
1859+
{ user_id: userId, position: 'global_position', organization_id: null },
1860+
{ user_id: userId, position: 'alpha_position', organization_id: ALPHA },
1861+
{ user_id: userId, position: 'beta_position', organization_id: BETA },
1862+
],
1863+
sys_user_permission_set: [
1864+
{ user_id: userId, permission_set_id: 'ps_global', organization_id: null },
1865+
{ user_id: userId, permission_set_id: 'ps_alpha', organization_id: ALPHA },
1866+
{ user_id: userId, permission_set_id: 'ps_beta', organization_id: BETA },
1867+
],
1868+
sys_permission_set: [
1869+
{ id: 'ps_global', name: 'global_set', system_permissions: ['global_cap'] },
1870+
{ id: 'ps_alpha', name: 'alpha_set', system_permissions: ['manage_metadata'] },
1871+
{ id: 'ps_beta', name: 'beta_set', system_permissions: ['beta_cap'] },
1872+
],
1873+
});
1874+
1875+
/** What §4 and §6 each let through, read off one resolution. */
1876+
const applied = (g: { positions: string[]; permissions: string[]; systemPermissions: string[] }) => ({
1877+
positions: g.positions.filter((p) => p.endsWith('_position')).sort(),
1878+
permissions: [...g.permissions].sort(),
1879+
systemPermissions: [...g.systemPermissions].sort(),
1880+
});
1881+
1882+
it('no tenant: §4 and §6 both keep the global rows and NOTHING scoped to an organization', async () => {
1883+
const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], ...grantRows('u1') });
1884+
const grants = await resolveUserAuthzGrants(ql, 'u1', {});
1885+
expect(applied(grants)).toEqual({
1886+
positions: ['global_position'],
1887+
permissions: ['global_set'],
1888+
systemPermissions: ['global_cap'],
1889+
});
1890+
});
1891+
1892+
it('a tenant: §4 and §6 both keep the global rows plus THAT organization\'s, never another\'s', async () => {
1893+
const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], ...grantRows('u1') });
1894+
expect(applied(await resolveUserAuthzGrants(ql, 'u1', { tenantId: ALPHA }))).toEqual({
1895+
positions: ['alpha_position', 'global_position'],
1896+
permissions: ['alpha_set', 'global_set'],
1897+
systemPermissions: ['global_cap', 'manage_metadata'],
1898+
});
1899+
expect(applied(await resolveUserAuthzGrants(ql, 'u1', { tenantId: BETA }))).toEqual({
1900+
positions: ['beta_position', 'global_position'],
1901+
permissions: ['beta_set', 'global_set'],
1902+
systemPermissions: ['beta_cap', 'global_cap'],
1903+
});
1904+
});
1905+
1906+
/**
1907+
* §6a — position-bound sets. Under a walled posture the catalog holds one
1908+
* copy of each built-in position PER ORGANIZATION (`everyone`, `org_member`,
1909+
* …), and an organization binds its own sets to its own copies. With no
1910+
* tenant the `sys_position` read is installation-wide, so every
1911+
* organization's copy of a name the caller holds used to feed its bindings in.
1912+
* This double ignores the context's tenant exactly as that read does.
1913+
*/
1914+
describe('§6a: a position row scoped to an organization binds nothing with no tenant', () => {
1915+
const catalog = () => ({
1916+
sys_position: [
1917+
{ id: 'pos_member_alpha', name: 'org_member', organization_id: ALPHA },
1918+
{ id: 'pos_member_beta', name: 'org_member', organization_id: BETA },
1919+
{ id: 'pos_everyone_alpha', name: 'everyone', organization_id: ALPHA },
1920+
{ id: 'pos_everyone_global', name: 'everyone', organization_id: null },
1921+
],
1922+
sys_position_permission_set: [
1923+
{ position_id: 'pos_member_alpha', permission_set_id: 'ps_alpha_members' },
1924+
{ position_id: 'pos_everyone_alpha', permission_set_id: 'ps_alpha_everyone' },
1925+
{ position_id: 'pos_everyone_global', permission_set_id: 'ps_global_everyone' },
1926+
],
1927+
sys_permission_set: [
1928+
{ id: 'ps_alpha_members', name: 'alpha_member_tools', system_permissions: ['manage_metadata'] },
1929+
{ id: 'ps_alpha_everyone', name: 'alpha_everyone_extra' },
1930+
{ id: 'ps_global_everyone', name: 'global_everyone_default' },
1931+
],
1932+
});
1933+
/** Removed from alpha, still an `org_member` of beta — the role name alpha bound its set to. */
1934+
const exMember = () => makeQl({
1935+
sys_user: [{ id: 'u_ex' }],
1936+
sys_member: [{ user_id: 'u_ex', organization_id: BETA, role: 'member' }],
1937+
sys_user_position: [],
1938+
sys_user_permission_set: [],
1939+
...catalog(),
1940+
});
1941+
1942+
it('no tenant: neither alpha\'s org_member binding nor alpha\'s everyone binding applies; the global everyone binding does', async () => {
1943+
const grants = await resolveUserAuthzGrants(exMember(), 'u_ex', {});
1944+
expect(grants.positions).toContain('org_member');
1945+
expect([...grants.permissions].sort()).toEqual(['global_everyone_default']);
1946+
expect(grants.systemPermissions).not.toContain('manage_metadata');
1947+
});
1948+
1949+
it('in beta: alpha\'s bindings still do not apply; in alpha (a current member there): they do', async () => {
1950+
const inBeta = await resolveUserAuthzGrants(exMember(), 'u_ex', { tenantId: BETA });
1951+
expect([...inBeta.permissions].sort()).toEqual(['global_everyone_default']);
1952+
const alphaMember = makeQl({
1953+
sys_user: [{ id: 'u_in' }],
1954+
sys_member: [{ user_id: 'u_in', organization_id: ALPHA, role: 'member' }],
1955+
sys_user_position: [],
1956+
sys_user_permission_set: [],
1957+
...catalog(),
1958+
});
1959+
const inAlpha = await resolveUserAuthzGrants(alphaMember, 'u_in', { tenantId: ALPHA });
1960+
expect([...inAlpha.permissions].sort()).toEqual(['alpha_everyone_extra', 'alpha_member_tools', 'global_everyone_default']);
1961+
expect(inAlpha.systemPermissions).toContain('manage_metadata');
1962+
});
1963+
});
1964+
1965+
describe('through the session arm: the removed member whose claim is dropped', () => {
1966+
/**
1967+
* `u_ex` was removed from `org_alpha` and is still a member of `org_beta`;
1968+
* `u_gone` has no membership left anywhere; both still hold the operator-
1969+
* authored `manage_metadata` set granted SCOPED to `org_alpha`, which the
1970+
* removal did not revoke. `u_member` is the control: a current `org_alpha`
1971+
* member holding the same grant. `u_global_ex` is removed the same way but
1972+
* holds the set GLOBALLY — a global grant is untouched by this card.
1973+
*/
1974+
const tables = () => ({
1975+
sys_user: ['u_ex', 'u_gone', 'u_member', 'u_global_ex'].map((id) => ({ id, email: `${id}@x.com` })),
1976+
sys_member: [
1977+
{ user_id: 'u_ex', organization_id: BETA, role: 'member' },
1978+
{ user_id: 'u_member', organization_id: ALPHA, role: 'member' },
1979+
{ user_id: 'u_global_ex', organization_id: BETA, role: 'member' },
1980+
],
1981+
sys_user_position: [],
1982+
sys_user_permission_set: [
1983+
{ user_id: 'u_ex', permission_set_id: 'ps_meta', organization_id: ALPHA },
1984+
{ user_id: 'u_gone', permission_set_id: 'ps_meta', organization_id: ALPHA },
1985+
{ user_id: 'u_member', permission_set_id: 'ps_meta', organization_id: ALPHA },
1986+
{ user_id: 'u_global_ex', permission_set_id: 'ps_meta', organization_id: null },
1987+
],
1988+
sys_permission_set: [{ id: 'ps_meta', name: 'alpha_metadata_editors', system_permissions: ['manage_metadata'] }],
1989+
});
1990+
const namingAlpha = (userId: string) => async () => ({
1991+
user: { id: userId, email: `${userId}@x.com` },
1992+
session: { id: `ses_${userId}`, token: 'tok', userId, activeOrganizationId: ALPHA },
1993+
});
1994+
1995+
let warnSpy: ReturnType<typeof vi.spyOn>;
1996+
beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); });
1997+
afterEach(() => { warnSpy.mockRestore(); });
1998+
1999+
for (const userId of ['u_ex', 'u_gone']) {
2000+
it(`${userId}: the claim is dropped AND the left organization's grant no longer applies`, async () => {
2001+
const ctx = await resolveAuthzContext({
2002+
ql: makeQl(tables()), headers: H(), getSession: namingAlpha(userId), tenancyPosture: 'isolated',
2003+
});
2004+
expect(ctx.userId).toBe(userId);
2005+
expect(ctx.tenantId).toBeUndefined();
2006+
expect(ctx.systemPermissions).not.toContain('manage_metadata');
2007+
expect(ctx.permissions).not.toContain('alpha_metadata_editors');
2008+
});
2009+
}
2010+
2011+
it('CONTROL · a current member with the same grant and that organization active keeps it', async () => {
2012+
const ctx = await resolveAuthzContext({
2013+
ql: makeQl(tables()), headers: H(), getSession: namingAlpha('u_member'), tenancyPosture: 'isolated',
2014+
});
2015+
expect(ctx.tenantId).toBe(ALPHA);
2016+
expect(ctx.systemPermissions).toContain('manage_metadata');
2017+
expect(ctx.permissions).toContain('alpha_metadata_editors');
2018+
});
2019+
2020+
it('a GLOBAL grant is unchanged: the removed member still holds it with no active organization', async () => {
2021+
const ctx = await resolveAuthzContext({
2022+
ql: makeQl(tables()), headers: H(), getSession: namingAlpha('u_global_ex'), tenancyPosture: 'isolated',
2023+
});
2024+
expect(ctx.tenantId).toBeUndefined();
2025+
expect(ctx.systemPermissions).toContain('manage_metadata');
2026+
});
2027+
});
2028+
2029+
describe('platform-admin standing is unchanged — it was only ever derived from an UNSCOPED grant', () => {
2030+
const POSTURE_ENV = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_PLATFORM_OWNER_EMAIL'] as const;
2031+
const saved: Record<string, string | undefined> = {};
2032+
beforeEach(() => {
2033+
for (const k of POSTURE_ENV) { saved[k] = process.env[k]; delete process.env[k]; }
2034+
});
2035+
afterEach(() => {
2036+
for (const k of POSTURE_ENV) {
2037+
if (saved[k] === undefined) delete process.env[k];
2038+
else process.env[k] = saved[k];
2039+
}
2040+
});
2041+
2042+
/** The row `bootstrapPlatformAdmin` mints: `admin_full_access`, organization null. */
2043+
const adminTables = (organizationId: string | null) => ({
2044+
sys_user: [{ id: 'u_admin', email: 'admin@x.com' }],
2045+
sys_member: [{ user_id: 'u_admin', organization_id: ALPHA, role: 'owner' }],
2046+
sys_user_position: [],
2047+
sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_admin', organization_id: organizationId }],
2048+
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata'] }],
2049+
});
2050+
2051+
it('the bootstrap-shaped UNSCOPED grant: PLATFORM_ADMIN with no tenant and with one', async () => {
2052+
const ql = makeQl(adminTables(null));
2053+
expect(await hasPlatformAdminStanding(ql, 'u_admin')).toBe(true);
2054+
const orgless = await resolveUserAuthzGrants(ql, 'u_admin', {});
2055+
expect(orgless.posture).toBe('PLATFORM_ADMIN');
2056+
expect(orgless.systemPermissions).toContain('manage_metadata');
2057+
expect((await resolveUserAuthzGrants(ql, 'u_admin', { tenantId: ALPHA })).posture).toBe('PLATFORM_ADMIN');
2058+
});
2059+
2060+
it('an ORG-scoped admin_full_access grant confers no platform standing, with or without that tenant', async () => {
2061+
const ql = makeQl(adminTables(ALPHA));
2062+
expect(await hasPlatformAdminStanding(ql, 'u_admin')).toBe(false);
2063+
expect((await resolveUserAuthzGrants(ql, 'u_admin', {})).posture).not.toBe('PLATFORM_ADMIN');
2064+
expect((await resolveUserAuthzGrants(ql, 'u_admin', { tenantId: ALPHA })).posture).not.toBe('PLATFORM_ADMIN');
2065+
});
2066+
});
2067+
});

0 commit comments

Comments
 (0)