Skip to content

Commit 2046d21

Browse files
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
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/core': patch
4+
'@objectstack/objectql': patch
5+
'@objectstack/rest': patch
6+
'@objectstack/client': patch
7+
---
8+
9+
feat(objectql,rest)!: a new assignment to a banned user is refused, and a banned user's records can be counted and handed over in bulk (#30205)
10+
11+
Clause-②: yes (narrowing) — the published `FieldErrorCode` enum gains the member `reference_user_banned`. A non-system create or update that sets a user field (the owner field, a `user` field, or a `lookup` to `sys_user`) to a user whose ban is in force is refused `400 VALIDATION_FAILED` with that field code, a write that answered `201` / `200` before. A value the row already stores is not judged, so a record that already holds the banned user stays editable. The two new routes and SDK methods are additions.
12+
Metadata-protocol: none
13+
Skills-docs: no move in `skills/**`, which teaches no user-assignment verdict; `content/docs/permissions/administrator-guide.mdx` gains "Handing over a banned user's work" and its offboarding quick path points at it.
14+
15+
<!-- adr-0087: not-required (no-migration-prescription) No key of any authorable schema is added, removed, renamed or re-shaped. What moves is the answer a data write gets when it names a banned user, and a record that already holds one is not judged, so nothing at rest needs rewriting. The new field code is a member of the response vocabulary FieldErrorCode, not of anything an author writes. -->
16+
17+
**BREAKING** accept-set narrowing at every data write door (`POST` / `PATCH /api/v1/data/OBJECT`, batch, import, and a flow's elevated record write, which keeps the reference check): banning a user signs them out, but the records they own and are assigned stay where they were, and new work could still be handed to them.
18+
19+
- **Before.** The Console's person picker hid a banned user, but `POST /api/v1/data/OBJECT` with `assignee` set to that user answered `201`, so new work landed on someone who cannot sign in.
20+
- **Now.** The write-time reference check also asks whether a referenced user's ban is in force (`banned` is true and `ban_expires` is empty or still ahead) and refuses such a value with `reference_user_banned`, beside `reference_not_found`. It is read the way the existence probe reads, so a caller who cannot read the user object is refused the same way. A ban whose expiry has passed is not in force.
21+
- **Unchanged.** A value every row of the write already stores (an edit form carries the whole record back), a system write the reference check already exempts (seed, install, restore), and a missing user (still `reference_not_found`).
22+
- **New: the count.** `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. Platform tables (`sys_*`), `managedBy` objects and `readonly` / `system` fields such as `created_by` are not counted; objects the caller cannot read are listed in `unreadable`; at most 200 fields are counted (`truncated`).
23+
- **New: the hand-over.** `POST /api/v1/security/users/ID/assigned-work/transfer` (`client.security.assignedWork.transfer`) with `{ toUserId, objects? }` moves up to 500 records per call to an active user, each as an ordinary update under the caller's context, and answers per field what moved and what failed, plus 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.
24+
- **`@objectstack/spec`.** `FieldErrorCode` gains `reference_user_banned`, with its message in `en`, `zh-CN`, `ja-JP` and `es-ES`.
25+
26+
**For a caller.** A write that names a banned user in a user field names an active one instead; the refusal names the field and the user. No stored metadata or row changes, so there is nothing for `objectstack migrate meta` to rewrite. An integration that reassigns work after a ban can call the transfer route and repeat it while `remaining` is above 0.

‎content/docs/permissions/administrator-guide.mdx‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,26 @@ through this membership, so don't skip it.
9898
Endpoint-level detail (payloads, password policies, phone-only accounts):
9999
[Authentication → Admin User Management](/docs/permissions/authentication#admin-user-management).
100100

101+
### Handing over a banned user's work
102+
103+
A ban does not move the records the person owns or is assigned. Two calls do,
104+
both run as you, so they count and change only what you could read and edit by
105+
hand:
106+
107+
- `GET /api/v1/security/users/USER_ID/assigned-work` answers, per object and
108+
user field (the owner field and every app user field such as an assignee),
109+
how many records hold the user. Objects you cannot read are listed in
110+
`unreadable`.
111+
- `POST /api/v1/security/users/USER_ID/assigned-work/transfer` with
112+
`{ "toUserId": "...", "objects": ["..."] }` (`objects` optional) moves up to
113+
500 records per call to an active user, each as an ordinary edit, so sharing,
114+
validation rules and hooks apply. It answers what moved, what failed and how
115+
many remain; call it again until `remaining` is 0.
116+
117+
The hand-over is optional and never delays the ban. Once a user is banned, a
118+
new record or edit that sets a user field to them is refused with the field
119+
code `reference_user_banned`; a record that already holds them stays editable.
120+
101121
## Step 3 — Assign positions
102122

103123
This is the daily 90%. An assignment is one row: **User Position**
@@ -181,7 +201,7 @@ Full model: [Permission Sets](/docs/permissions/permission-sets).
181201
| I need to… | Do this |
182202
|:--|:--|
183203
| Onboard a new employee | Invite/Create the user → Business Unit Member row → assign their position ([2](#step-2--add-people-users)→[3](#step-3--assign-positions)) |
184-
| Offboard someone | **Ban User** (blocks sign-in and the user's API keys immediately) — then remove assignments at leisure |
204+
| Offboard someone | **Ban User** (blocks sign-in and the user's API keys immediately) — then hand over their records ([below](#handing-over-a-banned-users-work)) and remove assignments at leisure |
185205
| "I can't sign in" | User record → is it banned? locked? **Unlock Account** or **Set Password** (temporary, must-change) |
186206
| "Why can't I see X?" | Studio → Access → **Explain access** for that user + object |
187207
| Someone changed departments | Update their Business Unit Member row; re-anchor their User Position rows |

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ const result = EnhancedApiErrorSchema.parse(data);
186186
| Property | Type | Required | Description |
187187
| :--- | :--- | :--- | :--- |
188188
| **field** | `string` | ✅ | Field path (supports dot notation) |
189-
| **code** | `Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| 'invalid_boolean' \| 'invalid_number' \| 'invalid_date' \| 'invalid_time' \| 'invalid_email' \| … +21 more>` | ✅ | Which constraint the value violated (field-level catalog, ADR-0114) |
189+
| **code** | `Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| 'invalid_boolean' \| 'invalid_number' \| 'invalid_date' \| 'invalid_time' \| 'invalid_email' \| … +22 more>` | ✅ | Which constraint the value violated (field-level catalog, ADR-0114) |
190190
| **message** | `string` | ✅ | Human-readable error message, rendered in the caller’s locale |
191191
| **label** | `string` | optional | Field display label in the caller’s locale |
192192
| **value** | `any` | optional | The invalid value that was provided |
@@ -220,6 +220,7 @@ const result = EnhancedApiErrorSchema.parse(data);
220220
* `invalid_value`
221221
* `reference_not_found`
222222
* `reference_ambiguous`
223+
* `reference_user_banned`
223224
* `rule_violation`
224225
* `json_schema_violation`
225226
* `invalid_initial_state`
@@ -258,6 +259,7 @@ const result = EnhancedApiErrorSchema.parse(data);
258259
* `invalid_value`
259260
* `reference_not_found`
260261
* `reference_ambiguous`
262+
* `reference_user_banned`
261263
* `rule_violation`
262264
* `json_schema_violation`
263265
* `invalid_initial_state`

‎packages/client/src/admin-surfaces.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,24 @@ describe('client.security.suggestedBindings', () => {
127127
expect(result).toEqual({ suggestion: { id: 'sug/3', status: 'pending' } });
128128
});
129129
});
130+
131+
describe('client.security.assignedWork (#30205)', () => {
132+
it('count GETs the user-scoped route, encoding the id, and unwraps the summary', async () => {
133+
const summary = { userId: 'u/1', objects: [{ object: 'ticket', field: 'owner_id', count: 3, transferable: true }], total: 3, truncated: false, unreadable: [] };
134+
const { client, fetchMock } = createMockClient({ success: true, data: summary });
135+
const result = await client.security.assignedWork.count('u/1');
136+
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/security/users/u%2F1/assigned-work');
137+
expect(result).toEqual(summary);
138+
});
139+
140+
it('transfer POSTs the target and the optional object list', async () => {
141+
const report = { fromUserId: 'u_1', toUserId: 'u_2', objects: [], updated: 0, failed: 0, remaining: 0 };
142+
const { client, fetchMock } = createMockClient({ success: true, data: report });
143+
const result = await client.security.assignedWork.transfer('u_1', { toUserId: 'u_2', objects: ['ticket'] });
144+
const [url, init] = fetchMock.mock.calls[0];
145+
expect(String(url)).toBe('http://localhost:3000/api/v1/security/users/u_1/assigned-work/transfer');
146+
expect(init.method).toBe('POST');
147+
expect(JSON.parse(init.body)).toEqual({ toUserId: 'u_2', objects: ['ticket'] });
148+
expect(result).toEqual(report);
149+
});
150+
});

‎packages/client/src/index.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,34 @@ import { RealtimeAPI } from './realtime-api';
220220
*/
221221
export type ApiRouteType = keyof ApiRoutes;
222222

223+
/**
224+
* [#30205] `security.assignedWork.count`'s answer: per object and user field,
225+
* how many records hold the user, as the caller can read them.
226+
*/
227+
export interface UserAssignedWorkSummary {
228+
userId: string;
229+
objects: Array<{ object: string; field: string; count: number; transferable: boolean }>;
230+
total: number;
231+
truncated: boolean;
232+
unreadable: string[];
233+
}
234+
235+
/** [#30205] `security.assignedWork.transfer`'s answer: what moved, what failed, what remains. */
236+
export interface UserWorkTransferReport {
237+
fromUserId: string;
238+
toUserId: string;
239+
objects: Array<{
240+
object: string;
241+
field: string;
242+
updated: number;
243+
failed: number;
244+
failures: Array<{ id: string; code: string; message: string }>;
245+
}>;
246+
updated: number;
247+
failed: number;
248+
remaining: number;
249+
}
250+
223251
export interface ClientConfig {
224252
baseUrl: string;
225253
token?: string;
@@ -6465,6 +6493,45 @@ export class ObjectStackClient {
64656493
return this.unwrapResponse(res);
64666494
},
64676495
},
6496+
6497+
/**
6498+
* [#30205] A user's open work — what an administrator needs when they ban
6499+
* someone. Both calls run as the CALLER: the counts are what the caller
6500+
* can read, and each moved record is an ordinary update under the
6501+
* caller's permissions (sharing, validation and hooks apply). Neither
6502+
* touches the ban, which stays `POST /api/v1/auth/admin/ban-user`.
6503+
*/
6504+
assignedWork: {
6505+
/**
6506+
* Per object and user field (the owner field and every app user
6507+
* field), how many records hold `userId`. Objects the caller cannot
6508+
* read are named in `unreadable`; `truncated` says more fields exist
6509+
* than were counted.
6510+
*/
6511+
count: async (userId: string): Promise<UserAssignedWorkSummary> => {
6512+
const res = await this.fetch(
6513+
`${this.baseUrl}/api/v1/security/users/${encodeURIComponent(userId)}/assigned-work`,
6514+
);
6515+
return this.unwrapResponse<UserAssignedWorkSummary>(res);
6516+
},
6517+
6518+
/**
6519+
* Hand up to 500 of `userId`'s records to the active user `toUserId`
6520+
* (optionally only on `objects`), and answer what moved, what failed
6521+
* and how many remain; call again until `remaining` is 0. A banned or
6522+
* unknown `toUserId` is refused before anything moves.
6523+
*/
6524+
transfer: async (
6525+
userId: string,
6526+
request: { toUserId: string; objects?: string[] },
6527+
): Promise<UserWorkTransferReport> => {
6528+
const res = await this.fetch(
6529+
`${this.baseUrl}/api/v1/security/users/${encodeURIComponent(userId)}/assigned-work/transfer`,
6530+
{ method: 'POST', body: JSON.stringify(request) },
6531+
);
6532+
return this.unwrapResponse<UserWorkTransferReport>(res);
6533+
},
6534+
},
64686535
};
64696536

64706537
/**

‎packages/core/src/security/index.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,27 @@ export {
537537
keepsFlowWriteReferenceCheck,
538538
} from './flow-write-reference-check.js';
539539

540+
// #30205 — a banned user's open work: the per-object counts, the optional bulk
541+
// hand-over (both read and written as the caller), and the ban-in-force
542+
// predicate objectql's write-time reference check refuses a new assignment by.
543+
export {
544+
USER_ASSIGNED_WORK_FIELD_CAP,
545+
USER_WORK_TRANSFER_ROW_CAP,
546+
USER_WORK_FAILURE_SAMPLE,
547+
userAssignedWorkFields,
548+
countUserAssignedWork,
549+
transferUserAssignedWork,
550+
UserWorkTransferRefusal,
551+
type UserAssignedWorkField,
552+
type UserWorkEngine,
553+
type UserAssignedWorkCount,
554+
type UserAssignedWorkSummary,
555+
type UserWorkFailureDescriber,
556+
type UserWorkTransferFailure,
557+
type UserWorkTransferOutcome,
558+
type UserWorkTransferReport,
559+
} from './user-assigned-work.js';
560+
540561
// Ruling of record 6355206139 — the EXTERNAL rung's OWD seam: plugin-security
541562
// stamps whether a held grant's RLS speaks, plugin-sharing floors the OWD.
542563
export {

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,14 @@ const CALLERS: Record<string, Classification> = {
264264
'the validation-predicate reads (`resolvePredicateRelated`: a pass/fail verdict, the value never served), and ' +
265265
"`resolveOneHopRelated`, the one-hop loader they and the formula fields read through, as the caller's own `find`",
266266
},
267+
// [#30205] A user's assigned work: which fields hold work for a user.
268+
'packages/core/src/security/user-assigned-work.ts': {
269+
kind: 'not-a-served-read',
270+
reason:
271+
'picks the fields whose target is the user object, then counts and updates rows of the object that DECLARES ' +
272+
'them, under the caller\'s own context; the only read of the target is the hand-over recipient\'s ban state ' +
273+
'(a fixed platform object), and no field of it is served',
274+
},
267275
'packages/objectql/src/integrity/dangling-reference-audit.ts': {
268276
kind: 'not-a-served-read',
269277
reason: 'an integrity audit: it probes whether a stored id resolves and reports ids, never a field of the target',

0 commit comments

Comments
 (0)