Uh oh!
There was an error while loading. Please reload this page.
fix(platform-objects,plugin-security,driver-sql): scope sys_user_preference and sys_capability uniqueness per organization (#8323) - #8461
Conversation
…erence and sys_capability uniqueness per organization Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
…eclarations; changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
…ant-scoped-unique-indexes
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 19 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
os-zhuang
commented
Aug 13, 2026
Docs check — answer: no published doc states or implies installation-wide uniqueness. Nothing to fix, no doc edits in this PR.Checked the four candidates plus the whole
|
| location | says | now false? |
|---|---|---|
permissions/authorization.mdx:179 | bootstrapDeclaredCapabilities seeds with managed_by:'package' + package_id, "idempotent, re-seeded on upgrade, never clobbers admin-authored rows" | no — still exact. Platform/package rows carry no organization, and the NULL-safe key part keeps them unique among themselves, so upsert-by-name resolves unchanged |
permissions/authorization.mdx:393 | capabilities seeded as first-class records from PLATFORM_CAPABILITIES | no |
permissions/permission-sets.mdx:138 | "Capabilities are first-class sys_capability records — named privileges" | no — names them, claims nothing about uniqueness |
references/security/misc.mdx:26 | name = "Stable capability key referenced by systemPermissions / requiredPermissions" | no — "stable", not "unique". Generated from the field's describe() |
The four files you named were the right places to look, and three of them (permissions-matrix.mdx, access-recipes.mdx, explain.mdx) contain no uniqueness language at all. The only exactly one in authorization.mdx is line 370, about the authz conformance matrix's state enum ("every authorization primitive sits in exactly one honest state") — unrelated.
Worth noting the reference table already documents scope as "platform = a platform-wide power; org = scoped to an organization" — the published model already carries the per-organization notion this PR makes physical, so the change moves the index toward the documented semantics rather than away.
sys_user_preference
Nothing documents (user_id, key) as installation-unique. Only two live mentions: data-modeling/objects.mdx:277,284, both about the managedBy: 'system-data' bucket, and the code excerpt there elides indexes with // …. (The other two hits are releases/v15.mdx and v16.mdx — untouched.)
One thing the sweep did turn up — filed, not fixed
content/docs/references/identity/position.mdx:64 documents sys_position.name as "Unique position name". That is the one place a strong uniqueness claim is published — and sys_position carries { fields: ['name'], unique: true } with no tenancy opt-out and managedBy: 'config', i.e. structurally identical to the sys_capability instance this PR fixes.
Filed as #8468 rather than folded in: it is outside the two objects the ruling named, and — unlike capabilities, where ADR-0066 D1's "admins EXTEND the registry" settles intent — I found no equivalent statement for positions, so whether installation-wide is wrong or merely unstated is a real question. If it is wrong the fix is one word plus the migration this PR already generalized to declared indexes; if it is right, the correct fix is the opposite (unique: 'global' explicitly, and the describe() stays). I did not want to guess on a security-class object.
I also swept the remaining platform objects: most bare declared uniques are legitimately installation-wide (sys_session.token, sys_api_key.key, sys_oauth_*.token, sys_device_code.*) and several already hand-write the organization composite (sys_team, sys_business_unit, sys_member — the ADR-0120 S6 spelling, valid indefinitely). sys_position was the only one that patterned like the fixed pair.
Standing by — not marking ready, not enabling auto-merge, not merging.
Generated by Claude Code
Generated by Claude Code
os-zhuang
commented
Aug 13, 2026
Reachability measured — ⛔ outcome 3, stopping as instructedTwo separate answers. The thing I originally flagged is not reachable. Measuring it surfaced a different, real consequence of the same root cause, which I have not fixed and have not guarded against. PR unchanged at 1. Can |
| # | call site | carries a tenant context? |
|---|---|---|
| 1 | security-plugin.ts:2538, the sole production call, inside runBootstrap | no |
| 2 | ctx.hook('kernel:ready', runBootstrap) (:2563) — boot | no |
| 3 | void runBootstrap() (:2565) — fallback when the kernel exposes no hook (test stubs) | no |
| 4 | ql.registerMiddleware post-sys_user create/insert (:2581-2590) — re-runs bootstrap after a signup, which can occur inside a tenant-scoped request | the trigger can; the seeder cannot |
Trigger 4 is the one worth the check, and it is closed by three independent facts:
- The seeder has no context channel.
SeedOptionsis{ logger?, materializedCapabilityNames? }. There is no parameter through which an organization could arrive. - It builds its own context.
const SYSTEM_CTX = { isSystem: true }is a module-level constant with notenantId, passed explicitly on all three access paths (ql.find/ql.insert/ql.update). The triggeringopCtxis never forwarded — the middleware callsrunBootstrap()with no arguments, andrunBootstrapcloses over the plugin-levelql, not a per-request engine. - No ambient tenant to leak. The only
AsyncLocalStoragein the engine istxStore(transactions). There is no async-local tenant store.
The stamp itself fills from the caller's active org, and this caller has none. So the org-local duplicate I flagged cannot occur. That worry is dead — and it would have been dead before this PR too.
2. What the measurement did find — the constraint was masking something else
tryFind(ql, 'sys_capability', { name }, 1) runs under isSystem, i.e. across organizations. Before this PR the global unique made an org-authored row named manage_users impossible (409). After it, that row is permitted — my own test asserts it, per ADR-0066 "admins EXTEND the registry". So the seeder's find-by-name can now return an organization's row where it previously could only ever return the platform's.
Measured against the seeder's own in-memory ql harness (which models isSystem faithfully — it filters on where only, ignoring organization). Direction predicted before running; all three matched:
| case | result |
|---|---|
DERIVED name (approve_invoice) + org row | ✅ safe — skippedAuthored: 1, org label preserved. The derivedNames.has(name) && row.managed_by !== 'platform' guard fires |
| CURATED name, platform row seeded first, org row added after | ✅ safe in the harness — platform row refreshed, org row untouched, 2 rows coexist |
CURATED name (manage_users), org row present, platform row not yet | ⛔ platformRowExists: false — the platform's own row is never seeded, and the org row's ORG CUSTOM LABEL is overwritten with Manage Users |
Two harms, the second worse than the first:
- An organization's authored capability row is silently overwritten with the platform's copy at boot.
- A single tenant's row suppresses the platform's own definition installation-wide — the curated row is never created, in any bucket.
find(..., { where: { name }, limit: 1 }) with no ORDER BY, and SQL guarantees no ordering there — so once two rows share a name, which one gets reconciled is arbitrary on a real database and may flip between boots.
Scope, stated honestly: I measured seeding behaviour only. I did not measure whether a missing platform capability row changes any authorization decision — capability rows are definitions and grants live in permission sets, so I have no evidence of privilege impact, and I am not claiming any. Only curated names are exposed (~8 of them); derived names are guarded. sys_user_preference is unaffected — nothing seeds preferences by name across organizations.
3. What I did not do
No fix, no guard, no PR widening — per your instruction, and because an unmeasured guard here would be worse than the measured absence. I have also not filed a card, since you said the sequencing is yours; say the word and I will file it with this measurement attached.
For sequencing: reverting is strictly worse — the global unique this PR removes is the release-blocking cross-tenant oracle, and this residual is a narrower, non-security correctness issue that requires an admin to author a capability whose name exactly matches a curated platform one. My read is that it is a follow-up, not a blocker, but that is your call.
Standing by. Not marking ready, not enabling auto-merge, not merging.
Generated by Claude Code
Generated by Claude Code
os-zhuang
commented
Aug 13, 2026
Residual filed as #8470 ( The card carries the three measured rows with row 2 marked safe in the harness only (the real It also states plainly that #8461unmasks rather than causes it — the nondeterministic No change to this PR. Generated by Claude Code Generated by Claude Code |
os-zhuang
commented
Aug 13, 2026
ACCEPT — PM review, |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8323
Implements option 2 only, per the maintainer ruling of 2026-08-13 10:05Z.
target:v17,security— release blocker.What was wrong
sys_user_preferenceandsys_capabilityboth declared their uniqueness as a table-level index with bareunique: true. At the declared-index level that is the positional spelling of'global'— the listed columns verbatim — so on a tenant-scoped object it materialized an installation-wide unique index:sys_user_preference{ fields: ['user_id','key'], unique: true }uniq_sys_user_preference_user_id_key (user_id, key)sys_capability{ fields: ['name'], unique: true }uniq_sys_capability_name (name)Both now say
unique: 'organization'(ADR-0120 D1), materializing(COALESCE(organization_id,'__global__'), ...).Why #4986 did not cover this — measured, and it is NOT a regression
The two
uniquespellings mean opposite things at the two levels, deliberately:unique: true→ per-organization since 多租户缺陷:unique 物化为全局唯一索引、无视 tenancy,与按租户分裂的 autonumber 序列自相矛盾(跨租户必然撞号 + 存在性探测泄露) #3696 (uniqueIndexesFromFieldsreadsisOrganizationScopedUnique, which acceptstrue);unique: true→ the listed columns verbatim.normalizeDeclaredIndexgates the organization prepend onidx?.unique === 'organization'strictly, so baretruenever reaches it.#4986 landed the
'organization'token on the declared-index path; it requires the explicit spelling.packages/lint/src/data-model-rules.tsnames this exact situation "the #4986 trap" and shipsunique/unscoped-declared-index(warning in 17.x) for it — these two objects were its two instances in the platform's own metadata. Pinned insql-driver-declared-index-organization-respelling.test.tsunder "the twouniquespellings diverge by design", including a positive pin that field-level baretruestill scopes per organization.Nothing about bare-spelling semantics is touched — that stays on #5082, and the publish-time authoring advisory is out of scope here (#8379 remains open and unaffected).
Migration staging, and why
schema-drift.tsis in this PRRespelling a declared index changes its generated name. On a deployed database that read as two unrelated findings: the composite missing (
create_index, safe) and the old global index orphaned (drop_index, destructive). An operator applying only the safe half keeps the global index — i.e. keeps the defect — while the plan reads as applied.Measured, by reverting only
schema-drift.tsand re-running: after a non-destructive apply, the cross-organization insert still answered 409 (expected 409 to be 201). The object-declaration change alone does not fix a deployed install.So the respelling now routes through the same
replace_unique_indexretirement the field-level migration has used since #3728 — reused, not re-rolled.LegacyUniqueReplacementgainslegacyColumnsso the matcher can recognise a multi-column legacy shape (with plainness guards: an index carrying an expression key part, a NULL-safe part or a WHERE predicate is not the verbatim global shape). Result on a deployed database: one finding for the table, categorisedsafe, CREATE before DROP, legacy index dropped only once the replacement is confirmed present. No--allow-destructiverequired, and the constraint is never unenforced at any point —initObjectsmaterializes the composite additively at boot, and the plan owns only the retirement.Reverse verification — both halves, predicted before running
UNIQUE_VIOLATIONschema-drift.tsNo prediction was contradicted, so nothing was retro-fitted.
Both fixtures — pre-fix and post-fix — are kept permanently in the suite, so the contrast is a standing assertion rather than a one-off measurement. Rejection cases assert the envelope (
isUniqueViolationErroris whatrest-server.tsmaps to409+code: UNIQUE_VIOLATION), never a baretoThrow().§3 confirmed end to end:
zhangsanin two organizations now holds an independentui.recentin each; the same key twice in one organization is still refused; a different user in the same organization is unaffected.Platform seed unaffected: platform capability rows carry no organization and the key part is NULL-safe (ADR-0120 D3), so they stay unique among themselves and
bootstrapSystemCapabilities' upsert-by-name still resolves. An organization may now define its own row of the same name — the ADR-0066 "admins EXTEND the registry" case.File surface
packages/platform-objects/src/identity/sys-user-preference.object.ts(user_id, key)→unique: 'organization'packages/plugins/plugin-security/src/objects/sys-capability.object.ts(name)→unique: 'organization'(cross-lane,domain:identity)packages/drivers/driver-sql/src/schema-drift.tslegacyUniqueReplacements;legacyColumnson the matcherpackages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.tspackages/platform-objects/src/identity/sys-user-preference.organization-unique.test.tspackages/plugins/plugin-security/src/objects/sys-capability.organization-unique.test.tspackages/plugins/plugin-security/src/objects/rbac-objects.test.tstoBe(true)packages/drivers/driver-sql/src/sql-driver-index-drift.test.ts,...-index-introspection-failure.test.tslegacyColumnson hand-built literals.changeset/tenant-scoped-platform-object-uniques.mdpackages/metadata-protocol/src/protocol.tsis not touched.Verification
driver-sql1446 passed / 52 skipped;platform-objects357 passed;plugin-security1037 passed. All three typecheck clean.scripts/pm/dispatch-gates.mjsagainst the actual changed paths and run locally — all green:nul-bytes,query-options-erasure,type-check-coverage,driver-conformance,cross-package-test-inputs,test-source-alias,type-source-resolution,tenant-chokepoint,adr-anchors,changeset-gate-self-tests,objectui-changeset,docs-audit-scope,empty-changeset,check-changeset-no-major,check-dev-prereqs.check:query-options-erasureinitially flagged the test surface growing 240 → 241. Fixed at the cause (typed the query instead ofas any); the ledger is back at 240, not grown.origin/mainmerged before pushing; everything above re-run after the merge.Generated by Claude Code