Skip to content

Commit 3da3da5

Browse files
huangyiireneclaude
andauthored
feat(metadata-protocol)!: cross-tenant uninstall must be declared — deletePackage refuses a call naming neither an organization nor allTenants (#7780) (#7995)
* feat(metadata-protocol)!: cross-tenant uninstall must be declared explicitly (#7780) `protocol.deletePackage` selected rows with `{ package_id }` and added an organization predicate only when the caller supplied one, so a call with no `organizationId` matched EVERY organization's rows — measured during #7705 at 5 of 5 deleted, including a foreign org's. The two doors of `DELETE /api/v1/packages/:id` disagreed about which semantic they invoked: the direct-mount REST registrar passes no org (cross-tenant), the dispatcher twin resolves one (org-scoped). Neither chose it; it fell out of a missing argument. `resolveActiveOrganizationId` (#4127) is entirely catch-wrapped, so an accidental org-less call and a deliberate env-wide one are byte-identical at the call site. Maintainer ruling (2026-08-12): 跨租户卸载必须显式声明,缺省缺参永远不等于「全部租户」. `deletePackage` gains `allTenants?: boolean`, the explicit carrier. The tenant scope must be readable off the request, and there are two ways to violate that, both answering `TENANT_SCOPE_REQUIRED` (400) and deleting nothing: organizationId | allTenants | result ---------------|----------------|-------------------------------- set | absent / false | org-scoped delete (unchanged) absent | true | cross-tenant delete absent | absent / false | 400 set | true | 400 An explicit `allTenants: false` is treated as undeclared — it is not an affirmative request for cross-tenant semantics, so it cannot authorise them. Both-supplied is refused rather than resolved: the two are contradictory, and resolving narrow-first makes the flag silently inert while resolving explicit-first ignores a named org and deletes every tenant's rows — the original defect wearing a flag. Refusing is also the only reading that stays correct when a request is composed from two places, which is exactly what the catch-wrapped resolver makes real. The REST door declares `allTenants: true`: `packages/rest` has no org to resolve, so of the two remedies the ruling allows only declaring is available. The no-org branch is still NOT narrowed to `organization_id IS NULL` — #7705 proved that orphans every org-scoped row. The remedy is explicitness. #7705 case 4 pinned the old no-org-clears-everything behaviour deliberately. It is rewritten, not deleted, into the halves of the ruled contract, and the reason it existed is carried by the case that goes red if anyone narrows the branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017VcwwwtZHpwrjWZihjpUvS * chore(spec): register the uninstall tenant-scope break on the ADR-0087 chain and regenerate docs (#7780) Two mechanical CI fixes on top of the accepted change. 1. `Check Changeset` — the changeset declares a breaking change and carried no ADR-0087 disposition. Registered rather than exempted, and the exemption was not available anyway: `no-migration-prescription` is refused when the changeset body carries a consumer-rewrite prescription, and this one does ("Callers that must be updated"). `unpublished` is false (all three bumped packages publish) and `already-registered` is false (the id is new here). The substantive reason is the same one the gate asks for: a consumer really does have to act — an uninstall that succeeded yesterday now answers 400 until it states its tenant scope — and which scope a given caller meant is an intent no transform can recover, because the catch-wrapped resolver makes an accidental org-less call byte-identical to a deliberate environment-wide one. That is the disposition `rest-requireauth-default-flip` (step 12) took for its own default flip, and `semantic/` already carries runtime-contract entries of this shape (`api-runtime-create-withdrawn`), so this is not a metadata-shaped entry forced into a metadata chain. New entry `semantic/17.package-uninstall-explicit-all-tenants.ts`, folded in with `gen:migration-registry`; `spec-changes.json` and `docs/protocol-upgrade-guide.md` regenerated as its projections. 2. `check:docs` — `TENANT_SCOPE_REQUIRED` was added to the error-code ledger and the generated reference docs were never regenerated. Ran `gen:schema` then `gen:docs`. Eleven pages move rather than the two CI named: the shared error envelope renders the code-enum arity, so `… +261 more` becomes `… +262 more` wherever it appears. All generated output, hand-edited nowhere. No `content/docs/releases/**` page is touched. Merged `origin/main` (clean) before regenerating, so the artifacts are generated against current main rather than the `59ac0c6` base. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017VcwwwtZHpwrjWZihjpUvS --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ecf0bef commit 3da3da5

23 files changed

Lines changed: 443 additions & 84 deletions
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/rest": minor
4+
"@objectstack/spec": minor
5+
---
6+
7+
feat(metadata-protocol)!: cross-tenant uninstall must be declared — `deletePackage` refuses a call that names neither an organization nor `allTenants` (#7780)
8+
9+
**This changes the contract of a destructive operation, and a caller that omits
10+
the organization today starts getting a 400. That is the point of the change,
11+
not a side effect of it.**
12+
13+
`protocol.deletePackage` selected its rows with `{ package_id }` and added an
14+
organization predicate only when the caller supplied one. With no
15+
`organizationId` the predicate matched **every organization's rows** — measured
16+
during #7705 at 5 of 5 deleted, including a foreign organization's.
17+
18+
Nobody chose that. It fell out of a missing argument, and the two doors of
19+
`DELETE /api/v1/packages/:id` disagreed about which semantic they were invoking:
20+
21+
- the direct-mount REST registrar (`packages/rest/src/package-routes.ts`) passes
22+
no organization and got the cross-tenant reading;
23+
- the dispatcher twin (`packages/runtime/src/domains/packages.ts`) resolves one
24+
and got the org-scoped reading.
25+
26+
Worse, the two are indistinguishable at the call site. `resolveActiveOrganizationId`
27+
(#4127) is entirely `catch`-wrapped, so any throw on the auth seam returns
28+
`undefined` — an accidental org-less call and a deliberate environment-wide one
29+
are byte-identical, and the accident silently selected the widest possible
30+
reading of a destructive operation.
31+
32+
Maintainer ruling (2026-08-12), quoted unchanged:
33+
34+
> 跨租户卸载必须显式声明,缺省缺参永远不等于「全部租户」.
35+
36+
**What changes**
37+
38+
- `deletePackage` gains `allTenants?: boolean`, the explicit carrier for
39+
cross-tenant semantics.
40+
- A call with neither `organizationId` nor `allTenants: true` is refused with
41+
`TENANT_SCOPE_REQUIRED` (HTTP 400) and deletes nothing. An explicit
42+
`allTenants: false` is treated as undeclared: it is not an affirmative request
43+
for cross-tenant semantics, so it cannot authorise them.
44+
- A call supplying **both** `organizationId` and `allTenants: true` is refused
45+
with the same code and status. The two are contradictory, not redundant — one
46+
scopes to a tenant, the other clears every tenant — and both silent
47+
resolutions are worse than a refusal: resolving narrow-first makes
48+
`allTenants: true` silently inert, and resolving explicit-first ignores a named
49+
organization and deletes every tenant's rows, which is the original defect
50+
wearing a flag. Rejecting is also the only reading that stays correct when a
51+
request is composed from two places (a resolver supplying the org, config
52+
supplying the flag). The message names both offending parameters.
53+
- The REST direct-mount door now declares `allTenants: true`. It has no
54+
organization to resolve (`packages/rest` carries no org plumbing at all), so of
55+
the two remedies the ruling allows, only declaring the intent is available
56+
there. Its observable behaviour is unchanged; what changed is that the width is
57+
now stated at the call site instead of inferred from an absent argument.
58+
59+
**What deliberately does NOT change**
60+
61+
The no-organization branch is still **not** narrowed to `organization_id IS
62+
NULL`. #7705 proved that narrowing orphans every org-scoped row — the same
63+
defect pointed the other way. The remedy here is explicitness, not narrowing.
64+
65+
**Callers that must be updated**
66+
67+
Any caller of `deletePackage` that omits `organizationId` and intends an
68+
environment-wide uninstall must now pass `allTenants: true`. The refusal message
69+
names both remedies.
70+
71+
Registered on the ADR-0087 migration chain (step 17,
72+
`package-uninstall-explicit-all-tenants`) rather than exempted: a consumer really
73+
does have to act — an uninstall that succeeded yesterday now answers 400 until it
74+
states its tenant scope — and which scope it meant is an intent no transform can
75+
recover, which is the same disposition `rest-requireauth-default-flip` took for
76+
its own default flip.
77+
78+
<!-- adr-0087: registered package-uninstall-explicit-all-tenants -->

‎content/docs/references/api/analytics.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const result = AnalyticsEndpoint.parse(data);
4444
| Property | Type | Required | Description |
4545
| :--- | :--- | :--- | :--- |
4646
| **success** | `boolean` | ✅ | Operation success status |
47-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
47+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
4848
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
4949
| **data** | `{ name: string; title?: string; measures: object[]; dimensions: object[] }[]` | ✅ | Available cubes, each as the `CubeMeta` discovery projection — the cube name, its title, and the measures/dimensions a client may name in a query. A bare array: there is no `cubes` wrapper object, and no cube `sql` is published. |
5050

@@ -79,7 +79,7 @@ const result = AnalyticsEndpoint.parse(data);
7979
| Property | Type | Required | Description |
8080
| :--- | :--- | :--- | :--- |
8181
| **success** | `boolean` | ✅ | Operation success status |
82-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
82+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
8383
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
8484
| **data** | `{ rows: Record<string, any>[]; fields: object[]; sql?: string }` | ✅ | |
8585

@@ -93,7 +93,7 @@ const result = AnalyticsEndpoint.parse(data);
9393
| Property | Type | Required | Description |
9494
| :--- | :--- | :--- | :--- |
9595
| **success** | `boolean` | ✅ | Operation success status |
96-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
96+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
9797
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
9898
| **data** | `{ sql: string; params: any[] }` | ✅ | |
9999

‎content/docs/references/api/auth.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ const result = AuthProvider.parse(data);
117117
| Property | Type | Required | Description |
118118
| :--- | :--- | :--- | :--- |
119119
| **success** | `boolean` | ✅ | Operation success status |
120-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
120+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
121121
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
122122
| **data** | `{ session: object; user: object; token?: string }` | ✅ | |
123123

@@ -153,7 +153,7 @@ const result = AuthProvider.parse(data);
153153
| Property | Type | Required | Description |
154154
| :--- | :--- | :--- | :--- |
155155
| **success** | `boolean` | ✅ | Operation success status |
156-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
156+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
157157
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
158158
| **data** | `{ id: string; email: string; emailVerified: boolean; name: string; … }` | ✅ | |
159159

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ const result = AutomationApiErrorCode.parse(data);
119119
| Property | Type | Required | Description |
120120
| :--- | :--- | :--- | :--- |
121121
| **success** | `boolean` | ✅ | Operation success status |
122-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
122+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
123123
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
124124
| **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The created flow definition |
125125

@@ -144,7 +144,7 @@ const result = AutomationApiErrorCode.parse(data);
144144
| Property | Type | Required | Description |
145145
| :--- | :--- | :--- | :--- |
146146
| **success** | `boolean` | ✅ | Operation success status |
147-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
147+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
148148
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
149149
| **data** | `{ name: string; deleted: boolean }` | ✅ | |
150150

@@ -187,7 +187,7 @@ const result = AutomationApiErrorCode.parse(data);
187187
| Property | Type | Required | Description |
188188
| :--- | :--- | :--- | :--- |
189189
| **success** | `boolean` | ✅ | Operation success status |
190-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
190+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
191191
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
192192
| **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | Full flow definition |
193193

@@ -213,7 +213,7 @@ const result = AutomationApiErrorCode.parse(data);
213213
| Property | Type | Required | Description |
214214
| :--- | :--- | :--- | :--- |
215215
| **success** | `boolean` | ✅ | Operation success status |
216-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
216+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
217217
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
218218
| **data** | `{ id: string; flowName: string; flowVersion?: integer; status: Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| … +2 more>; … }` | ✅ | Full execution log with step details |
219219

@@ -241,7 +241,7 @@ const result = AutomationApiErrorCode.parse(data);
241241
| Property | Type | Required | Description |
242242
| :--- | :--- | :--- | :--- |
243243
| **success** | `boolean` | ✅ | Operation success status |
244-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
244+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
245245
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
246246
| **data** | `{ flows: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | |
247247

@@ -269,7 +269,7 @@ const result = AutomationApiErrorCode.parse(data);
269269
| Property | Type | Required | Description |
270270
| :--- | :--- | :--- | :--- |
271271
| **success** | `boolean` | ✅ | Operation success status |
272-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
272+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
273273
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
274274
| **data** | `{ runs: object[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | |
275275

@@ -295,7 +295,7 @@ const result = AutomationApiErrorCode.parse(data);
295295
| Property | Type | Required | Description |
296296
| :--- | :--- | :--- | :--- |
297297
| **success** | `boolean` | ✅ | Operation success status |
298-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
298+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
299299
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
300300
| **data** | `{ name: string; enabled: boolean }` | ✅ | |
301301

@@ -325,7 +325,7 @@ const result = AutomationApiErrorCode.parse(data);
325325
| Property | Type | Required | Description |
326326
| :--- | :--- | :--- | :--- |
327327
| **success** | `boolean` | ✅ | Operation success status |
328-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
328+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
329329
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
330330
| **data** | `{ success: boolean; output?: any; error?: string; durationMs?: number }` | ✅ | |
331331

@@ -351,7 +351,7 @@ const result = AutomationApiErrorCode.parse(data);
351351
| Property | Type | Required | Description |
352352
| :--- | :--- | :--- | :--- |
353353
| **success** | `boolean` | ✅ | Operation success status |
354-
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +261 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
354+
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| … +262 more>; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
355355
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
356356
| **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The updated flow definition |
357357

0 commit comments

Comments
 (0)