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 |
…es organization-less rows only (objectstack-ai#20584) Fixes objectstack-ai#20555 Clause-②: no ## The measurement: reached The card's question was measured before any fix, on `main` at `1c761c0d`, which already contains PR objectstack-ai#20540 (merged as `f6ceddc3`). The probe is committed on this branch as `bf72f620` and later became the pin. It runs a real `ObjectQL` over a real `SqlDriver` (better-sqlite3 `:memory:`), the real platform object definitions and the real `SecurityPlugin`, resolved through the `security` service it registers. Principals are built by `buildContextForUser`, which runs the same `resolveUserAuthzGrants` the request path uses. What it read, stated abstractly: - **Reached.** The principal is a member of one organization only and has no organization active. It resolved permission sets that a different organization had authored under the names of built-in roles it holds as positions. The organization-less by-name read returned the other organization's rows. Their `systemPermissions` reached the resolved sets, and their object map (view-all and modify-all included) reached `getEffectiveObjectPermissions`. The capabilities did not reach the core envelope's `systemPermissions`, because §6b of `resolveUserAuthzGrants` resolves by id. They arrived only through plugin-security's by-name fold. - **Control.** With the principal's own organization active, the read was scoped and returned none of those rows. - **A second face of the same read.** An organization-less principal holding a global grant resolved another organization's same-named copy of the granted set, not the global row the grant names. Mechanism assumptions from the dispatch, each measured: - **A1 holds.** `resolvePermissionSetsForContextUnmemoized` folds positions and permissions into one request. The loader's by-name read carries no tenant when none is active, and the driver's `applyTenantScope` reads "no tenant" as an unscoped path. `resolveOwnOrganizationRow(rows, undefined)` then returns whichever row came first. - **A2 holds, at runtime.** After `f6ceddc3`, a resolution with no organization still lists every current membership's role in `positions`, plus the audience anchor. - **A3 holds.** `reserved-identity-names.ts` guards `sys_position.name` and `sys_user_position.position` only. The package-owned collision refusal (ADR-0086 D4) is about package ownership. Neither one touches an organization-authored `sys_permission_set.name`. - **A4: nothing legitimate is dropped.** The pin also holds the global grants an organization-less principal really holds. A global position assignment folded onto a global same-named set still resolves, and so does a global user grant to a global set. Both resolve identically with the principal's own organization active. ## The fix: one mechanism The change lands in `packages/plugins/plugin-security/src/security-plugin.ts`, in the permission-set loader built in `start()`. With no active organization, the by-name read now asks for organization-less rows only (`organization_id: null`). A row scoped to an organization applies only while that organization is active, and an organization-less row applies everywhere. This is the rule `resolveUserAuthzGrants` already applies to grant rows, and it matches ADR-0123 D2 ("tenant-scoped reads resolve to nothing"). A read with an active organization is unchanged. - **Why a predicate in the read, not a filter after it.** The read keeps its `limit`. Filtered afterwards, other organizations' copies of a name could fill the page and push out the global row the caller does hold. `organization_id: null` is the spec's has-no-value predicate. The organizations runtime already reads by it. - **No reserved-identity guard.** The scoped fold alone closes the cross-organization reach, so no second mechanism is added. Inside one organization, a set named after a position still folds for that organization's own members while it is active. That is the governed-fold question, which is already warned about at runtime. It is not this card. - **Blast radius.** Every consumer of `resolvePermissionSetsForContext` reads through this loader: the data-plane middleware, `getEffectiveObjectPermissions`, the delegated-admin gate, explain and `/me/apps`. So the fix reaches all of them from this one place. `packages/core/src/security/**` is not touched. - **Public surface.** Nothing reachable from the package's `exports` entry changes: no new export and no new option. ## Pins and their ablation `packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts` holds 10 cases: - a precondition: the principal really holds the colliding names; - six negative pins, one fact each; - three keep-pins: the global grants, the same grants with the principal's own organization active, and a control that the authored set does resolve for its own organization's member. Ablation, from the committed state at `c3237a7b`. The loader's read was put back unscoped through `scripts/ablation-replace.mjs` (anchor hit 1 time, blob `026ca66a` to `69cc688b`). A `trap` restored the file, and the restore was proven against the HEAD blob. ```text × the by-name read returns no other organization's row × the authored sets are not among the resolved sets × their systemPermissions do not reach the principal × their object map does not reach the effective map × a GLOBAL grant resolves the global row it names, not another organization's same-named copy × …and the same-named copy's systemPermissions do not reach that principal Tests 6 failed | 4 passed (10) ABLATION RESTORED: packages/plugins/plugin-security/src/security-plugin.ts blob 026ca66 == HEAD ``` The four that stay green are the precondition and the three keep-pins, which is the expected direction. On the first ablation run, the global-grant pin stayed green: the SQL driver returns the page ordered by id, and the global row's id sorted first. The fixture ids were reordered so that the other organization's copy sorts first (`c60be715`). The pin was re-ablated red after that. ## Local verification (all at `c3237a7b`) - `pnpm --filter @objectstack/plugin-security exec vitest run`: 144 files, 3062 passed and 16 skipped. - `pnpm --filter @objectstack/plugin-security run typecheck`: green. The new test file is in the `tsconfig.test.json` program, confirmed with `--listFiles`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands. All 63 were run, and `--ran` reports 63 run, 0 NOT-MEASURED, 0 UNRUN. - `check:type-check-debt` first answered exit 3 at this head: the ablation restore left the source newer than `dist/*.d.ts`. It answered 0 after `pnpm --filter @objectstack/plugin-security build`. - Earlier in the round, `check:engine-double-contract` flagged by-id and write verbs on the pin's observed engine. The loader under test calls `find` only, so the double now exposes `find` alone, and the ledger is unchanged. - A targeted dogfood subset ran against the rebuilt `dist/` (the fix marker was confirmed in `dist/index.mjs` and `dist/index.js`): `sharing-rule-org-less-caller`, `me-apps-and-everyone-baseline`, `two-doors-permission`, `showcase-permission-zoo` and `showcase-crud-persona-matrix`. 5 files, 87 tests passed. - Lint, narrowed and stated as such. `eslint --no-inline-config --format json` over the two touched TypeScript files reports 2 files, 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting: every `parserOptions` carries only `ecmaVersion` and `sourceType`, and there is no `parserOptions.project`. So this diff cannot move a verdict on any untouched file. The full `pnpm lint` run is CI's. ## Acceptance notes - **Boundary, not a regression.** A principal with no organization active no longer resolves a permission-set row that is stamped with an organization. Could a real principal have relied on that? It would need a global grant pointing at an organization-stamped set, or a global position named like one. No producer in this repository writes either. `bootstrapPlatformAdmin` points its global grant at an organization-less row, and Setup stamps both the grant and the set with the active organization. The remedy, stated in the changeset, is to make the organization active or to grant the set globally. - **Seeders are untouched.** `resolveOwnOrganizationRow(rows, undefined)` still returns the first row for the seeders' own single-posture pass. The fix changes the enforcement read only. - **A docblock to recheck.** The docblock on `callerOrganizationId` says a single-posture caller carries no organization. A single-posture session that has an active organization does carry one into `ctx.tenantId`, so that sentence is worth rechecking when the file is next touched. This PR does not change that behaviour. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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