OAuth: activate filtered ClickHouse roles per request from a JWT claim (#140)#141
Merged
Conversation
…T claim Implements #140. In OAuth mode, when oauth.role_claim is set, read that JSON-array claim from the validated token, keep only role names matching oauth.role_filter, and activate exactly that set per ClickHouse request via repeated HTTP `role=` query params. Only ever narrows the user's active roles; never grants. - config.ClickHouseConfig.Roles: per-request roles (never from CLI/file). - GetClickHouseClientWithOAuthForConfig: read+filter roles, fail closed with access-denied on an empty set (no fallback to the full grant), refuse on non-HTTP protocol. Applies to single- and multi-cluster paths. - newClientWithOAuth: the Bearer-vs-Basic auto-detect probe stays role-free so an ungranted role's 497 can't be misread as an auth-method mismatch; the detected method then builds the real role-carrying client. - clickhouse.connect(): install a TransportFunc RoundTripper that appends one `role=` param per role (Settings can't express repeated params). - Startup validation requires + compiles role_filter when role_claim is set. Tests: roleRoundTripper unit test, embedded-CH currentRoles()/ACCESS_DENIED integration, server-layer fail-closed/TCP-guard/gate-pass. NOTE: go.mod carries a temporary `replace => ../go-mcp-oauth-sdk` for the unmerged SDK branch; drop it and bump the require before the PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…aim/role_filter Drop the temporary local replace and pin go-mcp-oauth-sdk at the merged main commit (f4243cf) that carries RoleClaim/RoleFilter + RolesFromClaim. Document the new per-request role-activation config in CLAUDE.md, the helm chart values, and docs/oauth_authorization.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-request role activation read OAuth claims from context (OAuthClaimsKey), but nothing populates that key on the MCP tools/call path — and ValidateAuth returns nil oauthClaims by design (MCP delegates JWT crypto-validation to ClickHouse, which re-validates the forwarded token per request). So for MCP clients (claude.ai/ChatGPT — exactly what role_claim targets), claims were always nil and the request always failed closed regardless of the token. Fix: when no pre-validated claims are present, decode the role claim from the same token MCP forwards to CH (sharing a new decodeUnverifiedJWTClaims helper refactored out of emailFromUnverifiedJWT). The security boundary is unchanged: the filter only narrows, and CH re-validates the signature and rejects any activated role the user isn't granted (fail-closed). Pre-validated context claims are still preferred when present (e.g. the OpenAPI path). Adds MCP-forward-path tests: matching role passes the gate; no-match fails closed — both with oauthClaims == nil. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
role_filter uses regexp.MatchString (partial match), so an unanchored pattern like "anon" also matches "not_anon_real". Document anchoring (^…/…$) in the helm values and oauth_authorization.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…les) The IdP is the trust root and ClickHouse independently re-validates the token and enforces grants, so activating exactly the roles the claim carries is safe on its own — filtering is defense-in-depth, not mandatory. Drop the "role_filter required when role_claim is set" rule. - validateOAuthRuntimeConfig: only require role_filter to *compile* when set. - roleFilter(): empty pattern compiles to a match-all regex (activate all). - server constructor: pre-compile the filter whenever role_claim is set. - Unchanged: empty *resolved* set (claim absent/empty, or filter matched nothing) still fails closed — no fallback to the full grant. - Bump go-mcp-oauth-sdk to b30b055 (RoleFilter doc clarified to "optional"). - Docs/helm: role_filter is optional; both modes documented. Tests: config validation (claim w/o filter ok, invalid filter rejected) and server-layer no-filter (activates all claim roles; empty claim still denied). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #140.
In OAuth mode, read a list of ClickHouse roles from a configurable JWT claim, filter them by a regex, and activate only that set per ClickHouse request via repeated HTTP
role=query params. This only ever narrows the user's active roles — it never grants. Lets a single identity be confined to an anonymized sandbox on the untrusted LLM channel while the trusted channel keeps the full grant.Config (new)
oauth.role_claim— JWT claim holding a JSON array of role names (e.g.https://clickhouse/roles). Empty disables the feature.oauth.role_filter— regex; only matching role names are activated (e.g._mcp$). Required whenrole_claimis set (validated at startup).Behavior
ACCESS_DENIED(497) can't be misread as an auth-method mismatch and trigger a wrong Basic fallback. The detected method then builds the real, role-carrying client.role_claimis unset.Implementation notes
oauth.Claims.Extra) — no raw-JWT decode needed.clickhouse-go'sSettingsmap is single-valued and can't emit repeated?role=a&role=b, so multi-role is injected via aTransportFuncRoundTripper.role_claim/role_filterconfig +RolesFromClaimhelper) is already merged togo-mcp-oauth-sdkmain; this PR pins that commit (noreplace).Tests
RolesFromClaimunit tests (filter subset, empty, missing/non-array claim, dedupe, nil).roleRoundTripperunit test (repeatedrole=params, request not mutated); embedded-CH integration provingcurrentRoles()narrows to the activated set, the activated role's query works, and a non-activated role's query is denied; server-layer fail-closed / TCP-guard / gate-pass tests.go test ./...green in both repos.Out of scope (deployment-side, per the issue)
The IdP emitting the claim (e.g. an Auth0 Action emitting
https://clickhouse/roles) and the CH user being granted the named roles (role=only narrows what's already granted).Validation
Built
ghcr.io/altinity/altinity-mcp:feature-oauth-role-filter-41647a7and rolled it to theotelenv feature-off (norole_claim); e2e via claude.ai confirmed no regression (execute_queryclean).🤖 Generated with Claude Code