Repository navigation
Commit 2046d21
feat(objectql,rest)!: refuse a new assignment to a banned user; count and hand over their work (#30546)
domain:server#3 · os-bill · session_01Utp6CZavYYLh5k5r6jhgCF
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgVoGotjVhGswNmAoy5Ki3yc)_
Part of #30205
Clause-②: yes (narrowing) — the published `FieldErrorCode` enum gains
the member `reference_user_banned`, and a non-system create or update
that sets a user field to a user whose ban is in force is refused `400
VALIDATION_FAILED` / `reference_user_banned`, a write that answered
`201` / `200` before. A value the row already stores is not judged.
Metadata-protocol: none
## 维护者速读
- 改了什么:新记录不能再指派给已封禁的用户(写入返回 400,字段码
`reference_user_banned`);新增两条接口,按对象统计某用户名下的记录,并可一次性把它们转给一位在用的用户。
- 为什么改:封禁只会让人下线,他名下的工单、客户还挂着,新的工作也还能派给他,管理员没有办法交接。
-
风险与回滚:这是一次收窄(BREAKING):以前能成功的「指派给封禁用户」现在被拒。已经存着该用户的记录照常可编辑,系统写入(种子、安装、恢复)不受影响。封禁状态与存在性探测在同一次读取里取回,写入不多花一次查询。回滚就是
revert 本 PR,没有数据迁移。
-
先看:`packages/objectql/src/engine-referential-integrity.ts`(拒绝逻辑)、`packages/objectql/src/engine-references-and-master-detail.ts`
的
`referenceRow`(探测读取顺带取封禁列)、`packages/core/src/security/user-assigned-work.ts`(计数与交接的规则)、`packages/rest/src/user-work-routes.ts`(两条路由)。
- 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run
src/engine-banned-user-reference.test.ts`,应看到 10 个测试全部通过。
- 需要你做的:无。这是非治理路径、低于 3000 行的 BREAKING 收窄,按 pm-dispatch 由席位在合约审查 PASS
后落地;界面一半(封禁对话框显示计数并提供交接)仍在 #30205 上,所以这里写 Part of。
## What changes
**1. A new assignment to a banned user is refused.** The write-time
reference check (`assertReferencesResolve`) now also asks, for every
caller-supplied value whose target is `sys_user`, whether that user's
ban is in force (`banned` is true and `ban_expires` is empty or still
ahead), and refuses it with the field code `reference_user_banned`
beside `reference_not_found`. The ban columns come back in the existence
probe's own read (`referenceRow`, which selects `id` plus the
`USER_BAN_FIELDS` the target declares), so a write that names a user
pays no read beyond the one it already paid, and the read keeps the
probe's elevated, tenant-scoped context: a caller who cannot read the
user object is refused the same way. The #29062 rule carries over
unchanged: a value every row of the write already stores is not judged,
so an edit form that carries the whole record back still saves. A system
write keeps its exemption; a flow's `runAs: 'system'` keeps the check,
as it does for `reference_not_found`. If the probe's read itself fails,
the write is refused for neither reason, as before, and a warning names
the field. The check covers every object the engine writes, `sys_*`
tables included (a permission-set grant to a banned user is refused
too), while the count and hand-over below skip `sys_*` on purpose.
**2. Count a user's work.** `GET
/api/v1/security/users/:id/assigned-work`
(`client.security.assignedWork.count`) answers, per object and user
field, how many records hold the user, counted under the caller's own
context. The owner field, `user` fields and lookups to `sys_user` count;
platform tables (`sys_*`), `managedBy` objects, disabled packages and
`readonly` / `system` fields such as `created_by` do not. Objects the
caller cannot read are named in `unreadable`; at most 200 fields are
counted (`truncated`). Each count is one query the caller could issue
through `/data`; it is an administrator surface, not one to poll.
**3. Hand it over.** `POST
/api/v1/security/users/:id/assigned-work/transfer`
(`client.security.assignedWork.transfer`) with `{ toUserId, objects? }`
moves up to 500 records per call, each as an ordinary `update` under the
caller's context, so sharing, validation, approval locks and hooks all
apply. It answers per field what moved, what failed (row id, code, and
the sentence the `/data` door answers for the same throw, at most 10 per
field) and how many remain. A banned, unknown or identical `toUserId` is
refused before any write. Multi-valued user fields are counted but not
transferred. Neither route touches the ban: the ban stays `POST
/api/v1/auth/admin/ban-user`, urgent and alone.
### The new `FieldErrorCode` member
`@objectstack/spec`'s `FieldErrorCode` is a closed response vocabulary,
and this PR adds one member, `reference_user_banned`, with its message
template in `en`, `zh-CN`, `ja-JP` and `es-ES`
(`validation-message.ts`). That widens the published response face by
one member, which is why the declaration is `Clause-②: yes`. It is not
an authorable key: no schema an author writes gains, loses or reshapes
anything, which is why the changeset carries the ADR-0087 `not-required
(no-migration-prescription)` marker and `Metadata-protocol: none`. A
client that switches on the closed set gains one case; the regenerated
`content/docs/references/api/errors.mdx` lists it.
### Where the code lives, and the `engine.ts` touch
- The count and hand-over rules live in `@objectstack/core`
(`security/user-assigned-work.ts`): `@objectstack/rest` has objectql
only as a dev dependency, so a module both can import at runtime has to
sit in core. The ban predicate has one owner, core's
`security/user-ban.ts` (#30287): objectql takes `isUserBanInForce` and
`USER_BAN_FIELDS` from it through `@objectstack/core`, and
`user-assigned-work.ts` through `./user-ban.js`.
- `engine.ts` is in facade form on `main` (#29986), so the read lives in
its facet: in
`packages/objectql/src/engine-references-and-master-detail.ts`,
`referenceExists` now delegates to a new `referenceRow(target, id,
context, extraFields)`, the same `findOne` under
`ObjectQL.referenceCheckContext`, which also selects the extra fields
the target declares and lets a read error through. The catch stays in
`referenceExists`, where the read-seam baseline already records its
`null` as the declared third state, and the ban path catches its own
call, logs a warning and fails open. `packages/objectql/src/engine.ts`
gains only a one-line `referenceRow` stub, its slot in the facet host,
and the `referenceRow` and `logger` slots in the reference-check host
literal; `check:facade-delegation-only` and `check:durability-log-level`
are green and the baseline is untouched.
### Census rows the new routes need
- The two routes answer through the shared `sendOk` / `sendError` and
are declared to `check:route-envelope` at zero.
- The new REST family `user-work` arrives classified in the
authorization conformance matrix: row `anonymous-deny-user-work` (both
handlers open with `enforceAuth`), with its booted proof in
`showcase-anonymous-deny-surfaces.dogfood.test.ts` (anonymous 401 on
both routes; a member's count served 200, and a member's hand-over
reaching its own unknown-target 404 before any write). The matrix
docblock's family figure moves 18 → 19.
- The registrar file joins `REST_SERVER_REGISTRAR_FILES`, the docblock
floor (6), the canonical-query table (0) and the `objectQLProvider`
consumer census (6 sites). The two routes are `sdk` rows in the route
ledger.
- The transfer is a `no-record-echo` row in the internal-fields tripwire
and an `ANSWERS_THE_SENTENCE` row in the hook-refusal ledger: a
sandboxed hook refusing a moved row reads, in `failures[].message`, as
the hook's own sentence, and every fault shape §6 drives keeps its words
withheld.
- `user-assigned-work.ts` is classified `not-a-served-read` in core's
second-object read-exposure pin: the target read answers only a yes/no
about the target, never a row.
## Tests
- `packages/objectql/src/engine-banned-user-reference.test.ts` (10):
insert refused and nothing written; the read is elevated (the caller
cannot read `sys_user` and is refused all the same); a `user` field and
a future expiry; a multi-valued field refused for its banned member
only; active and expired-ban users allowed; update refused; a stored
value stays editable; a system write is exempt; a missing user stays
`reference_not_found`; a user read that throws refuses nothing and logs
a warning.
- `packages/core/src/security/user-assigned-work.test.ts` (11): field
picking, counts under the caller's context, `unreadable`, disabled
packages, the hand-over, per-row failures, the row cap and `remaining`,
the three refusals, the elevated target read, and the per-door failure
wording. The ban predicate's own tests stay with `user-ban.ts`.
- `packages/rest/src/user-work-routes.test.ts` (7) and
`packages/client/src/admin-surfaces.test.ts` (+2).
- The plugin-audit read-cost pins (`audit-milestone-summary`,
`audit-option-label-summary`, `implicit-reference-target-summary`) are
unchanged and green: a create that names a user still pays zero extra
reads.
- The new engine doubles open `update` with
`assertEngineUpdateDispatch`, refuse a logical operator rather than read
it as a field, and hold a `limit`, so `check:engine-double-contract`
(ledger rows recorded with `--write`), `check:where-matcher` and
`check:objectql-double-limit` stay green.
## Acceptance notes
- The ui half (the Setup ban dialog showing the counts and offering the
hand-over) stays on #30205; this PR does not touch `ui/**`.
- A record already assigned to the banned user stays editable on
purpose: refusing it would make every record they held uneditable until
someone moved it.
- The transfer does not run elevated. A row the caller could not change
by hand is a reported failure, not a skipped one, so `remaining` tells
the administrator what is left.
- Any caller who can write a user field can learn that a user id is
banned (the probe already disclosed existence); the code is not a
secret, and the ui half need not treat it as one.
## Review round 1
Contract review FAIL (comment 6662784185) named five must-fixes; each is
in this head:
1. plugin-audit read-count pins red: the ban columns are now read in the
existence probe's own `findOne` (`referenceRow`, d5d8874, moved into
the references facet by the main merge ab3fc8d) instead of a second
`find` on `sys_user`. No pin moved; the plugin-audit suite is green at
this head.
2. Tracker id in the matrix row's runtime `note:` string: moved to the
comment above the row (ed85181). No baseline entry.
3. Dogfood and gates pinned before the main merge: both re-run in full
at this head (Verification below).
4. Declaration: `Clause-②: yes (narrowing)` here and in the changeset;
the BREAKING banner, `patch`, the ADR-0087 marker and
`Metadata-protocol: none` stay.
5. `rest-server.ts` docblocks: `registerUserWorkEndpoints` now sits
after `registerSecurityExplainEndpoints`, each under its own docblock,
and the diff against main in that file is a pure addition (5856fb0).
The closing line became `Part of #30205` (no closing verb): the ui half
is still open on the card. `main` was merged again (ab3fc8d) to clear
a conflict with the #29986 codemod's fourth pass (#30463) in
`engine.ts`; the conflict resolution had moved the probe's catch into
`referenceRow`, which the read-seam rule flagged, so a5de9d1 puts it
back in `referenceExists`.
## Verification
Head `a5de9d1e6` (the landing head), verified locally under the shared
verify lock.
- **Dogfood (narrowing arm)**: `pnpm --filter @objectstack/dogfood test`
after a full `turbo build`, lock VERDICT command-exit 0, Test Files 420
passed / 3 skipped (423), Tests 3500 passed / 36 skipped (3536), no
failures.
- **Suites**: `@objectstack/plugin-audit` 71 files / 1137 tests,
including the three read-cost pins; `@objectstack/objectql` 553 files /
11034 tests; lock VERDICT command-exit 0.
- **Gates**: all 128 families `dispatch-gates.mjs --commands` derives
for this diff ran, and `--ran` reconciles 128 / 128 at exit 0. One was
red on the first run: `@objectstack/spec check:generated`, where
`check:test-typecheck`'s `tsc` died without diagnostics under the
default heap. Re-run with `NODE_OPTIONS=--max-old-space-size=8192` it
exits 0 with all 14 artifacts current. That is the same heap ceiling
earlier rounds hit, not a stale artifact.
- **Targeted**: `check:durability-log-level` (102 read seams, 1
baselined, none stale), `check:facade-delegation-only`,
`check:engine-double-contract`, `check:query-options-erasure`,
`check:adr-0087-registration`, `check:error-code-casing`, all exit 0.
- **Real boot, before and after** (on `6e44006f9`, before the main
merges; the behaviour has not moved since). Run with `pnpm dev --
--fresh`, the showcase app, field `showcase_announcement.owner_id`.
"Before" is that head with the ban check and the route registration
ablated in `src` and both packages rebuilt; `ablation-dist-preflight`
proved the markers absent from `dist/`, then present again after the
restore and rebuild.
| step | before | after |
|---|---|---|
| create a record owned by B, then ban B | `201` | `201` |
| **new** record with `owner_id: B` | `201` (assigned to a banned user)
| `400 VALIDATION_FAILED`, `fields[0].code: reference_user_banned` |
| edit the record that already holds B | `200` | `200` (stored value not
judged) |
| `GET …/users/B/assigned-work` | `404` | `200`,
`showcase_announcement.owner_id` count 2 |
| transfer to a banned user | `404` | `400`, `toUserId` /
`reference_user_banned`, nothing written |
| transfer to the admin | `404` | `200`: `updated 1`, `failed 1`
(`RECORD_LOCKED`, the edited record sits in an approval), `remaining 1`
|
- **Ablations** (`scripts/ablation-replace.mjs`, wrap mode, each
restored to the HEAD blob):
- Deleting the ban push turns 4 of the banned-user tests red.
- Counting with an empty filter turns the count test red.
- Dropping the banned-target guard turns 2 refusal tests red.
- Dropping the rest failure describer turns the hand-over row of the
hook-refusal ledger red on every §6 shape.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent fabd259 commit 2046d21
30 files changed
Lines changed: 1499 additions & 22 deletions
File tree
- .changeset
- content/docs
- permissions
- references/api
- packages
- client/src
- core/src/security
- objectql/src
- qa/dogfood/test
- rest/src
- spec/src
- api
- system
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
98 | 98 | | |
99 | 99 | | |
100 | 100 | | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
101 | 121 | | |
102 | 122 | | |
103 | 123 | | |
| |||
181 | 201 | | |
182 | 202 | | |
183 | 203 | | |
184 | | - | |
| 204 | + | |
185 | 205 | | |
186 | 206 | | |
187 | 207 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
186 | 186 | | |
187 | 187 | | |
188 | 188 | | |
189 | | - | |
| 189 | + | |
190 | 190 | | |
191 | 191 | | |
192 | 192 | | |
| |||
220 | 220 | | |
221 | 221 | | |
222 | 222 | | |
| 223 | + | |
223 | 224 | | |
224 | 225 | | |
225 | 226 | | |
| |||
258 | 259 | | |
259 | 260 | | |
260 | 261 | | |
| 262 | + | |
261 | 263 | | |
262 | 264 | | |
263 | 265 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
127 | 127 | | |
128 | 128 | | |
129 | 129 | | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
220 | 220 | | |
221 | 221 | | |
222 | 222 | | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
223 | 251 | | |
224 | 252 | | |
225 | 253 | | |
| |||
6465 | 6493 | | |
6466 | 6494 | | |
6467 | 6495 | | |
| 6496 | + | |
| 6497 | + | |
| 6498 | + | |
| 6499 | + | |
| 6500 | + | |
| 6501 | + | |
| 6502 | + | |
| 6503 | + | |
| 6504 | + | |
| 6505 | + | |
| 6506 | + | |
| 6507 | + | |
| 6508 | + | |
| 6509 | + | |
| 6510 | + | |
| 6511 | + | |
| 6512 | + | |
| 6513 | + | |
| 6514 | + | |
| 6515 | + | |
| 6516 | + | |
| 6517 | + | |
| 6518 | + | |
| 6519 | + | |
| 6520 | + | |
| 6521 | + | |
| 6522 | + | |
| 6523 | + | |
| 6524 | + | |
| 6525 | + | |
| 6526 | + | |
| 6527 | + | |
| 6528 | + | |
| 6529 | + | |
| 6530 | + | |
| 6531 | + | |
| 6532 | + | |
| 6533 | + | |
| 6534 | + | |
6468 | 6535 | | |
6469 | 6536 | | |
6470 | 6537 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
537 | 537 | | |
538 | 538 | | |
539 | 539 | | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
540 | 561 | | |
541 | 562 | | |
542 | 563 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
264 | 264 | | |
265 | 265 | | |
266 | 266 | | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
267 | 275 | | |
268 | 276 | | |
269 | 277 | | |
| |||
0 commit comments