|
| 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. |
0 commit comments