Skip to content

Commit 889139c

Browse files
fix(plugin-security,core): security/explain resolves the user it explains through enforcement's organization-claim check (#20580) (#20614)
Fixes #20580 Clause-②: yes (widening) ## What was wrong When an administrator explains another user's access, the explanation is computed in the administrator's own organization (#20515). Enforcement does one more thing for that same user first. Under a walled tenancy posture (`isolated`, `group`) it vets the organization the user's session claims: a claim that no current membership backs is dropped, and the user resolves with no active organization, so only their global grants apply (#15409, ruling B). The explainer never ran that check. For a user whose membership in the administrator's organization had ended, the explanation listed that organization's grants, and the verdicts they decide, while enforcement applied none of them. Enforcement was correct throughout; the explanation was wrong. ## What changes - **`@objectstack/core`:** the session arm's check becomes one exported function, `vetOrganizationClaim(claimedOrganizationId, accessibleOrgIds, tenancyPosture)`. It returns the claim while a current membership backs it, or while no wall is enforced, and `undefined` once the claim is dropped. `resolveAuthzContext`'s session arm now asks it through the identical boolean. There is no behaviour change: the 11 existing session-arm tests in `resolve-authz-context.test.ts` pass unchanged. - **`@objectstack/plugin-security`:** `explainAccessForCaller` asks `vetOrganizationClaim` about the explained user (their `accessible_org_ids`, and the plugin's tenancy posture) and resolves them in the organization it returns. The explainer spells no membership rule of its own. - `buildContextForUser`'s signature is unchanged. Its doc now says who vets the organization it is handed. **Landing point:** `security-plugin.ts`, as expected. The check was reachable only through a new core export. The only other exported path that runs it, `resolveAuthzContext` itself, would skip the explainer's ruled grants-cache bypass and would write a false "session claim dropped" log line. ## Evidence **Pin:** `packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts`. - **Rig:** a real `ObjectQL` on better-sqlite3 and on sqlite-wasm, the real platform objects and the real `SecurityPlugin`. - **Two faces:** every case compares the explanation with enforcement's own answer for the same principal over the same rows. Enforcement's face is `resolveAuthzContext` with a session that claims `org_alpha`, then `assembleExecutionContext`, then a `find` through the middleware. - **Walled (`isolated`, `group`):** the removed member's explanation lists no `org_alpha`-scoped set, the same sets enforcement resolves for them. Its `object_crud` verdict is `denies`, where their own read is refused 403 `PERMISSION_DENIED`. - **KEEP:** a current member's explanation still lists the set, as enforcement resolves it, and the read is granted on both faces. - **CONTROL, `single`:** there is no wall, so the claim stands on both faces. - **Result at HEAD:** 22 passed of 22. **Ablation.** The fix was committed first. `scripts/ablation-replace.mjs` ran it with EXIT, INT and TERM restore, plus a shell trap on an absolute path. - **Mutation:** the vetted organization was replaced by the caller's organization, unvetted. That is the pre-fix resolution. - **On disk:** anchor count 1 went to 0, and the marker count was 1 during the run. - **Result:** 8 failed, 14 passed (of 22). All 8 red cases are the removed-member pins (2 drivers, 2 walled postures, 2 pins each). KEEP, the precondition and the `single` control stayed green. - **Quoted:** AssertionError: expected [ 'member_default', 'qa_probe_editor' ] to not include 'qa_probe_editor' AssertionError: expected 'grants' to be 'denies' // Object.is equality - **Restore:** blob equals HEAD (`6a86472402fd`), and `git diff HEAD` is empty. - **First attempt:** the tool refused it before anything ran. The replacement text already occurred on disk, so its count could not rise. Nothing was measured; the rerun used a unique marker. ## Local verification Recorded at HEAD `bd7b46777`. Core's test and typecheck ran at `cf18f3e99`; the diff from there to HEAD touches only the plugin-security pin file. - `pnpm --filter @objectstack/plugin-security test`: 146 files, 3098 passed, 23 skipped. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. `check:test-typecheck` OK. - `pnpm --filter @objectstack/core test`: 56 files, 1522 passed. `vitest run --project local src/security/resolve-authz-context.test.ts`: 102 passed, including the 4 new `vetOrganizationClaim` contract cases. - `pnpm --filter @objectstack/core test:repo`: 3 files, 48 passed. - `pnpm --filter @objectstack/core typecheck`: exit 0. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands (the 51 derived at dispatch, plus 16 for the changeset and test-layer kinds). Each exit code was captured before any pipe, and `--ran` reconciled them: 64 exit 0, 3 NOT MEASURED, 0 unrun. - **NOT MEASURED:** `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`. Each exited 3 with PREREQUISITE NOT MET, because each needs most of the monorepo built, and the core change invalidates the build cache for nearly every package downstream of it. CI builds that closure before these steps. - **NOT MEASURED:** two runtime suites that exercise the session arm end to end (`packages-orgless-grants-capability-gate`, `packages-vetted-org-source`). Runtime's dependency closure was unbuilt here, so vitest reported "Failed to resolve entry for package" and ran no test. They are declared to CI. - **Lint, narrowed and measured:** - Population: `eslint.config.mjs`'s `files` globs, the ts and js family minus `NEVER_LINTED`. `--print-config` resolves a config for all 6 touched `.ts` files; the changeset `.md` matches no glob. - Count: `--no-inline-config --format json` over those 6 files, with 0 errors and 0 warnings. - Invariance: the config enables no type-aware linting (no `parserOptions.project`, no `projectService`). So this diff cannot move a verdict on an untouched file. - **Additive export:** `vetOrganizationClaim` has no other occurrence in the repository. The two star re-exporters of core (`runtime`, `plugin-hono-server`) declare no such name. ## Acceptance notes - **Surface widening (for the seat to re-judge `Clause-②`).** `@objectstack/core` gains one export, `vetOrganizationClaim`, with no behaviour change. The changeset grades core `minor` and plugin-security `patch`. The line above is copied from the claim as it stands. - **A separate explain-versus-enforce position, measured and left alone here.** The explained user's context carries no organization of its own: `buildContextForUser` returns none, and `explainAccessForCaller` sets none. Measured at `c96beb27`, better-sqlite3, one current member of the administrator's organization: - Under `isolated`, Layer 0 answers deny on a tenant object: the explanation says `allowed: false` with the fail-closed filter, while that member's own read is admitted. - Under every posture, a permission set authored in the database and scoped to that organization does not load for the explained user, while enforcement loads it. - Fixing it (set the explained context's organization to the vetted one, as `resolveDelegatorContext` does for a delegator) changes a current member's explanation, which this card's ruled pin keeps unchanged. It is reported to the seat for #20604, the explain-versus-enforce family card. - **Observation, not measured.** With no `tenancy` service registered, admission hands the resolver no posture (no claim is ever dropped), while plugin-security probes `org-scoping` and can resolve `isolated`. The explainer reads the plugin's posture. In that composition the two could disagree. Both read the same `tenancy` service when it is registered. ## Seat append (domain:services seat, `session_01XY5uCwTjZj7884yYtyur4H`) - The `Clause-②` line above was corrected from `no` to `yes (widening)` by the seat, following the dev's deviation 2. `@objectstack/core` gains one export, `vetOrganizationClaim`, and the changeset already grades core `minor`. The claim was corrected in place in the same act. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aa23e2c commit 889139c

7 files changed

Lines changed: 432 additions & 6 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/core': minor
4+
---
5+
6+
fix(plugin-security): `security/explain` resolves the user it explains in the organization enforcement resolves them in, so a member whose membership in the caller's organization has ended is no longer explained holding that organization's grants (#20580)
7+
8+
When an administrator explains another user, the explanation is computed in the administrator's own organization. Enforcement does one more thing for that same user first: under a walled tenancy posture (`isolated` or `group`), it drops an organization claim that no current membership backs, and the user resolves with no active organization, so only their global grants apply. The explainer skipped that check. For a user whose membership in the administrator's organization had ended, the explanation listed that organization's grants, and the verdicts they decide, while enforcement applied none of them.
9+
10+
The explainer now asks the same check before it resolves the user, and resolves them where it says. `@objectstack/core` exports that check as `vetOrganizationClaim(claimedOrganizationId, accessibleOrgIds, tenancyPosture)`. It returns the claimed organization while a current membership backs it or while no wall is enforced, and `undefined` once the claim is dropped. `resolveAuthzContext` asks the same function for a session's claim, so the two cannot disagree. This is a new export with no behaviour change to `resolveAuthzContext`.
11+
12+
Unchanged:
13+
14+
- Enforcement admits and refuses exactly what it did before.
15+
- A current member's explanation.
16+
- The `single` posture, where no claim is dropped on either side.
17+
- Explaining yourself, and a caller with no active organization.

‎packages/core/src/security/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ export {
137137
// surface that only knows a user id asks this instead of re-reading
138138
// `sys_*_permission_set` — the prohibition resolve-authz-context.ts states.
139139
hasPlatformAdminStanding,
140+
// [#20580] The session arm's membership check (#15409 ruling B), so the
141+
// permission explainer resolves the user it explains through the same one.
142+
vetOrganizationClaim,
140143
resolveLocalizationContext,
141144
type ResolvedAuthzContext,
142145
type ResolveAuthzInput,

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

Lines changed: 33 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 { hasPlatformAdminStanding, resolveAuthzContext, resolveUserAuthzGrants, resolveLocalizationContext } from './resolve-authz-context.js';
4+
import { hasPlatformAdminStanding, resolveAuthzContext, resolveUserAuthzGrants, resolveLocalizationContext, vetOrganizationClaim } 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';
@@ -1832,6 +1832,38 @@ describe('[#15409] a session organization claim that no membership backs', () =>
18321832
});
18331833
});
18341834

1835+
/**
1836+
* [#20580] The session arm's check, as the one exported function a second
1837+
* reader (plugin-security's permission explainer) asks about the user it
1838+
* explains. The session arm's own behaviour is pinned end to end above; this
1839+
* block pins the function's contract, so a change to either reader's answer
1840+
* goes through here.
1841+
*/
1842+
describe('[#20580] vetOrganizationClaim — the organization a claim resolves in', () => {
1843+
const HELD = ['org_beta', 'org_gamma'];
1844+
1845+
it('walled (`isolated`, `group`): a claim no current membership backs is dropped', () => {
1846+
expect(vetOrganizationClaim('org_alpha', HELD, 'isolated')).toBeUndefined();
1847+
expect(vetOrganizationClaim('org_alpha', HELD, 'group')).toBeUndefined();
1848+
expect(vetOrganizationClaim('org_alpha', [], 'isolated')).toBeUndefined();
1849+
});
1850+
1851+
it('walled: a claim a current membership backs stands as made', () => {
1852+
expect(vetOrganizationClaim('org_beta', HELD, 'isolated')).toBe('org_beta');
1853+
expect(vetOrganizationClaim('org_gamma', HELD, 'group')).toBe('org_gamma');
1854+
});
1855+
1856+
it('no wall (`single`, or no posture supplied): the claim stands whatever the memberships', () => {
1857+
expect(vetOrganizationClaim('org_alpha', HELD, 'single')).toBe('org_alpha');
1858+
expect(vetOrganizationClaim('org_alpha', [], undefined)).toBe('org_alpha');
1859+
});
1860+
1861+
it('no claim resolves in no organization', () => {
1862+
expect(vetOrganizationClaim(undefined, HELD, 'isolated')).toBeUndefined();
1863+
expect(vetOrganizationClaim('', HELD, 'single')).toBeUndefined();
1864+
});
1865+
});
1866+
18351867
/**
18361868
* [#20515] A resolution with NO active organization applies only the GLOBAL
18371869
* grants (`organization_id` null). A grant scoped to an organization applies

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

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -529,12 +529,13 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
529529
// Session-only by construction: an admitted API key sets `userId`, which
530530
// makes the session branch above unreachable, so a non-empty `tenantId` here
531531
// with no `keyPrincipal` can only have come from the session claim.
532+
//
533+
// [#20580] The check itself is {@link vetOrganizationClaim}, so the
534+
// permission explainer asks the same question about the user it explains.
532535
if (
533536
!keyPrincipal
534537
&& tenantId
535-
&& input.tenancyPosture
536-
&& postureEnforcesWall(input.tenancyPosture)
537-
&& !grants.accessible_org_ids.includes(tenantId)
538+
&& vetOrganizationClaim(tenantId, grants.accessible_org_ids, input.tenancyPosture) === undefined
538539
) {
539540
// [#15256 / 2A, mirrored] The one decision point where the drop is decided.
540541
warnSessionOrganizationClaimDropped({
@@ -565,6 +566,34 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
565566
return ctx;
566567
}
567568

569+
/**
570+
* [#15409 — maintainer ruling 2026-09-05, option B] The organization a
571+
* principal that CLAIMS `claimedOrganizationId` is resolved in: the claim
572+
* itself while a current membership backs it, `undefined` once it does not.
573+
*
574+
* This is the session arm of {@link resolveAuthzContext}, as one function.
575+
* Under a wall-enforcing posture (`isolated`, `group`) a claim on an
576+
* organization absent from `accessibleOrgIds` (the principal's
577+
* {@link UserAuthzGrants.accessible_org_ids}, which does not depend on the
578+
* organization the grants were resolved in) is dropped, and the principal
579+
* resolves with NO active organization. Under `single`, or with no posture
580+
* supplied, there is no wall and the claim stands as made.
581+
*
582+
* [#20580] A second reader asks it: the permission explainer, about the user
583+
* it explains in the caller's organization. That is how `security/explain`
584+
* resolves that user in the organization enforcement would resolve them in.
585+
* ⛔ Nothing else spells this rule — a caller that needs it calls this.
586+
*/
587+
export function vetOrganizationClaim(
588+
claimedOrganizationId: string | undefined,
589+
accessibleOrgIds: readonly string[],
590+
tenancyPosture: TenancyPosture | undefined,
591+
): string | undefined {
592+
if (!claimedOrganizationId) return undefined;
593+
if (!tenancyPosture || !postureEnforcesWall(tenancyPosture)) return claimedOrganizationId;
594+
return accessibleOrgIds.includes(claimedOrganizationId) ? claimedOrganizationId : undefined;
595+
}
596+
568597
/** The authorization grants a KNOWN user holds — a subset of {@link ResolvedAuthzContext}. */
569598
export interface UserAuthzGrants {
570599
positions: string[];

‎packages/plugins/plugin-security/src/explain-engine.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,10 @@ async function collectGrantProvenance(
568568
* very resolution that kept a removed member's grants. Each caller passes the
569569
* organization it is explaining in — the live principal's for a delegator
570570
* ({@link resolveDelegatorContext}), the caller's own for the explain API.
571+
* [#20580] The explain API passes the caller's organization only once
572+
* `vetOrganizationClaim` (`@objectstack/core`) has let it stand for the
573+
* explained user, as enforcement does for that user's session claim: this
574+
* function passes on what it is handed and vets nothing itself.
571575
* Omitted, the context is the user with NO active organization. The returned
572576
* context still carries no `tenantId` of its own; a caller that needs one sets
573577
* it, as {@link resolveDelegatorContext} does.

0 commit comments

Comments
 (0)