Skip to content

fix: fail the render when dex.github.orgs is empty - #1488

Merged
venkatamutyala merged 1 commit into
feat/cli-sso-through-oauth2-proxyfrom
fix/require-dex-github-orgs
Sep 5, 2026
Merged

venkatamutyala merged 1 commit into
feat/cli-sso-through-oauth2-proxyfrom
fix/require-dex-github-orgs

Conversation

@venkatamutyala

Copy link
Copy Markdown
Contributor

Into #1485, not main. One template guard.

The problem

dex.github.orgs is optional today:

templates/application-dex.yaml:63-68
  {{- if .Values.dex.github.orgs }}
  orgs:
  ...
  {{- end }}
  loadAllGroups: true

But it is the only thing restricting who Dex admits. Dex v2.44.0 connector/github/github.go:

func (c *githubConnector) getGroups(ctx, client, groupScope bool, userLogin string) {
	switch {
	case len(c.orgs) > 0:               return c.groupsForOrgs(...)   // only branch that can deny
	case c.org != "":                   return c.teamsForOrg(...)
	case groupScope && c.loadAllGroups: return c.userGroups(...)      // ← lands here
	}

groupsForOrgs is the sole source of github: user %q not in required orgs or teams (github.go:375). With the list empty that branch is unreachable, and because this chart sets loadAllGroups: true, control falls to userGroups — which returns the caller's groups and cannot return an error. Every GitHub account authenticates.

Nothing downstream catches it: oauth2-proxy runs email_domains=["*"] with no allowed_groups.

Scope of the exposure, precisely

  • The edge: fully open. Including the five hosts with no auth of their own — goldilocks, cluster-info, and the three Traefik dashboards, whose /api/rawdata lists every router, service, middleware and TLS config on the cluster.
  • OpenBao: still gated. The jwt roles' bound_claims require group membership, so a stranger gets no policy.
  • ArgoCD: depends on the tenant's rendered policy.csv, which is group-keyed.

So this is the edge, not all three — worth stating accurately, because the cluster reports Synced/Healthy throughout and nothing indicates it is open.

Why this is a footgun, not a live hole

values.yaml ships two placeholder orgs, so the default is safe. Reaching the bad state needs a tenant to override orgs to empty or null. The reason to guard it anyway is that the failure is silent and total.

This is pre-existing, not introduced by #1485. It rides along here because #1485 is what makes a Dex-issued token valuable enough to matter: toolbox is the first public client on this platform whose audience anything honors.

Why fail/not and not required

required rejects only nil and the empty string — an empty list passes straight through. Demonstrated:

# values: orgs: []
{{ required "REQUIRED FIRED" .Values.orgs }}   ->  renders  a: []
{{- if not .Values.orgs }}{{ fail "…" }}{{ end }}  ->  Error: FAIL/NOT FIRED

not is falsy for nil, empty list and empty string alike.

(#1485's description currently suggests required for this. That suggestion was mine and it was wrong; I'll correct it there.)

Verification

case expected result
default values renders ✅ renders, orgs: unchanged
dex.github.orgs=["acme"] renders ✅
dex.github.orgs=[] abort ✅ dex.github.orgs must be non-empty: it is the only restriction on who Dex admits
dex.github.orgs=null abort ✅

🤖 Generated with Claude Code

https://claude.ai/code/session_01TrpnuCduw2KivngVRQmnG7

dex.github.orgs was optional:

    {{- if .Values.dex.github.orgs }}
    orgs:
    ...
    {{- end }}
    loadAllGroups: true

but it is the only thing restricting who Dex admits. Dex v2.44.0 decides group
handling in connector/github/github.go getGroups:

    switch {
    case len(c.orgs) > 0:               return c.groupsForOrgs(...)
    case c.org != "":                   return c.teamsForOrg(...)
    case groupScope && c.loadAllGroups: return c.userGroups(...)
    }

groupsForOrgs is the only branch that can deny anyone - it is the sole source of
"github: user %q not in required orgs or teams" (github.go:375). With the list
empty that branch is unreachable and, because this chart sets loadAllGroups,
control lands on userGroups, which returns the caller's groups and cannot return
an error. Every GitHub account then authenticates.

Nothing downstream catches it: oauth2-proxy runs email_domains=["*"] with no
allowed_groups, so anyone Dex admits reaches every host behind the edge -
including the five that have no auth of their own (goldilocks, cluster-info, and
the three Traefik dashboards, whose /api/rawdata lists every router, service and
TLS config). OpenBao stays gated by the roles' bound_claims, and ArgoCD by its
rendered RBAC policy, so this is the edge rather than all three - but the cluster
would report Synced/Healthy throughout, with nothing saying it was open.

The default in values.yaml carries two orgs, so this is a footgun rather than a
live hole: it needs a tenant to override the list to empty or null. Rendering now
fails instead.

`fail` guarded by `not`, rather than `required`: Helm's `required` rejects only
nil and the empty string, so an empty list passes straight through it
(`required "x" .Values.orgs` with `orgs: []` renders `a: []`). `not` is falsy for
nil, empty list and empty string alike.

Verified: default values render unchanged; a single-org list renders; `orgs: []`
and `orgs: null` both abort with the message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrpnuCduw2KivngVRQmnG7
@venkatamutyala
venkatamutyala force-pushed the fix/require-dex-github-orgs branch from badb4a0 to 6a9d02a Compare September 5, 2026 07:46
@venkatamutyala
venkatamutyala merged commit b23ef05 into feat/cli-sso-through-oauth2-proxy Sep 5, 2026
2 checks passed
@venkatamutyala
venkatamutyala deleted the fix/require-dex-github-orgs branch September 5, 2026 07:47
venkatamutyala added a commit that referenced this pull request Sep 5, 2026
…1485)

* feat: let the argocd and bao CLIs authenticate through oauth2-proxy

Neither CLI can reach its backend on a GlueOps cluster today. Every request is
answered with a login redirect or a bare 401:

    ArgoCD  /                               -> 302  oauth2/start
    ArgoCD  /api/v1/session          (POST) -> 401
    ArgoCD  /version.VersionService/Version -> 302  oauth2/start
    Vault   /v1/sys/seal-status             -> 302  oauth2/start

The backends are healthy - `argocd version --grpc-web` returns the server version
over a port-forward and only the client version through the ingress. Three
independent things are in the way.

1. Dex had no client an operator could mint a token from. Add "toolbox", public
   so no client secret reaches developer machines. It is used with the device
   flow, which needs no redirect URI, so nothing is registered per developer and
   it works over SSH, in a container, or when the browser is on another machine.
   Loopback callbacks are deliberately NOT added to the argocd and vault clients:
   a localhost redirect only works when browser and CLI share a host, which is
   the case this is meant to solve.

2. oauth2-proxy only accepted a session cookie, so a CLI holding a valid Dex
   id_token was still redirected. skip_jwt_bearer_tokens plus oidc_extra_audiences
   lets that token satisfy the edge. Traefik already forwards the caller's
   Authorization header to the auth server, so nothing else is needed.

3. forwardauth stripped the caller's own bearer token. oauth2-forwardauth lists
   "authorization" in authResponseHeaders, and Traefik applies that list as an
   unconditional req.Header.Del(name) followed by a re-add only when the auth
   server returned the header - which oauth2-proxy never does, since
   set_authorization_header is off. The entry can therefore only ever strip the
   token before the backend sees it, which is fatal for a backend that
   authenticates the caller itself. Add oauth2-forwardauth-bearer without that
   entry, plus oauth2-api (401 rather than a redirect, for pure API/gRPC routes)
   and oauth2-with-redirect-bearer (for a host serving a UI and an API on the
   same routes, as ArgoCD does with gRPC-web at root paths).

Nothing is exempted from authentication: every request still passes the same
forwardauth, it just gains a second way to prove identity. Nor does this widen
who gets in - email_domains=["*"] with no allowed_groups already authorizes
anyone Dex admits, and Dex only admits members of the configured GitHub orgs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM

* feat(vault): route OpenBao's API without the errors-redirect plugin

The vault Ingress uses oauth2-with-redirect, whose errors-redirect plugin
rewrites any 401-403 response into a 302 to the login page. That is right for the
browser UI and wrong for an API: OpenBao answers "missing client token" and
"permission denied" with 403, and the plugin turns both into a login redirect. A
CLI then receives an HTML page where it expected JSON, and cannot distinguish an
expired edge token from simply not having logged in to OpenBao - which sends
anyone debugging it in entirely the wrong direction.

Add a second Ingress for /v1 using oauth2-no-redirect, which authenticates
identically - same forwardauth, nothing exempted - but returns status codes
instead of redirects. It also keeps stripping the caller's Authorization header,
which OpenBao does not need: it reads X-Vault-Token, and that takes precedence
over a bearer anyway.

The explicit priority is required rather than decorative: Traefik derives a router
priority from rule length when none is set, and the chart's "/" rule outranks a
lower explicit value.

Note this goes in the existing extraObjects list rather than a new key. A second
extraObjects: silently wins over the first in YAML, which drops every object
already there - including the vault-init-controller Application.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM

* fix: accept only the toolbox audience at the edge

oidc_extra_audiences also listed argocd and vault, but no flow presents a token
for either audience at the edge: the argocd CLI sends the toolbox token as
ARGOCD_AUTH_TOKEN and bao sends the same one.

Listing them was not free. With skip_jwt_bearer_tokens the edge accepts any
validly-signed token whose aud is in the set, so every ArgoCD browser-SSO
id_token and every Vault OIDC login token -- credentials that today unlock
exactly one service -- also opened the entire edge for every host behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM

* fix: fail the render when dex.github.orgs is empty (#1488)

dex.github.orgs was optional:

    {{- if .Values.dex.github.orgs }}
    orgs:
    ...
    {{- end }}
    loadAllGroups: true

but it is the only thing restricting who Dex admits. Dex v2.44.0 decides group
handling in connector/github/github.go getGroups:

    switch {
    case len(c.orgs) > 0:               return c.groupsForOrgs(...)
    case c.org != "":                   return c.teamsForOrg(...)
    case groupScope && c.loadAllGroups: return c.userGroups(...)
    }

groupsForOrgs is the only branch that can deny anyone - it is the sole source of
"github: user %q not in required orgs or teams" (github.go:375). With the list
empty that branch is unreachable and, because this chart sets loadAllGroups,
control lands on userGroups, which returns the caller's groups and cannot return
an error. Every GitHub account then authenticates.

Nothing downstream catches it: oauth2-proxy runs email_domains=["*"] with no
allowed_groups, so anyone Dex admits reaches every host behind the edge -
including the five that have no auth of their own (goldilocks, cluster-info, and
the three Traefik dashboards, whose /api/rawdata lists every router, service and
TLS config). OpenBao stays gated by the roles' bound_claims, and ArgoCD by its
rendered RBAC policy, so this is the edge rather than all three - but the cluster
would report Synced/Healthy throughout, with nothing saying it was open.

The default in values.yaml carries two orgs, so this is a footgun rather than a
live hole: it needs a tenant to override the list to empty or null. Rendering now
fails instead.

`fail` guarded by `not`, rather than `required`: Helm's `required` rejects only
nil and the empty string, so an empty list passes straight through it
(`required "x" .Values.orgs` with `orgs: []` renders `a: []`). `not` is falsy for
nil, empty list and empty string alike.

Verified: default values render unchanged; a single-org list renders; `orgs: []`
and `orgs: null` both abort with the message.


Claude-Session: https://claude.ai/code/session_01TrpnuCduw2KivngVRQmnG7

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: drop SkipDryRunOnMissingResource from the oauth2 middlewares (#1489)

The traefik.io CRDs are no longer rendered by this Application - they come from
the platform-crds layer-0 bundle, applied by captain_utils before the platform
chart is installed, as the comment at the top of this file already says. So there
is no window in which the Middleware type is unregistered when this syncs, and
nothing for the option to skip a dry run over. Removed from all eight
middlewares.

Two comments went with it, one of which was never accurate:

  # ensure it happens after CRDs are registered
  argocd.argoproj.io/sync-wave: "1"

A sync-wave orders resources within this Application's own sync; it cannot wait
on CRDs applied out-of-band by captain_utils. Whatever protection existed came
from SkipDryRunOnMissingResource, not from the wave. What the wave actually does
is keep the four leaf middlewares ahead of the four chains that reference them,
so the comment now says that.

The sync-waves themselves stay. Traefik registers no admission webhook for these
CRs, so Kubernetes will happily accept a chain naming a middleware that does not
exist - and Traefik then drops every router using that chain, which is a 404 on
the affected host until it resolves. Wave 2 on the chains is what keeps that
window closed on a first install.

Rendered output differs by exactly the twelve lines removed or reworded; all
eight Middlewares still render and both waves are intact.


Claude-Session: https://claude.ai/code/session_01TrpnuCduw2KivngVRQmnG7

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant