Repository navigation
fix(core)!: a banned user's credentials are refused while the ban holds, at every door - #30287
objectstack-fleet[bot] wants to merge 6 commits into
Conversation
A ban holds every credential of the user, at every door that accepts an API key; a ban with an expiry stops binding once it expires. One ban predicate (isUserBanInForce) now serves every place that asks. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 50 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 99ccb26c8acc4abed3619188e5fdae943c87af81 && git checkout 99ccb26c8acc4abed3619188e5fdae943c87af81
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 09720e2c8ac970d87a950ba9163233e291e7996d 1c3929139aa8e69c657c0a11d311147b6da25eda && git checkout -B drift-repro 09720e2c8ac970d87a950ba9163233e291e7996d && git merge --no-ff 1c3929139aa8e69c657c0a11d311147b6da25eda
node scripts/docs-audit/affected-docs.mjs --json 09720e2c8ac970d87a950ba9163233e291e7996d
|
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Contract reviewServed-tier: Face: the committed changeset and the PR body both carry Check-runs read on this head (two readings, the second at the end of the review): every completed run is ① Derived judgments
② Semver level
③ Boundary flagsPR-body open questions, each answered or escalated:
Prose, sentence by sentence against the diff:
④ Design judgmentThe narrowing is judged against the published spec on
No must-fix: the rule holds at every door that resolves a key, the refusal is indistinguishable from an unknown key on every door the PR names and on every anonymous-denying face, the key row is untouched, timed bans lapse as sign-in does, and the public text stays at rule level. Implemented-by: VERDICT: PASS |
…gets A key refused for its owner or for the tenancy posture now resolves to the same anonymous entry as a key that does not exist, on every composition, the guest-serving one included. The restore wording in the changeset and the auth docs is qualified by each key's inactivity window. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
…keys are refused while the ban holds The confirm question and the agent-facing description of the ban action now say that the user's API keys are refused until the unban and work again after it. Translations carried in zh-CN, ja-JP and es-ES; the en bundle regenerated. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
|
security#1 · os-sales · session_01L6bhgVfjk2i8T2D4RmxsKg Contract reviewServed-tier: Face: the committed changeset and the PR body both open with Check-runs on this head, two readings. First (at the start of the review, after the claim comment of 10:54 UTC): every completed run ① Derived judgments
② Semver level
③ Boundary flagsPR-body acceptance notes and open questions, each answered or escalated:
Prose, sentence by sentence against the diff:
④ Design judgmentThe narrowing is judged against the published spec on
The commits after Findings: must-fix — ② the changeset omits Implemented-by: VERDICT: FAIL |
Clause-②: yes (narrowing) — while a user's ban holds, a request authenticated by an API key that user owns is answered as a key that does not exist (the generic
401 UNAUTHENTICATED), at every door that accepts a key. The widening half:@objectstack/coreexports the one ban predicate,isUserBanInForce(withUSER_BAN_FIELDSand theUserBanFieldstype).Metadata-protocol: none
Skills-docs: updated here —
content/docs/permissions/authentication.mdxandcontent/docs/permissions/administrator-guide.mdxstate what a ban holds; the dogfood pinapi-key-banned-owner.dogfood.test.tsexercises it.维护者速读
sys_user(实测约 0.65 ms);被封禁用户的集成会收到 401,属预期;回滚即还原本 PR。packages/core/src/security/api-key.ts(准入处新增的封禁判定段)、packages/core/src/security/user-ban.ts、packages/qa/dogfood/test/api-key-banned-owner.dogfood.test.ts。pnpm --filter @objectstack/dogfood exec vitest run test/api-key-banned-owner.dogfood.test.ts,预期 4/4 通过。What changes
@objectstack/core, which every key-accepting door uses), with the same answer a key that does not exist gets: the generic401 UNAUTHENTICATED, same code, same message, on the REST data door (read and write) and on the MCP door. A ban with an expiry stops binding once it expires, as sign-in does.isUserBanInForce(packages/core/src/security/user-ban.ts) mirrors the sign-in check (the ban flag; an expiry lifts it only once strictly past; an unreadable expiry does not lift it). plugin-auth's principal-session mint and agent-forward identity view now ask it instead of their own copies.isolated) resolves to the same entry as a key that does not exist, on every composition: the guest's grants on a face that serves the guest, nothing elsewhere, never a session. Both exits share one helper (anonymousEntry,packages/core/src/security/resolve-authz-context.ts).503), as asys_api_keyread that throws already is; a store with nosys_usertable holds no ban.sys_user(three columns) per key resolution. Nothing earlier on the admission path loads the user row. Measured on the showcase stack (shared box): 0.650 ms per read (N=1000) against 7.738 ms per key-authenticatedGET /data(M=300).authentication.mdxandadministrator-guide.mdxsay what a ban holds. The Ban User confirmation and its agent-facing description (sys_user.ban_user) say the user's API keys are refused until the unban and work again after it; zh-CN / ja-JP / es-ES carried, en bundle regenerated (node scripts/check-i18n-bundles.mjs --write)..changeset/banned-user-credentials-refused.md(@objectstack/core,@objectstack/plugin-auth, patch, BREAKING narrowing banner, ADR-0087not-required (no-migration-prescription)).Tests
packages/core/src/security/api-key-banned-owner.test.ts(18): the predicate table; the seam refuses a banned owner's key with no principal; reads the ban columns by id as the system; a timed ban binds until it expires and not after; nothing is written during the ban and lifting it admits the same key; lifecycle answers first; absent owner row keeps today's answer; owner read outage is the branded503; missing table holds no ban;resolveAuthzContextresolves the same context for a banned owner's key as for a made-up key, never falls through to a session sent beside the key; controls for a non-banned owner and for a session with no key; on a face that serves the guest, with a guest anchor that grants a set, a banned owner's key and an unknown key resolve the same entry (the guest's set, in the one organization).packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts: the Ban User confirmation pin now carries the new sentence and asserts it names the API keys.packages/qa/dogfood/test/api-key-banned-owner.dogfood.test.ts(4, showcase + MCP): before the ban the key reads (200), writes (201) and opens an MCP session (200); while the ban holds every door answers the key exactly as it answers a made-up key (status, code, message,WWW-Authenticate), the refused write does not land, the ended session is 401, sign-in is403 BANNED_USER, and another member's key keeps working; after the unban the same key is served and the key row is not revoked; a 3-second timed ban binds before expiry and not after, with no sign-in in between.Acceptance notes
403 API_KEY_REVOKEDby ruling (located lifecycle refusal), so it is distinguishable from a made-up key today; this PR matches the banned owner's key to the made-up key's401, not to the revoked key's answer.last_used_at, so the inactivity window keeps running through a long ban; a key idle past the window when the ban is lifted answersAPI_KEY_INACTIVE(remedy: a new key).Verification
Head
f064a0e418(findings commits12c342a23d,f064a0e418on top of15b303e0bf). The ablation, package-suite and dogfood readings of the first round were taken on15b303e0bf; this round's readings are listed under "Patch round".Ablation (one-shot, not committed)
Mutation
if (isUserBanInForce(owner, nowMs) && (owner as any)?.__ablation_marker_r4 === 'never') {throughscripts/ablation-replace.mjs(anchor 1 to 0);pnpm --filter @objectstack/core build;ablation-dist-preflight.mjs @objectstack/core __ablation_marker_r4: marker present in 2 built files.Tests 5 failed | 12 passed (17)Tests 2 failed | 2 passed (4)Restore:
git checkout HEAD -- packages/core/src/security/api-key.ts, blob37adafdc53a5equals HEAD,git diff HEADempty; rebuilt;--absentpreflight: marker absent from all 14 built files, tree clean.A first attempt spelled the mutation
false && …; the bundler folded it away, the dist preflight read it absent, and that run's readings were discarded.Package suites and gates
pnpm --filter @objectstack/core typecheck && vitest run: Test Files 141 passed, Tests 3100 passed.pnpm --filter @objectstack/plugin-auth typecheck && vitest run: Test Files 220 passed, Tests 3876 passed, 10 skipped.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 93 derived families run, each exit code recorded before any pipe;--ranreconciliation: 93 run, 0 NOT-MEASURED (derived zero). Two first answeredPREREQUISITE NOT MET(exit 3:check:skill-examples,check:dual-build-cjs-loads) and exit 0 after building the unbuilt packages they read.pnpm --filter @objectstack/dogfood testat15b303e0bf: Test Files 1 failed | 419 passed | 3 skipped (423); Tests 1 failed | 3495 passed | 36 skipped (3532). The one red,permission-set-clone-door.dogfood.test.ts(cloningmember_defaultanswers 422 INVALID_METADATA for grants onsys_comment/sys_activity), fails identically with this diff'spackages/core/srcandpackages/plugins/plugin-auth/srcreverted to base and rebuilt (Tests 1 failed | 10 passed), so it is not this change; reported to the seat.Patch round (12c342a, f064a0e)
return anonymousEntry(ctx, input);in the refused exit replaced byreturn ctx;throughscripts/ablation-replace.mjs(anchor 1 to 0, blob 90c589104c01 to 01c893387b0b): Tests 1 failed | 17 passed (18), the failure being the new pin. Restore:git checkout HEAD --, hash equals HEAD,git diff HEADempty; Tests 18 passed (18).pnpm --filter @objectstack/core build && typecheck && vitest runat 12c342a: Test Files 141 passed, Tests 3101 passed.pnpm --filter @objectstack/dogfood exec vitest run test/api-key-banned-owner.dogfood.test.tsat 12c342a: Tests 4 passed (4).pnpm --filter @objectstack/platform-objects typecheck && vitest runat f064a0e (uncommitted tree identical): Test Files 110 passed, Tests 1514 passed.pnpm check:i18n: OK (10 packages, all bundles in sync).--ran: 93 run (exit 0, including the three new for this head:check:authz-resolver,check:i18n,check:i18n-stale-fill), 3 NOT-MEASURED (check:skill-examples,check:dual-build-cjs-loads,check:type-check-debt: exit 3, their build prerequisite could not take the shared verify lock, held 40+ minutes by another session; all three were exit 0 at15b303e0bf, and CI runs them).Generated by Claude Code