Skip to content

fix(runtime,plugin-auth)!: a banned user's OAuth access tokens and refresh grants are refused while the ban holds - #30360

Draft
objectstack-fleet[bot] wants to merge 7 commits into
claude/sec-hardening-r4from
claude/sec-hardening-r5
Draft

objectstack-fleet[bot] wants to merge 7 commits into
claude/sec-hardening-r4from
claude/sec-hardening-r5

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: follow-up owed — content/docs/permissions/authentication.mdx, content/docs/permissions/administrator-guide.mdx, packages/platform-objects/src/identity/sys-user.object.ts (the Ban User confirmation) (each names what a ban refuses; OAuth access tokens and refresh grants join that list)

Stacked on PR #30287 (branch claude/sec-hardening-r4; this branch merged its tip at 12c342a23d). Base is that branch so the diff shows only this change. Once #30287 merges: rebase with git rebase --onto origin/main 12c342a23d claude/sec-hardening-r5, retarget the base to main, re-run the gates and update the Verification shas below.

维护者速读

  • 改了什么:用户被封禁期间,其 OAuth 访问令牌在 MCP 入口按"无法验证的令牌"拒绝;令牌端点对其 refresh_token / authorization_code 授权按"无效授权"拒绝,不签发新令牌。
  • 为什么改:封禁应覆盖用户的全部凭据(登录、会话、API key 已覆盖),OAuth 令牌与续期是同一用户的凭据,需同一规则。
  • 风险与回滚:每次 MCP OAuth 请求多一次按主键读 sys_user 封禁列;读失败返回 503(与该入口其它授权输入一致)。不吊销、不改写任何令牌行;回滚即还原本 PR。
  • 先看:packages/runtime/src/security/oauth-subject-ban.ts(读取与 503 分类);packages/plugins/plugin-auth/src/oauth-grant-ban-refusal.ts(拒绝点与应答形状);resolve-execution-context.ts 中两处调用。
  • 两分钟验证:pnpm --filter @objectstack/dogfood exec vitest run test/oauth-banned-subject.dogfood.test.ts,预期 4/4 通过(封禁中 MCP 三次调用与 refresh 均与无效凭据应答一致,解封与到期后同一凭据恢复)。
  • 需要你做的:无,席位落地。

What changes

A ban holds every credential of the user. While a user's ban holds:

  • MCP door. An OAuth access token whose subject is banned resolves as a token that does not verify: no principal, no fall-back to a cookie session, the same 401 body and WWW-Authenticate header. An agent forward credential acting for a banned user resolves as one that does not redeem. The ban is read where the credential becomes its user (resolveExecutionContext → isOAuthSubjectBanned, packages/runtime/src/security/oauth-subject-ban.ts), with the shared predicate isUserBanInForce from @objectstack/core. The verifier (verifyMcpAccessToken) stays the local, I/O-free signature check.
  • Token endpoint. A grant that acts for a banned user (refresh_token, authorization_code, any extension grant that issues through the provider) is answered as an invalid grant of its type: 400 invalid_grant with the provider's own description (invalid refresh token, invalid code). The refusal runs in the provider's pre-write callback customTokenResponseFields, which receives the user row the provider already read, so it adds no read and leaves every row as it was (packages/plugins/plugin-auth/src/oauth-grant-ban-refusal.ts; its header records why this hook and not a before-hook or a database hook).
  • Expiry. A ban with an expiry stops binding once it has passed, exactly as sign-in reads it.
  • Not a revocation. No token, refresh token or consent row is revoked or rewritten; lifting the ban (or its expiry) admits the same access token and lets the same refresh token renew.
  • Cost. One primary-key read of sys_user (id, banned, ban_expires), as the system, per OAuth credential the MCP door resolves; zero at the token endpoint. A store fault on that read is 503 SERVICE_UNAVAILABLE naming sys_user; a missing user row or an unprovisioned table keeps today's answer.
  • The plugin-auth APIError code pin now also reads an OAuth 2.0 error response (a literal error beside an error_description, RFC 6749 §5.2) as a coded body, with fixtures for both directions: the token-endpoint refusal must answer exactly as the provider's own error does, which carries no ADR-0112 code.

Tests

  • packages/runtime/src/security/resolve-execution-context.test.ts — new top-level describe: banned subject resolves exactly as an unverified bearer; a future expiry binds, a passed one does not; not-banned, unbanned, no-row and no-table controls admit as before; a ban read that throws is 503 SERVICE_UNAVAILABLE naming sys_user; cost pinned at one ban-column read per token and none for an unverified bearer; a forward credential for a banned user resolves as one that does not redeem.
  • packages/plugins/plugin-auth/src/oauth-grant-ban-refusal.test.ts — the refusal against the provider's own error (class, status, body, headers) for each grant type; expiry in both spellings; not-banned and no-user controls.
  • packages/plugins/plugin-auth/src/auth-manager.oauth-consent-organization.test.ts — new top-level describe on the real provider with AuthManager's own options: a banned user's refresh grant equals an invalid refresh token's answer, writes no refresh row, and the same refresh token renews once the ban lapses; not-banned control.
  • packages/qa/dogfood/test/oauth-banned-subject.dogfood.test.ts — booted showcase, real DCR → authorize → consent → token: initialize, query_records, create_record and the refresh grant against a made-up token / an invalid refresh token, the refused write landing nothing; peer, unban and timed-ban controls.

Acceptance notes

  • The docs and the Ban User confirmation that list what a ban refuses gain OAuth access tokens and refresh grants in a follow-up (the Skills-docs: line above).
  • The plugin-auth APIError code pin gains a third accepted shape, the RFC 6749 §5.2 error response, so a token-endpoint refusal can carry exactly the provider's own body; a literal error with no error_description, or a non-literal error, is still reported (fixtures in both directions).
  • The full dogfood battery has one red, permission-set-clone-door.dogfood.test.ts, the known main red; the same test is red with the same assertion on a tree without this branch's commits (control below).

Verification

Readings at head 9032503647 unless stated; this branch is claude/sec-hardening-r4 (12c342a23d) plus the commits listed in the PR.

Unit and integration suites
  • pnpm --filter @objectstack/runtime test: Test Files 541 passed (541), Tests 8626 passed, 19 skipped (at 14794ebef2); src/security/resolve-execution-context.test.ts re-run at 9032503647: 47 passed (47).
  • pnpm --filter @objectstack/plugin-auth test: Test Files 221 passed (221), Tests 3885 passed, 10 skipped (at 14794ebef2; no plugin-auth file changed after it).
  • pnpm --filter @objectstack/runtime typecheck, pnpm --filter @objectstack/plugin-auth typecheck: exit 0.
Ablations (one per refusal; each red, then restored, blob equal to HEAD and `git diff HEAD` empty)
  • Access-token ban check removed from the resolver: 4 failed of 47 (banned subject, future expiry, store fault, cost); restored.
  • Forward-credential ban check removed: 1 failed of 47 (the forward test); restored.
  • Grant refusal short-circuited: 5 failed of 13 across oauth-grant-ban-refusal.test.ts and the real-provider describe; restored.
Dogfood
  • test/oauth-banned-subject.dogfood.test.ts: 4 passed (4).
  • Full battery (pnpm --filter @objectstack/dogfood test, at 14794ebef2; later commits touch only packages/runtime/src/security/resolve-execution-context.test.ts): Test Files 1 failed, 420 passed, 3 skipped (424); Tests 1 failed, 3499 passed, 36 skipped (3536). The failure is permission-set-clone-door.dogfood.test.ts, 422 INVALID_METADATA where 200 is expected. Control: the same file on 12c342a23d (no commit of this branch) fails the same assertion, 1 failed and 10 passed of 11.
Gates
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 9032503647: 96 commands, each run with its exit code captured before any pipe: 96 exit 0. --ran reconciliation: 96 derived, 96 run, 0 NOT-MEASURED (derived from recorded exit codes).

Generated by Claude Code

…resh grants are refused while the ban holds

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…aude/sec-hardening-r5

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…ponse as coded; the ban double is a bounded table filter

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/runtime, touching 14 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/connectors.mdx (via authorization_code (symbol, a field of const object INVALID_GRANT_DESCRIPTION), invalid_grant (literal, a string literal in refuseGrantForBannedUser))
  • content/docs/kernel/contracts/auth-service.mdx (via AuthManager (symbol, a top-level class), verifyMcpAccessToken (literal, a string literal in resolveExecutionContext))
  • content/docs/kernel/services-checklist.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via authorization_code (symbol, a field of const object INVALID_GRANT_DESCRIPTION))
  • content/docs/protocol/kernel/index.mdx (via resolveExecutionContext (symbol, a top-level function))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v18/18-1.mdx (via invalid_grant (literal, a string literal in refuseGrantForBannedUser))
  • content/docs/releases/v18/18-2.mdx (via AuthManager (symbol, a top-level class))
  • content/docs/releases/v18/18-3.mdx (via AuthManager (symbol, a top-level class), authorization_code (symbol, a field of const object INVALID_GRANT_DESCRIPTION))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 49 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 42 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f064a0e4180e2d9c4f3d5a98302552a615d3a6f7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 74bfd1dc55b68cf5edf2602aa119604743821b65 — the merge of head 903250364727741ab558df18aec54f3cce5d5477 into base f064a0e4180e2d9c4f3d5a98302552a615d3a6f7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older claude/sec-hardening-r4 holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 74bfd1dc55b68cf5edf2602aa119604743821b65 && git checkout 74bfd1dc55b68cf5edf2602aa119604743821b65
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f064a0e4180e2d9c4f3d5a98302552a615d3a6f7 903250364727741ab558df18aec54f3cce5d5477 && git checkout -B drift-repro f064a0e4180e2d9c4f3d5a98302552a615d3a6f7 && git merge --no-ff 903250364727741ab558df18aec54f3cce5d5477

node scripts/docs-audit/affected-docs.mjs --json f064a0e4180e2d9c4f3d5a98302552a615d3a6f7

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f064a0e4180e2d9c4f3d5a98302552a615d3a6f7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant