Skip to content

OAuth: activate filtered ClickHouse roles per request from a JWT claim (#140)#141

Merged
BorisTyshkevich merged 5 commits into
mainfrom
feature/oauth-role-filter
Jun 15, 2026
Merged

OAuth: activate filtered ClickHouse roles per request from a JWT claim (#140)#141
BorisTyshkevich merged 5 commits into
mainfrom
feature/oauth-role-filter

Conversation

@BorisTyshkevich

Copy link
Copy Markdown
Collaborator

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 when role_claim is set (validated at startup).

Behavior

  • Fail closed: an empty filtered set denies the request — no fallback to the user's default/full grant. Role activation also requires the HTTP protocol (refused otherwise).
  • Applies in both the Bearer (token user-directory) and Basic/sidecar (gating) CH auth paths — role activation is post-auth, orthogonal to the wire format — and in single- and multi-cluster routing.
  • The Bearer-vs-Basic auto-detect probe stays role-free, so an ungranted role's 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.
  • No behavior change when role_claim is unset.

Implementation notes

  • Role names are read from the validated token's namespaced/custom claims (oauth.Claims.Extra) — no raw-JWT decode needed.
  • clickhouse-go's Settings map is single-valued and can't emit repeated ?role=a&role=b, so multi-role is injected via a TransportFunc RoundTripper.
  • SDK side (role_claim/role_filter config + RolesFromClaim helper) is already merged to go-mcp-oauth-sdk main; this PR pins that commit (no replace).

Tests

  • SDK: RolesFromClaim unit tests (filter subset, empty, missing/non-array claim, dedupe, nil).
  • MCP: roleRoundTripper unit test (repeated role= params, request not mutated); embedded-CH integration proving currentRoles() 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.
  • Full 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-41647a7 and rolled it to the otel env feature-off (no role_claim); e2e via claude.ai confirmed no regression (execute_query clean).

🤖 Generated with Claude Code

BorisTyshkevich and others added 5 commits June 15, 2026 15:18
…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>
@BorisTyshkevich BorisTyshkevich merged commit 2b39221 into main Jun 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuth mode: activate ClickHouse roles per request from a JWT claim (SET ROLE / HTTP role=)

1 participant