Skip to content

Feat(operator): implement SPIFFE JWT-SVID authentication for client registration#473

Merged
Alan-Cha merged 5 commits into
mainfrom
feat/spiffe-auth
Jul 7, 2026
Merged

Feat(operator): implement SPIFFE JWT-SVID authentication for client registration#473
Alan-Cha merged 5 commits into
mainfrom
feat/spiffe-auth

Conversation

@Alan-Cha

@Alan-Cha Alan-Cha commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

Implements #1421 — eliminates admin credentials from operator client registration
by using the operator's SPIFFE identity (JWT-SVID) to authenticate with the
Keycloak Admin API.

Problem

The operator currently uses admin credentials to register OAuth clients. This means:

  • Admin credentials stored as secrets, requiring manual rotation
  • Operator compromise = full realm admin access
  • Long-lived credentials in the cluster

Solution

The operator authenticates with its SPIFFE JWT-SVID via Keycloak's federated-jwt
client authenticator, using only the scoped manage-clients role.

Architecture

Operator Pod
├─> spiffe-helper sidecar (standalone container)
│   └─> writes JWT-SVID to /opt/jwt_svid.token (auto-rotates)
├─> Manager reads JWT-SVID from file
└─> POST /realms/kagenti/protocol/openid-connect/token
    client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-spiffe
    → access token with manage-clients role
    → same Admin API endpoints as before

Changes

Go (internal/keycloak/admin.go)

New JWTSVIDGrantToken() method authenticates the operator using JWT-SVID.

Go (internal/controller/clientregistration_controller.go)

Dual authentication path:

  • UseSpiffeAuth=true: reads JWT-SVID from file, calls JWTSVIDGrantToken()
  • UseSpiffeAuth=false: admin credentials (legacy, default)

Security: path traversal protection (filepath.Clean + whitelist), JWT-SVID
never logged, all failures surface as Kubernetes Events.

Go (cmd/main.go)

Registers --use-spiffe-auth, --jwt-svid-path, --operator-client-id flags.

Helm (charts/kagenti-operator/)

  • configmap-spiffe-helper.yaml (new): configures spiffe-helper to write
    JWT-SVID to /opt/jwt_svid.token. Set spiffe.operatorAuth.jwtAudience
    explicitly — the fallback uses keycloak.publicUrl which defaults to empty.
  • manager.yaml: conditionally adds spiffe-helper sidecar (UID 65532, matching
    the manager so the 600-mode file is readable), jwt-svid emptyDir volume, SPIFFE
    flags — all gated on spiffe.operatorAuth.enabled.
  • values.yaml: new spiffe: block, both enabled and operatorAuth.enabled
    default to false.

Security improvements

Before After
Admin credentials (full realm) JWT-SVID (cryptographic, short-lived)
manage-* roles manage-clients only
Manual rotation Auto-rotates (spiffe-helper)
Secrets in namespace No secrets

E2E testing

Complete — multiple fresh-cluster runs, all 9 steps passed.
See https://gist.github.com/Alan-Cha/527be60c79b40aedd54fe7be586c3452

Related

Checklist

  • All commits signed-off (DCO)
  • Feature gated (default: disabled)
  • Backward compatible — UseSpiffeAuth=false uses admin credentials

Assisted-By: Claude Code (Anthropic AI) noreply@anthropic.com

Alan-Cha added 2 commits July 2, 2026 15:30
…egistration

Eliminates admin credentials from operator client registration by using
the operator's SPIFFE identity (JWT-SVID) to authenticate with the
Keycloak Admin API.

Changes:
- internal/keycloak/admin.go: new JWTSVIDGrantToken() method that
  authenticates using client_assertion_type jwt-spiffe and returns an
  access token with manage-clients role
- internal/controller/clientregistration_controller.go: dual auth path —
  UseSpiffeAuth=true reads JWT-SVID from file and calls JWTSVIDGrantToken;
  UseSpiffeAuth=false falls back to admin credentials. Includes path
  traversal protection, token exposure prevention, and Kubernetes Event
  recording for all failure modes.
- cmd/main.go: registers --use-spiffe-auth, --jwt-svid-path,
  --operator-client-id flags and wires them into the reconciler

Feature defaults to disabled (UseSpiffeAuth=false) for backward
compatibility.

Signed-off-by: Alan Cha <alan.cha@ibm.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Alan Cha <Alan.cha1@ibm.com>
…ation

Adds the operator-side helm chart support for SPIFFE authentication:

- configmap-spiffe-helper.yaml (new): ConfigMap that configures
  spiffe-helper to write the operator's JWT-SVID to /opt/jwt_svid.token.
  jwt_audience defaults to spiffe.operatorAuth.jwtAudience (must be set
  explicitly; the fallback uses keycloak.publicUrl which defaults to empty).
- manager.yaml: conditionally adds spiffe-helper sidecar container (runs
  as UID 65532 matching the manager, so the 600-mode JWT-SVID file is
  readable), jwt-svid emptyDir volume, spiffe-workload-api CSI volume,
  and --use-spiffe-auth/--jwt-svid-path/--operator-client-id flags when
  spiffe.operatorAuth.enabled=true.
- values.yaml: adds spiffe: block with spiffe.enabled and
  spiffe.operatorAuth.enabled both defaulting to false.

Signed-off-by: Alan Cha <alan.cha@ibm.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Alan Cha <Alan.cha1@ibm.com>
The Recorder field is typed as record.EventRecorder (old events API).
GetEventRecorderFor returns the old API type and is the correct call here.
Suppress the SA1019 staticcheck warning until the field type is migrated
to the new events.EventRecorder API.

Signed-off-by: Alan Cha <alan.cha@ibm.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Alan Cha <Alan.cha1@ibm.com>
@Alan-Cha Alan-Cha changed the title feat(operator): implement SPIFFE JWT-SVID authentication for client registration Feat(operator): implement SPIFFE JWT-SVID authentication for client registration Jul 6, 2026
Signed-off-by: Alan Cha <Alan.cha1@ibm.com>

@esnible esnible left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Clean, well-scoped implementation of JWT-SVID operator auth. The dual-auth split is correct: both paths converge on RegisterOrFetchClientWithToken with identical params, the SPIFFE path is fully feature-gated (spiffe.enabled + spiffe.operatorAuth.enabled, both default false), and legacy admin-credential auth is untouched as the default.

Verified against the branch:

  • All reused helpers (trimBaseURL, truncate, httpc, adminTokenResponse) and imports already exist in admin.go.
  • corev1 is imported and owner is in scope for all Recorder.Event calls.
  • The spiffe-workload-api CSI volume guard is correctly widened to or verifiedFetch.enabled (spiffe operatorAuth), so the sidecar's mount is satisfied.
  • Path-traversal protection (filepath.Clean + /opt/, /var/run/secrets/ prefix whitelist) is sound against .. sequences.
  • Error classification is appropriate: permanent failure on bad config path, transient requeue on read/auth failure. JWT-SVID is never logged.

All 16 CI checks green (including E2E), all commits signed-off. Two non-blocking suggestions inline.

Assisted-By: Claude Code

Comment thread charts/kagenti-operator/templates/manager/manager.yaml Outdated
Comment thread charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml Outdated
- spiffe-helper was hardcoded to :latest. Added
  spiffe.operatorAuth.spiffeHelper.image.{repository,tag,pullPolicy}
  values defaulting to ghcr.io/kagenti/kagenti-extensions/spiffe-helper:v0.6.0-alpha.4,
  matching the latest kagenti-extensions release and following the same
  repository/tag pattern as every other image in this chart.

- jwt_audience fallback (printf keycloak.publicUrl/realms/realm) produced
  /realms/kagenti when keycloak.publicUrl was unset (default: empty string),
  causing a silent runtime auth failure. Replaced with required so the
  misconfiguration surfaces at helm install instead.

Signed-off-by: Alan Cha <alan.cha@ibm.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Alan Cha <Alan.cha1@ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants