From 0aabb9d150a98d37e75fc1589ec9e919d9fc57c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:51:07 +0000 Subject: [PATCH 01/13] feat(core,objectql): count and hand over a banned user's work; refuse a new assignment to a banned user (WIP) Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude --- packages/core/src/security/index.ts | 21 ++ .../core/src/security/user-assigned-work.ts | 317 ++++++++++++++++++ .../src/engine-referential-integrity.ts | 66 +++- packages/objectql/src/engine.ts | 4 + packages/spec/src/api/errors.zod.ts | 4 + .../spec/src/system/validation-message.ts | 4 + 6 files changed, 412 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/security/user-assigned-work.ts diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 38a675ce52b..5bed3c8b1b8 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -533,6 +533,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, + isUserBanInForce, + userAssignedWorkFields, + countUserAssignedWork, + transferUserAssignedWork, + UserWorkTransferRefusal, + type UserAssignedWorkField, + type UserWorkEngine, + type UserAssignedWorkCount, + type UserAssignedWorkSummary, + 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 { diff --git a/packages/core/src/security/user-assigned-work.ts b/packages/core/src/security/user-assigned-work.ts new file mode 100644 index 00000000000..5247b9b4246 --- /dev/null +++ b/packages/core/src/security/user-assigned-work.ts @@ -0,0 +1,317 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30205] A banned user's open work: how much of it there is, and one + * optional bulk hand-over to someone else. + * + * Banning a user signs them out and keeps them out, but the records they own + * and the records assigned to them stay where they were, and nobody is told. + * This module answers the two questions an administrator has at that moment — + * "how many records does this person hold?" and "hand them to someone" — and + * the predicate the write path uses to stop new work landing on a banned user. + * + * ## Which records count + * + * Every business object's user reference: a field whose target is + * {@link USER_OBJECT} (a `user` field, or a `lookup` to it). That includes the + * platform's `owner_id`, which is itself a lookup to the user object, and the + * app's own fields such as an `assignee`. Left out, on purpose: + * + * - `readonly` and `system` fields: `created_by` / `updated_by` record who did + * something, not who holds the work, and moving them would rewrite history. + * - platform tables (`sys_*`) and `managedBy` objects: `sys_user_permission_set.user_id` + * is a grant, not work, and moving it would move the person's permissions. + * - a column the platform registers but provisions no storage for (an injected + * anchor on a federated object, `unprovisionedInjectedColumns`). + * + * A multi-valued user field is counted (any member names the user) but never + * transferred: replacing one member of a list is a different edit from + * reassigning a record, and the answer says `transferable: false`. + * + * ## Whose reads and writes + * + * The CALLER's. Every count and every row read runs under the caller's own + * context, so RBAC, row-level security and sharing decide what is counted — + * "across the objects the administrator can read" — and an object the caller + * cannot read is named in `unreadable` instead of being reported as zero. + * Every transfer write is an ordinary `update` under the same context, so + * sharing, validation rules and hooks apply to each row as they would to a + * hand edit, and a row the caller may not change is reported as a failure. + * Nothing here elevates except reading the TARGET user's ban state, which is + * a fact about the user and not about the caller's visibility (see + * {@link isUserBanInForce}). + * + * ## Bounded, repeatable, never in the ban's way + * + * At most {@link USER_ASSIGNED_WORK_FIELD_CAP} (object, field) pairs are + * counted, and the answer says `truncated` past that. One transfer call moves + * at most {@link USER_WORK_TRANSFER_ROW_CAP} rows and answers how many still + * name the user (`remaining`), so a caller repeats it until that is zero. The + * ban itself neither waits for nor depends on any of this: a ban can be urgent. + */ + +import { referenceTargetOf, unprovisionedInjectedColumns } from '@objectstack/spec/data'; + +/** The platform user object every user reference targets. */ +const USER_OBJECT = 'sys_user'; + +/** At most this many (object, field) pairs are counted in one answer. */ +export const USER_ASSIGNED_WORK_FIELD_CAP = 200; + +/** At most this many rows are moved by one transfer call. */ +export const USER_WORK_TRANSFER_ROW_CAP = 500; + +/** At most this many failed rows are described per (object, field) pair. */ +export const USER_WORK_FAILURE_SAMPLE = 10; + +/** + * Is this user row's ban in force? + * + * `banned` is `true` and the ban has no expiry, or one still in the future. + * better-auth leaves `banned` true past `ban_expires` until the user's next + * sign-in clears it, and such a user CAN sign in again, so an expired ban is + * not in force. A row that is not an object, or has no `banned: true`, is not + * banned. + */ +export function isUserBanInForce(row: unknown, now: number = Date.now()): boolean { + if (!row || typeof row !== 'object') return false; + const { banned, ban_expires: expires } = row as { banned?: unknown; ban_expires?: unknown }; + if (banned !== true) return false; + if (expires === null || expires === undefined || expires === '') return true; + const at = expires instanceof Date ? expires.getTime() : new Date(expires as string | number).getTime(); + // An expiry that does not parse cannot prove the ban lifted. + if (Number.isNaN(at)) return true; + return at > now; +} + +/** One user reference a record can hold work through. */ +export interface UserAssignedWorkField { + object: string; + field: string; + /** A multi-valued user field: counted, never transferred. */ + multiple: boolean; +} + +interface ObjectLike { + name?: unknown; + managedBy?: unknown; + fields?: unknown; +} + +/** + * The user references the records of `objects` hold work through, in + * declaration order, and whether the cap cut the list short. + */ +export function userAssignedWorkFields( + objects: Iterable, + cap: number = USER_ASSIGNED_WORK_FIELD_CAP, +): { fields: UserAssignedWorkField[]; truncated: boolean } { + const fields: UserAssignedWorkField[] = []; + for (const candidate of objects) { + const obj = candidate as ObjectLike; + const name = typeof obj?.name === 'string' ? obj.name : undefined; + if (!name || name.startsWith('sys_')) continue; + if (obj.managedBy !== undefined && obj.managedBy !== null) continue; + if (!obj.fields || typeof obj.fields !== 'object') continue; + const phantom = new Set(unprovisionedInjectedColumns(obj as any)); + for (const [field, def] of Object.entries(obj.fields as Record)) { + if (phantom.has(field)) continue; + if (referenceTargetOf(def) !== USER_OBJECT) continue; + const d = def as { readonly?: unknown; system?: unknown; multiple?: unknown }; + if (d.readonly === true || d.system === true) continue; + if (fields.length >= cap) return { fields, truncated: true }; + fields.push({ object: name, field, multiple: d.multiple === true }); + } + } + return { fields, truncated: false }; +} + +/** + * The slice of the data engine this module reads and writes through — the + * engine's own public methods, so every call takes the ordinary path. + */ +export interface UserWorkEngine { + getConfigs(): Record; + count(object: string, query?: any): Promise; + find(object: string, query?: any): Promise; + update(object: string, data: any, options?: any): Promise; +} + +/** The filter that matches the records a field holds `userId` in. */ +function holdsUser(ref: UserAssignedWorkField, userId: string): Record { + return { [ref.field]: ref.multiple ? { $contains: userId } : userId }; +} + +/** One (object, field) pair's count. */ +export interface UserAssignedWorkCount { + object: string; + field: string; + count: number; + transferable: boolean; +} + +/** What {@link countUserAssignedWork} answers. */ +export interface UserAssignedWorkSummary { + userId: string; + /** Every pair with at least one record, in declaration order. */ + objects: UserAssignedWorkCount[]; + /** The sum of `objects[].count`: record-field holdings, not distinct records. */ + total: number; + /** More pairs exist than were counted. */ + truncated: boolean; + /** Objects whose count the caller's read refused or could not run. */ + unreadable: string[]; +} + +/** Per-pair counts of the records that hold `userId`, read as the caller. */ +export async function countUserAssignedWork( + engine: UserWorkEngine, + userId: string, + context: unknown, +): Promise { + const { fields, truncated } = userAssignedWorkFields(Object.values(engine.getConfigs() ?? {})); + const objects: UserAssignedWorkCount[] = []; + const unreadable = new Set(); + let total = 0; + for (const ref of fields) { + if (unreadable.has(ref.object)) continue; + let count: number; + try { + count = await engine.count(ref.object, { where: holdsUser(ref, userId), context }); + } catch { + unreadable.add(ref.object); + continue; + } + if (!(count > 0)) continue; + objects.push({ object: ref.object, field: ref.field, count, transferable: !ref.multiple }); + total += count; + } + return { userId, objects, total, truncated, unreadable: [...unreadable] }; +} + +/** A refusal of the transfer as a whole, before any row moves. */ +export class UserWorkTransferRefusal extends Error { + constructor( + readonly status: number, + readonly code: 'VALIDATION_ERROR' | 'RESOURCE_NOT_FOUND', + message: string, + readonly field?: { field: string; code: 'reference_not_found' | 'reference_user_banned' }, + ) { + super(message); + this.name = 'UserWorkTransferRefusal'; + } +} + +/** One row a transfer could not move. */ +export interface UserWorkTransferFailure { + id: string; + code: string; + message: string; +} + +/** One (object, field) pair's transfer outcome. */ +export interface UserWorkTransferOutcome { + object: string; + field: string; + updated: number; + failed: number; + /** The first {@link USER_WORK_FAILURE_SAMPLE} failures. */ + failures: UserWorkTransferFailure[]; +} + +/** What {@link transferUserAssignedWork} answers. */ +export interface UserWorkTransferReport { + fromUserId: string; + toUserId: string; + objects: UserWorkTransferOutcome[]; + updated: number; + failed: number; + /** Records the caller can read that still hold the user in a transferable field. */ + remaining: number; +} + +function failureOf(id: unknown, err: unknown): UserWorkTransferFailure { + const e = err as { code?: unknown; message?: unknown } | null; + return { + id: String(id), + code: typeof e?.code === 'string' && e.code.length > 0 ? e.code : 'UPDATE_FAILED', + message: String(e?.message ?? err).slice(0, 500), + }; +} + +/** + * Move up to {@link USER_WORK_TRANSFER_ROW_CAP} records from `fromUserId` to + * `toUserId`, field by field, each as an ordinary update under the caller's + * context. `objects`, when given, limits the transfer to those object names. + * + * Refuses before any write when the target is the source, names no user, or + * names a user whose ban is in force ({@link UserWorkTransferRefusal}). + */ +export async function transferUserAssignedWork( + engine: UserWorkEngine, + fromUserId: string, + toUserId: string, + context: unknown, + options: { objects?: readonly string[]; rowCap?: number } = {}, +): Promise { + if (fromUserId === toUserId) { + throw new UserWorkTransferRefusal(400, 'VALIDATION_ERROR', 'toUserId must name a different user than the one whose work is transferred'); + } + // The target's existence and ban state are facts about the user, read the + // way the write path's reference check reads them: past the caller's row + // visibility, inside the caller's tenant. + const elevated = { ...((context as object | undefined) ?? {}), isSystem: true }; + const [target] = await engine.find(USER_OBJECT, { + where: { id: toUserId }, + fields: ['id', 'banned', 'ban_expires'], + limit: 1, + context: elevated, + }); + if (!target) { + throw new UserWorkTransferRefusal(404, 'RESOURCE_NOT_FOUND', `No user has id "${toUserId}"`, { field: 'toUserId', code: 'reference_not_found' }); + } + if (isUserBanInForce(target)) { + throw new UserWorkTransferRefusal(400, 'VALIDATION_ERROR', `User "${toUserId}" is banned and cannot be assigned new records; pick an active user`, { field: 'toUserId', code: 'reference_user_banned' }); + } + + const only = options.objects && options.objects.length > 0 ? new Set(options.objects) : undefined; + const { fields } = userAssignedWorkFields(Object.values(engine.getConfigs() ?? {})); + const refs = fields.filter((ref) => !ref.multiple && (!only || only.has(ref.object))); + let budget = options.rowCap ?? USER_WORK_TRANSFER_ROW_CAP; + const objects: UserWorkTransferOutcome[] = []; + let updated = 0; + let failed = 0; + let remaining = 0; + for (const ref of refs) { + let rows: any[] = []; + if (budget > 0) { + try { + rows = await engine.find(ref.object, { where: holdsUser(ref, fromUserId), fields: ['id'], limit: budget, context }); + } catch { + // An object the caller cannot read holds nothing this caller can move. + continue; + } + } + const outcome: UserWorkTransferOutcome = { object: ref.object, field: ref.field, updated: 0, failed: 0, failures: [] }; + for (const row of Array.isArray(rows) ? rows : []) { + if (row?.id === undefined || row?.id === null) continue; + budget -= 1; + try { + await engine.update(ref.object, { [ref.field]: toUserId }, { where: { id: row.id }, context }); + outcome.updated += 1; + } catch (err) { + outcome.failed += 1; + if (outcome.failures.length < USER_WORK_FAILURE_SAMPLE) outcome.failures.push(failureOf(row.id, err)); + } + } + try { + remaining += await engine.count(ref.object, { where: holdsUser(ref, fromUserId), context }); + } catch { + // Counted as unknown: the rows moved above are reported either way. + } + if (outcome.updated > 0 || outcome.failed > 0) objects.push(outcome); + updated += outcome.updated; + failed += outcome.failed; + } + return { fromUserId, toUserId, objects, updated, failed, remaining }; +} diff --git a/packages/objectql/src/engine-referential-integrity.ts b/packages/objectql/src/engine-referential-integrity.ts index 490fd7a9e6a..f8f71c9daff 100644 --- a/packages/objectql/src/engine-referential-integrity.ts +++ b/packages/objectql/src/engine-referential-integrity.ts @@ -52,7 +52,7 @@ import { referenceTargetOf, type EngineQueryOptions } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; -import { keepsFlowWriteReferenceCheck } from '@objectstack/core'; +import { isUserBanInForce, keepsFlowWriteReferenceCheck } from '@objectstack/core'; import { ValidationError, buildFieldError } from './validation/record-validator.js'; import type { ObjectQL } from './engine.js'; @@ -188,7 +188,7 @@ function isEmptyReferenceValue(v: unknown): boolean { * (see {@link masterDetailHeadersCallerReads}). */ export async function assertReferencesResolve( - this: Pick, + this: Pick, schema: any, data: Record | null | undefined, supplied: Record | null | undefined, @@ -203,7 +203,10 @@ export async function assertReferencesResolve( const fields = schema?.fields; if (!fields || !data) return; - const refused: Array<{ name: string; def: any; value: unknown; target: string }> = []; + const refused: Array<{ name: string; def: any; value: unknown; target: string; code?: 'reference_user_banned' }> = []; + // [#30205] The user ids that resolved, by the fields that named them: a + // banned user is a row that exists, so the existence probe passes them. + const users: Array<{ name: string; def: any; value: unknown; target: string }> = []; for (const name of Object.keys(fields)) { // [#4441, narrowing kept by #4743] A `readonly` field is never the // caller's to answer for — BY CONSTRUCTION, not by exemption. @@ -264,8 +267,13 @@ export async function assertReferencesResolve( ? await this.masterDetailHeaderExists(target, v, context) : await this.referenceExists(target, v, context); if (resolved === false) refused.push({ name, def, value: v, target }); + else if (resolved === true && target === BAN_CHECKED_USER_OBJECT) users.push({ name, def, value: v, target }); } } + if (users.length > 0) { + const banned = await bannedUserIds.call(this, users.map((u) => u.value), context); + for (const u of users) if (banned.has(String(u.value))) refused.push({ ...u, code: 'reference_user_banned' }); + } if (refused.length === 0) return; // [#29062] A refused value every row this write lands on already stores // is not a reference the write ADDS, so it is not this caller's to answer @@ -276,7 +284,7 @@ export async function assertReferencesResolve( .map((r) => buildFieldError( { field: r.name, - code: 'reference_not_found', + code: r.code ?? 'reference_not_found', def: r.def, value: String(r.value), constraint: { target: r.target }, @@ -286,6 +294,56 @@ export async function assertReferencesResolve( if (failures.length > 0) throw new ValidationError(failures); } +/** + * [#30205] The user object whose rows a reference check also asks about a ban. + */ +const BAN_CHECKED_USER_OBJECT = 'sys_user'; + +/** + * [#30205] Which of `ids` name a user whose ban is in force + * (`isUserBanInForce`, @objectstack/core). + * + * Banning a user leaves the row in place, so the existence probe resolves a + * banned user like any other, and a write could hand new work to someone who + * cannot sign in to do it (`POST /data/OBJECT` with `assignee` set to the + * banned user answered 201). The check refuses that with + * `reference_user_banned`, on the same terms as `reference_not_found`: only a + * caller-supplied value, only a value the write ADDS (a row that already holds + * the banned user stays editable, and is handed over by the transfer + * instead), never a system write the reference check exempts. + * + * Read the way the existence probe reads — `sudo()`-shaped, the caller's + * context spread under `isSystem: true` — because a ban is a fact about the + * user, not about what the caller may read: a caller with no read grant on + * the user object is refused the same way. One `find` for the whole write. + * A read that fails refuses nothing, and says so once at `warn`: like the + * probe, a check that cannot run must not invent a rejection. + */ +async function bannedUserIds( + this: Pick, + ids: ReadonlyArray, + context: ExecutionContext | undefined, +): Promise> { + const asked = [...new Set(ids.map((id) => String(id)))]; + try { + const rows = await this.find(BAN_CHECKED_USER_OBJECT, { + where: { id: asked.length === 1 ? asked[0] : { $in: asked } }, + fields: ['id', 'banned', 'ban_expires'], + context: { ...(context ?? {}), isSystem: true } as ExecutionContext, + } as EngineQueryOptions) as Array>; + const banned = new Set(); + for (const row of Array.isArray(rows) ? rows : []) { + if (row?.id != null && isUserBanInForce(row)) banned.add(String(row.id)); + } + return banned; + } catch (err) { + this.logger?.warn?.('the ban-state read of the referenced users failed — the write is not refused for a ban', { + object: BAN_CHECKED_USER_OBJECT, error: err, + }); + return new Set(); + } +} + /** * [#29062] The rows a write lands on, as stored before it, read on demand by * {@link assertReferencesResolve}: the by-id update's prior row, or the diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index e773f73b3c8..9c43ea7d918 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3137,7 +3137,11 @@ export class ObjectQL implements IObjectQLEngine { storedRows?: StoredReferenceRows, ): Promise { // [#25522 cut 10] The body moved to `engine-referential-integrity.ts`, its docblock with it (see `ReferentialIntegrityHost`). + // [#30205] `find` and `logger` serve the ban-state read of referenced users. + const readLogger = () => this.logger; const host: ThisParameterType = { + get logger() { return readLogger(); }, + find: (...a) => this.find(...a), masterDetailHeaderExists: (...a) => this.masterDetailHeaderExists(...a), referenceExists: (...a) => this.referenceExists(...a), }; diff --git a/packages/spec/src/api/errors.zod.ts b/packages/spec/src/api/errors.zod.ts index 8d46299ca5d..760f2e5eb87 100644 --- a/packages/spec/src/api/errors.zod.ts +++ b/packages/spec/src/api/errors.zod.ts @@ -285,6 +285,10 @@ export const FieldErrorCode = z.enum([ 'invalid_value', // rejected for a reason no other member names 'reference_not_found', // a lookup target that does not exist 'reference_ambiguous', // a lookup that matched more than one record + // a lookup to `sys_user` that names a user whose ban is in force: the write + // ADDS work for someone who cannot sign in, so it is refused (a value the + // row already stores is not the write's, as for `reference_not_found`). + 'reference_user_banned', // declarative rules layered above the field's own type 'rule_violation', // a validation rule said no 'json_schema_violation', // a declared JSON Schema said no diff --git a/packages/spec/src/system/validation-message.ts b/packages/spec/src/system/validation-message.ts index c751aaa604f..ae2b0e54834 100644 --- a/packages/spec/src/system/validation-message.ts +++ b/packages/spec/src/system/validation-message.ts @@ -132,6 +132,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_time_zoned: '{{label}} is a time of day with no time zone: drop the Z or offset (HH:MM or HH:MM:SS), or use a datetime field for an instant', invalid_option: '{{label}} must be one of: {{allowed}}', reference_not_found: '{{label}}: no {{target}} record has id "{{value}}"', + reference_user_banned: '{{label}}: user "{{value}}" is banned and cannot be assigned new records; pick an active user', invalid_option_value: '{{label}}: "{{value}}" is not one of: {{allowed}}', // `invalid_option`'s picklist sentences: the field takes its options from a // shared picklist, so the refusal names the list (`{{picklist}}`, a @@ -189,6 +190,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_time_zoned: '{{label}}是不带时区的时刻:请去掉 Z 或时区偏移(HH:MM 或 HH:MM:SS),表示时间点请改用日期时间字段', invalid_option: '{{label}}必须是以下值之一:{{allowed}}', reference_not_found: '{{label}}:不存在 id 为“{{value}}”的{{target}}记录', + reference_user_banned: '{{label}}:用户“{{value}}”已被封禁,不能再指派新记录,请选择一位在用的用户', invalid_option_value: '{{label}}:“{{value}}”不在允许的取值范围内:{{allowed}}', invalid_option_picklist: '{{label}}必须是选项列表“{{picklist}}”中的值之一:{{allowed}}', invalid_option_value_picklist: '{{label}}:“{{value}}”不是选项列表“{{picklist}}”中的值:{{allowed}}', @@ -235,6 +237,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_time_zoned: '{{label}}はタイムゾーンを持たない時刻です。Z やオフセットを外す(HH:MM または HH:MM:SS)か、時点を表すには日時フィールドを使ってください', invalid_option: '{{label}}は次のいずれかを指定してください:{{allowed}}', reference_not_found: '{{label}}:id が「{{value}}」の{{target}}レコードは存在しません', + reference_user_banned: '{{label}}:ユーザー「{{value}}」は利用停止中のため、新しいレコードを割り当てられません。有効なユーザーを選択してください', invalid_option_value: '{{label}}:「{{value}}」は指定できません(指定可能:{{allowed}})', invalid_option_picklist: '{{label}}は選択リスト「{{picklist}}」の値のいずれかを指定してください:{{allowed}}', invalid_option_value_picklist: '{{label}}:「{{value}}」は選択リスト「{{picklist}}」の値ではありません(指定可能:{{allowed}})', @@ -281,6 +284,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_time_zoned: '{{label}} es una hora del día sin zona horaria: quite la Z o el desfase (HH:MM o HH:MM:SS), o use un campo de fecha y hora para un instante', invalid_option: '{{label}} debe ser uno de: {{allowed}}', reference_not_found: '{{label}}: ningún registro de {{target}} tiene el id «{{value}}»', + reference_user_banned: '{{label}}: el usuario «{{value}}» está bloqueado y no se le pueden asignar registros nuevos; elija un usuario activo', invalid_option_value: '{{label}}: «{{value}}» no es uno de: {{allowed}}', invalid_option_picklist: '{{label}} debe ser uno de los valores de la lista de selección «{{picklist}}»: {{allowed}}', invalid_option_value_picklist: '{{label}}: «{{value}}» no es un valor de la lista de selección «{{picklist}}»: {{allowed}}', From 01ab40196f66fa3933e1e54b3f5ed51d4f922aaa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:52:43 +0000 Subject: [PATCH 02/13] feat(rest): serve a user's assigned-work count and bulk hand-over (WIP) Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude --- .../core/src/security/user-assigned-work.ts | 15 +- packages/rest/src/rest-route-ledger.ts | 5 + .../rest/src/rest-server-registrar-files.ts | 3 + packages/rest/src/rest-server.ts | 32 ++++ packages/rest/src/user-work-routes.ts | 144 ++++++++++++++++++ 5 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 packages/rest/src/user-work-routes.ts diff --git a/packages/core/src/security/user-assigned-work.ts b/packages/core/src/security/user-assigned-work.ts index 5247b9b4246..452b12bbd5d 100644 --- a/packages/core/src/security/user-assigned-work.ts +++ b/packages/core/src/security/user-assigned-work.ts @@ -132,11 +132,22 @@ export function userAssignedWorkFields( */ export interface UserWorkEngine { getConfigs(): Record; + /** A disabled package's object serves no data door, so it holds no work to count. */ + readonly registry?: { isObjectPackageDisabled?(name: string): boolean }; count(object: string, query?: any): Promise; find(object: string, query?: any): Promise; update(object: string, data: any, options?: any): Promise; } +/** The registered objects a data door serves: a disabled package's are left out. */ +function servedObjects(engine: UserWorkEngine): unknown[] { + const disabled = engine.registry?.isObjectPackageDisabled?.bind(engine.registry); + return Object.values(engine.getConfigs() ?? {}).filter((obj) => { + const name = (obj as ObjectLike | undefined)?.name; + return !(disabled && typeof name === 'string' && disabled(name) === true); + }); +} + /** The filter that matches the records a field holds `userId` in. */ function holdsUser(ref: UserAssignedWorkField, userId: string): Record { return { [ref.field]: ref.multiple ? { $contains: userId } : userId }; @@ -169,7 +180,7 @@ export async function countUserAssignedWork( userId: string, context: unknown, ): Promise { - const { fields, truncated } = userAssignedWorkFields(Object.values(engine.getConfigs() ?? {})); + const { fields, truncated } = userAssignedWorkFields(servedObjects(engine)); const objects: UserAssignedWorkCount[] = []; const unreadable = new Set(); let total = 0; @@ -275,7 +286,7 @@ export async function transferUserAssignedWork( } const only = options.objects && options.objects.length > 0 ? new Set(options.objects) : undefined; - const { fields } = userAssignedWorkFields(Object.values(engine.getConfigs() ?? {})); + const { fields } = userAssignedWorkFields(servedObjects(engine)); const refs = fields.filter((ref) => !ref.multiple && (!only || only.has(ref.object))); let budget = options.rowCap ?? USER_WORK_TRANSFER_ROW_CAP; const objects: UserWorkTransferOutcome[] = []; diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index b6dba498ca8..bafcb97ca03 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -437,6 +437,11 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ note: 'duplicate mount with the dispatcher /security domain' }, { route: 'POST /api/v1/security/suggested-bindings/:id/reopen', family: 'security', source: 'route-manager', disposition: 'sdk', client: 'security.suggestedBindings.reopen', note: 'duplicate mount with the dispatcher /security domain' }, + // ── a user's assigned work (#30205) ─────────────────────────────────────── + { route: 'GET /api/v1/security/users/:id/assigned-work', family: 'user-work', source: 'route-manager', disposition: 'gap', + note: 'new with the server half; the SDK method lands with the Setup ban dialog (the ui half)' }, + { route: 'POST /api/v1/security/users/:id/assigned-work/transfer', family: 'user-work', source: 'route-manager', disposition: 'gap', + note: 'new with the server half; the SDK method lands with the Setup ban dialog (the ui half)' }, // RETIRED (ADR-0131 cutover): `POST /api/v1/security/permission-sets/:id/ // discard-overlay` left with the "Discard Overlay" Setup action. No door // creates an environment overlay of a packaged permission set any more, and diff --git a/packages/rest/src/rest-server-registrar-files.ts b/packages/rest/src/rest-server-registrar-files.ts index 5b4227b86eb..c24ed80b7a6 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -123,6 +123,9 @@ export const REST_SERVER_META_ROUTE_FILES: readonly RestServerRegistrarFile[] = // moved slice; like the per-item writes it goes through the inner method's per-item // registrar, handed over in its scope. 'meta-app-bundle-routes.ts', + // [#30205] A user's assigned work: the counts and the hand-over. New, not a moved + // slice; it registers on the route manager itself, from a host literal. + 'user-work-routes.ts', ]; /** diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 3a74fbe6405..fdc327fba8b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -337,6 +337,8 @@ import { type SecurityExplainRouteHost, type SecurityRouteHost, } from './security-routes.js'; +// [#30205] A user's assigned work: the per-object counts and the bounded hand-over. +import { registerUserWorkEndpoints, type UserWorkRouteHost } from './user-work-routes.js'; // [#23050 slice 7] The discovery and OpenAPI routes' bodies moved to one module — see its header. import { registerDiscoveryEndpoints, @@ -4959,6 +4961,7 @@ export class RestServer { this.registerAnalyticsEndpoints(bp); this.registerSecurityEndpoints(bp); this.registerSecurityExplainEndpoints(bp); + this.registerUserWorkEndpoints(bp); // Data-action routes (e.g. GET /data/:object/export, POST // /data/:object/import) use static-literal action segments that // MUST be registered BEFORE the greedy GET /data/:object/:id @@ -6178,6 +6181,35 @@ export class RestServer { * business) and returns 501 when no security service exposing `explain` * is mounted (a deployment without `@objectstack/plugin-security`). */ + private registerUserWorkEndpoints(basePath: string): void { + // [#30205] The body lives in `user-work-routes.ts`. This literal hands it + // every member it reads (see `UserWorkRouteHost`); the engine provider + // FIELD goes as a getter, read when a handler reads it. + const readObjectQLProvider = () => this.objectQLProvider; + const host: UserWorkRouteHost = { + routeManager: this.routeManager, + get objectQLProvider() { return readObjectQLProvider(); }, + resolveRequestEnvironmentId: (...a) => this.resolveRequestEnvironmentId(...a), + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + enforceAuth: (...a) => this.enforceAuth(...a), + }; + registerUserWorkEndpoints.call(host, basePath); + } + + /** + * [#30205] Register a user's assigned-work routes: + * + * GET {basePath}/security/users/:id/assigned-work + * POST {basePath}/security/users/:id/assigned-work/transfer body: { toUserId, objects? } + * + * The first answers, per object and user field, how many records hold the + * user (the owner field and every app user field), counted as the caller + * can read them. The second hands up to 500 of those records to another + * active user per call, each as an ordinary update under the caller's + * context, and answers what moved, what failed and how many remain. + * Neither touches the ban. `@objectstack/core`'s `user-assigned-work.ts` + * holds the rules. + */ private registerSecurityExplainEndpoints(basePath: string): void { // [#23050 slice 6] The body moved to `security-routes.ts`. This literal // hands it every member it reads — the list IS its dependency on this diff --git a/packages/rest/src/user-work-routes.ts b/packages/rest/src/user-work-routes.ts new file mode 100644 index 00000000000..43fd84c38f3 --- /dev/null +++ b/packages/rest/src/user-work-routes.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30205] The assigned-work routes: a user's open work, counted, and handed + * over in bulk — what an administrator needs when they ban someone. + * + * GET {basePath}/security/users/:id/assigned-work per-object counts + * POST {basePath}/security/users/:id/assigned-work/transfer one bounded hand-over + * + * The rules (which records count, whose reads and writes, the caps) live in + * `@objectstack/core`'s `user-assigned-work.ts`; this module only serves them. + * Both routes run as the CALLER: the counts are what the caller can read, and + * each moved row is an ordinary update under the caller's context, so neither + * route needs a gate of its own beyond an authenticated caller — a caller who + * could not read or edit a record by hand cannot count or move it here either. + * + * Neither route touches the ban. A ban is urgent and stands alone + * (`POST /api/v1/auth/admin/ban-user`); these routes are the optional step + * beside it. + * + * Both declare their CLOSED query-parameter set — none — on the day they land + * (AGENTS.md, route rule 5), and the transfer body is closed as well: an + * unknown key is a `400`, never a silently ignored option. + * + * ## How the body reaches the class + * + * The shape the `rest-server.ts` split set (`data-action-routes.ts`' header + * carries the reasoning): the class keeps a private registrar method that hands + * this body one host literal of exactly the members it reads, methods as + * closures and the `objectQLProvider` field as a getter, read at request time. + */ + +import { sendError, sendServerFault } from '@objectstack/types'; +import { + countUserAssignedWork, + transferUserAssignedWork, + UserWorkTransferRefusal, + type UserWorkEngine, +} from '@objectstack/core'; +import { refuseUnknownQueryParams } from './query-allowlist.js'; +import { wiredEngineOrLoud, type RestServer } from './rest-server.js'; + +/** The `RestServer` members {@link registerUserWorkEndpoints} reads — every one of them, and no other. */ +export interface UserWorkRouteHost { + readonly routeManager: RestServer['routeManager']; + readonly objectQLProvider: RestServer['objectQLProvider']; + resolveRequestEnvironmentId: RestServer['resolveRequestEnvironmentId']; + resolveExecCtx: RestServer['resolveExecCtx']; + enforceAuth: RestServer['enforceAuth']; +} + +/** Neither route reads a query parameter. */ +export const USER_WORK_QUERY_PARAMS: readonly string[] = []; + +/** The keys a transfer body may carry. */ +const TRANSFER_BODY_KEYS = new Set(['toUserId', 'objects']); + +/** + * Register the assigned-work routes — `RestServer#registerUserWorkEndpoints`' + * body, called by that method with the server as `this`. + */ +export function registerUserWorkEndpoints(this: UserWorkRouteHost, basePath: string): void { + const isScoped = basePath.includes('/environments/:environmentId'); + + /** The request's engine, `undefined` when none is wired; an outage throws. */ + const resolveEngine = async (environmentId: string | undefined, req: any): Promise => + wiredEngineOrLoud( + Boolean(this.objectQLProvider), + async () => this.objectQLProvider!(await this.resolveRequestEnvironmentId(environmentId, req)), + ) as Promise; + + const userIdOf = (req: any): string | undefined => { + const id = req.params?.id; + return typeof id === 'string' && id.length > 0 ? id : undefined; + }; + + this.routeManager.register({ + method: 'GET', + path: `${basePath}/security/users/:id/assigned-work`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + if (refuseUnknownQueryParams(req, res, USER_WORK_QUERY_PARAMS)) return; + const userId = userIdOf(req); + if (!userId) return sendError(res, 400, 'VALIDATION_ERROR', 'The user id is required'); + const engine = await resolveEngine(environmentId, req); + if (!engine) return sendError(res, 501, 'NOT_IMPLEMENTED', 'No data engine is configured on this deployment'); + const summary = await countUserAssignedWork(engine, userId, context); + return res.json({ success: true, data: summary }); + } catch (err) { + return sendServerFault(res, 500, 'INTERNAL_ERROR', 'Counting the user\'s assigned work failed', err); + } + }, + metadata: { summary: 'Count the records a user owns or is assigned, per object, as the caller can read them' }, + }); + + this.routeManager.register({ + method: 'POST', + path: `${basePath}/security/users/:id/assigned-work/transfer`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + if (refuseUnknownQueryParams(req, res, USER_WORK_QUERY_PARAMS)) return; + const userId = userIdOf(req); + if (!userId) return sendError(res, 400, 'VALIDATION_ERROR', 'The user id is required'); + const body = req.body; + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return sendError(res, 400, 'VALIDATION_ERROR', 'The body must be an object: { toUserId, objects? }'); + } + const unknown = Object.keys(body).filter((k) => !TRANSFER_BODY_KEYS.has(k)); + if (unknown.length > 0) { + return sendError(res, 400, 'VALIDATION_ERROR', `Unknown body key(s): ${unknown.join(', ')}. A transfer takes toUserId and, optionally, objects`); + } + const toUserId = body.toUserId; + if (typeof toUserId !== 'string' || toUserId.length === 0) { + return sendError(res, 400, 'VALIDATION_ERROR', 'toUserId is required: the id of the active user who takes over the records'); + } + const objects = body.objects; + if (objects !== undefined + && !(Array.isArray(objects) && objects.every((o: unknown) => typeof o === 'string' && o.length > 0))) { + return sendError(res, 400, 'VALIDATION_ERROR', 'objects, when given, must be a list of object names'); + } + const engine = await resolveEngine(environmentId, req); + if (!engine) return sendError(res, 501, 'NOT_IMPLEMENTED', 'No data engine is configured on this deployment'); + try { + const report = await transferUserAssignedWork(engine, userId, toUserId, context, { objects }); + return res.json({ success: true, data: report }); + } catch (err) { + if (!(err instanceof UserWorkTransferRefusal)) throw err; + return sendError(res, err.status, err.code, err.message, err.field ? { + details: { fields: [{ field: err.field.field, code: err.field.code, message: err.message }] }, + } : undefined); + } + } catch (err) { + return sendServerFault(res, 500, 'INTERNAL_ERROR', 'Transferring the user\'s assigned work failed', err); + } + }, + metadata: { summary: 'Hand a user\'s owned and assigned records to another active user, as the caller, in bounded batches' }, + }); +} From 09bab3382eaa957e4e383e0772480ff25b5780b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:59:40 +0000 Subject: [PATCH 03/13] test: pin the banned-user refusal, the assigned-work count and the hand-over; add the SDK methods Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude --- packages/client/src/admin-surfaces.test.ts | 21 ++ packages/client/src/index.ts | 67 +++++ .../src/security/user-assigned-work.test.ts | 230 ++++++++++++++++++ .../src/engine-banned-user-reference.test.ts | 217 +++++++++++++++++ packages/rest/src/rest-route-ledger.ts | 6 +- packages/rest/src/user-work-routes.test.ts | 145 +++++++++++ packages/rest/src/user-work-routes.ts | 4 +- 7 files changed, 684 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/security/user-assigned-work.test.ts create mode 100644 packages/objectql/src/engine-banned-user-reference.test.ts create mode 100644 packages/rest/src/user-work-routes.test.ts diff --git a/packages/client/src/admin-surfaces.test.ts b/packages/client/src/admin-surfaces.test.ts index 977ff3a39f5..99d22e8cc45 100644 --- a/packages/client/src/admin-surfaces.test.ts +++ b/packages/client/src/admin-surfaces.test.ts @@ -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); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4d2de94cff6..b67dd091396 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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; @@ -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 => { + const res = await this.fetch( + `${this.baseUrl}/api/v1/security/users/${encodeURIComponent(userId)}/assigned-work`, + ); + return this.unwrapResponse(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 => { + 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(res); + }, + }, }; /** diff --git a/packages/core/src/security/user-assigned-work.test.ts b/packages/core/src/security/user-assigned-work.test.ts new file mode 100644 index 00000000000..b53b8e22cb6 --- /dev/null +++ b/packages/core/src/security/user-assigned-work.test.ts @@ -0,0 +1,230 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + countUserAssignedWork, + isUserBanInForce, + transferUserAssignedWork, + userAssignedWorkFields, + UserWorkTransferRefusal, + type UserWorkEngine, +} from './user-assigned-work.js'; + +const NOW = Date.parse('2026-11-05T12:00:00Z'); + +describe('[#30205] isUserBanInForce', () => { + it('a ban with no expiry, or one still ahead, is in force', () => { + expect(isUserBanInForce({ banned: true }, NOW)).toBe(true); + expect(isUserBanInForce({ banned: true, ban_expires: null }, NOW)).toBe(true); + expect(isUserBanInForce({ banned: true, ban_expires: '2026-11-06T00:00:00Z' }, NOW)).toBe(true); + expect(isUserBanInForce({ banned: true, ban_expires: new Date(NOW + 1000) }, NOW)).toBe(true); + }); + + it('an expired ban, an unbanned user and a non-row are not', () => { + expect(isUserBanInForce({ banned: true, ban_expires: '2026-11-04T00:00:00Z' }, NOW)).toBe(false); + expect(isUserBanInForce({ banned: false }, NOW)).toBe(false); + expect(isUserBanInForce({ banned: null }, NOW)).toBe(false); + expect(isUserBanInForce({}, NOW)).toBe(false); + expect(isUserBanInForce(null, NOW)).toBe(false); + }); + + it('an expiry that does not parse cannot prove the ban lifted', () => { + expect(isUserBanInForce({ banned: true, ban_expires: 'not a date' }, NOW)).toBe(true); + }); +}); + +const ticket = { + name: 'ticket', + fields: { + title: { type: 'text' }, + owner_id: { type: 'lookup', reference: 'sys_user' }, + assignee: { type: 'user' }, + watchers: { type: 'lookup', reference: 'sys_user', multiple: true }, + created_by: { type: 'lookup', reference: 'sys_user', readonly: true, system: true }, + account: { type: 'lookup', reference: 'account' }, + }, +}; +const account = { name: 'account', fields: { owner_id: { type: 'lookup', reference: 'sys_user' } } }; +const grant = { name: 'sys_user_permission_set', fields: { user_id: { type: 'lookup', reference: 'sys_user' } } }; +const managed = { name: 'auth_session', managedBy: 'better-auth', fields: { user_id: { type: 'lookup', reference: 'sys_user' } } }; + +describe('[#30205] userAssignedWorkFields', () => { + it('counts the owner field and every app user field; leaves provenance, platform tables and managed objects out', () => { + const { fields, truncated } = userAssignedWorkFields([ticket, account, grant, managed]); + expect(truncated).toBe(false); + expect(fields).toEqual([ + { object: 'ticket', field: 'owner_id', multiple: false }, + { object: 'ticket', field: 'assignee', multiple: false }, + { object: 'ticket', field: 'watchers', multiple: true }, + { object: 'account', field: 'owner_id', multiple: false }, + ]); + }); + + it('stops at the cap and says so', () => { + const { fields, truncated } = userAssignedWorkFields([ticket, account], 2); + expect(fields).toHaveLength(2); + expect(truncated).toBe(true); + }); +}); + +type Row = Record; + +/** An in-memory engine: equality and `$contains` filters, a per-object read/write refusal. */ +function fakeEngine(seed: Record, opts: { unreadable?: string[]; refuseUpdate?: (object: string, id: string) => boolean; disabled?: string[] } = {}) { + const tables = new Map(Object.entries(seed).map(([k, rows]) => [k, rows.map((r) => ({ ...r }))])); + const calls: Array<{ op: string; object: string; context: any; where?: any }> = []; + const match = (row: Row, where: Record) => Object.entries(where).every(([k, v]) => + v && typeof v === 'object' && '$contains' in v ? Array.isArray(row[k]) && row[k].includes(v.$contains) : row[k] === v); + const guard = (object: string, context: any) => { + if (opts.unreadable?.includes(object) && !context?.isSystem) throw Object.assign(new Error('denied'), { code: 'PERMISSION_DENIED' }); + }; + const engine: UserWorkEngine & { tables: typeof tables; calls: typeof calls } = { + tables, + calls, + registry: { isObjectPackageDisabled: (name: string) => opts.disabled?.includes(name) ?? false }, + getConfigs: () => ({ ticket, account, grant, user: { name: 'sys_user', fields: {} } }), + async count(object, q) { + calls.push({ op: 'count', object, context: q?.context, where: q?.where }); + guard(object, q?.context); + return (tables.get(object) ?? []).filter((r) => match(r, q?.where ?? {})).length; + }, + async find(object, q) { + calls.push({ op: 'find', object, context: q?.context, where: q?.where }); + guard(object, q?.context); + const rows = (tables.get(object) ?? []).filter((r) => match(r, q?.where ?? {})); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + async update(object, data, o) { + calls.push({ op: 'update', object, context: o?.context, where: o?.where }); + const row = (tables.get(object) ?? []).find((r) => match(r, o?.where ?? {})); + if (!row) return null; + if (opts.refuseUpdate?.(object, row.id)) throw Object.assign(new Error('Insufficient privileges'), { code: 'PERMISSION_DENIED' }); + Object.assign(row, data); + return row; + }, + }; + return engine; +} + +const CALLER = { userId: 'u_admin', tenantId: 'org_1' }; + +function seed(): Record { + return { + sys_user: [ + { id: 'u_gone', banned: true }, + { id: 'u_next', banned: false }, + { id: 'u_also_banned', banned: true, ban_expires: null }, + ], + ticket: [ + { id: 't1', owner_id: 'u_gone', assignee: 'u_gone', watchers: ['u_gone'] }, + { id: 't2', owner_id: 'u_gone', assignee: 'u_x', watchers: [] }, + { id: 't3', owner_id: 'u_x', assignee: 'u_gone', watchers: ['u_x', 'u_gone'] }, + ], + account: [{ id: 'a1', owner_id: 'u_gone' }, { id: 'a2', owner_id: 'u_x' }], + }; +} + +describe('[#30205] countUserAssignedWork', () => { + it('answers per (object, field) counts as the caller, and the total', async () => { + const engine = fakeEngine(seed()); + const summary = await countUserAssignedWork(engine, 'u_gone', CALLER); + expect(summary).toEqual({ + userId: 'u_gone', + objects: [ + { object: 'ticket', field: 'owner_id', count: 2, transferable: true }, + { object: 'ticket', field: 'assignee', count: 2, transferable: true }, + { object: 'ticket', field: 'watchers', count: 2, transferable: false }, + { object: 'account', field: 'owner_id', count: 1, transferable: true }, + ], + total: 7, + truncated: false, + unreadable: [], + }); + // Every count ran under the caller's own context, never elevated. + expect(engine.calls.every((c) => c.context === CALLER)).toBe(true); + }); + + it('an object the caller cannot read is named, not counted as zero', async () => { + const summary = await countUserAssignedWork(fakeEngine(seed(), { unreadable: ['account'] }), 'u_gone', CALLER); + expect(summary.unreadable).toEqual(['account']); + expect(summary.objects.map((o) => o.object)).not.toContain('account'); + }); + + it('a disabled package\'s object holds no work to count', async () => { + const summary = await countUserAssignedWork(fakeEngine(seed(), { disabled: ['account'] }), 'u_gone', CALLER); + expect(summary.objects.map((o) => o.object)).not.toContain('account'); + expect(summary.unreadable).toEqual([]); + }); +}); + +describe('[#30205] transferUserAssignedWork', () => { + it('moves every single-valued holding to the target as the caller, and answers what moved', async () => { + const engine = fakeEngine(seed()); + const report = await transferUserAssignedWork(engine, 'u_gone', 'u_next', CALLER); + expect(report).toEqual({ + fromUserId: 'u_gone', + toUserId: 'u_next', + objects: [ + { object: 'ticket', field: 'owner_id', updated: 2, failed: 0, failures: [] }, + { object: 'ticket', field: 'assignee', updated: 2, failed: 0, failures: [] }, + { object: 'account', field: 'owner_id', updated: 1, failed: 0, failures: [] }, + ], + updated: 5, + failed: 0, + remaining: 0, + }); + const t = engine.tables.get('ticket')!; + expect(t.map((r) => [r.owner_id, r.assignee])).toEqual([['u_next', 'u_next'], ['u_next', 'u_x'], ['u_x', 'u_next']]); + // A multi-valued field is never rewritten. + expect(t[0].watchers).toEqual(['u_gone']); + // Every write went through `update` under the caller's own context. + const writes = engine.calls.filter((c) => c.op === 'update'); + expect(writes).toHaveLength(5); + expect(writes.every((c) => c.context === CALLER)).toBe(true); + }); + + it('reports a row the caller may not change as a failure and keeps going', async () => { + const engine = fakeEngine(seed(), { refuseUpdate: (object, id) => object === 'ticket' && id === 't2' }); + const report = await transferUserAssignedWork(engine, 'u_gone', 'u_next', CALLER, { objects: ['ticket'] }); + expect(report.objects[0]).toEqual({ + object: 'ticket', field: 'owner_id', updated: 1, failed: 1, + failures: [{ id: 't2', code: 'PERMISSION_DENIED', message: 'Insufficient privileges' }], + }); + expect(report.failed).toBe(1); + // The refused row still names the user, and `objects` kept account out. + expect(report.remaining).toBe(1); + expect(engine.tables.get('account')![0].owner_id).toBe('u_gone'); + }); + + it('moves at most the row cap per call and answers what remains', async () => { + const engine = fakeEngine(seed()); + const first = await transferUserAssignedWork(engine, 'u_gone', 'u_next', CALLER, { rowCap: 3 }); + expect(first.updated).toBe(3); + expect(first.remaining).toBe(2); + const second = await transferUserAssignedWork(engine, 'u_gone', 'u_next', CALLER, { rowCap: 3 }); + expect(second.updated).toBe(2); + expect(second.remaining).toBe(0); + }); + + it('refuses a banned target, a missing one and the same user before any write', async () => { + for (const [to, status, code, fieldCode] of [ + ['u_also_banned', 400, 'VALIDATION_ERROR', 'reference_user_banned'], + ['u_nobody', 404, 'RESOURCE_NOT_FOUND', 'reference_not_found'], + ['u_gone', 400, 'VALIDATION_ERROR', undefined], + ] as const) { + const engine = fakeEngine(seed()); + const err = await transferUserAssignedWork(engine, 'u_gone', to, CALLER).then(() => null, (e) => e); + expect(err).toBeInstanceOf(UserWorkTransferRefusal); + expect({ status: err.status, code: err.code, field: err.field?.code }).toEqual({ status, code, field: fieldCode }); + expect(engine.calls.filter((c) => c.op === 'update')).toHaveLength(0); + } + }); + + it('reads the target\'s ban state elevated inside the caller\'s tenant, even when the caller cannot read users', async () => { + const engine = fakeEngine(seed(), { unreadable: ['sys_user'] }); + const err = await transferUserAssignedWork(engine, 'u_gone', 'u_also_banned', CALLER).then(() => null, (e) => e); + expect(err?.field?.code).toBe('reference_user_banned'); + const read = engine.calls.find((c) => c.object === 'sys_user')!; + expect(read.context).toEqual({ ...CALLER, isSystem: true }); + }); +}); diff --git a/packages/objectql/src/engine-banned-user-reference.test.ts b/packages/objectql/src/engine-banned-user-reference.test.ts new file mode 100644 index 00000000000..f9cfa7847e8 --- /dev/null +++ b/packages/objectql/src/engine-banned-user-reference.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30205] A new assignment to a banned user is refused at the server. + * + * Banning a user leaves the `sys_user` row in place, so the write-time + * reference check resolved a banned user like any other, and + * `POST /data/OBJECT` with `assignee` set to the banned user answered 201 — + * new work for someone who cannot sign in. The check now refuses it with the + * field code `reference_user_banned`, on the same terms as + * `reference_not_found`: only a value the caller supplied, only a value the + * write ADDS, never a system write. The ban-state read is elevated the way the + * existence probe is, so a caller who cannot read the user object is refused + * the same way. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; + +const user = { + name: 'sys_user', + label: 'User', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' as const }, + banned: { name: 'banned', label: 'Banned', type: 'boolean' as const }, + ban_expires: { name: 'ban_expires', label: 'Ban Expires', type: 'datetime' as const }, + }, +}; + +const ticket = { + name: 'ban_ticket', + label: 'Ticket', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' as const }, + assignee: { name: 'assignee', label: 'Assignee', type: 'lookup' as const, reference: 'sys_user' }, + reviewer: { name: 'reviewer', label: 'Reviewer', type: 'user' as const }, + watchers: { name: 'watchers', label: 'Watchers', type: 'lookup' as const, reference: 'sys_user', multiple: true }, + }, +}; + +function makeStubDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + if (v && typeof v === 'object' && '$in' in (v as any)) { + if (!(v as any).$in.includes(row[k])) return false; + continue; + } + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + if ((row[k] ?? null) !== (expected ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) return null; + const next = { ...cur, ...data, id }; + s.set(id, next); + return next; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async updateMany(object: string, ast: any, data: Record) { + const rows = await this.find(object, ast); + for (const r of rows) storeFor(object).set(r.id as string, { ...r, ...data, id: r.id }); + return rows.length; + }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +/** Capture a rejection so its structured `fields[]` can be inspected. */ +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e; + } + throw new Error('expected the write to be refused, but it succeeded'); +} + +const ALICE = { userId: 'u_alice' }; +const DAY = 24 * 60 * 60 * 1000; + +describe('[#30205] a new assignment to a banned user is refused', () => { + let engine: ObjectQL; + let stores: Map>>; + let userReads: Array<{ elevated: boolean }>; + + beforeEach(async () => { + engine = new ObjectQL(); + const stub = makeStubDriver(); + stores = stub.stores; + engine.registerDriver(stub.driver, true); + await engine.init(); + engine.registry.registerObject(user); + engine.registry.registerObject(ticket); + const sys = { context: { isSystem: true } } as any; + await engine.insert('sys_user', { id: 'u_alice', name: 'Alice', banned: false }, sys); + await engine.insert('sys_user', { id: 'u_bob', name: 'Bob', banned: true }, sys); + await engine.insert('sys_user', { id: 'u_carol', name: 'Carol', banned: true, ban_expires: new Date(Date.now() + DAY).toISOString() }, sys); + await engine.insert('sys_user', { id: 'u_dave', name: 'Dave', banned: true, ban_expires: new Date(Date.now() - DAY).toISOString() }, sys); + // The user object is unreadable to a non-system caller, as it is to a + // member without a read grant on it. + userReads = []; + engine.registerMiddleware(async (ctx: any, next: () => Promise) => { + if (ctx.object === 'sys_user' && ['find', 'findOne', 'count'].includes(ctx.operation)) { + const elevated = ctx.context?.isSystem === true; + userReads.push({ elevated }); + if (!elevated) throw Object.assign(new Error('no read access to sys_user'), { code: 'PERMISSION_DENIED', statusCode: 403 }); + } + await next(); + }); + }); + + it('an insert naming a banned user is refused, and nothing is written', async () => { + const err = await refusalOf(() => engine.insert('ban_ticket', { title: 'T', assignee: 'u_bob' }, { context: ALICE } as any)); + expect(err.name).toBe('ValidationError'); + expect(err.code).toBe('VALIDATION_FAILED'); + expect(err.fields).toHaveLength(1); + expect(err.fields[0]).toMatchObject({ field: 'assignee', code: 'reference_user_banned', value: 'u_bob' }); + expect(stores.get('ban_ticket')?.size ?? 0).toBe(0); + }); + + it('the caller cannot read the user object, and is refused all the same: the read is elevated', async () => { + await refusalOf(() => engine.insert('ban_ticket', { title: 'T', assignee: 'u_bob' }, { context: ALICE } as any)); + expect(userReads.length).toBeGreaterThan(0); + expect(userReads.every((r) => r.elevated)).toBe(true); + }); + + it('a `user` field and a ban with an expiry still ahead are refused too', async () => { + const err = await refusalOf(() => engine.insert('ban_ticket', { title: 'T', reviewer: 'u_carol' }, { context: ALICE } as any)); + expect(err.fields[0]).toMatchObject({ field: 'reviewer', code: 'reference_user_banned', value: 'u_carol' }); + }); + + it('a multi-valued user field is refused for its banned member only', async () => { + const err = await refusalOf(() => engine.insert('ban_ticket', { title: 'T', watchers: ['u_alice', 'u_bob'] }, { context: ALICE } as any)); + expect(err.fields).toHaveLength(1); + expect(err.fields[0]).toMatchObject({ field: 'watchers', code: 'reference_user_banned', value: 'u_bob' }); + }); + + it('an active user, and a user whose ban has expired, are assigned as before', async () => { + const a: any = await engine.insert('ban_ticket', { title: 'A', assignee: 'u_alice' }, { context: ALICE } as any); + expect(a.assignee).toBe('u_alice'); + const d: any = await engine.insert('ban_ticket', { title: 'D', assignee: 'u_dave' }, { context: ALICE } as any); + expect(d.assignee).toBe('u_dave'); + }); + + it('an update that repoints a field at a banned user is refused, and the stored value does not move', async () => { + const row: any = await engine.insert('ban_ticket', { title: 'T', assignee: 'u_alice' }, { context: ALICE } as any); + const err = await refusalOf(() => engine.update('ban_ticket', { assignee: 'u_bob' }, { where: { id: row.id }, context: ALICE } as any)); + expect(err.fields[0]).toMatchObject({ field: 'assignee', code: 'reference_user_banned' }); + const after: any = await engine.findOne('ban_ticket', { where: { id: row.id }, context: { isSystem: true } } as any); + expect(after.assignee).toBe('u_alice'); + }); + + it('a row that already holds the banned user stays editable: the value is not the write\'s', async () => { + // Assigned before the ban (a system write stands in for history). + await engine.insert('ban_ticket', { id: 't_old', title: 'Old', assignee: 'u_bob' }, { context: { isSystem: true } } as any); + // An edit form carries the whole record back, the banned assignee with it. + const updated: any = await engine.update( + 'ban_ticket', { title: 'Old, edited', assignee: 'u_bob' }, { where: { id: 't_old' }, context: ALICE } as any, + ); + expect(updated).toMatchObject({ title: 'Old, edited', assignee: 'u_bob' }); + }); + + it('a system write is exempt, as it is from the existence check', async () => { + const row: any = await engine.insert('ban_ticket', { title: 'S', assignee: 'u_bob' }, { context: { isSystem: true } } as any); + expect(row.assignee).toBe('u_bob'); + }); + + it('a missing user is still reference_not_found, not a ban', async () => { + const err = await refusalOf(() => engine.insert('ban_ticket', { title: 'T', assignee: 'u_nobody' }, { context: ALICE } as any)); + expect(err.fields[0]).toMatchObject({ field: 'assignee', code: 'reference_not_found' }); + }); +}); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index bafcb97ca03..a3f8e4fab72 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -438,10 +438,8 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'POST /api/v1/security/suggested-bindings/:id/reopen', family: 'security', source: 'route-manager', disposition: 'sdk', client: 'security.suggestedBindings.reopen', note: 'duplicate mount with the dispatcher /security domain' }, // ── a user's assigned work (#30205) ─────────────────────────────────────── - { route: 'GET /api/v1/security/users/:id/assigned-work', family: 'user-work', source: 'route-manager', disposition: 'gap', - note: 'new with the server half; the SDK method lands with the Setup ban dialog (the ui half)' }, - { route: 'POST /api/v1/security/users/:id/assigned-work/transfer', family: 'user-work', source: 'route-manager', disposition: 'gap', - note: 'new with the server half; the SDK method lands with the Setup ban dialog (the ui half)' }, + { route: 'GET /api/v1/security/users/:id/assigned-work', family: 'user-work', source: 'route-manager', disposition: 'sdk', client: 'security.assignedWork.count' }, + { route: 'POST /api/v1/security/users/:id/assigned-work/transfer', family: 'user-work', source: 'route-manager', disposition: 'sdk', client: 'security.assignedWork.transfer' }, // RETIRED (ADR-0131 cutover): `POST /api/v1/security/permission-sets/:id/ // discard-overlay` left with the "Discard Overlay" Setup action. No door // creates an environment overlay of a packaged permission set any more, and diff --git a/packages/rest/src/user-work-routes.test.ts b/packages/rest/src/user-work-routes.test.ts new file mode 100644 index 00000000000..e4be871c605 --- /dev/null +++ b/packages/rest/src/user-work-routes.test.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// [#30205] The REST face of a user's assigned work: the counts and the hand-over. + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} +function mockProtocol() { + return { getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), getMetaTypes: vi.fn().mockResolvedValue([]), getMetaItems: vi.fn().mockResolvedValue([]) }; +} +function mockRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.end = vi.fn(() => res); + return res; +} + +const CALLER = { userId: 'u_admin', tenantId: 'org_1' }; + +/** A data engine with one ticket object: the slice the routes call. */ +function mockEngine() { + const rows = [ + { id: 't1', owner_id: 'u_gone' }, + { id: 't2', owner_id: 'u_gone' }, + ]; + const users = [{ id: 'u_gone', banned: true }, { id: 'u_next', banned: false }]; + return { + rows, + getConfigs: () => ({ ticket: { name: 'ticket', fields: { owner_id: { type: 'lookup', reference: 'sys_user' } } } }), + count: vi.fn(async (_o: string, q: any) => rows.filter((r) => r.owner_id === q.where.owner_id).length), + find: vi.fn(async (o: string, q: any) => (o === 'sys_user' ? users : rows).filter((r: any) => + Object.entries(q.where).every(([k, v]) => r[k] === v)).slice(0, q.limit ?? Infinity)), + update: vi.fn(async (_o: string, data: any, opts: any) => { + const row = rows.find((r) => r.id === opts.where.id)!; + Object.assign(row, data); + return row; + }), + }; +} + +function buildServer(engine: any, callerCtx: any = CALLER) { + const rest = new RestServer( + mockServer() as any, mockProtocol() as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, + engine === undefined ? undefined : async () => engine, + ); + vi.spyOn(rest as any, 'resolveExecCtx').mockResolvedValue(callerCtx); + rest.registerRoutes(); + const routes = rest.getRoutes(); + return { + get: routes.find((r) => r.method === 'GET' && r.path === '/api/v1/security/users/:id/assigned-work'), + post: routes.find((r) => r.method === 'POST' && r.path === '/api/v1/security/users/:id/assigned-work/transfer'), + }; +} + +describe('[#30205] GET /security/users/:id/assigned-work', () => { + it('answers the per-object counts, read as the caller', async () => { + const engine = mockEngine(); + const { get } = buildServer(engine); + const res = mockRes(); + await get!.handler({ method: 'GET', params: { id: 'u_gone' }, headers: {}, query: {} } as any, res); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ + success: true, + data: { + userId: 'u_gone', + objects: [{ object: 'ticket', field: 'owner_id', count: 2, transferable: true }], + total: 2, + truncated: false, + unreadable: [], + }, + }); + expect(engine.count).toHaveBeenCalledWith('ticket', { where: { owner_id: 'u_gone' }, context: CALLER }); + }); + + it('refuses an unknown query parameter instead of dropping it, and counts nothing', async () => { + const engine = mockEngine(); + const { get } = buildServer(engine); + const res = mockRes(); + await get!.handler({ method: 'GET', params: { id: 'u_gone' }, headers: {}, query: { object: 'ticket' } } as any, res); + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + expect(engine.count).not.toHaveBeenCalled(); + }); + + it('an anonymous caller is refused before anything is counted', async () => { + const engine = mockEngine(); + const { get } = buildServer(engine, null); + const res = mockRes(); + await get!.handler({ method: 'GET', params: { id: 'u_gone' }, headers: {}, query: {} } as any, res); + expect(res.statusCode).toBe(401); + expect(engine.count).not.toHaveBeenCalled(); + }); + + it('answers 501 when no data engine is wired', async () => { + const { get } = buildServer(undefined); + const res = mockRes(); + await get!.handler({ method: 'GET', params: { id: 'u_gone' }, headers: {}, query: {} } as any, res); + expect(res.statusCode).toBe(501); + expect(res.body.error.code).toBe('NOT_IMPLEMENTED'); + }); +}); + +describe('[#30205] POST /security/users/:id/assigned-work/transfer', () => { + it('hands the records over through update, as the caller, and reports the outcome', async () => { + const engine = mockEngine(); + const { post } = buildServer(engine); + const res = mockRes(); + await post!.handler({ method: 'POST', params: { id: 'u_gone' }, headers: {}, query: {}, body: { toUserId: 'u_next' } } as any, res); + expect(res.statusCode).toBe(200); + expect(res.body.data).toMatchObject({ updated: 2, failed: 0, remaining: 0, toUserId: 'u_next' }); + expect(engine.update).toHaveBeenCalledWith('ticket', { owner_id: 'u_next' }, { where: { id: 't1' }, context: CALLER }); + expect(engine.rows.every((r) => r.owner_id === 'u_next')).toBe(true); + }); + + it('refuses a banned target with the field code, and writes nothing', async () => { + const engine = mockEngine(); + const { post } = buildServer(engine); + const res = mockRes(); + await post!.handler({ method: 'POST', params: { id: 'u_next' }, headers: {}, query: {}, body: { toUserId: 'u_gone' } } as any, res); + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + expect(res.body.error.details.fields[0]).toMatchObject({ field: 'toUserId', code: 'reference_user_banned' }); + expect(engine.update).not.toHaveBeenCalled(); + }); + + it('refuses an unknown body key, and a missing toUserId, before any read', async () => { + for (const body of [{ toUserId: 'u_next', dryRun: true }, {}, { toUserId: 'u_next', objects: 'ticket' }]) { + const engine = mockEngine(); + const { post } = buildServer(engine); + const res = mockRes(); + await post!.handler({ method: 'POST', params: { id: 'u_gone' }, headers: {}, query: {}, body } as any, res); + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + expect(engine.find).not.toHaveBeenCalled(); + } + }); +}); diff --git a/packages/rest/src/user-work-routes.ts b/packages/rest/src/user-work-routes.ts index 43fd84c38f3..651c8eb19cb 100644 --- a/packages/rest/src/user-work-routes.ts +++ b/packages/rest/src/user-work-routes.ts @@ -93,7 +93,7 @@ export function registerUserWorkEndpoints(this: UserWorkRouteHost, basePath: str return sendServerFault(res, 500, 'INTERNAL_ERROR', 'Counting the user\'s assigned work failed', err); } }, - metadata: { summary: 'Count the records a user owns or is assigned, per object, as the caller can read them' }, + metadata: { summary: 'Count the records a user owns or is assigned, per object, as the caller can read them', tags: ['security'] }, }); this.routeManager.register({ @@ -139,6 +139,6 @@ export function registerUserWorkEndpoints(this: UserWorkRouteHost, basePath: str return sendServerFault(res, 500, 'INTERNAL_ERROR', 'Transferring the user\'s assigned work failed', err); } }, - metadata: { summary: 'Hand a user\'s owned and assigned records to another active user, as the caller, in bounded batches' }, + metadata: { summary: 'Hand a user\'s owned and assigned records to another active user, as the caller, in bounded batches', tags: ['security'] }, }); } From 417089c2d479c70db50188112162370419ec8059 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 12:20:49 +0000 Subject: [PATCH 04/13] chore: regenerate the error reference, register the new registrar file, changeset and admin guide Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude --- .changeset/30205-banned-user-work-handover.md | 26 +++++++++++++++++++ .../docs/permissions/administrator-guide.mdx | 22 +++++++++++++++- content/docs/references/api/errors.mdx | 4 ++- .../rest-server-canonical-query-ast.test.ts | 5 ++++ .../src/rest-server-docblock-position.test.ts | 6 ++++- .../rest/src/rest-server-registrar-files.ts | 6 ++--- 6 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 .changeset/30205-banned-user-work-handover.md diff --git a/.changeset/30205-banned-user-work-handover.md b/.changeset/30205-banned-user-work-handover.md new file mode 100644 index 00000000000..6709920d9aa --- /dev/null +++ b/.changeset/30205-banned-user-work-handover.md @@ -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-②: no (narrowing) — 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 the field code `reference_user_banned`, 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. + + + +**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`. + +**FROM → TO.** A write that sets `assignee: 'BANNED_USER_ID'` → set it to an active user, or hand the banned user's records over with the transfer route first. An integration that reassigns work after a ban → call the transfer route, then repeat while `remaining` is above 0. diff --git a/content/docs/permissions/administrator-guide.mdx b/content/docs/permissions/administrator-guide.mdx index 702ebe4b64c..b4cbd83a879 100644 --- a/content/docs/permissions/administrator-guide.mdx +++ b/content/docs/permissions/administrator-guide.mdx @@ -97,6 +97,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** @@ -180,7 +200,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 immediately) — then remove assignments at leisure | +| Offboard someone | **Ban User** (blocks sign-in 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 | diff --git a/content/docs/references/api/errors.mdx b/content/docs/references/api/errors.mdx index 394f0e2f93d..1e3a69daddf 100644 --- a/content/docs/references/api/errors.mdx +++ b/content/docs/references/api/errors.mdx @@ -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 | @@ -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` @@ -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` diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index 94452450114..6353d4b6b45 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -211,6 +211,10 @@ interface CensusEntry { * the record page's stream reads (the discussion, its reactions, the activity * and the attachments) go through one server-built slot (`readStream`), so the * module's floor is 2. + * + * [#30205] 6, unchanged: a user's assigned work (`user-work-routes.ts`) hands + * the engine no `query:` slot of its own (the counts and reads are built in + * `@objectstack/core`), so the new module's floor is 0. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 0, @@ -233,6 +237,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -307,8 +308,11 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { // relationship types, the read policy, the host interface, the reach // interface, six module-level helpers' blocks, the function's, and the // two inner helpers' inside it). + // [#30205] 95 + 6 = 101: `user-work-routes.ts`, a new registrar, carries six + // (the module header, the host interface, the closed parameter set, the body + // keys, the function's, and the engine resolver's inside it). expect(Object.keys(MOVED_FILE_FLOORS).sort()) .toEqual(REST_SERVER_REGISTRAR_FILES.filter((file) => file !== FILE).sort()); - expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(95); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(101); }); }); diff --git a/packages/rest/src/rest-server-registrar-files.ts b/packages/rest/src/rest-server-registrar-files.ts index c24ed80b7a6..e111b7aade9 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -75,6 +75,9 @@ export const REST_SERVER_REGISTRAR_FILES = [ // [#27627] `registerMetaAppBundleRoute`' body, the app metadata bundle. New, not a // moved slice, and a `/meta` group — see {@link REST_SERVER_META_ROUTE_FILES}. 'meta-app-bundle-routes.ts', + // [#30205] `registerUserWorkEndpoints`' body, a user's assigned work: the counts and + // the hand-over. New, not a moved slice. + 'user-work-routes.ts', ] as const; /** One file on {@link REST_SERVER_REGISTRAR_FILES}. */ @@ -123,9 +126,6 @@ export const REST_SERVER_META_ROUTE_FILES: readonly RestServerRegistrarFile[] = // moved slice; like the per-item writes it goes through the inner method's per-item // registrar, handed over in its scope. 'meta-app-bundle-routes.ts', - // [#30205] A user's assigned work: the counts and the hand-over. New, not a moved - // slice; it registers on the route manager itself, from a host literal. - 'user-work-routes.ts', ]; /** From 13e254df878f72f0f80b24e1dcd4f86c08c90d8f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 12:47:31 +0000 Subject: [PATCH 05/13] test(rest,core): give the hand-over route its census rows; word a refused row as the /data door does The transfer report's per-row failure now carries the sentence the /data write door answers for the same throw (a hook's refusal reads as the hook's sentence, a crash is withheld and logged), through an optional describer the core helper takes. The census pins gain the new route and registrar. Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude --- packages/core/src/security/index.ts | 1 + .../second-object-read-exposure.pin.test.ts | 8 ++++++ .../src/security/user-assigned-work.test.ts | 11 ++++++++ .../core/src/security/user-assigned-work.ts | 15 ++++++++--- .../src/objectql-slot-consumer-census.test.ts | 9 ++++--- ...-response-internal-fields.tripwire.test.ts | 2 ++ ...route-hook-refusal-sentence.ledger.test.ts | 19 ++++++++++++-- packages/rest/src/user-work-routes.ts | 25 ++++++++++++++++++- 8 files changed, 81 insertions(+), 9 deletions(-) diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 5bed3c8b1b8..f87ae12bd55 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -549,6 +549,7 @@ export { type UserWorkEngine, type UserAssignedWorkCount, type UserAssignedWorkSummary, + type UserWorkFailureDescriber, type UserWorkTransferFailure, type UserWorkTransferOutcome, type UserWorkTransferReport, diff --git a/packages/core/src/security/second-object-read-exposure.pin.test.ts b/packages/core/src/security/second-object-read-exposure.pin.test.ts index c84f09424e1..8e46da19f3f 100644 --- a/packages/core/src/security/second-object-read-exposure.pin.test.ts +++ b/packages/core/src/security/second-object-read-exposure.pin.test.ts @@ -264,6 +264,14 @@ const CALLERS: Record = { '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', diff --git a/packages/core/src/security/user-assigned-work.test.ts b/packages/core/src/security/user-assigned-work.test.ts index b53b8e22cb6..c116f0e5961 100644 --- a/packages/core/src/security/user-assigned-work.test.ts +++ b/packages/core/src/security/user-assigned-work.test.ts @@ -196,6 +196,17 @@ describe('[#30205] transferUserAssignedWork', () => { expect(engine.tables.get('account')![0].owner_id).toBe('u_gone'); }); + it('a serving door may word a refused row the way its own write door answers it', async () => { + const engine = fakeEngine(seed(), { refuseUpdate: (object, id) => object === 'ticket' && id === 't2' }); + const seen: string[] = []; + const report = await transferUserAssignedWork(engine, 'u_gone', 'u_next', CALLER, { + objects: ['ticket'], + describeFailure: (_err, object) => { seen.push(object); return { code: 'FORBIDDEN', message: 'You may not change this record' }; }, + }); + expect(report.objects[0].failures).toEqual([{ id: 't2', code: 'FORBIDDEN', message: 'You may not change this record' }]); + expect(seen).toEqual(['ticket']); + }); + it('moves at most the row cap per call and answers what remains', async () => { const engine = fakeEngine(seed()); const first = await transferUserAssignedWork(engine, 'u_gone', 'u_next', CALLER, { rowCap: 3 }); diff --git a/packages/core/src/security/user-assigned-work.ts b/packages/core/src/security/user-assigned-work.ts index 452b12bbd5d..8fd62e03cec 100644 --- a/packages/core/src/security/user-assigned-work.ts +++ b/packages/core/src/security/user-assigned-work.ts @@ -241,7 +241,16 @@ export interface UserWorkTransferReport { remaining: number; } -function failureOf(id: unknown, err: unknown): UserWorkTransferFailure { +/** + * How a serving door words a refused row: the code and the caller-facing + * sentence its own write door would answer for the same throw. `undefined` + * keeps the raw reading below. + */ +export type UserWorkFailureDescriber = (err: unknown, object: string) => { code: string; message: string } | undefined; + +function failureOf(id: unknown, err: unknown, object: string, describe?: UserWorkFailureDescriber): UserWorkTransferFailure { + const described = describe?.(err, object); + if (described) return { id: String(id), code: described.code, message: described.message.slice(0, 500) }; const e = err as { code?: unknown; message?: unknown } | null; return { id: String(id), @@ -263,7 +272,7 @@ export async function transferUserAssignedWork( fromUserId: string, toUserId: string, context: unknown, - options: { objects?: readonly string[]; rowCap?: number } = {}, + options: { objects?: readonly string[]; rowCap?: number; describeFailure?: UserWorkFailureDescriber } = {}, ): Promise { if (fromUserId === toUserId) { throw new UserWorkTransferRefusal(400, 'VALIDATION_ERROR', 'toUserId must name a different user than the one whose work is transferred'); @@ -312,7 +321,7 @@ export async function transferUserAssignedWork( outcome.updated += 1; } catch (err) { outcome.failed += 1; - if (outcome.failures.length < USER_WORK_FAILURE_SAMPLE) outcome.failures.push(failureOf(row.id, err)); + if (outcome.failures.length < USER_WORK_FAILURE_SAMPLE) outcome.failures.push(failureOf(row.id, err, ref.object, options.describeFailure)); } } try { diff --git a/packages/rest/src/objectql-slot-consumer-census.test.ts b/packages/rest/src/objectql-slot-consumer-census.test.ts index d4e459a891f..b7c363132ad 100644 --- a/packages/rest/src/objectql-slot-consumer-census.test.ts +++ b/packages/rest/src/objectql-slot-consumer-census.test.ts @@ -153,6 +153,8 @@ const SLOT_FILES = [ 'batch-routes.ts', 'meta-object-state-routes.ts', 'meta-item-history-routes.ts', + // [#30205] A user's assigned work: the counts and the hand-over (the SIXTH consumer). + 'user-work-routes.ts', ] as const; const SOURCES = new Map(SLOT_FILES.map((file) => [ file, file === 'rest-server.ts' ? SOURCE : readFileSync(resolve(HERE, file), 'utf8'), @@ -228,12 +230,13 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', () // 5 UNCHANGED, over four files: 2 in `rest-server.ts` (the class head's // two), the batch door's 1, the state-machine read's 1 in // `meta-object-state-routes.ts` and `/published`'s 1 in - // `meta-item-history-routes.ts`. + // `meta-item-history-routes.ts`. [#30205] 6: a user's assigned work, its + // one site in `user-work-routes.ts`, shared by both of its routes. const sites = censusInvocationSites('objectQLProvider'); // The message carries the sites, so a failure here hands the next // reader the new census instead of a bare number. expect(sites.map((s) => `${s.file}:${s.line}: ${s.text}`).join('\n')).toBeTruthy(); - expect(sites).toHaveLength(5); + expect(sites).toHaveLength(6); }); it('⭐ [#18559] ALL FIVE consumers reach the slot through `wiredEngineOrLoud` — none reads it directly', () => { @@ -249,7 +252,7 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', () // the site rather than a bare number. expect(direct.map((s) => `${s.file}:${s.line}: ${s.text}`).join('\n')).toBe(''); expect(direct).toHaveLength(0); - expect(viaSeam).toHaveLength(5); + expect(viaSeam).toHaveLength(6); // ⭐ CONTROL for the window predicate itself, on BOTH axes. It reads a // WINDOW of source text, so "3 of 3" is only a reading if the same diff --git a/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts b/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts index 3f3c2c5b464..32284936f33 100644 --- a/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts +++ b/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts @@ -397,6 +397,8 @@ const DISPOSITIONS: Record = { 'POST /api/v1/security/suggested-bindings/:id/dismiss': { kind: 'no-record-echo', why: 'Dismissal receipt.' }, 'POST /api/v1/security/suggested-bindings/:id/reopen': { kind: 'no-record-echo', why: 'Re-open receipt.' }, 'POST /api/v1/security/explain': { kind: 'no-record-echo', why: 'Permission explanation verdict.' }, + // [#30205] The hand-over answers per-field counts and failed row ids, never a record. + 'POST /api/v1/security/users/:id/assigned-work/transfer': { kind: 'no-record-echo', why: 'Transfer report: per-field counts and failed row ids (id, code, refusal sentence), never a record body.' }, // Import: per-row RECEIPTS (row number, action, id, warnings/errors) plus // counts — measured, the written row is never spread into a result. diff --git a/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts b/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts index 6cbb82c23d5..8a9e4ce4eb2 100644 --- a/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts +++ b/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts @@ -154,13 +154,22 @@ function boot(error: () => Error, protocol: Record = {}): Booted { } const service = (label: string) => async () => rejecting(label, calls, error); const caller = { userId: 'u1', systemPermissions: ['manage_metadata', 'manage_platform_settings'] }; - const engine = { transaction: async (fn: Fn) => fn({}) }; + // [#30205] The assigned-work hand-over reads and writes through the data + // engine itself: one user field to move, an active target, and a row whose + // `update` the refusal reaches. + const engine = { + transaction: async (fn: Fn) => fn({}), + getConfigs: () => ({ [OBJECT]: { name: OBJECT, fields: { owner_id: { type: 'lookup', reference: 'sys_user' } } } }), + find: async (object: string) => (object === 'sys_user' ? [{ id: 'u2', banned: false }] : [{ id: 'r1' }]), + count: async () => 0, + update: async () => { calls.push('engine.update'); throw error(); }, + }; const rest = new RestServer( server, rejecting('protocol', calls, error, recorded), { api: { requireAuth: false } } as any, undefined, undefined, undefined, undefined, - async () => engine, // objectQLProvider (`POST /batch`) + async () => engine, // objectQLProvider (`POST /batch`, the assigned-work hand-over) service('email'), service('sharing'), undefined, // retired slot @@ -341,6 +350,12 @@ const ANSWERS_THE_SENTENCE: Record = { }, }, 'POST /api/v1/security/explain': { called: 'security.explain', body: { object: OBJECT, operation: 'read' } }, + // [#30205] The hand-over answers per row, inside its `200`: a refused row's + // entry in `failures` carries the `/data` door's sentence for that throw. + 'POST /api/v1/security/users/:id/assigned-work/transfer': { + called: 'engine.update', body: { toUserId: 'u2' }, + answer: (res) => res.body?.data?.objects?.[0]?.failures?.[0]?.message, + }, 'POST /api/v1/data/:object/:id/shares': { called: 'sharing.grant', body: {} }, 'DELETE /api/v1/data/:object/:id/shares/:shareId': { called: 'sharing.revoke' }, 'POST /api/v1/batch': { diff --git a/packages/rest/src/user-work-routes.ts b/packages/rest/src/user-work-routes.ts index 651c8eb19cb..e4e7915a55c 100644 --- a/packages/rest/src/user-work-routes.ts +++ b/packages/rest/src/user-work-routes.ts @@ -37,6 +37,7 @@ import { UserWorkTransferRefusal, type UserWorkEngine, } from '@objectstack/core'; +import { handleRouteError } from './error-response.js'; import { refuseUnknownQueryParams } from './query-allowlist.js'; import { wiredEngineOrLoud, type RestServer } from './rest-server.js'; @@ -55,6 +56,25 @@ export const USER_WORK_QUERY_PARAMS: readonly string[] = []; /** The keys a transfer body may carry. */ const TRANSFER_BODY_KEYS = new Set(['toUserId', 'objects']); +/** + * A refused row, worded the way the `/data` write door answers the same throw: + * the shared classification (`handleRouteError`) sent to a sink, so a hook's + * refusal reads as the hook's sentence, a crash or a store fault has its words + * withheld and is logged, and nothing here is a second rule. + */ +function rowFailureAnswer(err: unknown, object: string): { code: string; message: string } { + const sink: { body?: Record; status(code: number): typeof sink; json(body: Record): typeof sink } = { + status() { return sink; }, + json(body) { sink.body = body; return sink; }, + }; + handleRouteError(sink, err, object); + const body = sink.body ?? {}; + return { + code: typeof body.code === 'string' && body.code.length > 0 ? body.code : 'UPDATE_FAILED', + message: typeof body.error === 'string' ? body.error : 'The record could not be updated', + }; +} + /** * Register the assigned-work routes — `RestServer#registerUserWorkEndpoints`' * body, called by that method with the server as `this`. @@ -127,7 +147,10 @@ export function registerUserWorkEndpoints(this: UserWorkRouteHost, basePath: str const engine = await resolveEngine(environmentId, req); if (!engine) return sendError(res, 501, 'NOT_IMPLEMENTED', 'No data engine is configured on this deployment'); try { - const report = await transferUserAssignedWork(engine, userId, toUserId, context, { objects }); + const report = await transferUserAssignedWork(engine, userId, toUserId, context, { + objects, + describeFailure: rowFailureAnswer, + }); return res.json({ success: true, data: report }); } catch (err) { if (!(err instanceof UserWorkTransferRefusal)) throw err; From 458493b59f1dc76b524d395b9803b08fc5ed4a67 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 13:20:58 +0000 Subject: [PATCH 06/13] docs(changeset): word the caller guidance as prose, not a migration prescription Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude --- .changeset/30205-banned-user-work-handover.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/30205-banned-user-work-handover.md b/.changeset/30205-banned-user-work-handover.md index 6709920d9aa..4dfad1034e2 100644 --- a/.changeset/30205-banned-user-work-handover.md +++ b/.changeset/30205-banned-user-work-handover.md @@ -23,4 +23,4 @@ Skills-docs: no move in `skills/**`, which teaches no user-assignment verdict; ` - **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`. -**FROM → TO.** A write that sets `assignee: 'BANNED_USER_ID'` → set it to an active user, or hand the banned user's records over with the transfer route first. An integration that reassigns work after a ban → call the transfer route, then repeat while `remaining` is above 0. +**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. From 6e44006f925bf695bf499b326156e453c6c8ab57 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 14:13:31 +0000 Subject: [PATCH 07/13] test(dogfood,rest,core,objectql): classify the assigned-work family; hold the new doubles to the engine contracts The user-work REST family arrives classified: a matrix row (anonymous-deny-user-work) with its booted proof (anonymous 401 on both routes, a member's count served and hand-over reaching its own target check). The routes answer through the shared sendOk and are declared to the route-envelope gate. The new test doubles route update through assertEngineUpdateDispatch, refuse logical operators, and hold the limit. Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude --- .../src/security/user-assigned-work.test.ts | 10 +++- .../src/engine-banned-user-reference.test.ts | 5 +- .../dogfood/test/authz-conformance.matrix.ts | 11 +++- .../qa/dogfood/test/authz-conformance.test.ts | 2 + ...se-anonymous-deny-surfaces.dogfood.test.ts | 55 +++++++++++++++++++ packages/rest/src/user-work-routes.test.ts | 7 ++- packages/rest/src/user-work-routes.ts | 6 +- scripts/check-route-envelope.mjs | 2 + scripts/engine-double-contract.pinned.json | 10 ++++ 9 files changed, 99 insertions(+), 9 deletions(-) diff --git a/packages/core/src/security/user-assigned-work.test.ts b/packages/core/src/security/user-assigned-work.test.ts index c116f0e5961..87d49a0cd69 100644 --- a/packages/core/src/security/user-assigned-work.test.ts +++ b/packages/core/src/security/user-assigned-work.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { countUserAssignedWork, isUserBanInForce, @@ -69,12 +70,16 @@ describe('[#30205] userAssignedWorkFields', () => { type Row = Record; +/** Equality and `$contains`; a logical operator is refused, never read as a field. */ +const match = (row: Row, where: Record) => Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error('fake engine: unsupported logical operator ' + k); + return v && typeof v === 'object' && '$contains' in v ? Array.isArray(row[k]) && row[k].includes(v.$contains) : row[k] === v; +}); + /** An in-memory engine: equality and `$contains` filters, a per-object read/write refusal. */ function fakeEngine(seed: Record, opts: { unreadable?: string[]; refuseUpdate?: (object: string, id: string) => boolean; disabled?: string[] } = {}) { const tables = new Map(Object.entries(seed).map(([k, rows]) => [k, rows.map((r) => ({ ...r }))])); const calls: Array<{ op: string; object: string; context: any; where?: any }> = []; - const match = (row: Row, where: Record) => Object.entries(where).every(([k, v]) => - v && typeof v === 'object' && '$contains' in v ? Array.isArray(row[k]) && row[k].includes(v.$contains) : row[k] === v); const guard = (object: string, context: any) => { if (opts.unreadable?.includes(object) && !context?.isSystem) throw Object.assign(new Error('denied'), { code: 'PERMISSION_DENIED' }); }; @@ -95,6 +100,7 @@ function fakeEngine(seed: Record, opts: { unreadable?: string[]; return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, async update(object, data, o) { + assertEngineUpdateDispatch(data, o); calls.push({ op: 'update', object, context: o?.context, where: o?.where }); const row = (tables.get(object) ?? []).find((r) => match(r, o?.where ?? {})); if (!row) return null; diff --git a/packages/objectql/src/engine-banned-user-reference.test.ts b/packages/objectql/src/engine-banned-user-reference.test.ts index f9cfa7847e8..1f8a6c28876 100644 --- a/packages/objectql/src/engine-banned-user-reference.test.ts +++ b/packages/objectql/src/engine-banned-user-reference.test.ts @@ -66,7 +66,8 @@ function makeStubDriver() { async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, async find(object: string, ast: any) { - return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; }, async findOne(object: string, ast: any) { for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; @@ -191,7 +192,7 @@ describe('[#30205] a new assignment to a banned user is refused', () => { const row: any = await engine.insert('ban_ticket', { title: 'T', assignee: 'u_alice' }, { context: ALICE } as any); const err = await refusalOf(() => engine.update('ban_ticket', { assignee: 'u_bob' }, { where: { id: row.id }, context: ALICE } as any)); expect(err.fields[0]).toMatchObject({ field: 'assignee', code: 'reference_user_banned' }); - const after: any = await engine.findOne('ban_ticket', { where: { id: row.id }, context: { isSystem: true } } as any); + const after: any = await engine.findOne('ban_ticket', { where: { id: row.id }, context: { isSystem: true } }); expect(after.assignee).toBe('u_alice'); }); diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index eac70685660..8675e1d423c 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -24,7 +24,7 @@ // hand-curated regex table reaching 1 of 17 REST registrars and 4 of 17 // dispatcher domain files. // -// The population comes from `packages/rest/src/rest-route-ledger.ts` (18 +// The population comes from `packages/rest/src/rest-route-ledger.ts` (19 // families) and `packages/runtime/src/route-ledger.ts` (23 // domains) because those two are enumerated from a RUNNING server and guarded // in both directions by their own conformance tests — so a new family or @@ -311,6 +311,15 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ // family key needs. covers: ['rest-family:rest-route-ledger.ts:openapi'], note: 'Measured on a booted showcase before the change: an unauthenticated caller was served the document (and its viewer page, since retired) with 200, just as a signed-in caller was, while the record doors on the same boot answered it 401 — ADR-0056 D2 is default-deny and no ADR-0138 D2 door class names this endpoint. The cited proof drives the document anonymously (401, the REST flat envelope, nothing of it served) and with a signed-in member (200, the document) on one boot. The environment-scoped twin mounts only under project scoping, which the showcase does not enable; it is the same handler closure, pinned per base in rest/src/rest-api-description-anonymous-deny.test.ts beside the gated-session 403 and the permission-store-outage 503. A deployment that publishes its API description to readers without an account publishes a static copy of the document instead.' }, + // #30205 — a user's assigned work, counted and handed over: a family that + // arrives classified, the way `/approvals/act` did. + { id: 'anonymous-deny-user-work', summary: 'anonymous-deny on the assigned-work count and hand-over', state: 'enforced', + enforcement: 'rest/user-work-routes.ts registerUserWorkEndpoints — both handlers (GET assigned-work, POST assigned-work/transfer) open with enforceAuth (→ shouldDenyAnonymous) as their first act after resolving the caller, ahead of the query-parameter and body checks and of any engine read or write; an anonymous caller gets the REST seam\'s flat 401 UNAUTHENTICATED, and a signed-in caller counts and moves only what their own context can read and update', + proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', + // The REST family at ledger granularity: the family is exactly these two + // routes and both handlers open with the floor. + covers: ['rest-family:rest-route-ledger.ts:user-work'], + note: 'New in #30205. The cited proof drives both routes anonymously (401, the REST flat envelope, nothing counted or moved) and with a signed-in member on one boot (the count served 200; the hand-over reaching its own unknown-target refusal, 404, before any write). Row admission and the update of each moved record stay with the security layer behind the engine, because both routes run as the caller.' }, // #22432 — the `/i18n` dispatcher domain, under the ruling recorded on #22146 // (the domain gains the domain-level anonymous refusal every other dispatcher // domain has, with an objectui companion for the Console's sign-in page). diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 0a53c60c99a..7d83b259983 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -1103,6 +1103,8 @@ describe('the ledger-sourced population and its baseline bite', () => { 'rest-family:rest-route-ledger.ts:metadata', // [#22430] classified by `anonymous-deny-api-description`. 'rest-family:rest-route-ledger.ts:openapi', + // [#30205] classified by `anonymous-deny-user-work`: it arrived classified. + 'rest-family:rest-route-ledger.ts:user-work', ]); // …and the two halves partition the population exactly: nothing minted by // a ledger probe is both, and nothing is neither. diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts index dcf25665db1..1f6621475a2 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -51,6 +51,7 @@ // authz-row: anonymous-deny-packages // authz-row: anonymous-deny-analytics // authz-row: anonymous-deny-api-description +// authz-row: anonymous-deny-user-work // authz-row: anonymous-deny-i18n // // ── Why the translation faces are here (#22432) ──────────────────────────── @@ -180,6 +181,26 @@ const API_DESCRIPTION_ENDPOINTS: readonly ApiDescriptionEndpoint[] = [ const RETIRED_VIEWER_PATH = '/docs'; const NEVER_SERVED_PATH = '/this-route-does-not-exist'; +// ── #30205 — a user's assigned work, counted and handed over (@objectstack/rest) +// +// Both handlers open with the floor, ahead of the query-parameter check and +// any engine read. The ids are ones no user holds, so the member controls +// read nothing and write nothing: the count answers an empty summary, and the +// hand-over refuses the unknown target before any write. +interface UserWorkFace { + readonly face: string; + readonly method: 'GET' | 'POST'; + readonly path: string; + readonly body?: unknown; +} +const USER_WORK_FACES: readonly UserWorkFace[] = [ + { face: 'the assigned-work count', method: 'GET', path: '/security/users/usr_nobody_30205/assigned-work' }, + { + face: 'the assigned-work hand-over', method: 'POST', path: '/security/users/usr_nobody_30205/assigned-work/transfer', + body: { toUserId: 'usr_nobody_either_30205' }, + }, +]; + // ── #22432 — the translation faces (dispatcher-mounted; runtime domains/i18n.ts) // // One handler body serves every face, so the floor is its first statement. The @@ -809,6 +830,40 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => expect(await adminRes.text(), 'the retired path answers the admin the ordinary not-found body').toBe(controlBody); }); + // ── a user's assigned work (@objectstack/rest) — #30205 ───────────────── + // + // The refusal is each handler's first act, so the anonymous body is the REST + // seam's shared one, whole, and nothing is counted or moved. The member + // controls are the teeth: with a session the count is served 200 and the + // hand-over reaches its own target check (404), so both doors are mounted + // on this boot and the 401 is the floor's answer. + it.each(USER_WORK_FACES)('an anonymous caller on $face is denied (401) and served nothing', async (face) => { + const r = await anon(face.method, face.path, face.body); + expect(r.status, `${face.face}: an anonymous caller must be refused by the floor`).toBe(ANONYMOUS_DENY_STATUS); + const body = await r.json(); + expect(body, `${face.face}: the refusal is the REST seam's shared body, whole`).toEqual(ANONYMOUS_DENY_BODY); + expect(body, `${face.face}: nothing of the work rides on the refusal`).not.toHaveProperty('data'); + }); + + it('a signed-in member is served the assigned-work count (200) — the control', async () => { + const r = await stack.apiAs(memberToken, 'GET', USER_WORK_FACES[0].path); + expect(r.status, 'an authenticated member must clear the floor and be served the count').toBe(200); + const body = await r.json(); + expect(body.data, 'the served body is the count of a user who holds nothing').toMatchObject({ + userId: 'usr_nobody_30205', total: 0, objects: [], + }); + }); + + it('a signed-in member reaches the hand-over\'s own target check (404), and nothing moves — the control', async () => { + const face = USER_WORK_FACES[1]; + const r = await stack.apiAs(memberToken, face.method, face.path, face.body); + expect(r.status, 'an authenticated member must clear the floor and meet the target check').toBe(404); + const body = await r.json(); + expect(body.error?.details?.fields?.[0], 'the refusal names the unknown target').toMatchObject({ + field: 'toUserId', code: 'reference_not_found', + }); + }); + // ── the translation faces (dispatcher-mounted; runtime domains/i18n.ts) — #22432 // // The same domain-wide shape as analytics: the floor is the handler's first diff --git a/packages/rest/src/user-work-routes.test.ts b/packages/rest/src/user-work-routes.test.ts index e4be871c605..7b013cc0a5a 100644 --- a/packages/rest/src/user-work-routes.test.ts +++ b/packages/rest/src/user-work-routes.test.ts @@ -2,6 +2,7 @@ // [#30205] The REST face of a user's assigned work: the counts and the hand-over. import { describe, it, expect, vi } from 'vitest'; +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { RestServer } from './rest-server'; function mockServer() { @@ -35,8 +36,12 @@ function mockEngine() { getConfigs: () => ({ ticket: { name: 'ticket', fields: { owner_id: { type: 'lookup', reference: 'sys_user' } } } }), count: vi.fn(async (_o: string, q: any) => rows.filter((r) => r.owner_id === q.where.owner_id).length), find: vi.fn(async (o: string, q: any) => (o === 'sys_user' ? users : rows).filter((r: any) => - Object.entries(q.where).every(([k, v]) => r[k] === v)).slice(0, q.limit ?? Infinity)), + Object.entries(q.where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error('fake engine: unsupported logical operator ' + k); + return r[k] === v; + })).slice(0, q.limit ?? Infinity)), update: vi.fn(async (_o: string, data: any, opts: any) => { + assertEngineUpdateDispatch(data, opts); const row = rows.find((r) => r.id === opts.where.id)!; Object.assign(row, data); return row; diff --git a/packages/rest/src/user-work-routes.ts b/packages/rest/src/user-work-routes.ts index e4e7915a55c..188ec527b70 100644 --- a/packages/rest/src/user-work-routes.ts +++ b/packages/rest/src/user-work-routes.ts @@ -30,7 +30,7 @@ * closures and the `objectQLProvider` field as a getter, read at request time. */ -import { sendError, sendServerFault } from '@objectstack/types'; +import { sendError, sendOk, sendServerFault } from '@objectstack/types'; import { countUserAssignedWork, transferUserAssignedWork, @@ -108,7 +108,7 @@ export function registerUserWorkEndpoints(this: UserWorkRouteHost, basePath: str const engine = await resolveEngine(environmentId, req); if (!engine) return sendError(res, 501, 'NOT_IMPLEMENTED', 'No data engine is configured on this deployment'); const summary = await countUserAssignedWork(engine, userId, context); - return res.json({ success: true, data: summary }); + return sendOk(res, summary); } catch (err) { return sendServerFault(res, 500, 'INTERNAL_ERROR', 'Counting the user\'s assigned work failed', err); } @@ -151,7 +151,7 @@ export function registerUserWorkEndpoints(this: UserWorkRouteHost, basePath: str objects, describeFailure: rowFailureAnswer, }); - return res.json({ success: true, data: report }); + return sendOk(res, report); } catch (err) { if (!(err instanceof UserWorkTransferRefusal)) throw err; return sendError(res, err.status, err.code, err.message, err.field ? { diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 66c22b4eb95..619b2ef07a5 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -246,6 +246,8 @@ const MODULES = { 'packages/services/service-datasource/src/admin-routes.ts': { responses: 0, ok: 0, err: 0 }, 'packages/rest/src/external-datasource-routes.ts': { responses: 0, ok: 0, err: 0 }, 'packages/rest/src/package-routes.ts': { responses: 0, ok: 0, err: 0 }, + // [#30205] The assigned-work count and hand-over: born on the shared pair. + 'packages/rest/src/user-work-routes.ts': { responses: 0, ok: 0, err: 0 }, // #3636 put the right envelope on this module's three read routes but built it // inline in four places, so it carried a ratchet at 5 / 4 / 1 until those // collapsed behind a local `sendOk` — and then onto the shared one. diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 171fb7a76e7..22c3ec2e89c 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -31,6 +31,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/core/src/security/user-assigned-work.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/core/src/utils/metadata-activation-store.test.ts", "verb": "update", @@ -4721,6 +4726,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/rest/src/user-work-routes.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/runtime/src/action-body-write-stale-summary.test.ts", "verb": "delete", From 30927981cc024c82f6d415222e80d5752ba09543 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 15:36:20 +0000 Subject: [PATCH 08/13] refactor(core,objectql): ask main's one ban predicate instead of a second copy #30287 landed isUserBanInForce and USER_BAN_FIELDS in security/user-ban.ts while this branch carried its own; the hand-over and the reference check now read that one, so a ban means one thing at every door. Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude --- packages/core/src/security/index.ts | 1 - .../src/security/user-assigned-work.test.ts | 24 ------------------- .../core/src/security/user-assigned-work.ts | 23 ++---------------- .../src/engine-referential-integrity.ts | 4 ++-- 4 files changed, 4 insertions(+), 48 deletions(-) diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index dae4e0824fa..58ca3a3a563 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -544,7 +544,6 @@ export { USER_ASSIGNED_WORK_FIELD_CAP, USER_WORK_TRANSFER_ROW_CAP, USER_WORK_FAILURE_SAMPLE, - isUserBanInForce, userAssignedWorkFields, countUserAssignedWork, transferUserAssignedWork, diff --git a/packages/core/src/security/user-assigned-work.test.ts b/packages/core/src/security/user-assigned-work.test.ts index 87d49a0cd69..e250083864d 100644 --- a/packages/core/src/security/user-assigned-work.test.ts +++ b/packages/core/src/security/user-assigned-work.test.ts @@ -4,36 +4,12 @@ import { describe, it, expect } from 'vitest'; import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { countUserAssignedWork, - isUserBanInForce, transferUserAssignedWork, userAssignedWorkFields, UserWorkTransferRefusal, type UserWorkEngine, } from './user-assigned-work.js'; -const NOW = Date.parse('2026-11-05T12:00:00Z'); - -describe('[#30205] isUserBanInForce', () => { - it('a ban with no expiry, or one still ahead, is in force', () => { - expect(isUserBanInForce({ banned: true }, NOW)).toBe(true); - expect(isUserBanInForce({ banned: true, ban_expires: null }, NOW)).toBe(true); - expect(isUserBanInForce({ banned: true, ban_expires: '2026-11-06T00:00:00Z' }, NOW)).toBe(true); - expect(isUserBanInForce({ banned: true, ban_expires: new Date(NOW + 1000) }, NOW)).toBe(true); - }); - - it('an expired ban, an unbanned user and a non-row are not', () => { - expect(isUserBanInForce({ banned: true, ban_expires: '2026-11-04T00:00:00Z' }, NOW)).toBe(false); - expect(isUserBanInForce({ banned: false }, NOW)).toBe(false); - expect(isUserBanInForce({ banned: null }, NOW)).toBe(false); - expect(isUserBanInForce({}, NOW)).toBe(false); - expect(isUserBanInForce(null, NOW)).toBe(false); - }); - - it('an expiry that does not parse cannot prove the ban lifted', () => { - expect(isUserBanInForce({ banned: true, ban_expires: 'not a date' }, NOW)).toBe(true); - }); -}); - const ticket = { name: 'ticket', fields: { diff --git a/packages/core/src/security/user-assigned-work.ts b/packages/core/src/security/user-assigned-work.ts index 8fd62e03cec..772d1823812 100644 --- a/packages/core/src/security/user-assigned-work.ts +++ b/packages/core/src/security/user-assigned-work.ts @@ -51,6 +51,7 @@ */ import { referenceTargetOf, unprovisionedInjectedColumns } from '@objectstack/spec/data'; +import { isUserBanInForce, USER_BAN_FIELDS } from './user-ban.js'; /** The platform user object every user reference targets. */ const USER_OBJECT = 'sys_user'; @@ -64,26 +65,6 @@ export const USER_WORK_TRANSFER_ROW_CAP = 500; /** At most this many failed rows are described per (object, field) pair. */ export const USER_WORK_FAILURE_SAMPLE = 10; -/** - * Is this user row's ban in force? - * - * `banned` is `true` and the ban has no expiry, or one still in the future. - * better-auth leaves `banned` true past `ban_expires` until the user's next - * sign-in clears it, and such a user CAN sign in again, so an expired ban is - * not in force. A row that is not an object, or has no `banned: true`, is not - * banned. - */ -export function isUserBanInForce(row: unknown, now: number = Date.now()): boolean { - if (!row || typeof row !== 'object') return false; - const { banned, ban_expires: expires } = row as { banned?: unknown; ban_expires?: unknown }; - if (banned !== true) return false; - if (expires === null || expires === undefined || expires === '') return true; - const at = expires instanceof Date ? expires.getTime() : new Date(expires as string | number).getTime(); - // An expiry that does not parse cannot prove the ban lifted. - if (Number.isNaN(at)) return true; - return at > now; -} - /** One user reference a record can hold work through. */ export interface UserAssignedWorkField { object: string; @@ -283,7 +264,7 @@ export async function transferUserAssignedWork( const elevated = { ...((context as object | undefined) ?? {}), isSystem: true }; const [target] = await engine.find(USER_OBJECT, { where: { id: toUserId }, - fields: ['id', 'banned', 'ban_expires'], + fields: [...USER_BAN_FIELDS], limit: 1, context: elevated, }); diff --git a/packages/objectql/src/engine-referential-integrity.ts b/packages/objectql/src/engine-referential-integrity.ts index f8f71c9daff..600492a3547 100644 --- a/packages/objectql/src/engine-referential-integrity.ts +++ b/packages/objectql/src/engine-referential-integrity.ts @@ -52,7 +52,7 @@ import { referenceTargetOf, type EngineQueryOptions } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; -import { isUserBanInForce, keepsFlowWriteReferenceCheck } from '@objectstack/core'; +import { isUserBanInForce, keepsFlowWriteReferenceCheck, USER_BAN_FIELDS } from '@objectstack/core'; import { ValidationError, buildFieldError } from './validation/record-validator.js'; import type { ObjectQL } from './engine.js'; @@ -328,7 +328,7 @@ async function bannedUserIds( try { const rows = await this.find(BAN_CHECKED_USER_OBJECT, { where: { id: asked.length === 1 ? asked[0] : { $in: asked } }, - fields: ['id', 'banned', 'ban_expires'], + fields: [...USER_BAN_FIELDS], context: { ...(context ?? {}), isSystem: true } as ExecutionContext, } as EngineQueryOptions) as Array>; const banned = new Set(); From 6d576db52ec3e72f7312a74ecb257683652a5fd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 15:41:04 +0000 Subject: [PATCH 09/13] chore(objectql): mark the banned-user field code as field-addressed (ADR-0112 D6) Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude --- packages/objectql/src/engine-referential-integrity.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/engine-referential-integrity.ts b/packages/objectql/src/engine-referential-integrity.ts index 600492a3547..881afe7ddc3 100644 --- a/packages/objectql/src/engine-referential-integrity.ts +++ b/packages/objectql/src/engine-referential-integrity.ts @@ -272,7 +272,11 @@ export async function assertReferencesResolve( } if (users.length > 0) { const banned = await bannedUserIds.call(this, users.map((u) => u.value), context); - for (const u of users) if (banned.has(String(u.value))) refused.push({ ...u, code: 'reference_user_banned' }); + for (const u of users) { + if (!banned.has(String(u.value))) continue; + // adr0112-ok: D6 — a field-addressed FieldErrorCode; this entry becomes `{ field, code }` below + refused.push({ ...u, code: 'reference_user_banned' }); + } } if (refused.length === 0) return; // [#29062] A refused value every row this write lands on already stores From ed8518156865ac7e6e7656fbf014c5858b623c1a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 15:56:20 +0000 Subject: [PATCH 10/13] test(dogfood): keep the tracker id out of the matrix row's note string Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude --- packages/qa/dogfood/test/authz-conformance.matrix.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 8675e1d423c..0c7dd5a5ece 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -319,7 +319,8 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ // The REST family at ledger granularity: the family is exactly these two // routes and both handlers open with the floor. covers: ['rest-family:rest-route-ledger.ts:user-work'], - note: 'New in #30205. The cited proof drives both routes anonymously (401, the REST flat envelope, nothing counted or moved) and with a signed-in member on one boot (the count served 200; the hand-over reaching its own unknown-target refusal, 404, before any write). Row admission and the update of each moved record stay with the security layer behind the engine, because both routes run as the caller.' }, + // Added by #30205. + note: 'The cited proof drives both routes anonymously (401, the REST flat envelope, nothing counted or moved) and with a signed-in member on one boot (the count served 200; the hand-over reaching its own unknown-target refusal, 404, before any write). Row admission and the update of each moved record stay with the security layer behind the engine, because both routes run as the caller.' }, // #22432 — the `/i18n` dispatcher domain, under the ruling recorded on #22146 // (the domain gains the domain-level anonymous refusal every other dispatcher // domain has, with an objectui companion for the Console's sign-in page). From d5d88743dc983fbbd2bae7acedabaeffd5e49e4d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 16:04:15 +0000 Subject: [PATCH 11/13] fix(objectql): read a referenced user's ban state from the existence probe's own read The ban check issued a find on sys_user of its own on every write naming a user, which the plugin-audit read-cost pins (a create pays zero reads on the reference targets) caught. The probe now selects the target's declared ban columns beside the id (referenceRow, which referenceExists delegates to), so the check costs no read and keeps the probe's elevation. Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude --- .../src/engine-referential-integrity.ts | 65 +++++-------------- packages/objectql/src/engine.ts | 31 +++++++-- 2 files changed, 40 insertions(+), 56 deletions(-) diff --git a/packages/objectql/src/engine-referential-integrity.ts b/packages/objectql/src/engine-referential-integrity.ts index 881afe7ddc3..cee20fa2450 100644 --- a/packages/objectql/src/engine-referential-integrity.ts +++ b/packages/objectql/src/engine-referential-integrity.ts @@ -69,6 +69,7 @@ export interface ReferentialIntegrityHost { find: ObjectQL['find']; masterDetailHeaderExists: ObjectQL['masterDetailHeaderExists']; referenceExists: ObjectQL['referenceExists']; + referenceRow: ObjectQL['referenceRow']; } /** @@ -188,7 +189,7 @@ function isEmptyReferenceValue(v: unknown): boolean { * (see {@link masterDetailHeadersCallerReads}). */ export async function assertReferencesResolve( - this: Pick, + this: Pick, schema: any, data: Record | null | undefined, supplied: Record | null | undefined, @@ -204,9 +205,6 @@ export async function assertReferencesResolve( if (!fields || !data) return; const refused: Array<{ name: string; def: any; value: unknown; target: string; code?: 'reference_user_banned' }> = []; - // [#30205] The user ids that resolved, by the fields that named them: a - // banned user is a row that exists, so the existence probe passes them. - const users: Array<{ name: string; def: any; value: unknown; target: string }> = []; for (const name of Object.keys(fields)) { // [#4441, narrowing kept by #4743] A `readonly` field is never the // caller's to answer for — BY CONSTRUCTION, not by exemption. @@ -263,19 +261,18 @@ export async function assertReferencesResolve( if (v === null || v === undefined || v === '') continue; if (typeof v === 'object') continue; // [#22519] A master-detail header must also be one the caller can read. + if (def?.type !== 'master_detail' && target === BAN_CHECKED_USER_OBJECT) { + // [#30205] One probe answers both: the row's existence and its ban. + const row = await this.referenceRow(target, v, context, USER_BAN_FIELDS); + if (row === false) refused.push({ name, def, value: v, target }); + // adr0112-ok: D6 — a field-addressed FieldErrorCode; this entry becomes `{ field, code }` below + else if (row !== null && isUserBanInForce(row)) refused.push({ name, def, value: v, target, code: 'reference_user_banned' }); + continue; + } const resolved = def?.type === 'master_detail' ? await this.masterDetailHeaderExists(target, v, context) : await this.referenceExists(target, v, context); if (resolved === false) refused.push({ name, def, value: v, target }); - else if (resolved === true && target === BAN_CHECKED_USER_OBJECT) users.push({ name, def, value: v, target }); - } - } - if (users.length > 0) { - const banned = await bannedUserIds.call(this, users.map((u) => u.value), context); - for (const u of users) { - if (!banned.has(String(u.value))) continue; - // adr0112-ok: D6 — a field-addressed FieldErrorCode; this entry becomes `{ field, code }` below - refused.push({ ...u, code: 'reference_user_banned' }); } } if (refused.length === 0) return; @@ -300,12 +297,6 @@ export async function assertReferencesResolve( /** * [#30205] The user object whose rows a reference check also asks about a ban. - */ -const BAN_CHECKED_USER_OBJECT = 'sys_user'; - -/** - * [#30205] Which of `ids` name a user whose ban is in force - * (`isUserBanInForce`, @objectstack/core). * * Banning a user leaves the row in place, so the existence probe resolves a * banned user like any other, and a write could hand new work to someone who @@ -316,37 +307,13 @@ const BAN_CHECKED_USER_OBJECT = 'sys_user'; * the banned user stays editable, and is handed over by the transfer * instead), never a system write the reference check exempts. * - * Read the way the existence probe reads — `sudo()`-shaped, the caller's - * context spread under `isSystem: true` — because a ban is a fact about the - * user, not about what the caller may read: a caller with no read grant on - * the user object is refused the same way. One `find` for the whole write. - * A read that fails refuses nothing, and says so once at `warn`: like the - * probe, a check that cannot run must not invent a rejection. + * The ban columns ride the existence probe's own read (`referenceRow`), so the + * check costs no read of its own and is read the way the probe reads: + * elevated inside the caller's tenant, because a ban is a fact about the + * user, not about what the caller may read. A probe that cannot run refuses + * nothing, as before. */ -async function bannedUserIds( - this: Pick, - ids: ReadonlyArray, - context: ExecutionContext | undefined, -): Promise> { - const asked = [...new Set(ids.map((id) => String(id)))]; - try { - const rows = await this.find(BAN_CHECKED_USER_OBJECT, { - where: { id: asked.length === 1 ? asked[0] : { $in: asked } }, - fields: [...USER_BAN_FIELDS], - context: { ...(context ?? {}), isSystem: true } as ExecutionContext, - } as EngineQueryOptions) as Array>; - const banned = new Set(); - for (const row of Array.isArray(rows) ? rows : []) { - if (row?.id != null && isUserBanInForce(row)) banned.add(String(row.id)); - } - return banned; - } catch (err) { - this.logger?.warn?.('the ban-state read of the referenced users failed — the write is not refused for a ban', { - object: BAN_CHECKED_USER_OBJECT, error: err, - }); - return new Set(); - } -} +const BAN_CHECKED_USER_OBJECT = 'sys_user'; /** * [#29062] The rows a write lands on, as stored before it, read on demand by diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 9c43ea7d918..07a4844ac7a 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3137,13 +3137,11 @@ export class ObjectQL implements IObjectQLEngine { storedRows?: StoredReferenceRows, ): Promise { // [#25522 cut 10] The body moved to `engine-referential-integrity.ts`, its docblock with it (see `ReferentialIntegrityHost`). - // [#30205] `find` and `logger` serve the ban-state read of referenced users. - const readLogger = () => this.logger; + // [#30205] `referenceRow` answers a referenced user's ban state from the existence probe's own read. const host: ThisParameterType = { - get logger() { return readLogger(); }, - find: (...a) => this.find(...a), masterDetailHeaderExists: (...a) => this.masterDetailHeaderExists(...a), referenceExists: (...a) => this.referenceExists(...a), + referenceRow: (...a) => this.referenceRow(...a), }; return assertReferencesResolve.call(host, schema, data, supplied, context, msgCtx, storedRows); } @@ -3170,15 +3168,34 @@ export class ObjectQL implements IObjectQLEngine { * now refuses. */ private async referenceExists(target: string, id: unknown, context?: ExecutionContext): Promise { + const row = await this.referenceRow(target, id, context, []); + return row === null ? null : row !== false; + } + + /** + * [#30205] {@link referenceExists}'s probe, answering the row it found: + * `id` plus whichever of `extraFields` the target declares, `false` when the + * probe ran and found nothing, `null` when it could not run. Same read, same + * elevation, so a check that needs a column of the target (a user's ban + * state) costs no read of its own. + */ + private async referenceRow( + target: string, + id: unknown, + context: ExecutionContext | undefined, + extraFields: readonly string[], + ): Promise | false | null> { try { const resolved = this.resolveObjectName(target); - if (!this._registry.getObject(resolved)) return null; + const schema = this._registry.getObject(resolved); + if (!schema) return null; + const declared = (schema as { fields?: Record }).fields ?? {}; const row = await this.findOne(resolved, { where: { id }, - fields: ['id'], + fields: ['id', ...extraFields.filter((f) => f !== 'id' && f in declared)], context: ObjectQL.referenceCheckContext(context), } as any); - return !!row; + return row ? row as Record : false; } catch { return null; } From 5856fb0fd10689731640ed207dccdb7d2000f931 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 16:10:08 +0000 Subject: [PATCH 12/13] =?UTF-8?q?fix(rest):=20put=20each=20registrar's=20d?= =?UTF-8?q?ocblock=20back=20on=20its=20own=20method;=20declare=20Clause-?= =?UTF-8?q?=E2=91=A1=20yes=20(narrowing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user-work registrar had been inserted between the security-explain registrar's docblock and its body, so each method carried the other's docblock. It now sits after registerSecurityExplainEndpoints, and the diff against main is a pure addition. The changeset declares Clause-②: yes (narrowing): the published FieldErrorCode enum gains a member, which widens the public face. Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude --- .changeset/30205-banned-user-work-handover.md | 2 +- packages/rest/src/rest-server.ts | 34 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.changeset/30205-banned-user-work-handover.md b/.changeset/30205-banned-user-work-handover.md index 4dfad1034e2..5a59061899d 100644 --- a/.changeset/30205-banned-user-work-handover.md +++ b/.changeset/30205-banned-user-work-handover.md @@ -8,7 +8,7 @@ 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-②: no (narrowing) — 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 the field code `reference_user_banned`, 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. +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. diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index fdc327fba8b..20c49e386dd 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -6181,19 +6181,20 @@ export class RestServer { * business) and returns 501 when no security service exposing `explain` * is mounted (a deployment without `@objectstack/plugin-security`). */ - private registerUserWorkEndpoints(basePath: string): void { - // [#30205] The body lives in `user-work-routes.ts`. This literal hands it - // every member it reads (see `UserWorkRouteHost`); the engine provider - // FIELD goes as a getter, read when a handler reads it. - const readObjectQLProvider = () => this.objectQLProvider; - const host: UserWorkRouteHost = { + private registerSecurityExplainEndpoints(basePath: string): void { + // [#23050 slice 6] The body moved to `security-routes.ts`. This literal + // hands it every member it reads — the list IS its dependency on this + // class (see `SecurityExplainRouteHost`, and that module's header for why + // `securityServiceProvider` is a getter). + const readSecurityServiceProvider = () => this.securityServiceProvider; + const host: SecurityExplainRouteHost = { routeManager: this.routeManager, - get objectQLProvider() { return readObjectQLProvider(); }, + get securityServiceProvider() { return readSecurityServiceProvider(); }, resolveRequestEnvironmentId: (...a) => this.resolveRequestEnvironmentId(...a), resolveExecCtx: (...a) => this.resolveExecCtx(...a), enforceAuth: (...a) => this.enforceAuth(...a), }; - registerUserWorkEndpoints.call(host, basePath); + registerSecurityExplainEndpoints.call(host, basePath); } /** @@ -6210,20 +6211,19 @@ export class RestServer { * Neither touches the ban. `@objectstack/core`'s `user-assigned-work.ts` * holds the rules. */ - private registerSecurityExplainEndpoints(basePath: string): void { - // [#23050 slice 6] The body moved to `security-routes.ts`. This literal - // hands it every member it reads — the list IS its dependency on this - // class (see `SecurityExplainRouteHost`, and that module's header for why - // `securityServiceProvider` is a getter). - const readSecurityServiceProvider = () => this.securityServiceProvider; - const host: SecurityExplainRouteHost = { + private registerUserWorkEndpoints(basePath: string): void { + // [#30205] The body lives in `user-work-routes.ts`. This literal hands it + // every member it reads (see `UserWorkRouteHost`); the engine provider + // FIELD goes as a getter, read when a handler reads it. + const readObjectQLProvider = () => this.objectQLProvider; + const host: UserWorkRouteHost = { routeManager: this.routeManager, - get securityServiceProvider() { return readSecurityServiceProvider(); }, + get objectQLProvider() { return readObjectQLProvider(); }, resolveRequestEnvironmentId: (...a) => this.resolveRequestEnvironmentId(...a), resolveExecCtx: (...a) => this.resolveExecCtx(...a), enforceAuth: (...a) => this.enforceAuth(...a), }; - registerSecurityExplainEndpoints.call(host, basePath); + registerUserWorkEndpoints.call(host, basePath); } /** From a5de9d1e6e790b691b4a2f9fbb91f463fbbeddfe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 17:18:47 +0000 Subject: [PATCH 13/13] fix(objectql): keep the reference probe's catch in referenceExists, where the read-seam baseline licenses it The main merge had moved the catch into referenceRow, so the read-seam rule flagged a new silent catch and reported the referenceExists baseline entry stale. referenceRow now lets a read error through; referenceExists answers it null as before; the ban path catches it itself, logs a warning and fails open like the existence probe. A test pins the fail-open path. Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude --- .../src/engine-banned-user-reference.test.ts | 18 +++++++++ .../engine-references-and-master-detail.ts | 39 ++++++++++--------- .../src/engine-referential-integrity.ts | 15 +++++-- packages/objectql/src/engine.ts | 2 + 4 files changed, 52 insertions(+), 22 deletions(-) diff --git a/packages/objectql/src/engine-banned-user-reference.test.ts b/packages/objectql/src/engine-banned-user-reference.test.ts index 1f8a6c28876..3a88ae8e069 100644 --- a/packages/objectql/src/engine-banned-user-reference.test.ts +++ b/packages/objectql/src/engine-banned-user-reference.test.ts @@ -215,4 +215,22 @@ describe('[#30205] a new assignment to a banned user is refused', () => { const err = await refusalOf(() => engine.insert('ban_ticket', { title: 'T', assignee: 'u_nobody' }, { context: ALICE } as any)); expect(err.fields[0]).toMatchObject({ field: 'assignee', code: 'reference_not_found' }); }); + + it('a user read that throws refuses nothing and logs a warning, as the existence probe fails open', async () => { + const warnings: unknown[][] = []; + const logger = (engine as any).logger; + const warn = logger.warn; + logger.warn = (...a: unknown[]) => { warnings.push(a); }; + engine.registerMiddleware(async (ctx: any, next: () => Promise) => { + if (ctx.object === 'sys_user' && ctx.operation === 'findOne') throw new Error('user store unreachable'); + await next(); + }); + try { + const row: any = await engine.insert('ban_ticket', { title: 'T', assignee: 'u_bob' }, { context: ALICE } as any); + expect(row.assignee).toBe('u_bob'); + expect(warnings.some(([m]) => String(m).includes('could not read the referenced user'))).toBe(true); + } finally { + logger.warn = warn; + } + }); }); diff --git a/packages/objectql/src/engine-references-and-master-detail.ts b/packages/objectql/src/engine-references-and-master-detail.ts index e5dbc6ea4d7..643b3f1d981 100644 --- a/packages/objectql/src/engine-references-and-master-detail.ts +++ b/packages/objectql/src/engine-references-and-master-detail.ts @@ -190,32 +190,33 @@ export class ReferencesAndMasterDetailFacet { * now refuses. */ static async referenceExists(this: Pick, target: string, id: unknown, context?: ExecutionContext): Promise { - const row = await this.referenceRow(target, id, context, []); - return row === null ? null : row !== false; + try { + const row = await this.referenceRow(target, id, context, []); + return row === null ? null : row !== false; + } catch { + return null; + } } /** * [#30205] {@link referenceExists}'s probe, answering the row it found: * `id` plus whichever of `extraFields` the target declares, `false` when the - * probe ran and found nothing, `null` when it could not run. Same read, same - * elevation, so a check that needs a column of the target (a user's ban - * state) costs no read of its own. + * probe ran and found nothing, `null` when the target is not registered. + * Same read, same elevation, so a check that needs a column of the target + * (a user's ban state) costs no read of its own. A read that throws is the + * caller's to answer: {@link referenceExists} answers it `null`. */ static async referenceRow(this: Pick, target: string, id: unknown, context: ExecutionContext | undefined, extraFields: readonly string[]): Promise | false | null> { - try { - const resolved = this.resolveObjectName(target); - const schema = this._registry.getObject(resolved) as { fields?: Record } | undefined; - if (!schema) return null; - const declared = schema.fields ?? {}; - const row = await this.findOne(resolved, { - where: { id }, - fields: ['id', ...extraFields.filter((f) => f !== 'id' && f in declared)], - context: ObjectQL.referenceCheckContext(context), - }); - return row ? row as Record : false; - } catch { - return null; - } + const resolved = this.resolveObjectName(target); + const schema = this._registry.getObject(resolved) as { fields?: Record } | undefined; + if (!schema) return null; + const declared = schema.fields ?? {}; + const row = await this.findOne(resolved, { + where: { id }, + fields: ['id', ...extraFields.filter((f) => f !== 'id' && f in declared)], + context: ObjectQL.referenceCheckContext(context), + }); + return row ? row as Record : false; } /** diff --git a/packages/objectql/src/engine-referential-integrity.ts b/packages/objectql/src/engine-referential-integrity.ts index cee20fa2450..1bc793e7f3d 100644 --- a/packages/objectql/src/engine-referential-integrity.ts +++ b/packages/objectql/src/engine-referential-integrity.ts @@ -189,7 +189,7 @@ function isEmptyReferenceValue(v: unknown): boolean { * (see {@link masterDetailHeadersCallerReads}). */ export async function assertReferencesResolve( - this: Pick, + this: Pick, schema: any, data: Record | null | undefined, supplied: Record | null | undefined, @@ -263,7 +263,16 @@ export async function assertReferencesResolve( // [#22519] A master-detail header must also be one the caller can read. if (def?.type !== 'master_detail' && target === BAN_CHECKED_USER_OBJECT) { // [#30205] One probe answers both: the row's existence and its ban. - const row = await this.referenceRow(target, v, context, USER_BAN_FIELDS); + let row: Awaited>; + try { + row = await this.referenceRow(target, v, context, USER_BAN_FIELDS); + } catch (err) { + // Fails open like the existence probe: a check that cannot run refuses nothing. + this.logger?.warn?.('the reference check could not read the referenced user — neither its existence nor its ban was checked', { + object: target, field: name, error: err instanceof Error ? err.message : String(err), + }); + row = null; + } if (row === false) refused.push({ name, def, value: v, target }); // adr0112-ok: D6 — a field-addressed FieldErrorCode; this entry becomes `{ field, code }` below else if (row !== null && isUserBanInForce(row)) refused.push({ name, def, value: v, target, code: 'reference_user_banned' }); @@ -311,7 +320,7 @@ export async function assertReferencesResolve( * check costs no read of its own and is read the way the probe reads: * elevated inside the caller's tenant, because a ban is a fact about the * user, not about what the caller may read. A probe that cannot run refuses - * nothing, as before. + * nothing, as before, and logs a warning. */ const BAN_CHECKED_USER_OBJECT = 'sys_user'; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 1c5adfa6d9d..dd7579e2a0d 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3023,7 +3023,9 @@ export class ObjectQL implements IObjectQLEngine { ): Promise { // [#25522 cut 10] The body moved to `engine-referential-integrity.ts`, its docblock with it (see `ReferentialIntegrityHost`). // [#30205] `referenceRow` answers a referenced user's ban state from the existence probe's own read. + const readLogger = () => this.logger; const host: ThisParameterType = { + get logger() { return readLogger(); }, masterDetailHeaderExists: (...a) => this.masterDetailHeaderExists(...a), referenceExists: (...a) => this.referenceExists(...a), referenceRow: (...a) => this.referenceRow(...a),