Skip to content

Commit 25f0fb7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-27601-container-carries-refusal
2 parents 57664f3 + d93d9be commit 25f0fb7

23 files changed

Lines changed: 793 additions & 34 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/rest': patch
3+
'@objectstack/plugin-security': patch
4+
---
5+
6+
fix(rest)!: `security/explain` refuses an explicit empty `userId` with 400 instead of explaining the caller (#27442)
7+
8+
Clause-②: no (narrowing) — `GET`/`POST /api/v1/security/explain` refuses a request whose `userId` is the empty string, which it answered before. Nothing is widened.
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: the explain door refuses one request value, an empty userId, that it used to drop and answer as the caller. There is no authored or stored shape for a migration to rewrite and no tombstone. -->
12+
13+
**BREAKING** accept-set narrowing at the access-explanation door (`@objectstack/rest`), held by the engine too (`@objectstack/plugin-security`).
14+
15+
- **Before.** `POST /api/v1/security/explain` with `"userId": ""` (or `GET …?userId=`) dropped the empty id and answered `200` with an explanation of the CALLER. A client that lost the selected user (an empty picker) got a confident answer about a principal nobody asked about.
16+
- **Now.** The door answers `400 VALIDATION_FAILED`, in the same envelope as its other request refusals, and no explanation is computed. On `GET`, `?userId=` names the key, so it counts as explicit and is refused the same way.
17+
- **Unchanged.** An omitted `userId` still explains the caller; a `userId` naming another user is explained, refused `403`, or refused `404 RECORD_NOT_FOUND` exactly as before.
18+
- **Engine.** `explainAccessForCaller` no longer reads an empty `userId` as "the caller": it goes through the other-user arm, so a direct caller gets the `403` (no right to explain others) or the `404 RECORD_NOT_FOUND` an unknown id gets.
19+
20+
**The remedy.** Omit `userId` to explain your own access, or send the id of the user to explain.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/i18n': patch
3+
---
4+
5+
fix(i18n): the Studio `trackHistory` switch describes the v3 record page, not a History tab
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The object settings' `trackHistory` switch was still labelled "History tab" and said it shows a read-only History tab on record pages. The v3 record page has no History tab: field changes join the record's one timeline stream, under its Changes filter, as the spec's `enable.trackHistory` text says. The label and description now say that, in English and Chinese (the two languages the Studio catalogue carries). Copy only: the flag and what it does are unchanged.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: a non-system write to a `secret` field may only seal a new value or keep its own record's reference (#27582)
6+
7+
Clause-②: no (narrowing) — a non-system write to a `secret` field refuses a value already spelled `secret:` unless it is the reference that record's column already holds (so a create always refuses one), where it stored such a value as given before. Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: a write value the engine used to store as given is refused unless it is the record's own reference. The fix for a caller is to send the secret's value, write null, or omit the field, which the refusal says; there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing in the credential write path (`@objectstack/objectql`), served by every engine write door and every REST data door that writes through one.
13+
14+
- **Before.** A `secret` field value that starts with `secret:` is read as a reference to a credential already stored, not as a credential. Such a value was stored as given, from any caller, on a create or an update.
15+
- **Now.** From any caller but the system principal, such a value is accepted only when it is the reference that record's column already holds. On an update, every record the write lands on must already hold exactly that value, and the column is then left as it is, as an echoed read mask is. A create has no reference of its own, so it never takes one. Anything else is refused with `400 VALIDATION_ERROR`, naming the object and the field and never the value, before any credential row or record is written. The answer is the same whatever the reference names. This holds on the single and batch create, `insertMany` (the refused row is reported on its own and the others land), the by-id update and the predicate update, so on every REST door that writes through them: create, bulk, import and update.
16+
- **Unchanged.** The system principal (`isSystem: true`: seed replay, migration) still stores such a value as given. A new value is sealed as before, `null` clears, an echoed read mask changes nothing, and reads still mask. The Console's password field sends back the mask it read, so the form round trip is unaffected.
17+
- **Who sees it.** A caller without the system context (an API client, an import, a hook or script) that sends a `secret:` value to a `secret` field other than the reference that record already holds.
18+
19+
**The remedy.** To set the secret, send its value; to clear it, write `null`; to keep it, omit the field or echo the read mask.

‎content/docs/ai/connect-mcp.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,8 @@ Your own keys are listed (prefix only) and revoked under **Account → Developer
118118
→ API Keys**. Platform admins get the tenant-wide list at **Setup → Access
119119
Control → API Keys**, which requires `manage_platform_settings`.
120120

121-
Or mint one over REST:
121+
Or mint one over REST, from a signed-in session (the `-b cookies.txt` below). A
122+
request authenticated with an API key is refused `403 PERMISSION_DENIED`:
122123

123124
```bash
124125
curl -b cookies.txt -X POST https://your-deployment.example.com/api/v1/keys

‎content/docs/api/index.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ permission model grants anonymous users. Two ways to authenticate:
6868
- **API key** (scripts, CI, headless agents): mint one with `POST /api/v1/keys`
6969
(the key is shown once), or from the **Connect an Agent** page in the Console
7070
— **Account → Developer** for any signed-in user, **Setup → Connect an Agent**
71-
for platform admins. Send it as `x-api-key: osk_…` or
71+
for platform admins. Minting needs a signed-in session: a request
72+
authenticated with an API key is refused `403 PERMISSION_DENIED`, so a key
73+
cannot mint another key. Send the key as `x-api-key: osk_…` or
7274
`Authorization: Bearer osk_…`. Keys expire (90 days by default, at most a
7375
year) and stop working after 90 days without use; a refused key answers
7476
`403` with `API_KEY_EXPIRED`, `API_KEY_INACTIVE` or `API_KEY_REVOKED`, and the

‎content/docs/permissions/explain.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/jso
7171

7272
`operation` is one of `read | create | update | delete | transfer | restore |
7373
purge | export` (defaults to `read`); omit `userId` to explain **yourself**.
74+
An empty `userId` (`"userId": ""`, or `?userId=` with no value) names nobody, so
75+
it is refused `400 VALIDATION_FAILED` rather than read as "yourself".
7476

7577
`export` is the one operation whose answer you cannot infer from `read`. Since
7678
the [export axis](/docs/permissions/permission-sets#allowexport--the-export-axis)

‎content/docs/permissions/system-context.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ bugs — it is the protection or the side effect that silently does not happen.
136136
| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no position in the security catalog is not refused | plugin-security | Get: a system writer can store an assignment naming no position — the seed loader writes `stack.data` from `AppPlugin.start()` under a system context, as invitation acceptance and the platform's own bootstraps do. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `position`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no position of that name: the environment registry (ADR-0131 D3), one per deployment, which takes no organization, so a name only a `sys_position` row carries — any organization's — is refused like one nobody carries. It is the same stand-down as the engine's referential-integrity check for a lookup column | `assertPositionNamesCatalogRow` |
137137
| 23c | **Grant-name catalog check skipped** — a `sys_user_permission_set` write whose `permission_set` names no permission set in the security catalog is not refused | plugin-security | Get: a system writer can store a grant naming a set not yet registered — seed replay and boot provisioning may write the grant before the definition it names is registered. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `permission_set`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no set by that name; a grant that names no set at all is refused `required` for every caller. The grant names its set by name alone, and nothing derives the name from `permission_set_id` (ADR-0131 D4, D10) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onInsert`, `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onUpdate` |
138138
| 23d | **Grant-holder membership check skipped** — a `sys_user_position` or `sys_user_permission_set` write naming a user with no `sys_member` row in the row's organization is not refused | plugin-security | Get: a system writer can store such a grant — invitation acceptance writes the membership and its placement together, and seed replay, the organization-admin reconcile and the platform bootstraps write grants as the system. Lose: such a row takes effect once its user is admitted to the organization. A non-system insert, or a non-system update that moves `user_id` or `organization_id`, is refused `400 VALIDATION_FAILED` / `reference_not_found` at `user_id` instead, for every caller, a platform administrator included; the membership is read as `{ isSystem: true, tenantId }` with `tenantId` the row's own organization, never another one. A row stored with no organization is not judged. Under `single`, 18.0 stamps a new grant with the Default Organization, so such a row is one an earlier release wrote | `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert`, `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onUpdate` |
139+
| 23e | **A `secret` field takes a stored reference as given** — a value already spelled `secret:` is stored unchanged | objectql | Get: a system writer may copy a `secret:` reference onto a record as it stands (seed replay, a migration). Every other caller may only seal a new value or keep its own record's ref: on a create any such value is refused, and on an update it is accepted only when every record the write lands on already holds exactly that reference in that column, which is then left as it is, as an echoed read mask is. Any other reference is refused `400 VALIDATION_ERROR` before anything is written, with one answer whatever it names. Lose: the check that the reference is the record's own — a system writer that copies one is trusted to mean it. Inbound HTTP never carries the flag | `encryptSecretFields` |
139140
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `buildDriverOptions` |
140141
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite`, `packages/plugins/plugin-security/src/system-write-guard.ts#assertEngineOwnedWriteAllowed` |
141142
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` |
@@ -532,6 +533,7 @@ and CI refuses one that differs from what the tree produces.
532533
| `packages/objectql/src/engine-master-detail-related.ts#resolveOneHopRelated` | 1 | 29b |
533534
| `packages/objectql/src/engine-referential-integrity.ts#assertReferencesResolve` | 1 | 23 |
534535
| `packages/objectql/src/engine-referential-integrity.ts#masterDetailHeadersCallerReads` | 1 | 29c |
536+
| `packages/objectql/src/engine-secret-internal-fields.ts#encryptSecretFields` | 1 | 23e |
535537
| `packages/objectql/src/engine-secret-internal-fields.ts#stripSearchCompanionFromRead` | 1 | 27 |
536538
| `packages/objectql/src/engine-validate.ts#validate` | 1 | 21b |
537539
| `packages/objectql/src/engine.ts#dependentCountIsDisclosable` | 1 | 28 |

0 commit comments

Comments
 (0)