Skip to content

Commit b45c71e

Browse files
os-zhuangclaude
andauthored
fix(plugin-security,plugin-sharing,plugin-webhooks,platform-objects,service-messaging,spec): scope five tenant-scoped declared unique indexes per organization (#8554) (#8599)
* fix(#8554): scope five declared unique indexes per organization (wip) * fix(#8554): tests, docs, changeset * fix(#8554): typed query options, .js import extensions --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 694c350 commit b45c71e

26 files changed

Lines changed: 1424 additions & 29 deletions
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
"@objectstack/plugin-sharing": patch
4+
"@objectstack/plugin-webhooks": patch
5+
"@objectstack/platform-objects": patch
6+
"@objectstack/service-messaging": patch
7+
"@objectstack/spec": patch
8+
---
9+
10+
fix(plugin-security,plugin-sharing,plugin-webhooks,platform-objects,service-messaging,spec): five tenant-scoped declared unique indexes become per-organization (#8554)
11+
12+
Five platform objects declared their uniqueness as a table-level index with bare
13+
`unique: true`. At the DECLARED-index level that is the positional spelling of
14+
`'global'` — the listed columns verbatim — so on a tenant-scoped object each
15+
materialized an **installation-wide** unique index. (Field-level `unique: true`
16+
means the opposite, per-organization, and has since #3696; `packages/lint` names
17+
that divergence "the #4986 trap" and warns on it via
18+
`unique/unscoped-declared-index`.) These are the fourth act of the class ruled on
19+
2026-08-13, after `sys_user_preference` / `sys_capability` (#8461) and
20+
`sys_position` (#8556).
21+
22+
| object | package | was | now |
23+
|---|---|---|---|
24+
| `sys_permission_set` | `plugin-security` | `[name]` global | `[name]` per organization |
25+
| `sys_sharing_rule` | `plugin-sharing` | `[name]` global | `[name]` per organization |
26+
| `sys_webhook` | `plugin-webhooks` | `[name]` global | `[name]` per organization |
27+
| `sys_email_template` | `platform-objects` | `[name, locale]` global | `[name, locale]` per organization |
28+
| `sys_notification_preference` | `service-messaging` | `[user_id, topic, channel]` global | same, per organization |
29+
30+
Measured live on a real engine before the fix — two organizations, the same key,
31+
`OS_TENANCY_POSTURE=isolated`, driving the real shipped declarations. All five
32+
reproduced identically:
33+
34+
```
35+
org_jia POST the key → 201
36+
org_yi POST the SAME → 409 UNIQUE_VIOLATION
37+
org_yi POST an unused → 201 ← the control that makes it an oracle
38+
org_yi GET the key → total 0 ← refused by a row it cannot see
39+
```
40+
41+
Two consequences, both removed. **A cross-tenant existence oracle:** the 409 is a
42+
per-value answer about a row the caller cannot read, so an organization could
43+
enumerate another organization's permission-set, sharing-rule, webhook and
44+
template naming. **A functional dead end:** the second organization simply could
45+
not use the name, and the refusal did not say why. For
46+
`sys_notification_preference` the shape is the one #8323 measured on
47+
`sys_user_preference` — a user belonging to two organizations could not hold
48+
independent per-topic delivery toggles.
49+
50+
## ⚠️ Operators: a migration is REQUIRED, and deploying this release is not it
51+
52+
Respelling a declared index changes its generated **name**. On an existing
53+
database `initObjects` is additive: it creates the new per-organization composite
54+
at boot and **never drops the old global index**, which goes on enforcing. Until
55+
the retirement is applied, a deployed installation that has taken this release is
56+
still enumerable — that is asserted as a test, not assumed.
57+
58+
Run the migration:
59+
60+
```
61+
os migrate plan # shows one `replace_unique_index` per object, categorised `safe`
62+
os migrate apply # no --allow-destructive needed
63+
```
64+
65+
Each object plans as **one pure relaxation**, not as two findings. That matters:
66+
if it read as "composite missing" (safe) plus "old global index orphaned"
67+
(destructive, opt-in), an operator applying only the safe half would keep the
68+
global index — keep the defect — while the plan read as applied. The `#8461`
69+
`replace_unique_index` arm covers all five unchanged (no driver change in this
70+
release), applies CREATE-before-DROP so uniqueness is never unenforced in
71+
between, drops the legacy index only once the replacement is confirmed present,
72+
preserves every row, and converges to no drift.
73+
74+
Two columns are worth an operator's attention:
75+
76+
- `sys_notification_preference`'s replacement index name is **hash-suffixed** —
77+
`uniq_sys_notification_preference_a22d7d27` — because the natural name is 70
78+
characters and the limit is 60. That is expected, not corruption.
79+
- Rows with no `organization_id` (platform/seed rows) stay unique **among
80+
themselves**: the organization key part is NULL-safe
81+
(`COALESCE(organization_id, '__global__')`, ADR-0120 D3), so seeding by name
82+
keeps working and a tenant may hold its own row of the same name.
83+
84+
## Not breaking
85+
86+
A relaxation admits key pairs that were previously refused and refuses nothing
87+
that previously succeeded, so no caller that worked before fails now. Every read
88+
path for these five objects goes through the tenant-scoped data API, so no
89+
consumer resolves one of these names across organizations expecting at most one
90+
row. Shipped as `patch` for that reason — the same call #8556 made for the same
91+
shape.
92+
93+
Published text carrying the bare uniqueness claim was corrected at its source and
94+
the generated reference pages regenerated (`security/permission.mdx`,
95+
`automation/webhook.mdx`, and `integration/connector.mdx`, which embeds the same
96+
webhook schema), together with the `sys_permission_set` field description, its
97+
clone-dialog help text, the `sys_webhook` field description, and the matching
98+
translation bundles in all four shipped locales.

‎content/docs/automation/webhooks.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ its own encrypted column, see below.
9393
| Field | Type | Notes |
9494
|-------------------|-----------|------------------------------------------------------------------------------------|
9595
| `id` | text | Primary key. |
96-
| `name` | text | Unique snake_case name — referenced in logs and audit. |
96+
| `name` | text | snake_case name, unique **per organization** ([#8554](https://github.com/objectstack-ai/objectstack/issues/8554)) — referenced in logs and audit. |
9797
| `label` | text | Optional display label. |
9898
| `object_name` | text | Short object name whose record events fire this webhook. |
9999
| `triggers` | select | Multi-select of `create` / `update` / `delete` plus the opt-in bulk pair `bulk_update` / `bulk_delete` ([see below](#bulk-writes-bulk_update-and-bulk_delete)), stored as an array (the enqueuer also accepts a legacy comma-separated string). |

‎content/docs/references/automation/webhook.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const result = WebhookSchema.parse(data);
5858

5959
| Property | Type | Required | Description |
6060
| :--- | :--- | :--- | :--- |
61-
| **name** | `string` | ✅ | Webhook unique name (lowercase snake_case) |
61+
| **name** | `string` | ✅ | Webhook name, unique per organization (lowercase snake_case) |
6262
| **label** | `string` | optional | Human-readable webhook label |
6363
| **object** | `string` | optional | Object whose record events (create/update/delete, bulk_update/bulk_delete) trigger this webhook |
6464
| **triggers** | `Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]` | optional | Events that trigger execution |

‎content/docs/references/integration/connector.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,7 @@ Synchronization strategy
610610

611611
| Property | Type | Required | Description |
612612
| :--- | :--- | :--- | :--- |
613-
| **name** | `string` | ✅ | Webhook unique name (lowercase snake_case) |
613+
| **name** | `string` | ✅ | Webhook name, unique per organization (lowercase snake_case) |
614614
| **label** | `string` | optional | Human-readable webhook label |
615615
| **object** | `string` | optional | Object whose record events (create/update/delete, bulk_update/bulk_delete) trigger this webhook |
616616
| **triggers** | `Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]` | optional | Events that trigger execution |

‎content/docs/references/security/permission.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ const result = AdminScopeSchema.parse(data);
121121

122122
| Property | Type | Required | Description |
123123
| :--- | :--- | :--- | :--- |
124-
| **name** | `string` | ✅ | Permission set unique name (lowercase snake_case) |
124+
| **name** | `string` | ✅ | Permission set name, unique per organization (lowercase snake_case) |
125125
| **label** | `string` | optional | Display label |
126126
| **description** | `string` | optional | Human-readable description shown in Setup (persisted as sys_permission_set.description) |
127127
| **packageId** | `string` | optional | [ADR-0086 D3] Owning package id for a package-shipped set (absent = env-authored) |

0 commit comments

Comments
 (0)