Skip to content

Trust private CAs for upstream auth servers - #6428

Merged
jhrozek merged 13 commits into
mainfrom
upstream-private-ca
Aug 26, 2026
Merged

Trust private CAs for upstream auth servers#6428
jhrozek merged 13 commits into
mainfrom
upstream-private-ca

Conversation

@jhrozek

@jhrozek jhrozek commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Private-CA OAuth2 and OIDC upstreams could not complete discovery, token, user-info, or dynamic client registration requests, leaving in-cluster IdPs behind internal PKI unusable.
  • Add per-upstream caBundleRef support, PEM validation, read-only ConfigMap projection, and additive system-root-plus-private-CA TLS trust for embedded auth-server clients.
  • Roll workloads when a selected CA bundle changes or is removed, and surface invalid bundle content as a terminal status condition rather than repeatedly retrying reconciliation.

Fixes #6417

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (controller coverage via task operator-test, including CA rotation/removal and terminal invalid-bundle status handling)

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.

Changes

File(s) Change
cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go, generated CRDs Add upstream caBundleRef API documentation and generated schema updates.
cmd/thv-operator/pkg/controllerutil/{authserver,ca_bundle}.go Validate, project, hash, and configure upstream CA bundles.
cmd/thv-operator/controllers/{mcpserver,mcpremoteproxy,virtualmcpserver}_* Watch referenced ConfigMaps and roll workloads on CA changes.
pkg/networking/http_client.go Add additive system-root-plus-bundle trust configuration.
pkg/authserver/**, pkg/auth/dcr/** Pass per-upstream CA paths through OAuth2, OIDC, and DCR clients.
docs/arch/**, docs/operator/crd-api.md, example manifest Document trust semantics and provide a private-CA configuration example.
Associated *_test.go files Cover validation, client trust semantics, DCR propagation, watches, and rollout drift.

Does this introduce a user-facing change?

Yes. Operators can configure caBundleRef on embedded-auth OIDC and OAuth2 upstreams to trust an internal CA. The bundle augments system trust roots; it does not pin the upstream to that CA or disable publicly trusted certificates.

Special notes for reviewers

This is one vertical feature spanning CRD API, operator projection and rollout behavior, and runtime TLS clients. Please pay particular attention to the intentional additive trust semantics and to the checksum-driven rollout required because subPath ConfigMap mounts do not refresh in running pods.

@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Aug 25, 2026
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.45304% with 117 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.88%. Comparing base (9a9686c) to head (fbe8778).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
...perator/controllers/virtualmcpserver_controller.go 60.81% 29 Missing ⚠️
...d/thv-operator/controllers/mcpserver_controller.go 78.21% 22 Missing ⚠️
...-operator/controllers/mcpremoteproxy_controller.go 85.71% 12 Missing ⚠️
cmd/thv-operator/app/app.go 0.00% 10 Missing ⚠️
...perator/api/v1beta1/mcpexternalauthconfig_types.go 61.90% 8 Missing ⚠️
cmd/thv-operator/pkg/controllerutil/ca_bundle.go 86.79% 7 Missing ⚠️
cmd/thv-operator/controllers/upstream_ca_bundle.go 77.77% 6 Missing ⚠️
cmd/thv-operator/pkg/controllerutil/authserver.go 91.89% 6 Missing ⚠️
...rs/mcpremoteproxy_authserver_cabundle_configmap.go 78.94% 4 Missing ⚠️
...rollers/mcpserver_authserver_cabundle_configmap.go 78.94% 4 Missing ⚠️
... and 6 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6428      +/-   ##
==========================================
+ Coverage   77.82%   77.88%   +0.05%     
==========================================
  Files         762      766       +4     
  Lines       73449    73959     +510     
==========================================
+ Hits        57165    57600     +435     
- Misses      16279    16354      +75     
  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.

@samuv samuv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the thorough vertical implementation—the per-upstream mounts, additive system/private trust, DCR propagation, and checksum-driven rollouts are thoughtfully designed. The TLS propagation itself looks correct and retains hostname verification. However, several reconciliation and status-lifecycle paths need correction, and the Swagger documentation check currently fails.

Blockers

  1. [BLOCKER] cmd/thv-operator/pkg/controllerutil/authserver.go:346 — CA validation occurs too late for authServerRef. handleAuthServerRef marks the reference valid before bundle validation; malformed bundles then fail during Deployment construction and repeatedly requeue while AuthServerRefValidated=True. Validate bundles in both handleAuthServerRef paths, record the correct terminal condition, and add MCPServer/MCPRemoteProxy regression tests. Rule: operator “Terminal vs transient errors.”

  2. [BLOCKER] cmd/thv-operator/controllers/mcpserver_controller.go:2328 and mcpremoteproxy_controller.go:950 — repairing an invalid ConfigMap does not clear the failure condition. MCPServer never restores ExternalAuthConfigValidated=True; MCPRemoteProxy deliberately preserves InvalidConfig while the external-auth spec hash is unchanged, and ConfigMap content changes do not alter that hash. Add invalid→valid recovery handling and tests. Rule: operator “Status is reconstructable from observed state.”

  3. [BLOCKER] cmd/thv-operator/controllers/virtualmcpserver_controller.go:486 — all CA-resolution errors are converted into terminal validation failures. Ordinary transient ConfigMap Get failures are swallowed through return false, so reconciliation can stop without backoff until another event happens. Only InvalidCABundleError should be terminal; return other errors to controller-runtime. Rule: operator “No log-and-swallow.”

  4. [BLOCKER] pkg/authserver/config.go:551 — the new JSON/YAML-visible fields require regenerated Swagger artifacts, but docs/server/swagger.json, swagger.yaml, and docs.go are stale. The required “Verify Swagger Documentation” check currently fails. Run task docs and commit the generated changes.

Should-fix

  1. [SHOULD-FIX] cmd/thv-operator/pkg/controllerutil/ca_bundle.go:47ValidateCABundleSource applies the legacy 48-character ConfigMap-name limit needed by oidc-ca-bundle-<name>. These new volumes are index-named, so otherwise valid longer ConfigMap names are rejected terminally. Use shape-only validation for embedded-auth bundles and test a longer legal name.

  2. [SHOULD-FIX] examples/operator/external-auth/mcpexternalauthconfig_private_ca.yaml:34 — the example omits configMapRef.key and says it defaults to ca.crt, but the generated CRD requires key. Add key: ca.crt; otherwise the example is rejected by admission.

  3. [SHOULD-FIX] PR commits — commits 0703597 and 0e43d3f lack the Signed-off-by trailers required by CONTRIBUTING.md.

Verification notes

  • CRD compatibility, code generation, linting, unit tests, security scans, and Kubernetes lifecycle E2E checks pass.
  • Private CA trust is consistently propagated through OIDC discovery/JWKS/token/userinfo and OAuth2 DCR.
  • No existing review comments were present.

@jhrozek
jhrozek force-pushed the upstream-private-ca branch from 89af105 to baddbd6 Compare August 25, 2026 22:18
@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 25, 2026

@samuv samuv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Follow-up on the earlier review: Swagger regeneration landed and the docs check is green. The three reconciliation blockers are still open on this head, and two of the should-fixes were not applied.

Still blocking

  1. authServerRef still validates too late. ValidateEmbeddedAuthServerCABundles was added to handleExternalAuthConfig only. handleAuthServerRef still marks AuthServerRefValidated=True before any bundle check; malformed bundles fail during Deployment construction and requeue. handleInvalidUpstreamCABundle always writes ExternalAuthConfigValidated, including for authServerRef failures.

  2. Repairing an invalid ConfigMap still does not restore a valid condition. MCPServer never sets ExternalAuthConfigValidated=True after a successful CA check. MCPRemoteProxy still preserves InvalidConfig while the external-auth spec hash is unchanged, and ConfigMap content does not change that hash. No invalid→valid recovery tests were added.

  3. VirtualMCPServer still treats every CA-resolution error as terminal. validateAuthServerConfigCABundles stamps AuthServerConfigInvalid for all errors, and runAuthValidations returns false (no requeue). Only InvalidCABundleError should be terminal; ConfigMap Get failures must return to controller-runtime. The helper tests already classify this correctly; the controller does not use that distinction.

Still should-fix

  1. 48-character ConfigMap name limitResolveCABundle still calls ValidateCABundleSource, which enforces the oidc-ca-bundle- volume-name cap. These volumes are index-named (authserver-upstream-ca-{index}).
  2. Example still omits required key — the CRD requires configMapRef.key; examples/operator/external-auth/mcpexternalauthconfig_private_ca.yaml still has only a comment. Admission will reject it.
  3. Signed-off-by — the original commits were rewritten with the trailer, but the three follow-up commits (b7a0f616, b3152f88, bdadc0b) still lack it.

Addressed

  • Stale Swagger artifacts / failing “Verify Swagger Documentation” check.

Checklist

  • Tests: CA helper classification and terminal-invalid coverage exist; authServerRef validation and invalid→valid recovery tests are still missing.
  • Docs: Swagger and CRD reference regenerated. Example manifest is still invalid under the generated schema.
  • Registry: no impact.
  • Security: TLS trust path unchanged from the first review (hostname verification retained, additive system+bundle trust).
  • Backwards compatibility: additive caBundleRef; no v1beta1 break.

Comment thread cmd/thv-operator/controllers/mcpserver_controller.go
Comment thread cmd/thv-operator/controllers/mcpserver_controller.go
Comment thread cmd/thv-operator/controllers/mcpremoteproxy_controller.go
Comment thread cmd/thv-operator/controllers/virtualmcpserver_controller.go Outdated
Comment thread cmd/thv-operator/pkg/controllerutil/ca_bundle.go Outdated
Comment thread examples/operator/external-auth/mcpexternalauthconfig_private_ca.yaml Outdated
@jhrozek

jhrozek commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the second pass. That review landed against bdadc0b a few minutes before I pushed the fixes, so it's reviewing the older head — all three blockers and both code should-fixes are on 8e95962 now. Mapping them:

Blocker 1, late validation on authServerRef: 3e79675. Both handleAuthServerRef implementations now resolve the bundles where handleExternalAuthConfig already did. For the condition routing I went with a conditionType parameter on handleInvalidUpstreamCABundle rather than a Source field on InvalidCABundleError — the error is constructed down in ResolveCABundle, which has no idea which ref led there, whereas the call site always knows. MCPRemoteProxy needed a sentinel to signal the handled case up through validateAndHandleConfigs, since the unwrap at its Reconcile boundary can't tell the two ref paths apart.

Blocker 2, recovery: 30a7798 and 315a153. Root cause was CA failures sharing ConditionReasonInvalidConfig with the spec-derived failures, so both hash guards held them in place forever. They now use ConditionReasonInvalidCABundle, which is your "skip the preserve helper" option — the guard never sees a CA failure at all. I skipped the bundle-checksum-in-the-heal-key alternative because it needs a new status field on two CRDs and means carrying observed state forward as truth. Separately MCPServer had no ExternalAuthConfigValidated=True writer at all, so no amount of unblocking would have healed it; added one mirroring the MCPRemoteProxy twin. One wrinkle worth knowing about: splitting the reason meant mirrorInvalidOn* no longer recognised the CA condition as locally owned and started removing it a step before the CA check re-added it, restamping LastTransitionTime every reconcile. ownedByLocalValidation covers both reasons now and there's a test pinning it.

Blocker 3, vMCP: 45c2579. validateAuthServerConfigCABundles returns (terminal, error) and only stamps for InvalidCABundleError; runAuthValidations picked up the (bool, error) contract runValidations already documented. I also classified the CA checksum read on the deployment path — gated by the validation above today, but it returned terminal errors raw.

Should-fix 1: 06b5157, ValidateCABundleSourceShape without the cap, ResolveCABundle points at it, OIDC callers keep the length check.
Should-fix 2: 8e95962.

One thing your review didn't flag that I hit while writing the tests: the existing handleExternalAuthConfig CA check stamps the terminal reason for any error including a transient ConfigMap read, same defect as blocker 3. My first cut of the authServerRef block copied it and my own transient test caught it, so 3e79675 fixes all four sites rather than leaving two right and two wrong.

Tests: 8 authServerRef CA tests across the two controllers, 3 repair-then-reconcile tests, 2 vMCP classification tests, plus long-name coverage in the validation and ca_bundle packages. I checked each one fails without its production change — the first flap test I wrote passed either way because metav1.Time is second-resolution and a remove-then-re-add inside one second is invisible, so it seeds a timestamp an hour back instead.

The steady-state tests assert the condition is unchanged rather than the object's ResourceVersion. Status content is byte-identical across reconciles but the ResourceVersion still advances, because other writers in Reconcile issue unconditional no-op status patches. That's pre-existing and felt out of scope here.

No CRD schema change in any of this, condition reasons are plain string constants, so there's no missing generated output to look for.

On the sign-off trailer: the three follow-up commits are doc regeneration and still don't have it. Say the word if that blocks and I'll rewrite them.

jhrozek and others added 7 commits August 26, 2026 13:45
An embedded auth server could not complete an authorization-code exchange
with an OAuth2 or OIDC provider whose certificate is signed by a private
CA, so an in-cluster IdP behind an internal PKI was unusable as an
upstream.

Add caBundleRef to the OIDC and OAuth2 upstream configs. The operator
projects the selected ConfigMap key as a read-only ca.crt per upstream and
passes its path to the provider and DCR clients, so one upstream's private
CA is not implicitly trusted for another or for unrelated traffic.

Such an upstream may present either a publicly trusted certificate or a
private one, so these clients need the system roots as well as the bundle.
Add WithSystemRootsPlusCABundle for that and leave WithCABundle pinning:
its other callers include the JWKS and introspection clients that validate
incoming tokens, where a bundle is often configured precisely to restrict
trust to a private issuer.

The bundle is mounted with subPath, which kubelet never refreshes, so a
rotated CA reaches a running pod only through a pod template change. Hash
the selected bytes into a pod template annotation and compare it during
drift detection on MCPServer, MCPRemoteProxy and VirtualMCPServer,
including when caBundleRef is removed.

Resolve and PEM-validate each reference during reconciliation. A missing
ConfigMap, absent key, or malformed certificate surfaces as a status
condition rather than a pod that starts without the trust roots its
upstream requires.

Fixes #6417

Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
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>
The CABundleRef and ConfigMapRef doc comments were reworded when the
embedded auth CA key requirement was clarified, but crd-api.md is
generated by a separate task and still carried the old text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ValidateCABundleSource caps ConfigMap names at 48 characters because OIDC
CA bundle volumes are named oidc-ca-bundle-<configmap-name> and must fit a
63-character RFC 1123 label. Embedded auth server upstream bundles use
volumes named by provider index, so the ConfigMap name never reaches a
volume name and the cap terminally rejected otherwise-valid names.

Add ValidateCABundleSourceShape for the callers that do not embed the name,
and point ResolveCABundle at it. The OIDC call sites keep the cap.
CA bundle failures reused ConditionReasonInvalidConfig, which also marks
spec-derived failures from handleInvalidEmbeddedAuthServerConfig. Two guards
hold such a failure steady across reconciles by comparing generation and the
referenced config's spec hash. Neither covers ConfigMap content, so a CA
failure recorded under that reason can never be cleared once the ConfigMap
is repaired.

Add ConditionReasonInvalidCABundle for the content-derived failure so the
guards keep protecting only what they were written for.

Widen the mirror's ownership predicate to recognise the new reason as well,
otherwise mirrorInvalidOn* removes the condition a step before the CA check
re-adds it, restamping LastTransitionTime on every reconcile.
The condition was only ever removed or set False: MCPServer had no True
writer and no success reason, unlike its MCPRemoteProxy twin. A recorded
failure therefore outlived its cause, since nothing restored the condition
once validation started passing again.

Add the success reason and writer, mirroring
setMCPRemoteProxyExternalAuthConfigValidCondition. The guard that preserves
a terminal RunConfig failure is carried over unchanged, but deliberately does
not cover CA bundle failures, whose input is ConfigMap content that neither
generation nor the config hash tracks.

Three mirror subtests asserted the condition stays absent when the source is
valid, which only held because no writer existed. They now expect the
validated state that MCPRemoteProxy already reports.
handleAuthServerRef reported the reference as valid without ever resolving
the CA bundles it names. A malformed bundle was first noticed during
Deployment construction, which reports a generic build failure and requeues
forever while AuthServerRefValidated still reads True.

Resolve the bundles where the sibling externalAuthConfigRef path already
does, and record a failure on AuthServerRefValidated. Terminal errors are
routed there by passing the condition type into
handleInvalidUpstreamCABundle, which previously hardcoded the
externalAuthConfigRef condition. MCPRemoteProxy signals the handled case to
Reconcile with a sentinel, since the unwrap at its validateAndHandleConfigs
boundary cannot tell the two reference paths apart.

Classify terminal against transient in all four validation sites, including
the two pre-existing externalAuthConfigRef ones: only a content error is
recorded as a spec defect, while a failed ConfigMap read surfaces so the
caller requeues.
validateAuthServerConfigCABundles recorded every failure as a spec error and
runAuthValidations then discarded the error entirely, returning a bare bool.
Reconcile therefore stopped with no requeue on a failed ConfigMap read, so an
apiserver blip halted reconciliation until an unrelated watch event arrived,
under a status that blamed the spec.

Classify with the InvalidCABundleError the util layer already returns: stamp
and stop for malformed content, propagate untouched for a failed read.
runAuthValidations gains the (bool, error) contract runValidations already
documents and its other branches already use.

Apply the same classification where the deployment path reads the CA bundle
checksum. Validation gates that path, but returning a terminal error raw
would requeue forever if it were ever reached first.
The example omitted configMapRef.key and noted that it defaults to ca.crt.
The default applies to objects already stored, but the generated CRD marks
key as required, so admission rejects the example as written.
Rebasing onto main conflicted in the generated OpenAPI output, since main
gained the private_key_jwt DCR schema in the same files. The conflict was
resolved by regenerating rather than hand-merging.
@jhrozek
jhrozek force-pushed the upstream-private-ca branch from 8e95962 to 16995a9 Compare August 26, 2026 12:28
@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 26, 2026
samuv
samuv previously approved these changes Aug 26, 2026

@samuv samuv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed after the rebase. The earlier reconciliation blockers are addressed: authServerRef validates CA bundles before marking Valid, terminal CA failures use InvalidCABundle on the correct condition, ConfigMap repair restores Valid, and VirtualMCPServer requeues transient Get errors. The 48-character name cap no longer applies to embedded-auth bundles, and the example includes key: ca.crt.

Checklist

  • Tests: terminal vs transient, authServerRef, and invalid→valid recovery coverage is in place.
  • Docs: Swagger/CRD docs regenerated; example is admission-valid.
  • Registry: no impact.
  • Security: additive system-root-plus-bundle trust; hostname verification retained.
  • Backwards compatibility: additive caBundleRef; no v1beta1 break.

Adding the terminal CA bundle unwrap to validateAndHandleConfigs pushed it
to cyclomatic complexity 16, one over the limit. Move the branch into its
own method rather than raising the threshold.
@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 26, 2026

@samuv samuv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚀

@jhrozek
jhrozek merged commit 23e6d2f into main Aug 26, 2026
80 of 81 checks passed
@jhrozek
jhrozek deleted the upstream-private-ca branch August 26, 2026 13:25
@github-actions github-actions Bot mentioned this pull request Aug 27, 2026
2 tasks
alex-feel added a commit to alex-feel/toolhive that referenced this pull request Aug 29, 2026
The operator CRD's OIDC upstream config gained allowPrivateIPs in stacklok#6428, which also wired it through buildOIDCUpstreamRunConfig, so the capability this branch originally added now exists upstream.
stacklok#6428's own tests cover only the OAuth2 side (TestBuildOAuth2UpstreamRunConfig_TransportOptions), leaving the OIDC-side mapping without direct regression coverage.
Add a default-false assertion on the existing OIDC-upstream test case and an end-to-end test that exercises both OIDCUpstreamConfig.AllowPrivateIPs and OAuth2UpstreamConfig.AllowPrivateIPs reaching their run configs through BuildAuthServerRunConfig.
This closes the coverage half of the stacklok#4523 ask; the field itself shipped via stacklok#6428.

Signed-off-by: Aleksandr Filippov <71711753+alex-feel@users.noreply.github.com>
jhrozek pushed a commit that referenced this pull request Aug 31, 2026
…#6288)

The operator CRD's OIDC upstream config gained allowPrivateIPs in #6428, which also wired it through buildOIDCUpstreamRunConfig, so the capability this branch originally added now exists upstream.
#6428's own tests cover only the OAuth2 side (TestBuildOAuth2UpstreamRunConfig_TransportOptions), leaving the OIDC-side mapping without direct regression coverage.
Add a default-false assertion on the existing OIDC-upstream test case and an end-to-end test that exercises both OIDCUpstreamConfig.AllowPrivateIPs and OAuth2UpstreamConfig.AllowPrivateIPs reaching their run configs through BuildAuthServerRunConfig.
This closes the coverage half of the #4523 ask; the field itself shipped via #6428.

Signed-off-by: Aleksandr Filippov <71711753+alex-feel@users.noreply.github.com>
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.

VirtualMCPServer.authServerConfig.upstreamProviders has no way to trust a private CA for the upstream token/authorization endpoints

2 participants