Skip to content

feat(kms): add Vault Kubernetes and AppRole authentication#1

Merged
Andrei Kvapil (kvaps) merged 8 commits into
cozystack:mainfrom
sircthulhu:feat/vault-approle-auth
Jul 8, 2026
Merged

feat(kms): add Vault Kubernetes and AppRole authentication#1
Andrei Kvapil (kvaps) merged 8 commits into
cozystack:mainfrom
sircthulhu:feat/vault-approle-auth

Conversation

@sircthulhu

@sircthulhu Kirill Ilin (sircthulhu) commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

What this PR does

The Vault Transit KMS previously accepted only a static token (KKP_VAULT_TOKEN). This adds two on-demand login methods so no long-lived token has to sit in a Secret:

  • Kubernetes (KKP_VAULT_KUBERNETES_ROLE / _MOUNT / _JWT_FILE) — recommended. The proxy logs in with its projected ServiceAccount token, re-read from disk on each login so the kubelet's rotation is picked up. No stored credential.
  • AppRole (KKP_VAULT_ROLE_ID / KKP_VAULT_SECRET_ID / KKP_VAULT_APPROLE_MOUNT).

The static token remains supported; config enforces exactly one auth mode. The login strategy is generalized behind a tokenLogin func: mu is never held across the network call (logins serialized via loginMu with a TryLock fast path), and a failed proactive re-login falls back to the still-valid cached token with a warning. The proxy touches Vault only at startup and on DEK rotation, so a short-lived token is sufficient.

Docs (README, operator guide) updated — the operator guide's earlier mention of a Kubernetes-projected ServiceAccount token path is now the real, implemented Kubernetes-auth method.

Tests cover Kubernetes and AppRole round-trips, transparent re-auth on 403, the cached-token fallback, the concurrent path under -race, and config validation for all three modes.

The Vault KMS previously accepted only a static token. Route the
X-Vault-Token through a token source so it can authenticate via AppRole:
it logs in against the configured AppRole mount and re-authenticates on
demand when the cached token nears expiry or Vault rejects a call with
403. A static token stays the default when no AppRole credentials are set.

Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
Add KKP_VAULT_ROLE_ID / KKP_VAULT_SECRET_ID / KKP_VAULT_APPROLE_MOUNT and
validate that Vault auth is exactly one of a static token or an AppRole
(both a role id and a secret id). Pass the settings through to the KMS.

Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
Describe the AppRole option in the README and operator guide, and correct
the guide's stale reference to a Kubernetes-projected ServiceAccount token
path that the proxy does not implement.

Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@sircthulhu, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 399ec8c8-4aa7-495e-ac36-73e6a44df2e4

📥 Commits

Reviewing files that changed from the base of the PR and between 7829546 and fc05cc8.

📒 Files selected for processing (7)
  • README.md
  • cmd/proxy/main.go
  • docs/operator-guide.md
  • internal/config/proxy.go
  • internal/config/proxy_test.go
  • internal/kms/vault.go
  • internal/kms/vault_test.go
📝 Walkthrough

Walkthrough

Adds Vault Transit AppRole authentication alongside static token auth, with config validation, proxy wiring, retryable Vault login handling, and updated operator-facing documentation and tests.

Changes

Vault AppRole Authentication Support

Layer / File(s) Summary
Vault KMS auth strategy and request retry logic
internal/kms/vault.go
Adds AppRole config fields, replaces static token storage with a vaultAuth strategy, and implements token caching, renewal, and 403 retry logic.
Proxy config fields, env loading, and validation
internal/config/proxy.go
Adds AppRole fields and env vars, loads them, and validates exactly one Vault auth mode.
Proxy config validation tests
internal/config/proxy_test.go
Adds helper and tests for valid AppRole configs and invalid Vault auth combinations.
Vault KMS AppRole tests
internal/kms/vault_test.go
Adds fake AppRole Vault coverage for login, re-auth, bad credentials, fallback, and concurrency behavior.
Wiring and documentation
cmd/proxy/main.go, README.md, docs/operator-guide.md
Passes AppRole fields into KMS construction and updates environment and operator docs.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Proxy
  participant VaultKMS
  participant vaultAuth
  participant Vault

  Proxy->>VaultKMS: encrypt/decrypt request
  VaultKMS->>vaultAuth: currentToken()
  alt token cached and valid
    vaultAuth-->>VaultKMS: cached token
  else login needed
    vaultAuth->>Vault: AppRole login
    Vault-->>vaultAuth: client_token, lease_duration
    vaultAuth-->>VaultKMS: new token
  end
  VaultKMS->>Vault: transit POST
  alt 403 Forbidden
    VaultKMS->>vaultAuth: reauth()
    vaultAuth->>Vault: AppRole login
    Vault-->>vaultAuth: new client_token
    VaultKMS->>Vault: retry POST
    Vault-->>VaultKMS: 200 OK
  else 200 OK
    Vault-->>VaultKMS: response
  end
  VaultKMS-->>Proxy: result
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title captures the main change, adding Vault AppRole authentication, though the "Kubernetes" wording is imprecise.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces support for Vault AppRole authentication as an alternative to static tokens for the Vault Transit KMS, updating configuration loading, validation, and documentation accordingly. The review feedback highlights a performance bottleneck in the token retrieval logic, where holding a single mutex during network I/O blocks concurrent requests. The reviewer suggests introducing a separate mutex and implementing a non-blocking token renewal strategy to optimize concurrent performance.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread internal/kms/vault.go
Comment on lines +178 to +188
type vaultAuth struct {
client *http.Client
static string // set for static-token auth; empty for AppRole.
loginURL string // set for AppRole auth.
roleID string
secretID string

mu sync.Mutex
token string
expiry time.Time // zero once logged in => the token does not expire.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Holding the main mutex mu during the HTTP request in login blocks all other concurrent requests from retrieving the token, even if the cached token is still valid (e.g., during the renewSkew window). To prevent this performance bottleneck, we should introduce a separate loginMu mutex to serialize login attempts while allowing concurrent reads of the cached token.

Suggested change
type vaultAuth struct {
client *http.Client
static string // set for static-token auth; empty for AppRole.
loginURL string // set for AppRole auth.
roleID string
secretID string
mu sync.Mutex
token string
expiry time.Time // zero once logged in => the token does not expire.
}
type vaultAuth struct {
client *http.Client
static string // set for static-token auth; empty for AppRole.
loginURL string // set for AppRole auth.
roleID string
secretID string
mu sync.Mutex
token string
expiry time.Time // zero once logged in => the token does not expire.
loginMu sync.Mutex
}

Comment thread internal/kms/vault.go
Comment on lines +219 to 293
func (a *vaultAuth) currentToken(ctx context.Context) (string, error) {
if a.loginURL == "" {
return a.static, nil
}
a.mu.Lock()
defer a.mu.Unlock()
if a.token != "" && (a.expiry.IsZero() || time.Until(a.expiry) > renewSkew) {
return a.token, nil
}
if err := a.login(ctx); err != nil {
return "", err
}
return a.token, nil
}

// reauth drops the cached AppRole token so the next token call logs in again.
// It reports whether re-authentication is possible (false for static tokens).
func (a *vaultAuth) reauth() bool {
if a.loginURL == "" {
return false
}
a.mu.Lock()
a.token = ""
a.mu.Unlock()
return true
}

// login performs an AppRole login and caches the issued token and its TTL.
// The caller must hold a.mu.
func (a *vaultAuth) login(ctx context.Context) error {
reqBody, err := json.Marshal(map[string]string{"role_id": a.roleID, "secret_id": a.secretID})
if err != nil {
return fmt.Errorf("kms: marshal approle login: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.loginURL, bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("kms: build approle login: %w", err)
}
req.Header.Set("Content-Type", "application/json")

resp, err := a.client.Do(req)
if err != nil {
return fmt.Errorf("kms: approle login request: %w", err)
}
defer func() { _ = resp.Body.Close() }()

body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("kms: read vault response: %w", err)
return fmt.Errorf("kms: read approle login response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("kms: vault returned status %d: %s", resp.StatusCode, bytes.TrimSpace(body))
return fmt.Errorf("kms: approle login returned status %d: %s", resp.StatusCode, bytes.TrimSpace(body))
}

var lr struct {
Auth struct {
ClientToken string `json:"client_token"`
LeaseDuration int `json:"lease_duration"`
} `json:"auth"`
}
if err := json.Unmarshal(body, respOut); err != nil {
return fmt.Errorf("kms: decode vault response: %w", err)
if err := json.Unmarshal(body, &lr); err != nil {
return fmt.Errorf("kms: decode approle login response: %w", err)
}
if lr.Auth.ClientToken == "" {
return errors.New("kms: approle login returned an empty client token")
}

a.token = lr.Auth.ClientToken
if lr.Auth.LeaseDuration > 0 {
a.expiry = time.Now().Add(time.Duration(lr.Auth.LeaseDuration) * time.Second)
} else {
a.expiry = time.Time{}
}
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Implement a non-blocking token renewal strategy using loginMu.TryLock(). If a login is already in progress by another goroutine, other concurrent requests can continue using the existing token if it is not fully expired, completely avoiding blocking in the hot path. Additionally, we refactor login to be a pure, thread-safe function that does not modify the struct state directly, allowing it to run safely without holding the main mutex mu during network I/O.

func (a *vaultAuth) currentToken(ctx context.Context) (string, error) {
	if a.loginURL == "" {
		return a.static, nil
	}

	a.mu.Lock()
	token := a.token
	expiry := a.expiry
	a.mu.Unlock()

	if token != "" && (expiry.IsZero() || time.Until(expiry) > renewSkew) {
		return token, nil
	}

	if !a.loginMu.TryLock() {
		if token != "" && (expiry.IsZero() || time.Now().Before(expiry)) {
			return token, nil
		}
		a.loginMu.Lock()
	}
	defer a.loginMu.Unlock()

	a.mu.Lock()
	token = a.token
	expiry = a.expiry
	a.mu.Unlock()

	if token != "" && (expiry.IsZero() || time.Until(expiry) > renewSkew) {
		return token, nil
	}

	newToken, newExpiry, err := a.login(ctx)
	if err != nil {
		if token != "" && (expiry.IsZero() || time.Now().Before(expiry)) {
			return token, nil
		}
		return "", err
	}

	a.mu.Lock()
	a.token = newToken
	a.expiry = newExpiry
	a.mu.Unlock()

	return newToken, nil
}

// reauth drops the cached AppRole token so the next token call logs in again.
// It reports whether re-authentication is possible (false for static tokens).
func (a *vaultAuth) reauth() bool {
	if a.loginURL == "" {
		return false
	}
	a.mu.Lock()
	a.token = ""
	a.expiry = time.Time{}
	a.mu.Unlock()
	return true
}

// login performs an AppRole login and returns the issued token and its expiry.
func (a *vaultAuth) login(ctx context.Context) (string, time.Time, error) {
	reqBody, err := json.Marshal(map[string]string{"role_id": a.roleID, "secret_id": a.secretID})
	if err != nil {
		return "", time.Time{}, fmt.Errorf("kms: marshal approle login: %w", err)
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.loginURL, bytes.NewReader(reqBody))
	if err != nil {
		return "", time.Time{}, fmt.Errorf("kms: build approle login: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := a.client.Do(req)
	if err != nil {
		return "", time.Time{}, fmt.Errorf("kms: approle login request: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", time.Time{}, fmt.Errorf("kms: read approle login response: %w", err)
	}
	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
		return "", time.Time{}, fmt.Errorf("kms: approle login returned status %d: %s", resp.StatusCode, bytes.TrimSpace(body))
	}

	var lr struct {
		Auth struct {
			ClientToken   string "json:\"client_token\""
			LeaseDuration int    "json:\"lease_duration\""
		} "json:\"auth\""
	}
	if err := json.Unmarshal(body, &lr); err != nil {
		return "", time.Time{}, fmt.Errorf("kms: decode approle login response: %w", err)
	}
	if lr.Auth.ClientToken == "" {
		return "", time.Time{}, errors.New("kms: approle login returned an empty client token")
	}

	var expiry time.Time
	if lr.Auth.LeaseDuration > 0 {
		expiry = time.Now().Add(time.Duration(lr.Auth.LeaseDuration) * time.Second)
	}
	return lr.Auth.ClientToken, expiry, nil
}

Address review: when the cached AppRole token is still valid but within
renewSkew of expiry, a transient login failure now falls back to the
still-usable token instead of failing the request; a hard 403 (which
clears the token first) still surfaces the error. Document that the
AppRole secret_id must stay reusable for the on-demand re-login.

Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
@sircthulhu Kirill Ilin (sircthulhu) marked this pull request as ready for review July 8, 2026 06:44
Address review: serialize logins with a dedicated loginMu and make login
a pure function returning the token and expiry, so mu is never held during
the network call and a caller holding a valid token is not blocked by an
in-flight login. A concurrent login uses TryLock and falls back to the
still-usable token. Add a concurrent-Wrap race test.

Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
@sircthulhu

Copy link
Copy Markdown
Contributor Author

Thanks! Addressed in b555a95. The token mutex is no longer held across the AppRole login: logins are serialized by a dedicated loginMu, and login is now a pure function returning (token, expiry, error), so mu is only taken to read/update the cached token — never during the network call. A concurrent caller uses TryLock and falls back to the still-usable cached token instead of blocking on an in-flight login. Added a concurrent-Wrap test that passes under -race.

@kvaps Andrei Kvapil (kvaps) 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.

Nice work overall — the security mechanics are solid: fail-closed on hard 403 (the token is cleared before re-login, so a failed login surfaces the error instead of serving a stale token), no credentials leak into logs or error messages, locking is correct (mu is never held across network I/O, logins are serialized via loginMu with a TryLock fast path), and the tests cover exactly the scenarios that matter, including the concurrent path under -race.

Question (design): the design doc (data-encryption-design.md) prescribes Kubernetes Auth (projected ServiceAccount token) for this proxy, while this PR implements AppRole — and the operator guide edit removes the SA-token mention entirely. Since the secret_id must stay reusable with a long TTL, a long-lived secret still sits in a Kubernetes Secret, so the gain over a static token is moderate. Is AppRole a deliberate replacement for Kubernetes Auth (then let's update the design doc) or an intermediate step (then let's keep the SA-token path in the operator guide as the target)?

Must fix: a failed proactive re-login in currentToken falls back to the still-valid cached token silently. If re-login is permanently broken (expired secret_id, removed policy), the operator learns about it only when the cached token dies and the next DEK operation fails — at rotation time, the worst moment. Please log a warning on this fallback path.

Optional nits:

  • reauth() clears the cached token unconditionally: a goroutine handling a stale 403 can wipe a fresh token another goroutine just obtained, causing an extra login. Cheap fix: clear only if the cache still holds the token that got the 403.
  • TestVaultKMSAppRoleConcurrent asserts logins >= 1, but the design guarantees exactly one login for concurrent cold-start Wraps — == 1 would catch a serialization regression.
  • Docs: worth mentioning a sensible minimum token_ttl (> 30s, the renewSkew), and that the Vault address must be HTTPS since the secret_id now travels on every re-login.

Address review:
- Log a warning when a re-login fails and the still-valid cached token is
  served, so a permanently broken login (expired secret_id, revoked
  policy) is visible before it bites at DEK-rotation time.
- reauth now clears the cache only when it still holds the rejected token,
  so a concurrent goroutine's freshly obtained token is not wiped.
- Tighten the concurrent test to assert exactly one login (serialization).
- Docs: recommend a token_ttl above the renew skew and require an HTTPS
  Vault address, since the secret_id travels on every re-login.

Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
Kubernetes auth is the cleaner target for this proxy, so support it as a
first-class mode. The auth strategy is generalized into a tokenLogin, so
the proxy can log in with its projected ServiceAccount token
(KKP_VAULT_KUBERNETES_ROLE / _MOUNT / _JWT_FILE), re-reading the token
from disk on each login so the kubelet's rotation is picked up without a
restart. AppRole and the static token remain; the config enforces exactly
one auth mode. Docs mark Kubernetes as the recommended method.

Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
@sircthulhu Kirill Ilin (sircthulhu) changed the title feat(kms): add Vault AppRole authentication feat(kms): add Vault Kubernetes and AppRole authentication Jul 8, 2026
Lock in the advertised kubelet-rotation behavior: rotate the on-disk JWT
and revoke the cached token between two Wraps, then assert the forced
re-login sends the rotated token rather than a cached one.

Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>

@kvaps Andrei Kvapil (kvaps) 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.

All review points are addressed in 7829546 — the warning on the cached-token fallback, the scoped re-auth invalidation, the exact single-login test assertion, and the doc notes on token_ttl and HTTPS. Per the team discussion, AppRole stays for now and Kubernetes Auth lands as a follow-up together with a design-doc update. LGTM.

@sircthulhu

Kirill Ilin (sircthulhu) commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Good call — I agree Kubernetes auth is the cleaner target. Added it as a first-class, recommended mode (ea66f0c): the proxy logs in with its projected ServiceAccount token, re-read from disk on each login so kubelet rotation is picked up, with no stored credential. AppRole and the static token remain as alternatives; config enforces exactly one mode.

The earlier round is in too (7829546): warn on the cached-token fallback, scoped reauth invalidation, == 1 in the concurrent test, and the token_ttl/HTTPS doc notes. Also added a test that proves the SA token is re-read on each login (fc05cc8).

@kvaps Andrei Kvapil (kvaps) merged commit 027a116 into cozystack:main Jul 8, 2026
4 checks passed
@sircthulhu Kirill Ilin (sircthulhu) deleted the feat/vault-approle-auth branch July 8, 2026 09:57
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.

2 participants