You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit fdc2dc0
Browse filesBrowse the repository at this point in the historyBrowse files
fix(metadata-protocol)!: a save into a second package of an object a runtime-authored row owns is refused before any write, as a code-owned object's is (ADR-0029 D9.9)
6
+
7
+
Clause-②: no (narrowing)
8
+
9
+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is added, removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the accept set of the metadata save door for one shape: an `object` save naming a package other than the one whose runtime-authored row already owns the object is now refused where it was written and then discarded by the registry. The remedy is in the request a caller sends (bind it to the owner's package, or name no package), never a rewrite of a package source or a stored row; a row of that shape stored before this change is not rewritten (the registry never held it) and leaves by an ordinary delete naming its package. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
10
+
11
+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12
+
13
+
An object has exactly one registry entry, so it has exactly one owning package (ADR-0029 D3), and `sys_metadata` must not describe a shape the registry cannot hold (D9.9). The save door enforced that against a CODE-shipped owner only. Against an object that a package's `sys_metadata` row created at runtime, a save naming a second package was stored, and the registry's single-owner rule then refused it into a warning. The save answered `200`, the data plane kept the first package's schema, and `sys_metadata` held a second-package row that nothing served.
14
+
15
+
`saveMetaItem` now refuses that save before any write, with the refusal and the prescription that the code-owner case gives.
16
+
17
+
What changes for you, where package `P`'s runtime-authored row owns object `NAME` and `Q` is any other package:
18
+
19
+
- FROM `PUT /api/v1/meta/object/NAME?package=Q` (`saveMetaItem({ type: 'object', name: NAME, packageId: Q, … })`) answering `200` "Saved object … package=Q", with a `Q`-bound row stored while the registry kept `P`'s schema and logged `registerObject failed … already owned by package "P"`, TO `422 OBJECT_OVERLAY_PACKAGE_MISMATCH` with nothing stored for `Q`: "Cannot layer object 'NAME': the overlay is bound to package 'Q', but the object is owned by package 'P'. …"
20
+
- FROM the same save with `?mode=draft` storing a `Q`-bound draft row, TO the same `422` with no draft row.
21
+
- FROM `duplicatePackage` from `P` to `Q` listing in `copied` an object whose name does not carry `P`'s namespace prefix (so the copy keeps `NAME`), with a `Q`-bound row stored that the registry never held, TO that object listed in `failed` with the refusal's text and nothing stored for it.
22
+
- The same save naming `P`, or naming no package, answers `200` as before.
23
+
24
+
**Remedy, in one line.** Save the object with `?package=P` (or with no package, which addresses `P`'s row), or give `Q`'s object its own name. A `Q`-bound row stored before this change is removed with `DELETE /api/v1/meta/object/NAME?package=Q`.
25
+
26
+
**Also changed, in a log line only.** At boot, `loadMetaFromDb` still counts a stored second-package row in its per-record `errors` and leaves `P` registered. Its `Failed to hydrate object/NAME` warning now carries the D9.9 refusal's text and `code=OBJECT_OVERLAY_PACKAGE_MISMATCH`, in place of the registry's ownership message and `code=OBJECT_OWNERSHIP_CONFLICT`.
27
+
28
+
**Unchanged.**
29
+
30
+
- A code-shipped owner. The package door still answers first (`403 NOT_OVERRIDABLE`).
31
+
- Every type except `object`. Two packages may still hold one name.
feat(plugin-security,cli)!: `sys_user_permission_set.permission_set_id` is retired, the boot-time grant-name backfill is deleted, and a grant is unique per user, set and organization (ADR-0131 D4/D10)
**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes.
14
+
15
+
A permission-set grant names its set by `permission_set`, its one reference (ADR-0131 D4). The legacy id column is no longer declared, and the grant's uniqueness now keys the name.
16
+
17
+
FROM → TO:
18
+
19
+
-`sys_user_permission_set.permission_set_id` → `permission_set`, the machine name of the set the grant holds. A seed row or data-door write that still carries `permission_set_id` is refused with `400 INVALID_FIELD`. Fix: delete the key and name the set, `{ user_id, permission_set: '<set name>', organization_id }`.
20
+
- The unique index (`user_id`, `permission_set_id`, `organization_id`) and the (`permission_set_id`) index → one unique index on (`user_id`, `permission_set`, `organization_id`), declared `unique: 'organization'`: its organization key part is NULL-safe (ADR-0120 D3), so it refuses a second grant of one set to one user in one organization AND a second platform-wide grant (organization NULL), for every writer and every write path — batches, multi-row updates, concurrent writes and raw SQL included. The data doors answer `409 UNIQUE_VIOLATION` (the engine throws `DuplicateRecordError`, `DUPLICATE_RECORD`). Fix: keep the grant that exists; to change it, update that row.
21
+
- The boot-time grant-name backfill (`runOneTimeGrantPermissionSetNameBackfill`, the `sys_migration` entry it recorded at `kernel:bootstrapped`) → `os migrate organization-ownership --apply`. Its verified rewrite names every grant from the physical `permission_set_id` column by raw SQL, verifies each name against the security catalog, and its `column-drops` step then drops the column through the schema-drift ceremony (schema sync is additive, so an undeclared column stays until it is dropped). Fix: run the ceremony on every database upgraded from a release before the `permission_set` column existed, then restart the server: the boot's organization-administrator sweep revokes, by name, a newly named grant whose pair no longer qualifies, and a server left running across `--apply` serves it until then.
22
+
23
+
On a 17.x database every grant names nothing until `--apply` names it, so between the first v18 boot and `--apply` no grant confers anything, the platform administrator's included.
24
+
25
+
What else changes:
26
+
27
+
-`os migrate organization-ownership --plan` reports a `grants` block on `sys_user_permission_set`, keyed like the new index (a NULL organization keyed with NULL):
28
+
-`duplicates`: groups of named grants holding one set for one user in one organization. `--apply`'s `column-drops` step does not drop `permission_set_id` while one group remains.
29
+
-`rewriteCollisions`: legacy grants whose derived name their user already holds in the same organization. `--apply`'s rewrite names none of them and blocks, naming both ids.
30
+
- On a database without the `permission_set` column it counts zero and names the remedy (boot this release once), instead of refusing the plan.
31
+
32
+
The ceremony deletes no grant: remove the one you do not keep, then re-run.
33
+
- The organization-administrator reconcile and its boot sweep write no grant for a pair holding an unnamed grant in that organization: the ceremony's rewrite names that grant, and a named grant beside it would hold the key the rename needs.
34
+
-`--apply` drops the two retired indexes before the column, in a separate earlier schema-drift apply: MySQL refuses to drop a column a key still uses.
35
+
- The organization-administrator reconcile and its boot sweep, the platform-admin bootstrap's unnamed-grant leg (Leg C) and the catalog reference report read grants by name only. A legacy grant that names nothing confers nothing, still withholds the platform-admin promotion while it is unscoped, and is reached by no revocation until the ceremony's rewrite names it; once named, the boot sweep revokes it where its pair no longer qualifies. The report's `UnnamedGrant` entries lose `permissionSetId`.
36
+
-`GET /meta/object/sys_user_permission_set` no longer serves the hidden `permission_set_id` field.
fix(core,cloud-connection,mcp)!: every API-key admission names its owner-posture source, so a held owner's key is refused at the marketplace install-local doors and on every MCP stdio call
10
+
11
+
Clause-②: no (narrowing) — no published accept-set widens; two more seams refuse a key whose owner holds an account-scope posture, the owner-posture input of the shared key-admission seam becomes required, and `resolveApiKeyPrincipal` no longer answers a held owner's key with a principal.
12
+
13
+
<!-- adr-0087: not-required (runtime-interface-only packages/core/src/security/resolve-authz-context.ts#ResolveAuthzInput) An authentication-policy change at runtime TypeScript call sites, not a metadata change. No authorable spec key, stored shape or metadata surface moves, so `objectstack migrate meta` has nothing to rewrite. The input that becomes required lives on a runtime interface and on two runtime functions of `@objectstack/core` (`resolveApiKeyAdmission`, `resolveApiKeyPrincipal`), none of them a metadata surface; the affected party is a TypeScript caller, and the compiler names its call site. The packages publish (not unpublished), no ADR-0087 id covers this and none is added (not registered or already-registered). -->
14
+
15
+
**BREAKING** (an accept-set narrowing at two API-key doors, and a required input on the shared key-admission seam), shipped as `minor` under the launch-window convention for breaking changes.
16
+
17
+
Ruling C′ on #23287, item 1: "a held user's API key is refused at every seam with the same code". Item 4: "The refusal is not a revocation: the key resumes the moment the user enrols (or the account posture clears)". `AUTH_GATE_SCOPES` decides which posture holds a key: `MFA_REQUIRED` (scope `account`) holds it; `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` (scope `credential`) do not.
18
+
19
+
-**The marketplace install-local doors (`@objectstack/cloud-connection`).** The resolver behind all five doors now hands the shared resolver the auth service's per-user posture computation and refuses on the posture it decided for whichever credential admitted the caller. A key whose owner holds `MFA_REQUIRED` gets `403 MFA_REQUIRED` with the session's message, before any effect. It used to be served there.
20
+
-**The MCP stdio transport (`@objectstack/mcp`).**`OS_MCP_STDIO_API_KEY` is resolved with the owner's posture on every call of a live session, and a held owner's call is refused before any read or write with the envelope REST answers (`code``MFA_REQUIRED`, `status` 403, the session's remediation message). A data tool's error and the record resource carry that message, as they carry every other refusal on this transport. A held key still starts the transport and says so once in a warning: the key is valid, and the next call after the owner enrols is served with no restart.
21
+
-**The owner-posture input is required (`@objectstack/core`).**`ResolveAuthzInput.userAuthGates`, and the `tenancyPosture` and `userAuthGates` parameters of `resolveApiKeyAdmission` and `resolveApiKeyPrincipal`, are now required; `undefined` is a value passed on purpose. A seam that forgets the source no longer compiles, which is how the two seams above were missed. `@objectstack/runtime` re-exports these, so its importers see the same change.
22
+
-**`resolveApiKeyPrincipal` collapses a held owner's key to no principal.** A principal-or-nothing answer cannot carry the posture's code, so a host that answers requests should read `resolveApiKeyAdmission` and refuse its `held` outcome with `gate.code`.
23
+
-**The storage download authorizer (`@objectstack/service-storage`)** names the same source. The storage family's door already refused a held owner's key on the same request, so nothing changes on the wire.
24
+
25
+
What changes for you:
26
+
27
+
-**Refused now:** an API key whose owner owes MFA enrolment past the grace window gets `403 MFA_REQUIRED` at the install-local doors, and every principal-bound MCP stdio call it makes is refused with the same code and message. It works again the moment the owner enrols, with nothing reissued.
28
+
-**Unchanged:** a key whose owner's password expired, or whose administrator forced a password change, keeps working on both.
29
+
-**Code that calls `resolveAuthzContext`, `resolveApiKeyAdmission` or `resolveApiKeyPrincipal` directly** now passes the owner-posture source explicitly: `userAuthGates: userAuthGateSourceOf(authService)` wherever the kernel's `auth` service is reachable, or `undefined` on a kernel composed without `plugin-auth`. The two positional functions also take `tenancyPosture` explicitly (`undefined` when no `tenancy` service is registered).
Copy file name to clipboardExpand all lines: content/docs/permissions/tenant-audit-census.mdx
+16-16Lines changed: 16 additions & 16 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.
122
122
123
123
The same holds twice over for the context. An options argument spelled as a
124
124
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
125
-
forwarding shim cannot, and **53 of the 227 sites are spelled that way**. A
125
+
forwarding shim cannot, and **53 of the 225 sites are spelled that way**. A
126
126
context resolved from an inline literal or a local `const` can be tested for
127
127
`isSystem`; one arriving from a helper call cannot.
128
128
@@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
187
187
188
188
| carried figure | where it survives | this census |
189
189
| :--- | :--- | ---: |
190
-
| 175 write call sites | quoted in the merged changeset |**227**|
190
+
| 175 write call sites | quoted in the merged changeset |**225**|
191
191
| 24 carrying no tenant context | quoted in the merged changeset |**2** provable and tenancy-enabled; **32** more whose options argument is unreadable |
192
-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card |**154 of 227** decidable, **73** undecidable |
193
-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration**|**not reproduced**: 122 decidably elevated, 0 decidably not, 97 undecidable |
192
+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card |**154 of 225** decidable, **71** undecidable |
193
+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration**|**not reproduced**: 121 decidably elevated, 0 decidably not, 96 undecidable |
194
194
| 141 and 132, two independent re-derivations | the card that filed this work | — |
195
195
196
196
**The differences are not reconciled, and deliberately so.** The old census's
@@ -207,11 +207,11 @@ would report a smaller number and would not say so.
207
207
208
208
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
209
209
figure has no surviving corroboration anywhere in the tree.** This census reads
210
-
122 of 227 (54%) as decidably elevated, with 97 more whose elevation is a
210
+
121 of 225 (54%) as decidably elevated, with 96 more whose elevation is a
211
211
run-time fact — so the claim is neither confirmed nor refuted, and the honest
212
212
answer is that a static reading cannot settle it.
213
213
214
-
⇒ **Cite `2 / 227`, and say what it is**: the sites whose options argument was
214
+
⇒ **Cite `2 / 225`, and say what it is**: the sites whose options argument was
215
215
READ and holds no tenant context, against a decidably tenancy-enabled object.
216
216
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
217
217
without tenant context" — **32 further sites** have an options argument this
@@ -223,31 +223,31 @@ cannot read, and they are neither in nor out.
223
223
224
224
| what | count |
225
225
| :--- | ---: |
226
-
| write call sites on the application surface |**227**|
226
+
| write call sites on the application surface |**225**|
227
227
| …whose object name is statically decidable | 154 |
228
-
| …whose object name is chosen at run time |73|
228
+
| …whose object name is chosen at run time |71|
229
229
| …against an object with tenancy ENABLED | 153 |
230
230
| …against an object that declares tenancy off | 1 |
231
-
| threading a tenant context |166|
231
+
| threading a tenant context |164|
232
232
| PROVABLY carrying none (options read, no context key) |**8**|
233
233
| …of those, against a decidably tenancy-enabled object |**2**|
234
234
| options argument UNREADABLE — may or may not carry one | 53 |
235
235
| …of those, against a decidably tenancy-enabled object | 32 |
236
-
| threading a decidably ELEVATED (`isSystem`) context |122|
236
+
| threading a decidably ELEVATED (`isSystem`) context |121|
237
237
| threading a context that is decidably NOT elevated | 0 |
238
-
| threading a context whose elevation is a run-time fact |97|
238
+
| threading a context whose elevation is a run-time fact |96|
239
239
240
240
| how the instrument reached the site | count |
241
241
| :--- | ---: |
242
-
| receiver carried a readable engine type |189|
242
+
| receiver carried a readable engine type |187|
243
243
| receiver erased, placed by the object NAME | 28 |
244
244
| receiver erased, placed by an `object: string` PARAMETER | 5 |
245
245
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
246
246
247
247
| object name spelled inline | 104 |
248
248
| object name spelled through a `const`| 50 |
249
249
| object name is an `object: string` parameter | 7 |
250
-
| object name is some other run-time expression |66|
250
+
| object name is some other run-time expression |64|
251
251
252
252
### Subtractions the census could NOT defend — enforced
253
253
@@ -297,12 +297,12 @@ holds still. They are required to be HERE and to say WHEN they were true;
297
297
their values are not compared. The reasoning, and the measurement behind it,
298
298
are in `scripts/check-tenant-audit-census.mjs`.
299
299
300
-
Measured on 2026-10-14 at `f98ac45c5`.
300
+
Measured on 2026-10-14 at `95898bba1`.
301
301
302
302
| corpus scale (not enforced) | count |
303
303
| :--- | ---: |
304
-
| tracked non-test sources scanned |650|
305
-
| engine-shaped types recognised |76|
304
+
| tracked non-test sources scanned |649|
305
+
| engine-shaped types recognised |75|
306
306
| declared objects in the registry | 116 |
307
307
| same-named calls subtracted as non-engine | 164 |
0 commit comments