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 b388cfe
Browse filesBrowse the repository at this point in the historyBrowse files
fix(objectql)!: the engine refuses evaluating an internal field for every caller but a system context (#29003)
domain:server · os-dev · session_01FEkQ5nqYhjhRCqHrhVA59A
Refs #28284
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: updated here —
`content/docs/permissions/system-context.mdx` (row 18e) and
`content/docs/api/error-catalog.mdx` (`INVALID_FIELD`) state who is
refused and who is served; no file under `skills/**` teaches filtering
on an `internal: true` field, so no skill line changes.
## 维护者速读
- 改了什么:对声明为 `internal: true` 的字段做过滤、排序、分组或聚合,现在由引擎自己拒绝(`400
INVALID_FIELD`,与数据接口原有的拒绝同一句话),除系统上下文外对所有调用者生效;数据接口、HTTP MCP、stdio MCP
都改为问引擎这一处定义。
- 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)
-
风险与回滚:BREAKING(收窄)只影响一种写法:服务端代码以普通用户身份读引擎、并按内部字段过滤。树内所有这类查找都以系统身份运行,不受影响;回滚即还原本
PR。
-
先看:`packages/objectql/src/internal-field-evaluation-guard.ts`(唯一定义),`packages/objectql/src/engine.ts`
四个读入口的接线,`packages/metadata-protocol/src/protocol-query-validation.ts`
的委托与无闸门时的暴露检查。
- 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run
src/internal-field-evaluation-guard.test.ts`,预期 29 条全绿。
- 需要你做的:无,席位落地(落地前需另一位代理做契约复核)。
## What changes
**One definition, in the engine**
(`packages/objectql/src/internal-field-evaluation-guard.ts`). The
`internal: true` flag means a value is never returned on a generic data
exit, and the engine is what omits the column from rows. The refusal to
evaluate such a field now lives beside it:
- `find`, `findOne`, `count` and `aggregate` apply it before the
credential guard, to every caller but a system context (the credential
guard's ADR-0100 line). Positions: a filter (including nested-relation
conditions and `$field` comparands), a sort, a group key, an aggregation
operand, an aggregation's own filter, and the same positions inside
`expand`, each level against its own target.
- `ObjectQL#assertNoInternalFieldEvaluated(object, query, {
beforeRelationTarget })` answers the same question for any caller, for a
door that judges before it reads. The hook runs at each relation hop
before anything of the target is judged.
- The refusal is the data door's: `INVALID_FIELD` / 400 with `object`,
`field`, `fields` and `param`, and the same sentence.
**Spec** (`packages/spec/src/contracts/objectql-engine.ts`):
`QueryEvaluationGate`, `InternalFieldEvaluationQuery`,
`InternalFieldEvaluationOptions`; `IObjectQLEngine extends
QueryEvaluationGate`. API-surface and export-origin snapshots
regenerated.
**Data door** (`packages/metadata-protocol`):
`assertNoInternalFieldEvaluated` asks the engine member and hands it
`refuseUnservedRelationTarget` as the hop hook, so its answers and their
order are unchanged. Its private copy of the walk is deleted.
`MetadataHostEngine` types the member off the slot contract, optional
like `transaction`. On an engine without the member (a test double) the
door judges no field, and its relation-target exposure question is still
asked at every hop by an exposure-only walk.
**HTTP MCP bridge** (`packages/runtime/src/action-execution.ts`):
`callData` asks the engine member on its `aggregate` and `query` arms.
**stdio MCP bridge** (`packages/mcp/src/stdio-data-bridge.ts`): its copy
of the refusal is retired; the engine's read path answers it.
**Docs**: system-context census row 18e
(`rejectInternalFieldEvaluation`, register regenerated); the
`INVALID_FIELD` entry of the error catalog.
## Tests
- `objectql/src/internal-field-evaluation-guard.test.ts` (new, 29): real
`ObjectQL` over a recording driver. Member and admin refused in every
position, the driver never asked; a context-less caller judged; a system
context served, the column still off the row; ordinary fields served;
the public member binds every caller, runs the hop hook first, and
matches the read path's message.
- `objectql/src/protocol-data-door-internal-field-positions.test.ts`
(moved from metadata-protocol, unchanged cases, 50): the door's position
matrix over the engine member.
-
`metadata-protocol/src/protocol.data-door-internal-field-delegation.test.ts`
(new, 6): the door asks the member before the engine reads, for a system
context too, with its exposure question as the hook; an engine without
the member is judged on no field, and its exposure question is still
asked at every hop.
-
`runtime/src/action-execution-calldata-aggregate-internal-field.test.ts`
(rewritten, 51): real `ObjectQL`, compared with the real `findData`
envelope for member, admin and system.
- `mcp/src/stdio-data-bridge.internal-field-evaluation.test.ts`
(rewritten, 5).
-
`qa/dogfood/test/mcp-internal-field-evaluate-positions.dogfood.test.ts`:
the stdio and HTTP rows, each compared word for word with REST (48
rows).
- Moved pins: `internal-fields.test.ts` reads the flagged column as the
system context the real lookups use; census rows in
`federated-injected-column-readers.test.ts` and
`core/src/security/second-object-read-exposure.pin.test.ts`;
engine-double ledger regenerated.
## Acceptance notes
- **`Clause-②: yes (narrowing)`.** The read path narrows, and the
published `objectql` slot contract widens by a member (the reading of
the contract review on #28875, record 6567682353). `Metadata-protocol:
none`: nothing an author writes is refused. All five packages take
`patch`; a new spec type would take `minor`, but the changeset gate
refuses `minor` beside `none`. The changeset carries the BREAKING
banner, FROM → TO, and the ADR-0087 `no-migration-prescription` marker.
- **Exemption.** Every server lookup in the tree that matches on a
flagged column (API key, share link, SCIM credential, approval token,
the auth adapter) already runs as a system context; the census row names
them.
- **A CONTRACT_REVIEW_TIER review by another agent is owed before
landing.**
- Open PRs touching the engine read paths and
`protocol-query-validation.ts` were re-read before opening (#28887,
#28850, #28731, #28764): no shared symbol; `protocol.ts` overlaps
textually only. The branch merges cleanly onto current `main`.
## Verification
- **Red first:** measured on the base sources; the readings are in the
private hand-back the seat holds.
- **Ablation** (one time, `scripts/ablation-replace.mjs`): the engine
read path's call into the walk removed, dist rebuilt and
`ablation-dist-preflight --absent` exit 0: 24 objectql pins and 28
dogfood rows red. Restored: blob == HEAD (`a5773c29`), `git diff HEAD`
empty, present-mode preflight 4 dist hits.
- **Suites** (full `pnpm --filter <pkg> test`, exit 0): spec 789 files /
23315 tests; objectql 526 / 10114; metadata-protocol 297 (+4 skipped) /
22049; runtime 512 / 8296; mcp 52 / 640; core 129 / 2892. `test:repo`:
spec, objectql, runtime exit 0.
- **Typecheck:** `turbo run typecheck --continue` 188 of 190 tasks; the
other two (`cli`, `dogfood`) ran out of heap at the default and pass
with `NODE_OPTIONS=--max-old-space-size=6144` (70 of 70).
`check:test-typecheck` exit 0 for spec, objectql, runtime, mcp. `eslint
--no-inline-config` over the touched files: exit 0.
- **Dogfood battery** (owed by the `(narrowing)` arm), under
`os-verify-lock.sh`: `pnpm --filter @objectstack/dogfood test` exit 0,
382 files passed, 3 skipped (3172 tests passed, 28 skipped). Run before
the last fixup, which touched only a runtime test's null guard, a
metadata-protocol test double, the engine-double ledger and the spec
snapshots, none of which the battery reads.
- **Gates:** `dispatch-gates.mjs --commands` derived 118 on this head;
`--ran` reconciles 118 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. The
four artifact-roster gates whose roster sits under a touched directory
(`check:meta-url-spelling`, `check:authz-resolver`,
`check:error-code-casing`, `check:route-ledger-census`) exit 0.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `eslint` passes on the touched files.
- [x] 4. One branch off `main`, one pull request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: the generated artifacts that move
(api-surface, export-origins) are regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English, apart from the
maintainer quick-read.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5nqYhjhRCqHrhVA59A)_
Co-authored-by: Claude <noreply@anthropic.com>
fix(objectql)!: the engine refuses filtering, sorting, grouping and aggregating on an `internal: true` field for every caller but a system context
10
+
11
+
Clause-②: yes (narrowing)
12
+
Metadata-protocol: none
13
+
14
+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and nothing an author writes is refused: the `internal: true` flag is read as before. The narrowing is a runtime one, on reads made as a user: a query that evaluates a flagged field answers 400 INVALID_FIELD from the engine itself, with the data door's sentence. The published type change adds a member to the `objectql` slot contract and is satisfied by the one implementer, ObjectQL. There is nothing for `objectstack migrate meta` to rewrite and no tombstone; the remedy is the refusal's own sentence (use another field), or running the server's own read as a system context. -->
15
+
Skills-docs: updated here — `content/docs/permissions/system-context.mdx` (row 18e: the engine's read path) and `content/docs/api/error-catalog.mdx` (`INVALID_FIELD`) state who is refused and who is served; no file under `skills/**` teaches filtering on an `internal: true` field, so no skill line changes.
16
+
17
+
**BREAKING** for one shape only: server code that reads the engine as a user (not as a system context) and filters, sorts, groups or aggregates on a field declared `internal: true`.
18
+
19
+
- FROM: the engine ran such a read.
20
+
- TO: the engine refuses it with the data door's `400 INVALID_FIELD`, naming the object, the field and the position, before anything is read. A read made as a system context (`isSystem: true`) is served as before, and the flagged column is still never returned on a row.
21
+
22
+
What to do: run a server-side lookup that matches on a flagged field as a system context, or match on another field.
23
+
24
+
-**`@objectstack/objectql`**: the `internal: true` evaluate refusal is defined once, in the engine. `find`, `findOne`, `count` and `aggregate` apply it to every caller but a system context, in every position the data door judges: a filter (including a relation hop and a `$field` comparand), a sort, a group key, an aggregation operand and an aggregation's own filter, and the same positions inside `expand`. `ObjectQL#assertNoInternalFieldEvaluated` answers the same question for any caller, for a door that judges before it reads.
25
+
-**`@objectstack/spec`**: new types on the `objectql` slot contract: `QueryEvaluationGate`, `InternalFieldEvaluationQuery` and `InternalFieldEvaluationOptions`. `IObjectQLEngine` extends `QueryEvaluationGate`.
26
+
-**`@objectstack/metadata-protocol`**: the data door asks the engine's refusal instead of keeping its own copy. Its answers are unchanged.
27
+
-**`@objectstack/runtime`**: the HTTP MCP data bridge asks the engine's refusal directly. Its answers are the data door's.
28
+
-**`@objectstack/mcp`**: the stdio data bridge's own copy of the refusal is retired in favour of the engine's. Its answers are the data door's.
29
+
30
+
The level is patch rather than the minor a new spec type would take: the changeset gate refuses `minor` beside `Metadata-protocol: none`, and nothing an author writes is refused.
Copy file name to clipboardExpand all lines: content/docs/permissions/system-context.mdx
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -123,6 +123,7 @@ bugs — it is the protection or the side effect that silently does not happen.
123
123
| 18b | Settings namespace read scope not applied | service-settings | Get: a read of `sys_setting`, `sys_setting_audit` or `sys_platform_setting` across every namespace, whatever each namespace's `readPermission` names — the settings service's own reads of its stores take this path. Lose: the per-namespace read gate the generic data API applies to every other caller, the same rule the settings routes enforce |`settingsReadDoorMiddleware`|
124
124
| 18c |**Credential-column filter / sort refusal not applied** — a `where`, `orderBy`, per-aggregation `filter` or `expand` entry naming a `password` / `secret` column is evaluated | objectql | Get: the server's own match on a stored credential value, a lookup that never returns it. Every other caller, administrators included, is refused `400 INVALID_FIELD` for anything but a has-a-value check (ADR-0100). Lose: nothing a wire caller reaches — `isSystem` is never set on an inbound request |`rejectCredentialEvaluation`|
125
125
| 18d |**Stored-metadata type read gate not applied** — a read of `sys_metadata` / `sys_metadata_history` is not narrowed by the capability its row's type requires | objectql | Get: every row of the family on `find` / `findOne` / `count` / `aggregate` — the metadata loader, the `/meta` doors' own store reads, boot (a read with no execution context is not narrowed either). Lose: the narrowing that serves a stored row of a capability-gated type (`datasource`, `external_catalog`) only to holders of the capability the `/meta` type gate names for it |`storedMetadataTypeReadFilter`|
126
+
| 18e |**`internal: true` evaluate refusal not applied on the engine's read path** — a filter, sort, group key, aggregation operand, per-aggregation `filter`, nested-relation condition or `expand` entry naming a field declared `internal: true` is evaluated | objectql | Get: the server's own match on a flagged column (the API-key, share-link, SCIM-credential and approval-token lookups, the better-auth adapter), which still never returns the value. Every other caller of `find` / `findOne` / `count` / `aggregate`, administrators included, is refused `400 INVALID_FIELD`; the data door asks the same judgement for every caller, a system context included. Lose: nothing a wire caller reaches — `isSystem` is never set on an inbound request |`rejectInternalFieldEvaluation`|
126
127
127
128
### 2. Write pipeline and data integrity
128
129
@@ -544,6 +545,7 @@ and CI refuses one that differs from what the tree produces.
0 commit comments