Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,9 @@ type Handler struct {
executeExchangeFn func(ctx context.Context, req exchange.ExchangeExecuteRequest) (string, *exchange.ExchangeQuoteSummary, error)

// encryptionKeySource is the env var name that resolved the credential
// encryption key. Empty when no credStore is configured. Used by the
// /health endpoint only — never logged outside that one place.
// encryption key. Empty when no credStore is configured. Read only by the
// /health credential_store check, which keeps it out of the public body
// and logs the dev-key case server-side (#15).
encryptionKeySource string

// Optional Azure revoke client factory injected by tests. When nil, the
Expand Down
17 changes: 14 additions & 3 deletions internal/api/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"context"
"sync"
"time"

"github.com/LeanerCloud/cloud-commitments-go/pkg/logging"
Expand Down Expand Up @@ -83,14 +84,21 @@ func (h *Handler) checkConfigStore(ctx context.Context) HealthCheck {
// Returns one of three logical states via Status + Message:
//
// - "healthy" / "ok": real key loaded from a Secrets Manager / Vault.
// - "degraded" / "dev_key_in_use": CREDENTIAL_ENCRYPTION_ALLOW_DEV_KEY=1 was
// set, so the all-zero key is in use. Acceptable for local dev only;
// - "degraded" / "credential store degraded": CREDENTIAL_ENCRYPTION_ALLOW_DEV_KEY=1
// was set, so the all-zero key is in use. Acceptable for local dev only;
// monitoring should alert on this state in any deployed environment.
// /health is a public route, so the body stays generic and the cause
// (dev key in use) goes to the server log only, like checkConfigStore (#15).
// - "unhealthy" / "not configured": no credStore was wired in (config error).
//
// NOTE: "ok" only confirms the key is valid (LoadKey succeeded + startup guard
// passed); it does NOT guarantee that all DB rows have been re-keyed. Detect
// pre-rekey state by alerting on application-level decrypt-failure ERROR logs.
// devKeyWarnOnce limits the dev-key warning to once per process: /health is
// polled every few seconds by load balancers, and the degraded status in the
// response already keeps monitoring alerting.
var devKeyWarnOnce sync.Once

func (h *Handler) checkCredentialStore() HealthCheck {
if h.credStore == nil {
return HealthCheck{
Expand All @@ -99,9 +107,12 @@ func (h *Handler) checkCredentialStore() HealthCheck {
}
}
if h.encryptionKeySource == credentials.EnvAllowDev {
devKeyWarnOnce.Do(func() {
logging.Warnf("health: credential store is using the insecure all-zero dev key (%s=1)", credentials.EnvAllowDev)
})
return HealthCheck{
Status: "degraded",
Message: "dev_key_in_use",
Message: "credential store degraded",
}
}
return HealthCheck{Status: "healthy"}
Expand Down
50 changes: 49 additions & 1 deletion internal/api/health_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package api

import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"

"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
Expand Down Expand Up @@ -58,7 +61,37 @@ func TestHandler_checkCredentialStore_DevKeyInUse(t *testing.T) {
}
check := h.checkCredentialStore()
assert.Equal(t, "degraded", check.Status)
assert.Equal(t, "dev_key_in_use", check.Message)
// /health is a public route: the body must not say which key is in use
// (#15). The detail goes to the server log only.
assert.Equal(t, "credential store degraded", check.Message)
}

// TestHandler_GetHealth_DevKeyNotDisclosed checks the public /health body
// never names the dev key or the env var that selected it (#15).
func TestHandler_GetHealth_DevKeyNotDisclosed(t *testing.T) {
ctx := context.Background()
mockStore := new(MockConfigStore)
mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil)

handler := &Handler{
config: mockStore,
auth: new(MockAuthService),
credStore: &stubCredStore{},
encryptionKeySource: credentials.EnvAllowDev,
}

response, err := handler.GetHealth(ctx)
require.NoError(t, err)

// Monitoring still sees the degraded state.
assert.Equal(t, "degraded", response.Status)
assert.Equal(t, "degraded", response.Checks["credential_store"].Status)

body, err := json.Marshal(response)
require.NoError(t, err)
for _, leak := range []string{"dev_key", "dev key", credentials.EnvAllowDev, "CREDENTIAL_ENCRYPTION"} {
assert.NotContains(t, strings.ToLower(string(body)), strings.ToLower(leak))
}
}

func TestHandler_checkCredentialStore_NotConfigured(t *testing.T) {
Expand Down Expand Up @@ -228,3 +261,18 @@ func TestHealthCheck_Structure(t *testing.T) {
assert.Equal(t, "unhealthy", check.Status)
assert.Equal(t, "Service unavailable", check.Message)
}

// TestHandler_checkCredentialStore_DevKeyWarnsOnce checks the dev-key warning
// is logged once per process, not on every /health probe.
func TestHandler_checkCredentialStore_DevKeyWarnsOnce(t *testing.T) {
devKeyWarnOnce = sync.Once{}
t.Cleanup(func() { devKeyWarnOnce = sync.Once{} })
buf := captureDefaultLog(t)

h := &Handler{credStore: &stubCredStore{}, encryptionKeySource: credentials.EnvAllowDev}
for i := 0; i < 3; i++ {
assert.Equal(t, "degraded", h.checkCredentialStore().Status)
}

assert.Equal(t, 1, strings.Count(buf.String(), "insecure all-zero dev key"), buf.String())
}
3 changes: 2 additions & 1 deletion internal/server/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ type Application struct {

// encKeySource is the env var name that resolved the credential encryption
// key (e.g. "CREDENTIAL_ENCRYPTION_KEY_SECRET_NAME"). Set during
// reinitializeAfterConnect; surfaced via /health.
// reinitializeAfterConnect. /health reports only a generic degraded state
// from it, never the source itself (#15).
encKeySource string

// State from the most recent migration attempt. Surfaced by /health so
Expand Down
Loading