Repository navigation
feat(objectql,rest)!: refuse a new assignment to a banned user; count and hand over their work - #30546
feat(objectql,rest)!: refuse a new assignment to a banned user; count and hand over their work#30546objectstack-fleet[bot] wants to merge 15 commits into
Conversation
… a new assignment to a banned user (WIP) Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <noreply@anthropic.com>
…nd-over; add the SDK methods Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <noreply@anthropic.com>
…e, changeset and admin guide Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <noreply@anthropic.com>
…used 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 <noreply@anthropic.com>
…rescription Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…-dvfr9q Resolves the administrator guide's offboarding row: main's wording (the ban also blocks the user's API keys) with this branch's hand-over link. Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 5 package(s): 65 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 16 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 185 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 54c4508c65e4f01803245cc9c2da68b36bce5b53 && git checkout 54c4508c65e4f01803245cc9c2da68b36bce5b53
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 17566664299edac816b6672a673ac82b192d24ea a5de9d1e6e790b691b4a2f9fbb91f463fbbeddfe && git checkout -B drift-repro 17566664299edac816b6672a673ac82b192d24ea && git merge --no-ff a5de9d1e6e790b691b4a2f9fbb91f463fbbeddfe
node scripts/docs-audit/affected-docs.mjs --json 17566664299edac816b6672a673ac82b192d24ea
|
…cond 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 <noreply@anthropic.com>
…ADR-0112 D6) Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <noreply@anthropic.com>
…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 <noreply@anthropic.com>
|
domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
Implemented-by: VERDICT: FAIL Must-fix, each on its own line for the card:
|
…are Clause-② yes (narrowing) 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 <noreply@anthropic.com>
Resolves the engine.ts conflict with the #29986 codemod's fourth pass (#30463), which moved referenceExists' body into engine-references-and-master-detail.ts: referenceRow moves there with it, referenceExists delegates to it in the facet, and engine.ts keeps a one-line stub and the host slot. Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <noreply@anthropic.com>
…here 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 <noreply@anthropic.com>
|
domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC Contract review (delta)Served-tier: ① Derived judgmentsPer round-0 must-fix, verified from the tree at a5de9d1:
The new code from the
② Semver level
③ Boundary flags
④ Design judgment
Implemented-by: VERDICT: PASS Not a must-fix on this tree, but owed before ready and read by the seat at hand-over: the live PR body (declaration line |
domain:server#3 · os-bill · session_01Utp6CZavYYLh5k5r6jhgCF
Requested by Bill · project thread
Fixes #30205
Clause-②: no (narrowing) — a non-system create or update that sets a user field to a user whose ban is in force is refused
400 VALIDATION_FAILED/reference_user_banned, a write that answered201/200before. A value the row already stores is not judged.Metadata-protocol: none
维护者速读
reference_user_banned);新增两条接口,按对象统计某用户名下的记录,并可一次性把它们转给一位在用的用户。packages/objectql/src/engine-referential-integrity.ts(拒绝逻辑,读封禁状态的方式与存在性探测一致)、packages/core/src/security/user-assigned-work.ts(计数与交接的规则)、packages/rest/src/user-work-routes.ts(两条路由)。pnpm --filter @objectstack/objectql exec vitest run src/engine-banned-user-reference.test.ts,应看到 9 个测试全部通过。What changes
1. A new assignment to a banned user is refused. The write-time reference check (
assertReferencesResolve) now also asks, for every caller-supplied value that resolves tosys_user, whether that user's ban is in force (bannedis true andban_expiresis empty or still ahead), and refuses it with the field codereference_user_bannedbesidereference_not_found. It is one extrafindonsys_userper write ($infor several ids), read with the same elevated context the existence probe uses, so a caller who cannot read the user object is refused the same way. The #29062 rule carries over unchanged: a value every row of the write already stores is not judged, so an edit form that carries the whole record back still saves. A system write keeps its exemption; a flow'srunAs: 'system'keeps the check, as it does forreference_not_found. If the ban-state read itself fails, the write is not refused for a ban and a warning is logged.2. Count a user's work.
GET /api/v1/security/users/:id/assigned-work(client.security.assignedWork.count) answers, per object and user field, how many records hold the user, counted under the caller's own context. The owner field,userfields and lookups tosys_usercount; platform tables (sys_*),managedByobjects, disabled packages andreadonly/systemfields such ascreated_bydo not. Objects the caller cannot read are named inunreadable; at most 200 fields are counted (truncated).3. Hand it over.
POST /api/v1/security/users/:id/assigned-work/transfer(client.security.assignedWork.transfer) with{ toUserId, objects? }moves up to 500 records per call, each as an ordinaryupdateunder the caller's context, so sharing, validation, approval locks and hooks all apply. It answers per field what moved, what failed (row id, code, and the sentence the/datadoor answers for the same throw, at most 10 per field) and how many remain. A banned, unknown or identicaltoUserIdis refused before any write. Multi-valued user fields are counted but not transferred. Neither route touches the ban: the ban staysPOST /api/v1/auth/admin/ban-user, urgent and alone.The new
FieldErrorCodemember@objectstack/spec'sFieldErrorCodeis a closed response vocabulary, and this PR adds one member,reference_user_banned, with its message template inen,zh-CN,ja-JPandes-ES(validation-message.ts). It is not an authorable key: no schema an author writes gains, loses or reshapes anything, which is why the changeset carries the ADR-0087not-required (no-migration-prescription)marker. A client that switches on the closed set gains one case; the regeneratedcontent/docs/references/api/errors.mdxlists it.Where the code lives, and the one
engine.tstouch@objectstack/core(security/user-assigned-work.ts), not in a new objectql module as first planned:@objectstack/resthas objectql only as a dev dependency, so a shared module both can import at runtime has to sit in core. objectql and the hand-over both ask core'ssecurity/user-ban.ts(fix(core)!: a banned user's credentials are refused while the ban holds, at every door #30287), so the ban predicate has one owner.packages/objectql/src/engine.tschanges only in the reference-check host literal (4 lines): it now handsfindandloggertoassertReferencesResolvefor the ban-state read. Nothing else inengine.tsmoves.Census rows the new routes need
sendOk/sendErrorand are declared tocheck:route-envelopeat zero.user-workarrives classified in the authorization conformance matrix: rowanonymous-deny-user-work(both handlers open withenforceAuth), with its booted proof inshowcase-anonymous-deny-surfaces.dogfood.test.ts(anonymous 401 on both routes; a member's count served 200, and a member's hand-over reaching its own unknown-target 404 before any write). The matrix docblock's family figure moves 18 → 19.REST_SERVER_REGISTRAR_FILES, the docblock floor (6), the canonical-query table (0) and theobjectQLProviderconsumer census (6 sites). The two routes aresdkrows in the route ledger.no-record-echorow in the internal-fields tripwire and anANSWERS_THE_SENTENCErow in the hook-refusal ledger: a sandboxed hook refusing a moved row reads, infailures[].message, as the hook's own sentence, and every fault shape §6 drives keeps its words withheld.user-assigned-work.tsis classifiednot-a-served-readin core's second-object read-exposure pin: the ban-state read answers only a yes/no about the target, never a row.Tests
packages/objectql/src/engine-banned-user-reference.test.ts(9): insert refused and nothing written; the read is elevated (the caller cannot readsys_userand is refused all the same); auserfield and a future expiry; a multi-valued field refused for its banned member only; active and expired-ban users allowed; update refused; a stored value stays editable; a system write is exempt; a missing user staysreference_not_found.packages/core/src/security/user-assigned-work.test.ts(14): the ban predicate, field picking, counts under the caller's context,unreadable, disabled packages, the hand-over, per-row failures, the row cap andremaining, the three refusals, the elevated target read, and the per-door failure wording.packages/rest/src/user-work-routes.test.ts(7) andpackages/client/src/admin-surfaces.test.ts(+2).updatewithassertEngineUpdateDispatch, refuse a logical operator rather than read it as a field, and hold alimit, socheck:engine-double-contract(ledger rows recorded with--write),check:where-matcherandcheck:objectql-double-limitstay green.Acceptance notes
domain:uicard; this PR does not touchui/**.remainingtells the administrator what is left.Verification
Head
6e44006f9, verified locally under the shared verify lock.Real boot, before and after (
pnpm dev -- --fresh, showcase,showcase_announcement.owner_id). "Before" is this head with the ban check and the route registration ablated insrcand both packages rebuilt (ablation-dist-preflightproved the markers absent fromdist/, then present again after restore and rebuild).201201owner_id: B201(assigned to a banned user)400 VALIDATION_FAILED,fields[0].code: reference_user_banned200200(stored value not judged)GET …/users/B/assigned-work404200,showcase_announcement.owner_idcount 2404400,toUserId/reference_user_banned, nothing written404200:updated 1,failed 1(RECORD_LOCKED, the edited record sits in an approval),remaining 1Ablations (
scripts/ablation-replace.mjs, wrap mode, each restored to the HEAD blob): deleting the ban push turns 4 of 9engine-banned-user-referencetests red; counting with an empty filter turns the count test red; dropping the banned-target guard turns 2 refusal tests red; dropping the rest failure describer turns the hand-over row of the hook-refusal ledger red on every §6 shape.Suites: rest 392 files / 7648 tests; core 141 / 3103 (security 63 / 1217 on the final head); objectql 552 / 10993; client 54 / 699. Typecheck: rest, core, objectql, client and spec clean.
Dogfood (narrowing arm):
pnpm --filter @objectstack/dogfood teston6e44006f9, lock VERDICT command-exit 0, Test Files 419 passed / 3 skipped (422), Tests 3496 passed / 36 skipped (3532). The first full run, on13e254df, was red only inauthz-conformance.test.ts(11 tests, the new family unclassified), which this head fixes.Gates: all 128 families
dispatch-gates.mjs --commandsderives for this diff ran, and--ranreconciles 128 / 128 at exit 0.check:plugin-teardown-shape --self-testneeded its pinned fixture commit fetched into this shallow clone first.🤖 Generated with Claude Code
https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF