Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/30205-banned-user-work-handover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@objectstack/spec': patch
'@objectstack/core': patch
'@objectstack/objectql': patch
'@objectstack/rest': patch
'@objectstack/client': patch
---

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)

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.
Metadata-protocol: none
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.

<!-- 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. -->

**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.

- **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.
- **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.
- **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`).
- **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`).
- **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.
- **`@objectstack/spec`.** `FieldErrorCode` gains `reference_user_banned`, with its message in `en`, `zh-CN`, `ja-JP` and `es-ES`.

**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.
22 changes: 21 additions & 1 deletion content/docs/permissions/administrator-guide.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,26 @@ through this membership, so don't skip it.
Endpoint-level detail (payloads, password policies, phone-only accounts):
[Authentication → Admin User Management](/docs/permissions/authentication#admin-user-management).

### Handing over a banned user's work

A ban does not move the records the person owns or is assigned. Two calls do,
both run as you, so they count and change only what you could read and edit by
hand:

- `GET /api/v1/security/users/USER_ID/assigned-work` answers, per object and
user field (the owner field and every app user field such as an assignee),
how many records hold the user. Objects you cannot read are listed in
`unreadable`.
- `POST /api/v1/security/users/USER_ID/assigned-work/transfer` with
`{ "toUserId": "...", "objects": ["..."] }` (`objects` optional) moves up to
500 records per call to an active user, each as an ordinary edit, so sharing,
validation rules and hooks apply. It answers what moved, what failed and how
many remain; call it again until `remaining` is 0.

The hand-over is optional and never delays the ban. Once a user is banned, a
new record or edit that sets a user field to them is refused with the field
code `reference_user_banned`; a record that already holds them stays editable.

## Step 3 — Assign positions

This is the daily 90%. An assignment is one row: **User Position**
Expand Down Expand Up @@ -181,7 +201,7 @@ Full model: [Permission Sets](/docs/permissions/permission-sets).
| I need to… | Do this |
|:--|:--|
| 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)) |
| Offboard someone | **Ban User** (blocks sign-in and the user's API keys immediately) — then remove assignments at leisure |
| 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 |
| "I can't sign in" | User record → is it banned? locked? **Unlock Account** or **Set Password** (temporary, must-change) |
| "Why can't I see X?" | Studio → Access → **Explain access** for that user + object |
| Someone changed departments | Update their Business Unit Member row; re-anchor their User Position rows |
Expand Down
4 changes: 3 additions & 1 deletion content/docs/references/api/errors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ const result = EnhancedApiErrorSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **field** | `string` | ✅ | Field path (supports dot notation) |
| **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) |
| **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) |
| **message** | `string` | ✅ | Human-readable error message, rendered in the caller’s locale |
| **label** | `string` | optional | Field display label in the caller’s locale |
| **value** | `any` | optional | The invalid value that was provided |
Expand Down Expand Up @@ -220,6 +220,7 @@ const result = EnhancedApiErrorSchema.parse(data);
* `invalid_value`
* `reference_not_found`
* `reference_ambiguous`
* `reference_user_banned`
* `rule_violation`
* `json_schema_violation`
* `invalid_initial_state`
Expand Down Expand Up @@ -258,6 +259,7 @@ const result = EnhancedApiErrorSchema.parse(data);
* `invalid_value`
* `reference_not_found`
* `reference_ambiguous`
* `reference_user_banned`
* `rule_violation`
* `json_schema_violation`
* `invalid_initial_state`
Expand Down
21 changes: 21 additions & 0 deletions packages/client/src/admin-surfaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,24 @@ describe('client.security.suggestedBindings', () => {
expect(result).toEqual({ suggestion: { id: 'sug/3', status: 'pending' } });
});
});

describe('client.security.assignedWork (#30205)', () => {
it('count GETs the user-scoped route, encoding the id, and unwraps the summary', async () => {
const summary = { userId: 'u/1', objects: [{ object: 'ticket', field: 'owner_id', count: 3, transferable: true }], total: 3, truncated: false, unreadable: [] };
const { client, fetchMock } = createMockClient({ success: true, data: summary });
const result = await client.security.assignedWork.count('u/1');
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/security/users/u%2F1/assigned-work');
expect(result).toEqual(summary);
});

it('transfer POSTs the target and the optional object list', async () => {
const report = { fromUserId: 'u_1', toUserId: 'u_2', objects: [], updated: 0, failed: 0, remaining: 0 };
const { client, fetchMock } = createMockClient({ success: true, data: report });
const result = await client.security.assignedWork.transfer('u_1', { toUserId: 'u_2', objects: ['ticket'] });
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('http://localhost:3000/api/v1/security/users/u_1/assigned-work/transfer');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ toUserId: 'u_2', objects: ['ticket'] });
expect(result).toEqual(report);
});
});
67 changes: 67 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,34 @@ import { RealtimeAPI } from './realtime-api';
*/
export type ApiRouteType = keyof ApiRoutes;

/**
* [#30205] `security.assignedWork.count`'s answer: per object and user field,
* how many records hold the user, as the caller can read them.
*/
export interface UserAssignedWorkSummary {
userId: string;
objects: Array<{ object: string; field: string; count: number; transferable: boolean }>;
total: number;
truncated: boolean;
unreadable: string[];
}

/** [#30205] `security.assignedWork.transfer`'s answer: what moved, what failed, what remains. */
export interface UserWorkTransferReport {
fromUserId: string;
toUserId: string;
objects: Array<{
object: string;
field: string;
updated: number;
failed: number;
failures: Array<{ id: string; code: string; message: string }>;
}>;
updated: number;
failed: number;
remaining: number;
}

export interface ClientConfig {
baseUrl: string;
token?: string;
Expand Down Expand Up @@ -6465,6 +6493,45 @@ export class ObjectStackClient {
return this.unwrapResponse(res);
},
},

/**
* [#30205] A user's open work — what an administrator needs when they ban
* someone. Both calls run as the CALLER: the counts are what the caller
* can read, and each moved record is an ordinary update under the
* caller's permissions (sharing, validation and hooks apply). Neither
* touches the ban, which stays `POST /api/v1/auth/admin/ban-user`.
*/
assignedWork: {
/**
* Per object and user field (the owner field and every app user
* field), how many records hold `userId`. Objects the caller cannot
* read are named in `unreadable`; `truncated` says more fields exist
* than were counted.
*/
count: async (userId: string): Promise<UserAssignedWorkSummary> => {
const res = await this.fetch(
`${this.baseUrl}/api/v1/security/users/${encodeURIComponent(userId)}/assigned-work`,
);
return this.unwrapResponse<UserAssignedWorkSummary>(res);
},

/**
* Hand up to 500 of `userId`'s records to the active user `toUserId`
* (optionally only on `objects`), and answer what moved, what failed
* and how many remain; call again until `remaining` is 0. A banned or
* unknown `toUserId` is refused before anything moves.
*/
transfer: async (
userId: string,
request: { toUserId: string; objects?: string[] },
): Promise<UserWorkTransferReport> => {
const res = await this.fetch(
`${this.baseUrl}/api/v1/security/users/${encodeURIComponent(userId)}/assigned-work/transfer`,
{ method: 'POST', body: JSON.stringify(request) },
);
return this.unwrapResponse<UserWorkTransferReport>(res);
},
},
};

/**
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,27 @@ export {
keepsFlowWriteReferenceCheck,
} from './flow-write-reference-check.js';

// #30205 — a banned user's open work: the per-object counts, the optional bulk
// hand-over (both read and written as the caller), and the ban-in-force
// predicate objectql's write-time reference check refuses a new assignment by.
export {
USER_ASSIGNED_WORK_FIELD_CAP,
USER_WORK_TRANSFER_ROW_CAP,
USER_WORK_FAILURE_SAMPLE,
userAssignedWorkFields,
countUserAssignedWork,
transferUserAssignedWork,
UserWorkTransferRefusal,
type UserAssignedWorkField,
type UserWorkEngine,
type UserAssignedWorkCount,
type UserAssignedWorkSummary,
type UserWorkFailureDescriber,
type UserWorkTransferFailure,
type UserWorkTransferOutcome,
type UserWorkTransferReport,
} from './user-assigned-work.js';

// Ruling of record 6355206139 — the EXTERNAL rung's OWD seam: plugin-security
// stamps whether a held grant's RLS speaks, plugin-sharing floors the OWD.
export {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,14 @@ const CALLERS: Record<string, Classification> = {
'the validation-predicate reads (`resolvePredicateRelated`: a pass/fail verdict, the value never served), and ' +
"`resolveOneHopRelated`, the one-hop loader they and the formula fields read through, as the caller's own `find`",
},
// [#30205] A user's assigned work: which fields hold work for a user.
'packages/core/src/security/user-assigned-work.ts': {
kind: 'not-a-served-read',
reason:
'picks the fields whose target is the user object, then counts and updates rows of the object that DECLARES ' +
'them, under the caller\'s own context; the only read of the target is the hand-over recipient\'s ban state ' +
'(a fixed platform object), and no field of it is served',
},
'packages/objectql/src/integrity/dangling-reference-audit.ts': {
kind: 'not-a-served-read',
reason: 'an integrity audit: it probes whether a stored id resolves and reports ids, never a field of the target',
Expand Down
Loading
Loading