Skip to content
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ Tagged releases publish a container image to `ghcr.io/cozystack/keycloak-kms-pro
| `KKP_UPSTREAM_USER` / `KKP_UPSTREAM_PASSWORD` | credential the proxy verifies on the upstream (Keycloak) SCRAM leg |
| `KKP_BACKEND_USER` / `KKP_BACKEND_PASSWORD` | credential the proxy uses on the downstream (CNPG) SCRAM leg |
| `KKP_KEK` | base64 32-byte KEK for the static KMS (mutually exclusive with the Vault settings below) |
| `KKP_VAULT_ADDR` / `KKP_VAULT_TOKEN` / `KKP_VAULT_KEY_NAME` / `KKP_VAULT_MOUNT` | Vault Transit KMS settings |
| `KKP_VAULT_ADDR` / `KKP_VAULT_KEY_NAME` / `KKP_VAULT_MOUNT` | Vault Transit KMS settings |
| `KKP_VAULT_KUBERNETES_ROLE` / `KKP_VAULT_KUBERNETES_MOUNT` / `KKP_VAULT_KUBERNETES_JWT_FILE` | Vault Kubernetes auth (**recommended**): the proxy logs in with its projected ServiceAccount token (mount defaults to `kubernetes`, JWT file to the standard in-pod path) |
| `KKP_VAULT_ROLE_ID` / `KKP_VAULT_SECRET_ID` / `KKP_VAULT_APPROLE_MOUNT` | Vault AppRole auth: the proxy logs in and re-authenticates on demand (mount defaults to `approle`) |
| `KKP_VAULT_TOKEN` | static Vault token auth (mutually exclusive with the AppRole / Kubernetes options) |
| `KKP_DEKSET_FILE` | path to a JSON-encoded wrapped DEK set (lets the proxy and the backfill tool share a key) |
| `KKP_FIELDS` | `disabled`, `email-only`, or empty for the full default set |
| `KKP_LENIENT` | `true` downgrades fail-loud rules to passthrough — required for the Keycloak Liquibase bootstrap window only, must be off in steady state |
Expand All @@ -64,6 +67,12 @@ The operator-facing guides live under [`docs/`](./docs): [operator-guide](./docs

- **Static KMS** (`KKP_KEK`): a 32-byte AES-256 KEK provided in-cluster as a `Secret`. Suitable for tests and bootstrap.
- **Vault Transit** (`KKP_VAULT_*`): the KEK lives in Vault. KEK rotation (`vault write -f transit/keys/<key>/rotate`) is transparent to the proxy — the `vault:vN:` version tag on each wrapped DEK lets Vault decrypt across rotations without re-encrypting any column data. Optionally run `vault write transit/rewrap/<key> ciphertext=…` later to bring the stored wrap up to the latest KEK version.
- **Vault auth** — exactly one of, all logging in on demand and re-authenticating as the issued token expires (the proxy touches Vault only at startup and on DEK rotation, so a short-lived token is fine):
- **Kubernetes** (`KKP_VAULT_KUBERNETES_ROLE`; mount via `KKP_VAULT_KUBERNETES_MOUNT`, default `kubernetes`; JWT via `KKP_VAULT_KUBERNETES_JWT_FILE`, default the standard in-pod ServiceAccount token path) — **recommended**. The proxy logs in with its projected ServiceAccount token, re-read from disk each login so the kubelet's rotation is picked up. No long-lived credential is stored.
- **AppRole** (`KKP_VAULT_ROLE_ID` + `KKP_VAULT_SECRET_ID`, mount via `KKP_VAULT_APPROLE_MOUNT`). The `secret_id` must stay reusable — provision the role without `secret_id_num_uses=1` and with a `secret_id_ttl` no shorter than the token TTL, otherwise the on-demand re-login fails once the secret id is consumed or expires.
- **Static token** (`KKP_VAULT_TOKEN`) — simplest; a long-lived token in a Secret.

For all methods give a `token_ttl` comfortably above the 30s renew skew, and point `KKP_VAULT_ADDR` at an HTTPS endpoint — credentials travel on every login.

## Search support

Expand Down
15 changes: 11 additions & 4 deletions cmd/proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,17 @@ func buildCipher(ctx context.Context, cfg *config.ProxyConfig) (*crypto.Cipher,
func buildKMS(cfg *config.ProxyConfig) (kms.KMS, error) {
if cfg.VaultAddr != "" {
return kms.NewVaultKMS(kms.VaultConfig{
Address: cfg.VaultAddr,
Token: cfg.VaultToken,
KeyName: cfg.VaultKeyName,
Mount: cfg.VaultMount,
Address: cfg.VaultAddr,
Token: cfg.VaultToken,
KeyName: cfg.VaultKeyName,
Mount: cfg.VaultMount,
AppRoleMount: cfg.VaultAppRoleMount,
RoleID: cfg.VaultRoleID,
SecretID: cfg.VaultSecretID,

KubernetesRole: cfg.VaultKubernetesRole,
KubernetesMount: cfg.VaultKubernetesMount,
KubernetesJWTFile: cfg.VaultKubernetesJWTFile,
})
}
return kms.NewStaticKMS(cfg.KEK)
Expand Down
29 changes: 24 additions & 5 deletions docs/operator-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,25 @@ Keycloak ── kkp-proxy Service ──┬── replica 1 ──┐
Required for this shape:

- `replicas: 2` on the proxy Deployment.
- `KKP_VAULT_ADDR`, `KKP_VAULT_TOKEN` (or projected ServiceAccount
token), `KKP_VAULT_KEY_NAME`, `KKP_VAULT_MOUNT` — `KKP_KEK` MUST be
unset.
- `KKP_VAULT_ADDR`, `KKP_VAULT_KEY_NAME`, `KKP_VAULT_MOUNT`, and exactly
one Vault auth mode (`KKP_KEK` MUST be unset):
- **Kubernetes** (`KKP_VAULT_KUBERNETES_ROLE`; mount via
`KKP_VAULT_KUBERNETES_MOUNT`, default `kubernetes`; token via
`KKP_VAULT_KUBERNETES_JWT_FILE`, default the in-pod path) —
**recommended**. The proxy logs in with its projected ServiceAccount
token, which the kubelet rotates, so no long-lived credential is
stored. Bind the Vault role to the proxy's ServiceAccount + namespace.
- **AppRole** (`KKP_VAULT_ROLE_ID` + `KKP_VAULT_SECRET_ID`, mount via
`KKP_VAULT_APPROLE_MOUNT`, default `approle`). Provision a reusable
`secret_id` (avoid `secret_id_num_uses=1`, keep `secret_id_ttl` no
shorter than the token TTL) — the proxy reuses it for every on-demand
re-login.
- **Static token** (`KKP_VAULT_TOKEN`) — simplest, a long-lived token in
a Secret.

For the login methods point `KKP_VAULT_ADDR` at HTTPS (credentials
travel on every login) and keep `token_ttl` above the proxy's 30s renew
skew.
- `KKP_BACKEND_CA_FILE` + `KKP_BACKEND_SERVER_NAME` pointing at the
CNPG `*-ca` Secret + the read-write Service DNS.
- `KKP_TLS_CERT_FILE`/`KKP_TLS_KEY_FILE` for the listener side
Expand Down Expand Up @@ -74,7 +90,7 @@ ciphertext.
| `kc-db-ca` (CNPG-managed name varies) | CNPG operator | CA bundle for `KKP_BACKEND_CA_FILE` so the downstream TLS leg is verified |
| `kc-db-app` (CNPG default) | CNPG operator | `KKP_BACKEND_PASSWORD` (the credential the proxy uses to authenticate to CNPG) |
| `keycloak-creds` | operator | Keycloak's own admin password + `KC_DB_PASSWORD` (must match `KKP_UPSTREAM_PASSWORD` because the proxy verifies it on the upstream leg) |
| Vault token | platform | Either a Kubernetes-projected ServiceAccount token (preferred) or a long-lived Vault token in a Secret |
| Vault auth | platform | Kubernetes auth bound to the proxy ServiceAccount (recommended — no stored credential), AppRole `role_id`/`secret_id` in a Secret, or a static Vault token (`KKP_VAULT_TOKEN`) |

## Required network policies

Expand Down Expand Up @@ -143,7 +159,10 @@ The list, with a brief intent for each:
| `KKP_LENIENT` | `true` for bootstrap; **must be `false` in production** |
| `KKP_KEK` | Static KMS — dev only |
| `KKP_DEKSET_FILE` | Path to the wrapped DEK set (required so DEK survives restarts) |
| `KKP_VAULT_ADDR` / `KKP_VAULT_TOKEN` / `KKP_VAULT_KEY_NAME` / `KKP_VAULT_MOUNT` | Vault Transit KMS — production default |
| `KKP_VAULT_ADDR` / `KKP_VAULT_KEY_NAME` / `KKP_VAULT_MOUNT` | Vault Transit KMS — production default |
| `KKP_VAULT_KUBERNETES_ROLE` / `KKP_VAULT_KUBERNETES_MOUNT` / `KKP_VAULT_KUBERNETES_JWT_FILE` | Vault Kubernetes auth (recommended) — login with the pod ServiceAccount token (mount default `kubernetes`) |
| `KKP_VAULT_ROLE_ID` / `KKP_VAULT_SECRET_ID` / `KKP_VAULT_APPROLE_MOUNT` | Vault AppRole auth — the proxy logs in and re-authenticates on demand (mount default `approle`) |
| `KKP_VAULT_TOKEN` | Static Vault token auth (exactly one auth mode) |
| `KKP_TLS_CERT_FILE` / `KKP_TLS_KEY_FILE` | Listener TLS termination |
| `KKP_BACKEND_CA_FILE` / `KKP_BACKEND_SERVER_NAME` | TLS re-origination to CNPG |
| `KKP_HEALTH_ADDR` | `/healthz` + `/readyz` endpoint (k8s probes) |
Expand Down
105 changes: 83 additions & 22 deletions internal/config/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ type ProxyConfig struct {
VaultToken string
VaultKeyName string
VaultMount string
// VaultRoleID/VaultSecretID select AppRole authentication against the
// Vault Transit KMS instead of a static VaultToken. When set, the proxy
// logs in via AppRole and re-authenticates on demand as the token expires.
// VaultAppRoleMount is the AppRole auth mount path; defaults to "approle".
VaultAppRoleMount string
VaultRoleID string
VaultSecretID string
// VaultKubernetesRole selects Vault Kubernetes authentication (preferred in
// production): the proxy logs in with its projected ServiceAccount token,
// which the kubelet rotates, so no long-lived credential is stored.
// VaultKubernetesMount defaults to "kubernetes"; VaultKubernetesJWTFile
// defaults to the standard in-pod ServiceAccount token path.
VaultKubernetesRole string
VaultKubernetesMount string
VaultKubernetesJWTFile string
// TLSCertFile/TLSKeyFile, when both set, make the proxy terminate TLS on
// the Keycloak-facing listener. Both must be set or both
// must be empty.
Expand Down Expand Up @@ -98,6 +113,12 @@ const (
envVaultToken = "KKP_VAULT_TOKEN" //nolint:gosec // env var name, not a credential
envVaultKeyName = "KKP_VAULT_KEY_NAME"
envVaultMount = "KKP_VAULT_MOUNT"
envVaultAppRoleMount = "KKP_VAULT_APPROLE_MOUNT"
envVaultRoleID = "KKP_VAULT_ROLE_ID"
envVaultSecretID = "KKP_VAULT_SECRET_ID" //nolint:gosec // env var name, not a credential
envVaultK8sRole = "KKP_VAULT_KUBERNETES_ROLE"
envVaultK8sMount = "KKP_VAULT_KUBERNETES_MOUNT"
envVaultK8sJWTFile = "KKP_VAULT_KUBERNETES_JWT_FILE" //nolint:gosec // env var name, not a credential
envTLSCertFile = "KKP_TLS_CERT_FILE"
envTLSKeyFile = "KKP_TLS_KEY_FILE"
envMetricsAddr = "KKP_METRICS_ADDR"
Expand Down Expand Up @@ -140,26 +161,32 @@ func LoadProxyConfig() (*ProxyConfig, error) {
return nil, err
}
cfg := &ProxyConfig{
ListenAddr: os.Getenv(envListenAddr),
BackendAddr: os.Getenv(envBackendAddr),
KEK: kek,
UpstreamUser: os.Getenv(envUpstreamUser),
UpstreamPassword: os.Getenv(envUpstreamPassword),
BackendUser: os.Getenv(envBackendUser),
BackendPassword: os.Getenv(envBackendPassword),
Fields: fields,
Lenient: os.Getenv(envLenient) == "true",
DEKSetFile: os.Getenv(envDEKSetFile),
VaultAddr: os.Getenv(envVaultAddr),
VaultToken: os.Getenv(envVaultToken),
VaultKeyName: os.Getenv(envVaultKeyName),
VaultMount: os.Getenv(envVaultMount),
TLSCertFile: os.Getenv(envTLSCertFile),
TLSKeyFile: os.Getenv(envTLSKeyFile),
MetricsAddr: os.Getenv(envMetricsAddr),
HealthAddr: os.Getenv(envHealthAddr),
BackendCAFile: os.Getenv(envBackendCAFile),
BackendServerName: os.Getenv(envBackendServerName),
ListenAddr: os.Getenv(envListenAddr),
BackendAddr: os.Getenv(envBackendAddr),
KEK: kek,
UpstreamUser: os.Getenv(envUpstreamUser),
UpstreamPassword: os.Getenv(envUpstreamPassword),
BackendUser: os.Getenv(envBackendUser),
BackendPassword: os.Getenv(envBackendPassword),
Fields: fields,
Lenient: os.Getenv(envLenient) == "true",
DEKSetFile: os.Getenv(envDEKSetFile),
VaultAddr: os.Getenv(envVaultAddr),
VaultToken: os.Getenv(envVaultToken),
VaultKeyName: os.Getenv(envVaultKeyName),
VaultMount: os.Getenv(envVaultMount),
VaultAppRoleMount: os.Getenv(envVaultAppRoleMount),
VaultRoleID: os.Getenv(envVaultRoleID),
VaultSecretID: os.Getenv(envVaultSecretID),
VaultKubernetesRole: os.Getenv(envVaultK8sRole),
VaultKubernetesMount: os.Getenv(envVaultK8sMount),
VaultKubernetesJWTFile: os.Getenv(envVaultK8sJWTFile),
TLSCertFile: os.Getenv(envTLSCertFile),
TLSKeyFile: os.Getenv(envTLSKeyFile),
MetricsAddr: os.Getenv(envMetricsAddr),
HealthAddr: os.Getenv(envHealthAddr),
BackendCAFile: os.Getenv(envBackendCAFile),
BackendServerName: os.Getenv(envBackendServerName),
}
if v := os.Getenv(envMaxConnections); v != "" {
n, perr := strconv.Atoi(v)
Expand Down Expand Up @@ -226,12 +253,46 @@ func (c *ProxyConfig) validateKMS() error {
return fmt.Errorf("config: one of %s or %s is required", envKEK, envVaultAddr)
case hasStatic && len(c.KEK) != kekBytes:
return fmt.Errorf("config: %s must decode to %d bytes (got %d)", envKEK, kekBytes, len(c.KEK))
case hasVault && (c.VaultToken == "" || c.VaultKeyName == ""):
return fmt.Errorf("config: %s requires %s and %s", envVaultAddr, envVaultToken, envVaultKeyName)
case hasVault:
return c.validateVaultAuth()
}
return nil
}

// validateVaultAuth checks the Vault Transit settings: a KEK name plus exactly
// one auth mode — a static token, an AppRole (both role id and secret id), or
// Kubernetes (a role, logging in with the pod ServiceAccount token).
func (c *ProxyConfig) validateVaultAuth() error {
if c.VaultKeyName == "" {
return fmt.Errorf("config: %s requires %s", envVaultAddr, envVaultKeyName)
}
hasToken := c.VaultToken != ""
hasAppRole := c.VaultRoleID != "" || c.VaultSecretID != ""
hasK8s := c.VaultKubernetesRole != ""
switch {
case boolCount(hasToken, hasAppRole, hasK8s) > 1:
return fmt.Errorf("config: configure exactly one Vault auth mode — %s, AppRole (%s), or Kubernetes (%s)",
envVaultToken, envVaultRoleID, envVaultK8sRole)
case hasAppRole && (c.VaultRoleID == "" || c.VaultSecretID == ""):
return fmt.Errorf("config: AppRole auth requires both %s and %s", envVaultRoleID, envVaultSecretID)
case !hasToken && !hasAppRole && !hasK8s:
return fmt.Errorf("config: %s requires one of %s, AppRole (%s), or Kubernetes (%s)",
envVaultAddr, envVaultToken, envVaultRoleID, envVaultK8sRole)
}
return nil
}

// boolCount counts how many of the given conditions are true.
func boolCount(bs ...bool) int {
n := 0
for _, b := range bs {
if b {
n++
}
}
return n
}

// selectFields builds the FieldSet matching the KKP_FIELDS env value.
func selectFields(value string) (*FieldSet, error) {
switch value {
Expand Down
55 changes: 55 additions & 0 deletions internal/config/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,61 @@ func TestProxyConfigValidateKEKLength(t *testing.T) {
}
}

// vaultProxyConfig returns a valid config using Vault Transit (no static KEK).
func vaultProxyConfig() *ProxyConfig {
c := validProxyConfig()
c.KEK = nil
c.VaultAddr = "http://vault:8200"
c.VaultKeyName = "keycloak-kek"
return c
}

func TestProxyConfigValidateVaultAppRole(t *testing.T) {
t.Parallel()

c := vaultProxyConfig()
c.VaultRoleID = "role-abc"
c.VaultSecretID = "secret-xyz"
if err := c.Validate(); err != nil {
t.Fatalf("Validate rejected a valid AppRole config: %v", err)
}
}

func TestProxyConfigValidateVaultKubernetes(t *testing.T) {
t.Parallel()

c := vaultProxyConfig()
c.VaultKubernetesRole = "keycloak-proxy"
if err := c.Validate(); err != nil {
t.Fatalf("Validate rejected a valid Kubernetes-auth config: %v", err)
}
}

func TestProxyConfigValidateVaultAuthMisconfig(t *testing.T) {
t.Parallel()

cases := map[string]func(*ProxyConfig){
"token and approle": func(c *ProxyConfig) {
c.VaultToken, c.VaultRoleID, c.VaultSecretID = "t", "r", "s"
},
"token and kubernetes": func(c *ProxyConfig) {
c.VaultToken, c.VaultKubernetesRole = "t", "r"
},
"approle and kubernetes": func(c *ProxyConfig) {
c.VaultRoleID, c.VaultSecretID, c.VaultKubernetesRole = "r", "s", "r"
},
"approle missing secret id": func(c *ProxyConfig) { c.VaultRoleID = "r" },
"no auth at all": func(c *ProxyConfig) {},
}
for name, mutate := range cases {
c := vaultProxyConfig()
mutate(c)
if err := c.Validate(); err == nil {
t.Errorf("%s: Validate accepted an invalid Vault auth config", name)
}
}
}

func TestLoadProxyConfigFromEnv(t *testing.T) {
kek := base64.StdEncoding.EncodeToString(make([]byte, kekBytes))
t.Setenv(envListenAddr, ":5432")
Expand Down
Loading
Loading