diff --git a/CHANGELOG.md b/CHANGELOG.md index 39be396..f062cdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dashboard JSON moved from `deployments/dashboards/` to `charts/dashboards/` ### Changed +- HyperFleet API authentication now uses the `ServiceAccount` Authorization scheme instead of `Bearer` - BREAKING CHANGE: `tracing` moved from top-level to `monitoring.tracing` - BREAKING CHANGE: `monitoring.serviceMonitor.additionalLabels` renamed to `monitoring.serviceMonitor.labels` - Default tracing sampler changed from `parentbased_traceidratio` to `parentbased_always_on` diff --git a/charts/README.md b/charts/README.md index 8c50b5f..557488c 100644 --- a/charts/README.md +++ b/charts/README.md @@ -68,7 +68,7 @@ helm install hyperfleet-sentinel oci://quay.io/redhat-services-prod/hyperfleet-t | config.clients.hyperfleetApi.baseUrl | string | `"http://hyperfleet-api:8000"` | API base URL (use in-cluster service name) | | config.clients.hyperfleetApi.version | string | `"v1"` | API version | | config.clients.hyperfleetApi.timeout | string | `"10s"` | HTTP client timeout | -| config.clients.hyperfleetApi.auth | object | `{"audience":"hyperfleet-api","enabled":false,"expirationSeconds":3600,"tokenCacheTtl":"30s","tokenPath":"/var/run/secrets/hyperfleet/token"}` | Optional JWT authentication via a Kubernetes projected service account token. When enabled, a projected volume is mounted and the token is sent as a Bearer Authorization header on every API request. | +| config.clients.hyperfleetApi.auth | object | `{"audience":"hyperfleet-api","enabled":false,"expirationSeconds":3600,"tokenCacheTtl":"30s","tokenPath":"/var/run/secrets/hyperfleet/token"}` | Optional JWT authentication via a Kubernetes projected service account token. When enabled, a projected volume is mounted and the token is sent using the ServiceAccount Authorization header on every API request. | | config.clients.hyperfleetApi.auth.enabled | bool | `false` | Enable JWT authentication | | config.clients.hyperfleetApi.auth.audience | string | `"hyperfleet-api"` | Audience for the projected service account token | | config.clients.hyperfleetApi.auth.tokenPath | string | `"/var/run/secrets/hyperfleet/token"` | Full path where the token file is mounted in the container | diff --git a/charts/values.yaml b/charts/values.yaml index f673a95..0e58669 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -128,7 +128,7 @@ config: # -- HTTP client timeout timeout: 10s # -- Optional JWT authentication via a Kubernetes projected service account token. - # When enabled, a projected volume is mounted and the token is sent as a Bearer + # When enabled, a projected volume is mounted and the token is sent using the ServiceAccount # Authorization header on every API request. auth: # -- Enable JWT authentication diff --git a/docs/metrics.md b/docs/metrics.md index 6e99b9c..16da712 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -144,7 +144,7 @@ rate(hyperfleet_sentinel_poll_duration_seconds_sum[5m]) / - `resource_selector`: Label selector - `error_type`: Type of error: - `fetch_error`: Generic API failure (HTTP 5xx, timeout, DNS, malformed response) - - `auth_error`: Bearer token could not be read from disk (no HTTP request sent) + - `auth_error`: Service account token could not be read from disk (no HTTP request sent) - `auth_rejected`: API or gateway returned HTTP 401 (Unauthorized) or 403 (Forbidden) **Use Cases:** diff --git a/internal/client/client.go b/internal/client/client.go index 24019df..0b4a14f 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -48,9 +48,10 @@ type HyperFleetClient struct { // NewHyperFleetClient creates a new HyperFleet API client. // sentinelName and version are used to build the User-Agent header sent with every request. -// tokenPath is optional; when non-empty the client reads a bearer token from that file and -// injects it as an Authorization header on every request. tokenCacheTTL controls how long -// the token is cached before the file is re-read; 0 disables caching and re-reads the file on every request. +// tokenPath is optional; when non-empty the client reads a service account token +// from that file and injects it using the ServiceAccount authorization scheme on +// every request. tokenCacheTTL controls how long the token is cached before the +// file is re-read; 0 disables caching and re-reads the file on every request. func NewHyperFleetClient( endpoint string, timeout time.Duration, sentinelName, version string, pageSize int32, tokenPath string, tokenCacheTTL time.Duration, @@ -305,7 +306,7 @@ func (c *HyperFleetClient) setAuthHeader(req *http.Request) error { if err != nil { return &TokenError{cause: err} } - req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Authorization", "ServiceAccount "+tok) return nil } @@ -327,7 +328,7 @@ func (c *HyperFleetClient) VerifyConnectivity(ctx context.Context, resourceType } req.Header.Set("User-Agent", c.userAgent) if authErr := c.setAuthHeader(req); authErr != nil { - return fmt.Errorf("bearer token unavailable: %w", authErr) + return fmt.Errorf("service account token unavailable: %w", authErr) } resp, err := c.httpClient.Do(req) diff --git a/internal/client/client_test.go b/internal/client/client_test.go index beba4d8..cb44185 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -988,8 +988,8 @@ func TestVerifyConnectivity_SendsAuthHeader(t *testing.T) { if err := client.VerifyConnectivity(context.Background(), "clusters"); err != nil { t.Fatalf("VerifyConnectivity returned unexpected error: %v", err) } - if receivedAuth != "Bearer test-token" { - t.Errorf("Expected Authorization header %q, got %q", "Bearer test-token", receivedAuth) + if receivedAuth != "ServiceAccount test-token" { + t.Errorf("Expected Authorization header %q, got %q", "ServiceAccount test-token", receivedAuth) } } diff --git a/internal/client/token.go b/internal/client/token.go index 3969ba4..89dcaba 100644 --- a/internal/client/token.go +++ b/internal/client/token.go @@ -9,14 +9,16 @@ import ( "time" ) -// TokenError is returned when a bearer token cannot be read from disk. It is +// TokenError is returned when a service account token cannot be read from disk. It is // never retriable — the file path is wrong or the file is unreadable, which // requires operator intervention rather than a retry. type TokenError struct { cause error } -func (e *TokenError) Error() string { return fmt.Sprintf("bearer token unavailable: %v", e.cause) } +func (e *TokenError) Error() string { + return fmt.Sprintf("service account token unavailable: %v", e.cause) +} func (e *TokenError) Unwrap() error { return e.cause } // IsTokenError reports whether any error in err's chain is a TokenError. @@ -25,7 +27,7 @@ func IsTokenError(err error) bool { return errors.As(err, &t) } -// fileTokenSource reads a bearer token from disk on every call, or caches it +// fileTokenSource reads a service account token from disk on every call, or caches it // for cacheTTL when cacheTTL > 0. A zero cacheTTL disables caching and causes // the file to be re-read on every request. It is safe for concurrent use. type fileTokenSource struct { diff --git a/internal/client/token_test.go b/internal/client/token_test.go index 48ad6ab..6c4cc3c 100644 --- a/internal/client/token_test.go +++ b/internal/client/token_test.go @@ -185,7 +185,7 @@ func TestTokenError_Unwrap(t *testing.T) { } } -func TestNewHyperFleetClient_BearerToken(t *testing.T) { +func TestNewHyperFleetClient_ServiceAccountToken(t *testing.T) { dir := t.TempDir() tokenFile := filepath.Join(dir, "token") if err := os.WriteFile(tokenFile, []byte("test-jwt-token"), 0600); err != nil { @@ -218,7 +218,7 @@ func TestNewHyperFleetClient_BearerToken(t *testing.T) { t.Fatalf("FetchResources: %v", err) } - want := "Bearer test-jwt-token" + want := "ServiceAccount test-jwt-token" if receivedAuth != want { t.Errorf("Authorization = %q, want %q", receivedAuth, want) } diff --git a/internal/config/config.go b/internal/config/config.go index 1fbd086..fba4290 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -79,8 +79,8 @@ type ClientsConfig struct { } // HyperFleetAPIAuthConfig defines optional JWT authentication via a Kubernetes -// projected service account token. When set, the bearer token is read from -// TokenPath and injected into every API request. +// projected service account token. When set, the token is read from TokenPath +// and injected using the ServiceAccount authorization scheme in every API request. type HyperFleetAPIAuthConfig struct { TokenPath string `yaml:"token_path" mapstructure:"token_path"` TokenCacheTTL time.Duration `yaml:"token_cache_ttl" mapstructure:"token_cache_ttl"` diff --git a/internal/sentinel/sentinel.go b/internal/sentinel/sentinel.go index ac847e9..745dd0a 100644 --- a/internal/sentinel/sentinel.go +++ b/internal/sentinel/sentinel.go @@ -102,7 +102,7 @@ func classifyPollError(err error) (errorType string, statusCode int) { // logTriggerError logs a poll failure at the service boundary with the // status code, error classification, and resource context needed to // diagnose auth rejections without digging through error strings. Does not -// log the bearer token. +// log the service account token. func (s *Sentinel) logTriggerError(ctx context.Context, msg string, err error) { errorType, statusCode := classifyPollError(err) resourceSelector := metrics.GetResourceSelectorLabel(s.config.ResourceSelector) diff --git a/internal/sentinel/sentinel_test.go b/internal/sentinel/sentinel_test.go index e94f1b4..ea5ea12 100644 --- a/internal/sentinel/sentinel_test.go +++ b/internal/sentinel/sentinel_test.go @@ -853,7 +853,7 @@ func TestLogTriggerError_Fields(t *testing.T) { } // TestLogTriggerError_DoesNotLogToken verifies the boundary log for an auth -// failure never leaks the bearer token value, even though the request that +// failure never leaks the service account token value, even though the request that // produced the error carried it in the Authorization header. func TestLogTriggerError_DoesNotLogToken(t *testing.T) { const secretToken = "super-secret-test-token-xyz" @@ -895,7 +895,7 @@ func TestLogTriggerError_DoesNotLogToken(t *testing.T) { s.logTriggerError(ctx, "Trigger failed", triggerErr) if strings.Contains(buf.String(), secretToken) { - t.Errorf("Expected log output to NOT contain the bearer token, but it did:\n%s", buf.String()) + t.Errorf("Expected log output to NOT contain the service account token, but it did:\n%s", buf.String()) } }