fix(core,plugin-security)!: a grants resolution with no active organization applies only global grants (#20515) - #20540
Conversation
… 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 <noreply@anthropic.com>
…n arm and the cache Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…nts 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 <noreply@anthropic.com>
…egator leg resolving in an organization Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…ember's organization-scoped manage_metadata Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
… bindings from answer to the same rule With no tenant the sys_position read is installation-wide, so every organization's copy of a held position name fed its bindings in. Each row now answers grantAppliesInTenant, a no-op when a tenant is given. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
… global grants Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…ility 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 34 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3490b2eb34b79ee6105cd8ebf8caac53985aa2de && git checkout 3490b2eb34b79ee6105cd8ebf8caac53985aa2de
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ba5927f714af7516105706b36a05cedf34d5fa1b 4e3e4f5e43bfcc8814dff9700a914f5eec19aae1 && git checkout -B drift-repro ba5927f714af7516105706b36a05cedf34d5fa1b && git merge --no-ff 4e3e4f5e43bfcc8814dff9700a914f5eec19aae1
node scripts/docs-audit/affected-docs.mjs --json ba5927f714af7516105706b36a05cedf34d5fa1b
|
|
Contract reviewServed-tier: Inputs read: card #20515 (body and all 4 comments: triage 5879424995, claim 5880440445, os-dev-report 5881640095, seat answer 5881665885); PR #20540 (body, 10-file list, net diff against the merge-base ① Derived judgments1.
2. Platform-admin standing — RIGHT, unchanged.
3. Every no-tenant caller in
4. Grants cache — RIGHT. 5. The explainer, option (b) — RIGHT; the residual is pre-existing.
6. The door pins (
7. The ② Semver level
③ Boundary flagsThe four deviations, answered.
The open question — A. Confirmed at ②. The four out-of-scope notes.
PR-body sentences judged (everything not named here is TRUE against the head and the inputs).
Check-runs on
What the verdict rests on. The code judgments in ① and ② are all RIGHT: the rule is stated once and true for every row shape, no principal gains or keeps a grant it should not have, platform-admin standing and global grants are unchanged, the pins are real, the fixture edit is faithful, the changeset is honest. The head is nevertheless not green: a derived gate family on this exact head concluded Implemented-by: VERDICT: FAIL |
…g 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: DELTA review on the review of record 5881827545 (FAIL on ① Derived judgments1. What moved (the delta census) — exactly two commits. 2. The merge is clean and no hunk was lost — RIGHT.
3.
4.
5. Docs (the five pages the docs-drift bot lists; none edited by the PR; each listed for the
6. PR body — the three sentences 5881827545 found PARTLY FALSE, re-judged, and every sentence added this round.
② Semver level
③ Boundary flagsPatch-round os-dev-report 5882332070, deviations — all answered.
Carriers from round 1 (5881827545 ③): unchanged. The explain ≠ enforce residual (#20431 class, pre-existing in shape), the §6a no-tenant page cap, the position-name fold with no tenant (escalated as a follow-up measurement card), and the §3 The flag raised by this record's first rendering — the PR body's verification parenthetical — is closed. The seat corrected the one sentence through a relay Check-runs on
What the verdict rests on. Every judgment in ① and ② is RIGHT: the merge is clean and lossless, the dogfood fixture keeps every original assertion and pins both faces of the rule, the three probe-only cells are real arms through the real resolver, no docs sentence is false, the changeset is unchanged and honest, and the one PR-body sentence found partly false is corrected and now TRUE. Every check-run on this head is concluded and green or skipped by roster; no derived gate family concluded Implemented-by: VERDICT: PASS |
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 predicategrantAppliesInTenant: 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:
sys_user_position(the triage's:802).sys_user_permission_set(the triage's:829).sys_positionrows 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):resolveDelegatorContextresolves the on-behalf-of delegator in the live principal's organization. That is an enforcement input: the D10 intersection.explainAccessForCallerresolves an explained user in the caller's organization.No second check was added to
requireManageMetadataor to any other door. Theplugin-sharingadminOrgScopeguard 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,resolveRequestScopeintoresolveExecutionContextintoresolveAuthzContext, under anisolatedposture). The base is unmodified397572ed5, which already includes PR #20514. The "after" column is the committed pin filepackages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts: every cell of it has an arm there, and the file is green at4e3e4f5e4. Patch round 1 added the three arms that were probe-only readings atc9e6463ce: the control atPATCH disable, the platform admin withorg_alphaactive atPATCH disable, and the org-less no-grant row.The gate is observable at two doors:
PATCH /packages/:id/disableis 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 fix(runtime): DELETE /packages/:id refuses an org-less uninstall before it touches the registry (#20492) #20514, an org-less caller who passes the gate reaches the door's own organization check (400TENANT_SCOPE_REQUIRED). A caller refused by the gate gets 403.org_alpha)org_alpha, still inorg_beta, holds anorg_alpha-scopedmanage_metadataset (a2)TENANT_SCOPE_REQUIRED(gate passed)PERMISSION_DENIEDorg_alphamember, same grant,org_alphaactiveadmin_full_access),org_alphaactiveThe base readings come from a throwaway probe at
397572ed5; the first "after" reading was taken at514e681cd, and the committed file reproduces every "after" cell. The committed file adds a third removed-member arm:org_alphabound the set to its own copy oforg_member, and the member is still anorg_memberinorg_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/**, atc9e6463ce)resolveAuthzContextfirst resolution (resolve-authz-context.ts:428)active_organization_id, else the session'sactiveOrganizationIdgroup-posture principals with no active organization: their data wall still spans every member organization, but their organization-scoped grants now need that organization active.resolveAuthzContextdropped-claim re-resolution (:548)hasPlatformAdminStanding(:1184,{ nowMs }only)PLATFORM_ADMINderives only from the unscopedadmin_full_accessuser grant or the declared-administrator config (H2).customSession(auth-manager.ts:3987)activeOrganizationId ?? undefinedpositions[]loses organization-scopedsys_user_positionnames when no organization is active.isPlatformAdminis unchanged.isPlatformAdminUserId(:7009), throughhasPlatformAdminStandingmakeExecutionContextResolver(current-user-endpoints.ts:424)activeOrganizationId ?? undefinedresolveAuthzContextbuildContextForUser(explain-engine.ts:581)resolveDelegatorContextintobuildContextForUser(explain-engine.ts:686), an enforcement pathexplainAccessForCallerintobuildContextForUser(security-plugin.ts:4690)assertIssuable(invitation-placement.ts:153)organizationId ?? undefinedrunAs:'user'(service-automation/src/plugin.ts:909)tenantIdresolveAuthzContext: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:1152mcp/src/plugin.ts:164), API key onlysingle/group)No caller needs every organization's grants, so no option was added to
ResolveUserAuthzGrantsOptionsand the grants-cache key is unchanged (H4 moot).tenantIdalready keys cache entries; a new pin checks, with the cache on, that anorg_aentry and a no-tenant entry never serve each other.Clause-②staysyes (narrowing): theyesarm is now carried bybuildContextForUser'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 shippedbootstrapPlatformAdmin, undersingle, after the fix:admin_full_accessrow readsorganization_id: null;hasPlatformAdminStandinganswerstrue;PLATFORM_ADMIN, withmanage_metadataheld.hasPlatformAdminStandingloses nothing, so it needs no answer of its own. Core pins cover both polarities: the unscoped grant isPLATFORM_ADMINwith and without a tenant; an organization-scopedadmin_full_accessconfers 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 equalsresolveUserAuthzGrants.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
resolveDelegatorContextbuilds the D10 delegator leg throughbuildContextForUser. SobuildContextForUsertakes the organization to resolve in, and each caller names it.Pinned in
explain-engine.test.ts,security-plugin.test.tsand the parity suite, which now runs two cases inorg1on both sides.What the explainer shows for the removed member, against enforcement:
org_betamanage_metadataorg_alpha(the left organization)org_alpha-scopedmanage_metadatasetThe 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
SqlDriverover the shipped per-organization built-in catalog (bootstrapBuiltinRolesfororg_jiaandorg_yi). The user is a current member oforg_jia(admin) andorg_yi(member), with no organization active.positions: [org_admin, org_member, everyone].org_admin,org_memberandeveryone, and their bindings. A member removed fromorg_jiaand still inorg_yikeptorg_jia'sorg_member-boundmanage_metadataset with no tenant. A user with no membership at all picked uporg_jia'severyonebinding.org_jiaactive, onlyorg_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_metadatathrough §6a after the §4 and §6 fix, so §6a asks the same predicate.applyTenantScopestays the one spelling of the wall, as the The Layer-0 tenant wall's strict equality annihilates the driver's platform bucket: #2734's fix is defeated on every walled read, and the org-less RBAC catalog reads ZERO for every principal #10103 comment requires.Verification (head
4e3e4f5e4, after mergingorigin/main288611e3ewith a true merge; the first round's merge was31d281d3b)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/3and3/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.tsalone: 16 passed. That is the 13 it had, plus 3 for the organization-scoped persona.@objectstack/corevitest run --project local: 56 files, 1518 passed.@objectstack/plugin-securityexplain-engine,security-pluginandper-organization-catalog: 361 passed.@objectstack/runtimethe door file (13 pins) pluspackages-uninstall-refuse-before-mutate: 21 passed.@objectstack/plugin-sharingsharing-rule-positions-name-authority: 7 passed.@objectstack/runtimeand@objectstack/dogfoodexit 0; the runtime test-typecheck ledger is unchanged.Full suites at
c9e6463ce's source, before the first merge (the twoorigin/mainmerges since then brought main's ownpackages/restchanges,rest-server.ts,meta-item-read-gate.tsand four test files, and main'spackages/runtimetestmeta-list-projection-parity.test.ts; this PR's patch round moved its runtime door-pin file. For those suites, the verdict is the head'sTest Coreruns):plugin-securityruntimelocalrestlocalplugin-authplugin-hono-serverservice-automationplugin-sharingplugin-approvalsorganizationsmcpcloud-connectionservice-datasourceservice-settingsservice-storageclientAll green. The consumer direction is the downstream importers of
@objectstack/corenamed in the H1 census, plus their own consumersplugin-approvalsandclient.This table omitted
@objectstack/dogfoodin the first round, and its shard 3/3 was red onc9e6463ce. The whole dogfood suite is the first bullet above.Typecheck:
@objectstack/core,@objectstack/plugin-securityand@objectstack/runtimetypecheckall exit 0. Eachcheck:test-typecheckis OK with its debt ledger unchanged.Fixture triage (dogfood, patch round 1):
sharing-rule-org-less-caller.dogfood.test.ts(plugin-sharing: amanage_sharingholder whose session has no ACTIVE organization reads every tenant's sharing rules (adminOrgScope falls open) #8158's HTTP proof) gave its exposed org-less personamanage_sharingthrough a grant scoped toorg_8158_a. That grant reachedadminOrgScopeonly through the defect, so shard 3/3 went red on "the refusal names the ORGANIZATION".adminOrgScope(plugin-sharing: amanage_sharingholder whose session has no ACTIVE organization reads every tenant's sharing rules (adminOrgScope falls open) #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.manage_sharingholder whose session has no ACTIVE organization reads every tenant's sharing rules (adminOrgScope falls open) #8158 filed it: scoped toorg_8158_a, no membership, no active organization. It is refused 403PERMISSION_DENIEDat 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.org_8158_aactive and still reads only its own tenant.main: that case measured exactly this persona reachingadminOrgScope's message.packages/qa/dogfood:sys_user_permission_setorsys_user_positionrow. The two other hits, inmembership-actor-attribution, are reads of the auto-grant row.test/armed.ts:215resolves through the realresolveAuthzContext(whatever the session carries), and its users are armed through memberships.authz-conformance.matrix.tsrows cite §3/§4/§6 as enforcement sites, and none of them states the old no-tenant reading.Fixture triage (plugin-sharing, one file, two cases):
sharing-rule-positions-name-authority.test.tsgave an org-less callermanage_sharingthrough 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 stillsharing_admin, neveradmin_full_access. Three explain fixtures were re-judged to resolve inorg1, where the scoped set applies.Ablations, each through
scripts/ablation-replace.mjsin WRAP mode, with a script-leveltraprestore on the absolute path. Core resolves fromsrcin both the core and runtime suites, andexplain-engineis imported relatively, so nodist/leg applies. Each is labelled with the source state it was measured at.4e3e4f5e4(resolver blob1f0d2889e626, which includes §6a). The predicate was put back to the old skip condition. Anchor 1 to 0, blob1f0d2889e626toa03a16f630a3.u_ex;u_gone; cache on) and the 6 removed-member door pins (DELETE and disable, for a2, a2b and the position-bound arm).git diff HEADempty.490bd8a377af) and is superseded.92716c91af53is stillexplain-engine.ts's blob at4e3e4f5e4.buildContextForUserstopped passing its tenant. Blob92716c91af53to372ec2454029. Red: 6 pins, which are the three re-judged fixtures, explain-in-an-organization, delegator-in-org_alphaand the route caller-in-org_alpha. Restored and proven the same way.1f0d2889e626is still the resolver's blob at4e3e4f5e4. The §6a predicate was replaced by a filter that keeps every row. Blob1f0d2889e626to404c23da10ec. Red: both §6a core pins and 4 door pins: the position-bound arm, plus the a2 arm, whoseorg_betamembership also reachesorg_alpha'sorg_memberbinding. Restored and proven the same way.Gates:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat4e3e4f5e4derived 72 commands. That is 68, plus four@objectstack/specfamilies:check:empty-state,check:liveness,check:strictness-ledgerandcheck:variant-docs. All 72 were run with exit codes recorded before any pipe, and all ended 0.check:type-check-debtfirst exited 3 (PREREQUISITE NOT MET): ablation 1's restore left core's source newer than itsdist/. 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 jsonover the 10 changed.tsfiles at4e3e4f5e4gave 10 files, 0 errors, 0 warnings.eslint.config.mjs's**/*.{ts,...}object minusNEVER_LINTEDandpackages/spec/**, and all 10 files are in it.parserOptions.project, as the config itself states), so this diff cannot move any untouched file's verdict. The fullpnpm lintis CI's.Acceptance notes
Review nit ①5, not carried:
explainAccessForCallerreadstenantIdwhereresolvePermissionSetsForContextreadsorganizationId ?? tenantId. Patch round 1 does not otherwise touchsecurity-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 plugin-security:
security.explainreports a record visible under a row-levelusingthat compares two fields of different classes, whilefindrefuses the same read withINVALID_FILTER/ 400 #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_positionread 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 (
resolvePermissionSetsForContextrequesting position names as permission-set names, loaded throughdbLoaderForContext) is a separate seam. NOT MEASURED here.groupposture: 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