fix(plugin-security): an organization-less permission-set read resolves organization-less rows only - #20584
Conversation
…sition-name fold Records, over a real ObjectQL + SqlDriver, which sys_permission_set rows the permission-set loader returns for a principal with no active organization, and whether their capabilities reach the resolved sets and the effective object map. Readings only; converted into the pin once the fix lands. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…es organization-less rows only With no active organization the permission-set loader read sys_permission_set by name with no tenant, which the driver treats as an unscoped path: every organization's row of each requested name came back and the first one won. The requested names include the caller's positions, which with no active organization still carry every membership's role and the everyone anchor. The read now asks for organization-less rows only when no organization is active, the rule the grants resolver already applies to grant rows. Scoped reads are unchanged. The measurement probe becomes the pin. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…grant pin can fail The SQL driver returns the by-name read ordered by id, and the global row's id sorted first, so the global-grant pin stayed green against the unscoped read. The other organization's copy now sorts first. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…n its own Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
The loader under test calls find alone, and the plugin's kernel:ready bootstraps are never fired, so no by-id or write verb is needed. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Contract reviewServed-tier: Inputs read: card #20555 (body and all three comments — triage 5882737881, claim 5883430106, os-dev-report 5884201563), PR #20584 (body, file list, net diff against Check-runs as read: 26 success (Check Changeset, Governed Surface Queue Guard, Build Core, Dogfood Regression Gate 1/3–3/3, Dogfood Verify CLI, Temporal Conformance, Type Check source / debt ledger / workspace / consumer gates, Test Core 2/6, 3/6 and 6/6, and the PR-shape checks), 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke), 4 still in progress (Lint & Repo Gates, Test Core 1/6, 4/6, 5/6), 0 failure. The seat checks convergence before landing; this record judges ①②③. ① Derived judgmentsThe diff is three files:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20555
Clause-②: no
The measurement: reached
The card's question was measured before any fix, on
mainat1c761c0d, which already contains PR #20540 (merged asf6ceddc3). The probe is committed on this branch asbf72f620and later became the pin. It runs a realObjectQLover a realSqlDriver(better-sqlite3:memory:), the real platform object definitions and the realSecurityPlugin, resolved through thesecurityservice it registers. Principals are built bybuildContextForUser, which runs the sameresolveUserAuthzGrantsthe request path uses.What it read, stated abstractly:
systemPermissionsreached the resolved sets, and their object map (view-all and modify-all included) reachedgetEffectiveObjectPermissions. The capabilities did not reach the core envelope'ssystemPermissions, because §6b ofresolveUserAuthzGrantsresolves by id. They arrived only through plugin-security's by-name fold.Mechanism assumptions from the dispatch, each measured:
resolvePermissionSetsForContextUnmemoizedfolds positions and permissions into one request. The loader's by-name read carries no tenant when none is active, and the driver'sapplyTenantScopereads "no tenant" as an unscoped path.resolveOwnOrganizationRow(rows, undefined)then returns whichever row came first.f6ceddc3, a resolution with no organization still lists every current membership's role inpositions, plus the audience anchor.reserved-identity-names.tsguardssys_position.nameandsys_user_position.positiononly. The package-owned collision refusal (ADR-0086 D4) is about package ownership. Neither one touches an organization-authoredsys_permission_set.name.The fix: one mechanism
The change lands in
packages/plugins/plugin-security/src/security-plugin.ts, in the permission-set loader built instart(). 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 ruleresolveUserAuthzGrantsalready applies to grant rows, and it matches ADR-0123 D2 ("tenant-scoped reads resolve to nothing"). A read with an active organization is unchanged.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: nullis the spec's has-no-value predicate. The organizations runtime already reads by it.resolvePermissionSetsForContextreads 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.exportsentry changes: no new export and no new option.Pins and their ablation
packages/plugins/plugin-security/src/orgless-position-name-fold.test.tsholds 10 cases:Ablation, from the committed state at
c3237a7b. The loader's read was put back unscoped throughscripts/ablation-replace.mjs(anchor hit 1 time, blob026ca66ato69cc688b). Atraprestored the file, and the restore was proven against the HEAD blob.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 thetsconfig.test.jsonprogram, confirmed with--listFiles.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 63 commands. All 63 were run, and--ranreports 63 run, 0 NOT-MEASURED, 0 UNRUN.check:type-check-debtfirst answered exit 3 at this head: the ablation restore left the source newer thandist/*.d.ts. It answered 0 afterpnpm --filter @objectstack/plugin-security build.check:engine-double-contractflagged by-id and write verbs on the pin's observed engine. The loader under test callsfindonly, so the double now exposesfindalone, and the ledger is unchanged.dist/(the fix marker was confirmed indist/index.mjsanddist/index.js):sharing-rule-org-less-caller,me-apps-and-everyone-baseline,two-doors-permission,showcase-permission-zooandshowcase-crud-persona-matrix. 5 files, 87 tests passed.eslint --no-inline-config --format jsonover the two touched TypeScript files reports 2 files, 0 errors and 0 warnings.eslint.config.mjsenables no type-aware linting: everyparserOptionscarries onlyecmaVersionandsourceType, and there is noparserOptions.project. So this diff cannot move a verdict on any untouched file. The fullpnpm lintrun is CI's.Acceptance notes
bootstrapPlatformAdminpoints 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.resolveOwnOrganizationRow(rows, undefined)still returns the first row for the seeders' own single-posture pass. The fix changes the enforcement read only.callerOrganizationIdsays a single-posture caller carries no organization. A single-posture session that has an active organization does carry one intoctx.tenantId, so that sentence is worth rechecking when the file is next touched. This PR does not change that behaviour.Generated by Claude Code