Skip to content

Trust private CAs for RFC 8693 trusted issuers - #6437

Merged
jhrozek merged 11 commits into
mainfrom
trusted-issuer-private-ca
Aug 28, 2026
Merged

Trust private CAs for RFC 8693 trusted issuers#6437
jhrozek merged 11 commits into
mainfrom
trusted-issuer-private-ca

Conversation

@jhrozek

@jhrozek jhrozek commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

  • TrustedIssuerConfig (RFC 8693 trustedIssuers) had no way to trust a
    private CA when fetching an external issuer's OIDC discovery document and
    JWKS. Against an in-cluster IdP served over HTTPS with a non-public CA
    (e.g. Keycloak behind a cert-manager certificate), the fetch fails with
    x509: certificate signed by unknown authority, and the only workaround
    was a process-wide SSL_CERT_FILE override — exactly what caBundleRef
    on upstreamProviders (VirtualMCPServer.authServerConfig.upstreamProviders has no way to trust a private CA for the upstream token/authorization endpoints #6417/Trust private CAs for upstream auth servers #6428) was meant to make unnecessary. This
    closes the same gap on the other leg: token-exchange validation against
    an external issuer, not login against an upstream IdP.
  • Adds caBundleRef to TrustedIssuerConfig, mirroring the
    CABundleSource/configMapRef shape already used by upstreamProviders.
    Trust is additive and scoped to that issuer's client only.
  • Threads the reference through: CRD type + deepcopy → shape/PEM validation
    → CA bundle ConfigMap indexing/watch → checksum-driven pod rollout →
    projected volume/mount on the auth-server pod → runtime CAFilePath
    MultiIssuerTokenValidator's per-issuer HTTP client
    (WithSystemRootsPlusCABundle).
  • Fixes a pre-existing bug found while wiring this up: buildTrustedIssuerConfigs
    (admission-time validation) dropped ActorMatcher, so a syntactically
    invalid CEL expression in trustedIssuers[].actorMatcher passed admission
    and only surfaced as a CrashLoopBackOff when the auth server started,
    instead of a status condition.
  • Regenerates CRDs and OpenAPI docs for the new field.

Fixes #6429

Type of change

  • Bug fix

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

caBundleRef is a new optional field on TrustedIssuerConfig — additive, no
existing field changes.

Does this introduce a user-facing change?

Yes: trustedIssuers[] entries can now set caBundleRef to trust a private
CA when fetching that issuer's OIDC discovery document and JWKS, matching the
existing upstreamProviders[].oidcConfig.caBundleRef /
oauth2Config.caBundleRef behavior.

Special notes for reviewers

  • ActorMatcher propagation fix (cccc7a64f) is kept as its own commit so
    it can be reviewed/reverted independently of the CA bundle feature.
  • The checksum for the auth-server pod's CA rollout annotation now hashes
    upstream-provider and trusted-issuer CA bytes separately (with a marker),
    so upstream=[X], issuers=[] can't hash identically to
    upstream=[], issuers=[X].

Generated with Claude Code

@github-actions github-actions Bot added the size/L Large PR: 600-999 lines changed label Aug 26, 2026
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.41758% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.94%. Comparing base (28dd90a) to head (5f6ed75).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
...or/controllers/virtualmcpserver_authz_configmap.go 0.00% 4 Missing ⚠️
cmd/thv-operator/controllers/upstream_ca_bundle.go 25.00% 3 Missing ⚠️
cmd/thv-operator/pkg/controllerutil/authserver.go 92.85% 3 Missing ⚠️
...perator/api/v1beta1/mcpexternalauthconfig_types.go 50.00% 2 Missing ⚠️
...d/thv-operator/controllers/mcpserver_controller.go 80.00% 2 Missing ⚠️
...-operator/controllers/mcpremoteproxy_controller.go 88.88% 1 Missing ⚠️
...perator/controllers/virtualmcpserver_controller.go 87.50% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6437      +/-   ##
==========================================
- Coverage   78.00%   77.94%   -0.07%     
==========================================
  Files         766      767       +1     
  Lines       74069    74147      +78     
==========================================
+ Hits        57780    57796      +16     
- Misses      16284    16346      +62     
  Partials        5        5              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@JAORMX

JAORMX commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Panel review — 3-axis: Spec / Standards / Domain (security + operator + reuse + duplication)

Fixed point: main (23e6d2f) → PR head (2142ea1e), 26 files, +736/−64, 7 commits.


Spec (vs #6429)

  • WRONG — feature endpoint unreached. TrustedIssuerConfig has no caBundleRef; only UpstreamProviderConfig (mcpexternalauthconfig_types.go:1057). Per-spec CA bundle not implemented for the issuer leg.
  • Per-issuer HTTP client (multi_issuer_validator.go:475, :502) builds NewHttpClientBuilder() without .WithSystemRootsPlusCABundle(...); CAFilePath wired only for upstream OAuth2 (authserver.go:1178), not JWKS fetch (:263).
  • Checksum annotation (authserver.go:385-402) hashes upstream CA + {0} separator only; no issuer-level CA exists to hash separately.
  • ActorMatcher fix (cccc7a64f) kept as separate commit — could not confirm restoration in this workspace (silent / needs verification).

Standards (.claude/rules/operator.md, go-style.md, security.md)

  • Positive: new MutateAndPatchStatus adopted (external_auth_mirror.go:120); ObservedGeneration stamped; duration fields metav1.Duration; owner refs / projected volume correct; RequeueAfter used for expected-not-ready.
  • Violation (pre-existing, but PR touches these files): virtualmcpserver_controller.go:363-384 uses r.Status().Update, not MutateAndPatchStatus — sole-array-ownership broken against concurrent runtime writes.
  • Violation (pre-existing, touched by PR fan-out): mcpserver_controller.go, mcpremoteproxy_controller.go same — do not copy; migrate these controllers.

Domain (5 reviewers, synthesized — no cross-axis merge)

Critical — operator (kubernetes-operator-expert):

  • virtualmcpserver_controller.go:363-384: r.Status().Update violates controllerutil.MutateAndPatchStatus mandate; concurrent runtime status writer will erase conditions.
  • virtualmcpserver_authz_configmap.go:37-54: CA ConfigMap lookup is in-memory list-and-filter (no index); violates .claude/rules/operator.md "Index every field you List-filter by". Need client.MatchingFields index.
  • virtualmcpserver_controller.go:537-542: CA invalid reason is AuthServerConfigInvalid instead of InvalidCABundle — breaks condition-reason parity with MCPServer / MCPRemoteProxy.

Medium — security (secure-code-reviewer):

  • mcpexternalauthconfig_types.go:462-467: CABundleSource.configMapRef has no admission-time CEL validation; configMapRef: {} admitted, rejected only at reconcile-time (ca_bundle.go:46-56). Add +kubebuilder:validation:XValidation requiring non-empty configMapRef when present.
  • Positive confirmation: per-issuer WithSystemRootsPlusCABundle (multi_issuer_validator.go:507-514); no SSL_CERT_FILE process-wide override; ConfigMap reads namespace-bound (mcpserver_authserver_cabundle_configmap.go:21-35); CA path index-derived (not user-controlled); ActorMatcher evaluated against verified claims (handler.go:692-711) with no claim-value logging.

Clean — reuse (library-reuse-reviewer) / duplication (code-duplication-reviewer): no over-build (net: -0); ResolveCABundle reused; checksum uses stdlib sha256; volume mount parallels oidc_volumes.go but factored via generateCABundleVolumes (similar, incidental — keep separate); checksum independent from FNV config hash (independent:).


Cross-confirmed findings (≥2 axes agree)

  1. MutateAndPatchStatus migration missing on virtualmcpserver_controller status path.
  2. CA ConfigMap indexing / MatchingFields missing.
  3. Admission-time CEL validation missing for caBundleRef.configMapRef.

Verdict

Does not fully implement #6429. The CA-bundle pipeline (index → checksum → projected volume → CAFilePath) is correctly built for upstream providers; the trusted-issuer endpoint (TrustedIssuerConfig) is unreached: no CRD field, no per-issuer CA injection, no separate issuer checksum hash. Positive: architecture sound (no security boundary bypass, no process-wide CA override, namespace-bound), reuse clean. Must fix caBundleRef field + per-issuer client + checksum separation + index + CEL validation + MutateAndPatchStatus migration before merge.

Ship-blockers: 4 (spec endpoint, operator migration + index, CEL validation). Judgement call: condition-reason parity (InvalidCABundle) — discuss; ActorMatcher isolation — verify commit. Mechanical fixes available: CEL + index + checksum separation are deterministic; MutateAndPatchStatus migration structural (needs agreement).

jhrozek and others added 8 commits August 27, 2026 09:20
The new CAFilePath fields on the authserver upstream run-configs are part
of the generated API surface, so the committed spec no longer matched what
swag produces and the docgen check failed.

Regenerating also drops the package qualification from 21 schema keys
(authserver, tokenexchange, ratelimit/types, audit, operator v1beta1).
swag qualifies a key only when it sees the same package name twice, and
the added fields shift which packages it double-counts. No API change --
the renames and their $ref updates account for nearly all of the diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dedupe-enums workaround only recognized an exactly doubled enum array,
but swag's repeat count varies by machine -- this branch's docs were
generated on one that tripled them, so the arrays survived untouched and
the docgen check still failed against CI's deduped output.

Match any whole-number repeat instead of only 2x, and collapse the three
affected arrays in the generated spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
buildTrustedIssuerConfigs omitted ActorMatcher while its controllerutil
twin copied it, so the CRD-time validator always compiled an empty matcher.
A malformed CEL expression therefore passed admission and only surfaced
when the auth server failed to start, as a CrashLoopBackOff rather than a
status condition.
The negative CA bundle test asserted the error contained "x509", but jwx's
httprc layer does not propagate that cause: the registration fails as
"resource registered but not ready" once the fetch times out. Assert on the
stage that failed instead, which together with the success case still pins
the bundle as the load-bearing difference.
The rebase resolved the generated OpenAPI conflict by taking the base, so
the trusted issuer ca_file_path had to be regenerated back in.
Both generateCABundleVolumes calls exceeded the 130-character line limit.

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: private CA trust for RFC 8693 trusted issuers

Reviewed across four axes (Kubernetes operator, security, Go quality + tests, docs). 0 HIGH, 4 MEDIUM, 6 LOW.

The core is sound, and I verified the high-risk properties rather than assuming them:

  • Trust is genuinely per-issuer - x509.SystemCertPool() returns a copy on Go 1.26, Build() makes a fresh Transport+tls.Config per call, newExternalIssuerConfig runs once per issuer. No shared pool, no default-transport mutation. The doc comment's "scoped to that issuer's client only" is accurate as written.
  • Fails closed - invalid bundle at startup errors out of NewMultiIssuerTokenValidator; invalid after admission gives a terminal condition and the operator refuses the Deployment update, so the running pod keeps its old valid bundle. No silent fallback to system roots.
  • SSRF defenses untouched - the bundle only sets TLSClientConfig.RootCAs; protectedDialerControl, ValidatingTransport, DisableKeepAlives, SameHostRedirectPolicy all preserved and unreordered.
  • No spurious rollout on upgrade - the embeddedAuthServerCABundleValue to inlined ResolveCABundle refactor is byte-for-byte behaviour-preserving. The dropped if value == nil { continue } was reachable only for ref == nil, now filtered before the call, since a non-nil ref with a nil ConfigMapRef errors in ValidateCABundleSourceShape (validation/oidc_validation.go:47).
  • Index drift is impossible - buildTrustedIssuerRunConfigs, generateTrustedIssuerCABundleVolumes, and trustedIssuerCABundleFilePath all index the same unfiltered slice; generateCABundleVolumes' continue skips emission without shifting index. Volume names cap at 23 chars, distinct prefix and mount base from upstream.
  • Watches fully wired - the shared field index (consumed by both MCPServer and MCPRemoteProxy) and the vmcp inline check were both extended. No half-wire.
  • ActorMatcher fix is complete - a field-by-field audit of both builders against the 12-field CRD struct found no other dropped field.
  • The renames are docs-safe - zero hits for invalid upstream CA bundle outside Go source.

Two things I'd want addressed before merge

Both are inline below: the condition message no longer identifies which reference failed (M1), and the new example authorizes nothing (M2). M1 is mechanical (~10 lines: 2 signatures, 4 call sites, 1 test seed). M2 needs your intent on four values, so I've diagnosed rather than suggested.

Not anchorable inline (files/lines not in the diff)

MEDIUM - docs/arch/17-token-exchange-delegation.md is untouched, and one bullet is now wrong. That doc owns trustedIssuers - both the Go field comment (mcpexternalauthconfig_types.go:805) and docs/arch/README.md:138 route there. Its "Operational notes" documents every other per-issuer transport knob (discovery redirects, JWKS caching, insecureAllowHTTP, allowPrivateIPs) and says nothing about caBundleRef - an operator whose private-CA issuer fails TLS reads exactly that list and finds no answer. Worse, line 800 says:

  • Misconfiguration surfaces as a pod crash, not an operator condition - check pod logs, not kubectl describe.

A malformed caBundleRef now does surface as ConditionReasonInvalidCABundle. Suggest one bullet in Operational notes, narrowing that pod-crash bullet, and adding caBundleRef to the trustedIssuers YAML example at ~line 154.

LOW - two stale doc lines left behind by their own hunks. docs/arch/03-transport-architecture.md:788 still says "(for embedded auth-server upstreams)" - line 307 was correctly widened, 788 wasn't. docs/arch/09-operator-architecture.md:227 still says "the same upstream configuration" - line 225 was updated, 227 wasn't. Both just need "upstream" dropped.

LOW - example header comment. Line 1 still reads # Embedded auth server using a private CA for its OIDC upstream. It now also demonstrates a trusted issuer.

INFO - stale test seed. mcpserver_externalauth_test.go:755 seeds Message: "invalid upstream CA bundle: already recorded", a message the operator can no longer emit. SetStatusCondition doesn't reset LastTransitionTime on a message-only change so the test still passes, but the seed now undercuts its own "already recorded" premise.

INFO - release note. The ActorMatcher propagation fix is a user-visible admission behaviour change: a malformed CEL actorMatcher that previously passed admission and only failed at auth-server startup is now rejected up front. Worth calling out.

INFO - the 10s subtest is a defensible trade-off given jwx doesn't propagate the x509 cause and the fetch context is detached, but making httpTimeout injectable would let it run in milliseconds.

Filed as COMMENT rather than REQUEST_CHANGES - M1 and M2 are the two I'd treat as blocking, your call on the rest.

Comment thread cmd/thv-operator/controllers/mcpserver_controller.go Outdated
Comment thread cmd/thv-operator/controllers/mcpremoteproxy_controller.go Outdated
Comment thread cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
Comment thread cmd/thv-operator/pkg/controllerutil/authserver.go
Comment thread cmd/thv-operator/controllers/virtualmcpserver_controller.go
Comment thread cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
Comment thread cmd/thv-operator/pkg/controllerutil/authserver_test.go
Comment thread cmd/thv-operator/controllers/upstream_ca_bundle.go
@jhrozek
jhrozek force-pushed the trusted-issuer-private-ca branch from 2142ea1 to 28e0125 Compare August 27, 2026 12:59
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/L Large PR: 600-999 lines changed labels Aug 27, 2026
jhrozek and others added 2 commits August 27, 2026 15:43
handleInvalidCABundle formatted the unwrapped *InvalidCABundleError
instead of the full error returned by ValidateEmbeddedAuthServerCABundles,
so the "trustedIssuers[%d] (%q) caBundleRef:" / "upstreamProviders[%d]
(%q) caBundleRef:" prefix never reached the status condition message,
leaving operators unable to tell which reference failed when two refs
point at the same ConfigMap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The example's trusted issuer authorized nothing: no allowedActors or
actorMatcher, and it reused the upstream OIDC clientId as a dangling
allowedDelegateClients entry with no matching delegateClients
declaration. Add allowedActors, a properly declared reporting-delegate
client, and a placeholder secret for it.

Also cover caBundleRef in the docs that describe trusted issuers
(arch/09, arch/17, and the CRD field comment) and drop stale
"upstream"-only wording left over from adding the issuer-level bundle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Aug 27, 2026
@jhrozek

jhrozek commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the pass. Traced the two central claims against the actual code and they don't hold up on the current branch:

  • TrustedIssuerConfig does have caBundleRefmcpexternalauthconfig_types.go:467.
  • The per-issuer HTTP client does call .WithSystemRootsPlusCABundle(...) when CAFilePath != ""multi_issuer_validator.go:512-513.

The stated fixed point (2142ea1e) isn't anywhere in this branch's history, so I think this ran against a stale or unrelated checkout rather than the PR head. Happy to have it re-run against the current head (ee2e2d1) if useful — let me know if anything still looks off once it's looking at the right commit.

@jhrozek

jhrozek commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Went through this one line by line and verified both flagged MEDIUMs by tracing the actual code — both confirmed, both fixed:

  • M1handleInvalidCABundle now takes the full wrapped error instead of the unwrapped *InvalidCABundleError, so the trustedIssuers[%d]/upstreamProviders[%d] field-path prefix survives into the condition message. (mcpserver_controller.go, mcpremoteproxy_controller.go)
  • M2 — the example now has allowedActors, and the delegate-client reference is renamed off the reused toolhive-client to a properly declared reporting-delegate client with a matching secret.

Also picked up the LOWs that were pure documentation/mechanical fixes: the stale "upstream"-only wording in arch/03 and arch/09, the example header comment, the RBAC-privilege sentence on CABundleRef (+ regenerated crd-api.md), and caBundleRef coverage in arch/17 including narrowing the now-inaccurate "pod crash" bullet. Added a clarifying comment on the dead annotation-pruning entry rather than removing it — wasn't confident enough to delete outright.

Left three open as judgment calls rather than folding them into this pass: the domain-separator hash change (you flagged the rollout-cost tradeoff yourself), collapsing the duplicated CRD→runtime converters, and the two test-coverage gaps. Replied on each inline.

Pushed as 865a943 + ee2e2d1.

Generate CRDs CI caught a stale diff after the CABundleRef comment
was expanded in an earlier commit.
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Aug 28, 2026
@jhrozek
jhrozek merged commit 3a700c5 into main Aug 28, 2026
49 checks passed
@jhrozek
jhrozek deleted the trusted-issuer-private-ca branch August 28, 2026 08:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

trustedIssuers has no way to trust a private CA for the external issuer's JWKS endpoint

2 participants