mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
9c1d446e40
The pre-G-1 config validator accepted CERTCTL_AUTH_TYPE=jwt and the
startup log faithfully echoed 'authentication enabled type=jwt'.
Reasonable people read that and concluded JWT auth was on. It wasn't.
The auth-middleware wiring at cmd/server/main.go unconditionally routed
every request through the api-key bearer middleware regardless of
cfg.Auth.Type. So CERTCTL_AUTH_TYPE=jwt quietly compared the incoming
'Authorization: Bearer <token>' against whatever string the operator put
in CERTCTL_AUTH_SECRET — real JWT clients got 401, and operators who
treated CERTCTL_AUTH_SECRET as a *signing* secret (because they thought
they were configuring JWT) had effectively handed an attacker an api-key.
A security finding masquerading as a config option.
We chose the audit-recommended structural fix: remove the option, fail
fast at startup, and add the gateway-fronting pattern as the documented
forward path. Implementing JWT middleware would have meant jwks vs
static-secret rotation, claim mapping, expiry enforcement, audience and
issuer validation, key rollover semantics, and regression coverage at the
same depth as the existing api-key path — a feature, not a fix. Operators
who genuinely need JWT/OIDC front certctl with an authenticating gateway
(oauth2-proxy / Envoy ext_authz / Traefik ForwardAuth / Pomerium /
Authelia) and run the upstream certctl with CERTCTL_AUTH_TYPE=none. Same
shape works on docker-compose and Helm.
The change is comprehensive across 7 phases — every surface that
mentioned 'jwt' as a certctl-auth-type is updated, plus structural
backstops (typed enum, runtime guard, helm template validation, CI grep
guard) so the lie can't reappear.
Files changed:
Phase 1 — production code (typed enum + jwt removal):
- internal/config/config.go: AuthType typed alias + AuthTypeAPIKey /
AuthTypeNone constants + ValidAuthTypes() helper. Validate() routes
literal 'jwt' through a dedicated multi-line diagnostic naming the
authenticating-gateway pattern, then cross-checks against
ValidAuthTypes(). Secret-required branch simplified to api-key-only.
Field comment on AuthConfig.Type rewritten to drop jwt and point at
the gateway pattern.
- internal/api/middleware/middleware.go: AuthConfig.Type field comment
references the typed config.AuthType constants.
- internal/api/handler/health.go: same treatment for HealthHandler.AuthType.
- cmd/server/main.go: defense-in-depth runtime switch immediately after
config.Load() — exits 1 on any unsupported auth-type that bypassed the
validator. Auth-disabled startup log explicitly names the
authenticating-gateway pattern.
Phase 2 — tests (Red→Green, contract pinning):
- internal/config/config_test.go: TestValidate_JWTAuth_RejectedDedicated
(two table rows pinning the dedicated G-1 error fires regardless of
whether Secret is set), TestValidAuthTypesDoesNotContainJWT (property
guard against future re-introduction),
TestValidAuthTypesIsExactly_APIKey_None (allowed-set contract),
TestValidate_GenericInvalidAuthType (pins non-jwt invalid values still
hit the generic invalid-auth-type error). Removed the prior
TestValidate_JWTAuth_MissingSecret happy-path since its premise is
inverted post-G-1.
- internal/api/handler/health_test.go: removed
TestAuthInfo_ReturnsAuthType_JWT (which baked the silent-downgrade lie
into the regression suite). Pre-existing _APIKey test continues to
cover the api-key happy path.
Phase 3 — spec, docs, env templates:
- api/openapi.yaml: auth_type enum dropped to [api-key, none] with
inline comment naming the G-1 closure.
- .env.example (root): CERTCTL_AUTH_TYPE comment block rewritten to drop
jwt and point at the gateway pattern; secret-required conditional
simplified to api-key-only.
- docs/architecture.md: middleware-stack bullet rewritten to drop the
JWT mention; new H3 'Authenticating-gateway pattern (JWT, OIDC, mTLS)'
section explaining the design rationale and listing oauth2-proxy /
Envoy ext_authz / Traefik ForwardAuth / Pomerium / Authelia / Caddy
forward_auth / Apache mod_auth_openidc / nginx auth_request as the
standard fronting options.
- docs/upgrade-to-v2-jwt-removal.md (new ~125 lines): migration guide
with preconditions, what-changes, both recovery paths, complete
docker-compose oauth2-proxy walkthrough, Traefik ForwardAuth and Envoy
ext_authz patterns, rollback posture.
Phase 4 — Helm chart (template validation + docs):
- deploy/helm/certctl/templates/_helpers.tpl: new certctl.validateAuthType
helper mirroring the existing certctl.tls.required pattern. Fails
template render on any server.auth.type outside {api-key, none} with
a multi-line diagnostic.
- deploy/helm/certctl/templates/server-deployment.yaml,
server-configmap.yaml, server-secret.yaml: invoke the helper at the
top of each template that depends on .Values.server.auth.type.
- deploy/helm/certctl/values.yaml: auth: block comment expanded with the
G-1 rationale and gateway-pattern cross-reference.
- deploy/helm/CHART_SUMMARY.md: server.auth.type table row now surfaces
the allowed set and points at the upgrade doc.
- deploy/helm/certctl/README.md: new 'JWT / OIDC via authenticating
gateway' section with a Kubernetes-flavored oauth2-proxy + certctl
walkthrough.
Phase 5 — release surface:
- CHANGELOG.md: new [unreleased] top entry with Breaking / Removed /
Added / Changed sections; explicit pointer at
docs/upgrade-to-v2-jwt-removal.md from the Breaking subsection.
Phase 6 — CI guardrail:
- .github/workflows/ci.yml: new 'Forbidden auth-type literal regression
guard (G-1)' step. Scoped patterns catch the actual regression shapes
(map literal, slice literal, switch case, OpenAPI enum, env-file
default, AuthType('jwt') cast). Comments and the dedicated rejection
branch are intentionally exempt; connector-package JWT references
(Google OAuth2 / step-ca) are exempt as out-of-scope external
protocols. Verified locally: the guard passes on the actual tree and
fires on all 4 synthetic regression patterns.
Out of scope (explicitly untouched):
- internal/connector/discovery/gcpsm/gcpsm.go — Google OAuth2 service-
account JWT (external protocol).
- internal/connector/issuer/googlecas/googlecas.go — same.
- internal/connector/issuer/stepca/stepca.go — step-ca's provisioner
one-time-token JWT for /sign API.
- docs/test-env.md, docs/connectors.md, docs/features.md — describe
external CAs' use of JWT, not certctl's auth shape.
- Implementing actual JWT middleware. Feature, not a fix.
Verification (all gates pass):
- go build ./... — clean
- go vet ./... — clean
- go test -short ./... — every package green
- go test -short -race ./internal/config/... ./internal/api/... — clean
- govulncheck ./... — no vulnerabilities in our code
- helm lint deploy/helm/certctl/ — clean
- helm template with auth.type=api-key — renders OK
- helm template with auth.type=none — renders OK
- helm template with auth.type=jwt — fails with validateAuthType
diagnostic (exit 1)
- python3 yaml.safe_load on api/openapi.yaml — parses
- CI guardrail mirror — clean on real tree, fires on all 4 synthetic
regression patterns
- Smoke test: 'CERTCTL_AUTH_TYPE=jwt ./certctl-server' exits non-zero
with: 'Failed to load configuration: CERTCTL_AUTH_TYPE=jwt is no
longer accepted (G-1 silent auth downgrade): no JWT middleware ships
with certctl. To use JWT/OIDC, run an authenticating gateway
(oauth2-proxy / Envoy ext_authz / Traefik ForwardAuth / Pomerium) in
front of certctl and set CERTCTL_AUTH_TYPE=none on the upstream.
See docs/architecture.md "Authenticating-gateway pattern" and
docs/upgrade-to-v2-jwt-removal.md for the migration walkthrough'
config pkg coverage: ValidAuthTypes 100%, Validate 94.7%, total 75.5%.
Refs: coverage-gap-audit-2026-04-24-v5/unified-audit.md
§2 P1 cluster, cat-g-jwt_silent_auth_downgrade
Audit recommendation followed verbatim: 'Remove jwt from
validAuthTypes until middleware ships'.
195 lines
7.1 KiB
Smarty
195 lines
7.1 KiB
Smarty
{{/*
|
|
Expand the name of the chart.
|
|
*/}}
|
|
{{- define "certctl.name" -}}
|
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
|
{{- end }}
|
|
|
|
{{/*
|
|
Create a default fully qualified app name.
|
|
*/}}
|
|
{{- define "certctl.fullname" -}}
|
|
{{- if .Values.fullnameOverride }}
|
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
|
{{- else }}
|
|
{{- $name := default .Chart.Name .Values.nameOverride }}
|
|
{{- if contains $name .Release.Name }}
|
|
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
|
{{- else }}
|
|
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
|
{{- end }}
|
|
{{- end }}
|
|
{{- end }}
|
|
|
|
{{/*
|
|
Create chart name and version as used by the chart label.
|
|
*/}}
|
|
{{- define "certctl.chart" -}}
|
|
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
|
{{- end }}
|
|
|
|
{{/*
|
|
Common labels
|
|
*/}}
|
|
{{- define "certctl.labels" -}}
|
|
helm.sh/chart: {{ include "certctl.chart" . }}
|
|
{{ include "certctl.selectorLabels" . }}
|
|
{{- if .Chart.AppVersion }}
|
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
|
{{- end }}
|
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
{{- with .Values.commonLabels }}
|
|
{{ toYaml . }}
|
|
{{- end }}
|
|
{{- end }}
|
|
|
|
{{/*
|
|
Selector labels for the main service (server, agent, postgres)
|
|
*/}}
|
|
{{- define "certctl.selectorLabels" -}}
|
|
app.kubernetes.io/name: {{ include "certctl.name" . }}
|
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
{{- end }}
|
|
|
|
{{/*
|
|
Server selector labels
|
|
*/}}
|
|
{{- define "certctl.serverSelectorLabels" -}}
|
|
{{ include "certctl.selectorLabels" . }}
|
|
app.kubernetes.io/component: server
|
|
{{- end }}
|
|
|
|
{{/*
|
|
Agent selector labels
|
|
*/}}
|
|
{{- define "certctl.agentSelectorLabels" -}}
|
|
{{ include "certctl.selectorLabels" . }}
|
|
app.kubernetes.io/component: agent
|
|
{{- end }}
|
|
|
|
{{/*
|
|
PostgreSQL selector labels
|
|
*/}}
|
|
{{- define "certctl.postgresSelectorLabels" -}}
|
|
{{ include "certctl.selectorLabels" . }}
|
|
app.kubernetes.io/component: postgres
|
|
{{- end }}
|
|
|
|
{{/*
|
|
Service account name
|
|
*/}}
|
|
{{- define "certctl.serviceAccountName" -}}
|
|
{{- if .Values.serviceAccount.create }}
|
|
{{- default (include "certctl.fullname" .) .Values.serviceAccount.name }}
|
|
{{- else }}
|
|
{{- default "default" .Values.serviceAccount.name }}
|
|
{{- end }}
|
|
{{- end }}
|
|
|
|
{{/*
|
|
Server image
|
|
*/}}
|
|
{{- define "certctl.serverImage" -}}
|
|
{{- $image := .Values.server.image }}
|
|
{{- printf "%s:%s" $image.repository (coalesce $image.tag .Chart.AppVersion) }}
|
|
{{- end }}
|
|
|
|
{{/*
|
|
Agent image
|
|
*/}}
|
|
{{- define "certctl.agentImage" -}}
|
|
{{- $image := .Values.agent.image }}
|
|
{{- printf "%s:%s" $image.repository (coalesce $image.tag .Chart.AppVersion) }}
|
|
{{- end }}
|
|
|
|
{{/*
|
|
PostgreSQL image
|
|
*/}}
|
|
{{- define "certctl.postgresImage" -}}
|
|
{{- $image := .Values.postgresql.image }}
|
|
{{- printf "%s:%s" $image.repository $image.tag }}
|
|
{{- end }}
|
|
|
|
{{/*
|
|
Database connection string
|
|
*/}}
|
|
{{- define "certctl.databaseURL" -}}
|
|
postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ include "certctl.fullname" . }}-postgres:5432/{{ .Values.postgresql.auth.database }}?sslmode=disable
|
|
{{- end }}
|
|
|
|
{{/*
|
|
Server URL (for agents). HTTPS-only as of v2.2 — see docs/tls.md.
|
|
*/}}
|
|
{{- define "certctl.serverURL" -}}
|
|
https://{{ include "certctl.fullname" . }}-server:{{ .Values.server.service.port }}
|
|
{{- end }}
|
|
|
|
{{/*
|
|
TLS Secret name resolver.
|
|
|
|
Operator-facing precedence:
|
|
1. server.tls.existingSecret — operator points at a pre-existing kubernetes.io/tls Secret
|
|
2. server.tls.certManager.secretName — explicit secret name for the cert-manager Certificate CR
|
|
3. "<fullname>-tls" — default when cert-manager is enabled but secretName is blank
|
|
|
|
Never emits an empty string — that case is already excluded by certctl.tls.required below,
|
|
which must be invoked by any template that depends on the resolved secret name.
|
|
*/}}
|
|
{{- define "certctl.tls.secretName" -}}
|
|
{{- if .Values.server.tls.existingSecret -}}
|
|
{{- .Values.server.tls.existingSecret -}}
|
|
{{- else if .Values.server.tls.certManager.secretName -}}
|
|
{{- .Values.server.tls.certManager.secretName -}}
|
|
{{- else -}}
|
|
{{- printf "%s-tls" (include "certctl.fullname" .) -}}
|
|
{{- end -}}
|
|
{{- end }}
|
|
|
|
{{/*
|
|
TLS configuration gate.
|
|
|
|
HTTPS is the only supported listener mode (v2.2+). The server refuses to start
|
|
without a cert/key pair mounted at server.tls.mountPath, so `helm template` /
|
|
`helm install` must fail loudly at render-time rather than shipping a broken
|
|
Deployment that crash-loops with "tls config required".
|
|
|
|
Operators MUST configure EXACTLY ONE of:
|
|
(a) server.tls.existingSecret: <name-of-kubernetes.io/tls-secret>
|
|
(b) server.tls.certManager.enabled: true (+ issuerRef.name populated)
|
|
|
|
Any template that mounts the TLS Secret must call
|
|
`{{ include "certctl.tls.required" . }}` at the top so this guard runs once
|
|
per affected resource. No-op when configured correctly.
|
|
*/}}
|
|
{{- define "certctl.tls.required" -}}
|
|
{{- if and (not .Values.server.tls.existingSecret) (not .Values.server.tls.certManager.enabled) -}}
|
|
{{- fail "\n\ncertctl refuses to start without TLS.\n\nSet EXACTLY ONE of:\n --set server.tls.existingSecret=<your-kubernetes.io/tls-secret-name>\nOR\n --set server.tls.certManager.enabled=true \\\n --set server.tls.certManager.issuerRef.name=<your-issuer-or-clusterissuer>\n\nSee docs/tls.md for the full setup walkthrough, including bootstrap\nguidance for air-gapped clusters without cert-manager.\n" -}}
|
|
{{- end -}}
|
|
{{- if and .Values.server.tls.certManager.enabled (not .Values.server.tls.certManager.issuerRef.name) -}}
|
|
{{- fail "\n\nserver.tls.certManager.enabled=true but server.tls.certManager.issuerRef.name is empty.\n\nSet:\n --set server.tls.certManager.issuerRef.name=<your-issuer-or-clusterissuer>\n\nSee docs/tls.md.\n" -}}
|
|
{{- end -}}
|
|
{{- end }}
|
|
|
|
{{/*
|
|
Auth-type validation gate.
|
|
|
|
G-1 (P1): pre-G-1 the chart accepted server.auth.type=jwt and the
|
|
certctl-server container silently routed every request through the
|
|
api-key bearer middleware (no JWT impl ships with certctl). Post-G-1
|
|
the chart fails at template-time with a pointer at the authenticating-
|
|
gateway pattern. The valid set must stay in sync with
|
|
internal/config.ValidAuthTypes() in the Go binary; if you add a value
|
|
there you must add it here too (and update the property test in
|
|
internal/config/config_test.go that pins both surfaces).
|
|
|
|
Any template that consumes .Values.server.auth.type should call
|
|
`{{ include "certctl.validateAuthType" . }}` at the top so this guard
|
|
runs once per affected resource. No-op when configured correctly.
|
|
*/}}
|
|
{{- define "certctl.validateAuthType" -}}
|
|
{{- $valid := list "api-key" "none" -}}
|
|
{{- if not (has .Values.server.auth.type $valid) -}}
|
|
{{- fail (printf "\n\nserver.auth.type=%q is not supported (valid: %v).\n\nFor JWT/OIDC, run an authenticating gateway in front of certctl\n(oauth2-proxy / Envoy ext_authz / Traefik ForwardAuth / Pomerium) and\nset server.auth.type=none here so the gateway terminates federated\nidentity. See docs/architecture.md \"Authenticating-gateway pattern\"\nand docs/upgrade-to-v2-jwt-removal.md for the migration walkthrough.\n\nG-1 audit closure: pre-G-1 the chart accepted type=jwt and the binary\nsilently downgraded to api-key middleware. The chart now fails at\ntemplate time so misconfigured deployments cannot ship.\n" .Values.server.auth.type $valid) -}}
|
|
{{- end -}}
|
|
{{- end }}
|