Skip to content

Commit d71ff32

Browse files
os-zhuangclaude
andauthored
fix(platform-objects,plugin-security,driver-sql): scope sys_user_preference and sys_capability uniqueness per organization (#8323) (#8461)
* fix(platform-objects,plugin-security,driver-sql): scope sys_user_preference and sys_capability uniqueness per organization Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH * test(platform-objects,plugin-security): pin the organization-scoped declarations; changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 27358d5 commit d71ff32

10 files changed

Lines changed: 1138 additions & 10 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/plugin-security": patch
4+
"@objectstack/driver-sql": patch
5+
---
6+
7+
fix(platform-objects,plugin-security,driver-sql): `sys_user_preference` and `sys_capability` uniqueness is per organization (#8323)
8+
9+
Both objects declared their uniqueness as a table-level index with bare
10+
`unique: true`. At the DECLARED-index level that is the positional spelling of
11+
`'global'` — the listed columns verbatim — so on a tenant-scoped object it
12+
materialized an **installation-wide** unique index. (Field-level `unique: true`
13+
means the opposite, per-organization, and has since #3696; `packages/lint` names
14+
that divergence "the #4986 trap" and warns on it via
15+
`unique/unscoped-declared-index`.) Measured on a deployment running
16+
`OS_TENANCY_POSTURE=isolated`:
17+
18+
- **A user in two organizations could never persist a preference key they had
19+
already used in the first one.** `sys_user_preference`'s `(user_id, key)` was
20+
installation-wide, so the second organization's write was refused by a row the
21+
caller cannot read — and `data-objectstack`'s `userState.save()` swallows the
22+
failure by design, so "recent items" and similar preferences silently stopped
23+
persisting in a user's second workspace, with no error anywhere.
24+
- **`sys_capability.name` refusals were an existence oracle across tenants.** An
25+
organization could POST a name and read `409` vs `201` to learn whether some
26+
other organization — or the platform seed — already held it, while its own
27+
`GET` on that name returned zero rows.
28+
29+
Both declarations now say `unique: 'organization'` (ADR-0120 D1), materializing
30+
`(COALESCE(organization_id,'__global__'), …)`. Platform-seeded rows carry no
31+
organization and the key part is NULL-safe (ADR-0120 D3), so they stay unique
32+
among themselves and `bootstrapSystemCapabilities`' upsert-by-name is unaffected.
33+
Same-organization duplicates are still refused — the constraint is scoped, not
34+
removed.
35+
36+
The bare `unique: true` spelling itself is **unchanged**; whether it should be
37+
reinterpreted is #5082 (v18), and the publish-time authoring advisory is #8379.
38+
39+
**Migration (`@objectstack/driver-sql`).** Respelling a declared index changes
40+
its generated name, which on a deployed database read as two unrelated findings:
41+
the composite missing (`create_index`, safe) and the old global index orphaned
42+
(`drop_index`, **destructive**). An operator applying only the safe half would
43+
have kept the global index — i.e. kept the defect — while the plan read as
44+
applied. The declared-index respelling now routes through the same
45+
`replace_unique_index` retirement the field-level `unique` migration has used
46+
since #3728: one finding, categorised `safe`, CREATE before DROP, and the legacy
47+
index dropped only once the replacement is confirmed present. Any two rows
48+
colliding on `(organization, …fields)` already collided on `(…fields)`, so the
49+
replacement can neither fail on existing data nor lose any.
50+
51+
Operators upgrading a deployed database should run `os migrate plan` / `os
52+
migrate apply` — no `--allow-destructive` is required. Until the retirement is
53+
applied the old index keeps enforcing, so the constraint is never unenforced at
54+
any point in the migration.

packages/drivers/driver-sql/src/schema-drift.ts

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,20 @@ export interface LegacyUniqueReplacement {
10421042
column: string;
10431043
legacyNames: string[];
10441044
replacement: ExpectedIndex;
1045+
/**
1046+
* The EXACT physical key columns the superseded index must have, in key
1047+
* order — the shape the replacement relaxes away from.
1048+
*
1049+
* For a field-level unique this is `[column]`: the pre-#3696 single-column
1050+
* global index. For a DECLARED index respelled from the global spelling to
1051+
* `'organization'` (#8323) it is the index's listed columns, which may be
1052+
* several — `sys_user_preference`'s `(user_id, key)` is the case that put
1053+
* this here. Matching on the name alone is not enough (an unrelated index
1054+
* may collide with the generated spelling), and matching on a single leading
1055+
* column is not enough either: `(user_id, key)` and `(user_id, tenant)` share
1056+
* one, and only one of them is the index being replaced.
1057+
*/
1058+
legacyColumns: string[];
10451059
}
10461060

10471061
/**
@@ -1092,6 +1106,7 @@ export function legacyUniqueReplacements(args: {
10921106
const columns = [tenantField, name];
10931107
out.push({
10941108
column: name,
1109+
legacyColumns: [name],
10951110
legacyNames,
10961111
// NULL-safe organization key part (ADR-0120 D3). Still a pure
10971112
// relaxation to create from under the legacy GLOBAL single-column
@@ -1105,6 +1120,59 @@ export function legacyUniqueReplacements(args: {
11051120
},
11061121
});
11071122
}
1123+
1124+
// ── Declared indexes respelled from the global spelling to 'organization' ──
1125+
//
1126+
// #8323: the same retirement, one level up. A declared index's bare
1127+
// `unique: true` is the positional spelling of `'global'` — the listed
1128+
// columns VERBATIM — so respelling it `'organization'` changes the
1129+
// materialized shape from `(…listed)` to `(COALESCE(tenant,'__global__'),
1130+
// …listed)`, and with it the generated NAME. On a deployed database that
1131+
// reads as two unrelated findings: the composite is missing (create, safe)
1132+
// and the old global index is an orphan (drop, DESTRUCTIVE, opt-in). An
1133+
// operator who applies only the safe half keeps the global index — and the
1134+
// global index is the defect, so the migration would look applied while the
1135+
// cross-organization refusal it exists to remove is still enforced.
1136+
//
1137+
// Routing it through the SAME `replace_unique_index` op the field-level
1138+
// retirement uses states it as what it is: one pure relaxation, categorised
1139+
// `safe`, applied CREATE-before-DROP so uniqueness is never unenforced in
1140+
// between, and dropping the old index only once the replacement is confirmed
1141+
// present. Any two rows colliding on `(tenant, …listed)` already collided on
1142+
// `(…listed)`, so the create cannot fail on existing data and no data is lost.
1143+
for (const idx of Array.isArray(declaredIndexes) ? declaredIndexes : []) {
1144+
if (idx?.unique !== 'organization') continue;
1145+
// An EXPLICITLY NAMED index keeps its name across the respelling, so there
1146+
// is no second name to retire — same name, new definition, which is
1147+
// `recreate_index`'s job (drop-then-create under one name). Emitting a
1148+
// replacement here as well would propose dropping the very index the
1149+
// recreate is rebuilding.
1150+
if (typeof idx?.name === 'string' && idx.name.trim()) continue;
1151+
const listed = Array.isArray(idx?.fields)
1152+
? idx.fields.filter((f): f is string => typeof f === 'string' && f.length > 0)
1153+
: [];
1154+
if (listed.length === 0) continue;
1155+
// Every listed column must exist physically, or there is no index to match
1156+
// and nothing the replacement could be created from.
1157+
if (!listed.every((c) => physicalColumns.has(c))) continue;
1158+
const replacement = normalizeDeclaredIndex(table, idx, tenantField);
1159+
if (!replacement) continue;
1160+
const legacyName = buildIndexName(table, listed, true);
1161+
// The S6 hand-written composite already lists the tenant column, so
1162+
// `normalizeDeclaredIndex` prepends nothing and the "legacy" name IS the
1163+
// current name. Nothing was superseded; the D4 NULL-safe tightening path
1164+
// owns that transition.
1165+
if (legacyName === replacement.name) continue;
1166+
// An index the CURRENT metadata declares is by definition not legacy
1167+
// (#3955) — the same guard the field-level arm applies.
1168+
if (declaredNames.has(legacyName)) continue;
1169+
out.push({
1170+
column: listed[0],
1171+
legacyColumns: listed,
1172+
legacyNames: [legacyName],
1173+
replacement,
1174+
});
1175+
}
11081176
return out;
11091177
}
11101178

@@ -1197,13 +1265,25 @@ export function diffManagedIndexes(args: {
11971265

11981266
// ── 1. Legacy platform-wide unique superseded by a tenant composite ──
11991267
for (const l of legacy) {
1200-
// Only a *single-column unique on that very column* is the legacy shape.
1201-
// Matching on the name alone would let an unrelated index that happens to
1202-
// collide with the legacy spelling be dropped.
1268+
// Only a *plain unique on exactly those columns, in key order* is the
1269+
// legacy shape. Matching on the name alone would let an unrelated index
1270+
// that happens to collide with the legacy spelling be dropped.
1271+
//
1272+
// `legacyColumns` is `[column]` for the field-level retirement and the
1273+
// declared index's listed columns for the #8323 respelling — the same
1274+
// question either way, asked once. The plainness guards matter for the
1275+
// multi-column arm: an index carrying an expression key part, a NULL-safe
1276+
// organization part or a WHERE predicate is NOT the verbatim global shape
1277+
// being relaxed, whatever its column identities read as.
12031278
const present = l.legacyNames.filter((n) => {
12041279
const p = byName.get(n);
12051280
if (!p || p.primary || isRuntimeManagedIndex(p, runtimeCreated, tenantField)) return false;
1206-
return p.unique && p.columns.length === 1 && p.columns[0] === l.column;
1281+
if (!p.unique || p.partial === true) return false;
1282+
if ((p.expressions?.length ?? 0) > 0 || (p.nullSafeColumns?.length ?? 0) > 0) return false;
1283+
return (
1284+
p.columns.length === l.legacyColumns.length &&
1285+
p.columns.every((c, i) => c === l.legacyColumns[i])
1286+
);
12071287
});
12081288
if (present.length === 0) continue;
12091289
for (const n of present) explained.add(n);
@@ -1213,7 +1293,7 @@ export function diffManagedIndexes(args: {
12131293
table,
12141294
column: l.column,
12151295
expected: indexSignature(l.replacement.columns, true, l.replacement.nullSafeColumns),
1216-
actual: indexSignature([l.column], true),
1296+
actual: indexSignature(l.legacyColumns, true),
12171297
severity: 'warning',
12181298
category: 'safe',
12191299
op: {
@@ -1226,7 +1306,7 @@ export function diffManagedIndexes(args: {
12261306
...(l.replacement.nullSafeColumns ? { nullSafeColumns: l.replacement.nullSafeColumns } : {}),
12271307
},
12281308
message:
1229-
`${table}.${l.column}: a legacy platform-wide UNIQUE index (${present.join(', ')}) still enforces ` +
1309+
`${table}.${l.legacyColumns.join('+')}: a legacy platform-wide UNIQUE index (${present.join(', ')}) still enforces ` +
12301310
`uniqueness across ALL tenants, but metadata scopes it per '${l.replacement.columns[0]}' — a second ` +
12311311
`tenant reusing the value is rejected on insert (#3696). Replacing it with ${indexSignature(l.replacement.columns, true, l.replacement.nullSafeColumns)} ` +
12321312
`is a pure relaxation: run "os migrate apply".`,

0 commit comments

Comments
 (0)