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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Grafana dashboard for adapter metrics (`charts/dashboards/hyperfleet-adapter.json`) — covers events processed, processing duration, errors by type, resource deletions, and adapter health ([HYPERFLEET-1360](https://issues.redhat.com/browse/HYPERFLEET-1360))
- `clients.hyperfleet_api.auth.scheme` (Helm: `adapterConfig.hyperfleetApi.auth.scheme`) configures the Authorization header scheme sent with the service account token; defaults to `Bearer` for backwards compatibility, set to `ServiceAccount` when fronted by a gateway that differentiates human-jwt callers from machine callers ([HYPERFLEET-1480](https://issues.redhat.com/browse/HYPERFLEET-1480))

### Fixed

Expand Down
13 changes: 7 additions & 6 deletions charts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,17 @@ helm install hyperfleet-adapter oci://REGISTRY/hyperfleet-adapter \

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| adapterConfig | object | `{"create":true,"hyperfleetApi":{"auth":{"audience":"hyperfleet-api","enabled":false,"expirationSeconds":3600,"tokenCacheTtl":"30s","tokenPath":"/var/run/secrets/hyperfleet/token"},"baseUrl":"http://hyperfleet-api:8000","version":"v1"},"log":{"level":"info"}}` | Adapter deployment configuration. Controls how the adapter-config ConfigMap is created. Use `adapterConfig.yaml` for inline YAML, `adapterConfig.files` for chart-packaged files, or set `create: false` and provide `configMapName` to reference an existing ConfigMap. |
| adapterConfig | object | `{"create":true,"hyperfleetApi":{"auth":{"audience":"hyperfleet-api","enabled":false,"expirationSeconds":3600,"scheme":"Bearer","tokenCacheTtl":"30s","tokenPath":"/var/run/secrets/hyperfleet/token"},"baseUrl":"http://hyperfleet-api:8000","version":"v1"},"log":{"level":"info"}}` | Adapter deployment configuration. Controls how the adapter-config ConfigMap is created. Use `adapterConfig.yaml` for inline YAML, `adapterConfig.files` for chart-packaged files, or set `create: false` and provide `configMapName` to reference an existing ConfigMap. |
| adapterConfig.create | bool | `true` | Create the adapter-config ConfigMap |
| adapterConfig.hyperfleetApi | object | `{"auth":{"audience":"hyperfleet-api","enabled":false,"expirationSeconds":3600,"tokenCacheTtl":"30s","tokenPath":"/var/run/secrets/hyperfleet/token"},"baseUrl":"http://hyperfleet-api:8000","version":"v1"}` | HyperFleet API connection settings injected as environment variables |
| adapterConfig.hyperfleetApi | object | `{"auth":{"audience":"hyperfleet-api","enabled":false,"expirationSeconds":3600,"scheme":"Bearer","tokenCacheTtl":"30s","tokenPath":"/var/run/secrets/hyperfleet/token"},"baseUrl":"http://hyperfleet-api:8000","version":"v1"}` | HyperFleet API connection settings injected as environment variables |
| adapterConfig.hyperfleetApi.baseUrl | string | `"http://hyperfleet-api:8000"` | API base URL (`HYPERFLEET_API_BASE_URL`) |
| adapterConfig.hyperfleetApi.version | string | `"v1"` | API version (`HYPERFLEET_API_VERSION`) |
| adapterConfig.hyperfleetApi.auth | object | `{"audience":"hyperfleet-api","enabled":false,"expirationSeconds":3600,"tokenCacheTtl":"30s","tokenPath":"/var/run/secrets/hyperfleet/token"}` | JWT bearer token authentication via Kubernetes projected ServiceAccount token |
| adapterConfig.hyperfleetApi.auth.enabled | bool | `false` | Enable bearer token auth (`HYPERFLEET_API_AUTH_TOKEN_PATH`) |
| adapterConfig.hyperfleetApi.auth.audience | string | `"hyperfleet-api"` | ServiceAccount token audience (used for the projected volume) |
| adapterConfig.hyperfleetApi.auth | object | `{"audience":"hyperfleet-api","enabled":false,"expirationSeconds":3600,"scheme":"Bearer","tokenCacheTtl":"30s","tokenPath":"/var/run/secrets/hyperfleet/token"}` | Token-based authentication via a Kubernetes projected service account token |
| adapterConfig.hyperfleetApi.auth.enabled | bool | `false` | Enable token auth (`HYPERFLEET_API_AUTH_TOKEN_PATH`) |
| adapterConfig.hyperfleetApi.auth.audience | string | `"hyperfleet-api"` | Service account token audience (used for the projected volume) |
| adapterConfig.hyperfleetApi.auth.tokenPath | string | `"/var/run/secrets/hyperfleet/token"` | Absolute path where the token file is mounted |
| adapterConfig.hyperfleetApi.auth.expirationSeconds | int | `3600` | Token lifetime in seconds for the projected ServiceAccount token |
| adapterConfig.hyperfleetApi.auth.scheme | string | `"Bearer"` | Authorization header scheme used when sending the token (`HYPERFLEET_API_AUTH_SCHEME`). Defaults to `Bearer`; set to `ServiceAccount` when fronted by a gateway that differentiates machine callers from human-jwt callers. |
| adapterConfig.hyperfleetApi.auth.expirationSeconds | int | `3600` | Token lifetime in seconds for the projected service account token |
| adapterConfig.hyperfleetApi.auth.tokenCacheTtl | string | `"30s"` | How long the token is cached in memory (`HYPERFLEET_API_AUTH_TOKEN_CACHE_TTL`). Zero means re-read on every request. |
| adapterConfig.log | object | `{"level":"info"}` | Log level for the adapter |
| adapterConfig.log.level | string | `"info"` | Log level (`debug`, `info`, `warn`, `error`) |
Expand Down
2 changes: 2 additions & 0 deletions charts/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ spec:
{{- if .Values.adapterConfig.hyperfleetApi.auth.enabled }}
- name: HYPERFLEET_API_AUTH_TOKEN_PATH
value: {{ .Values.adapterConfig.hyperfleetApi.auth.tokenPath | quote }}
- name: HYPERFLEET_API_AUTH_SCHEME
value: {{ .Values.adapterConfig.hyperfleetApi.auth.scheme | quote }}
- name: HYPERFLEET_API_AUTH_TOKEN_CACHE_TTL
value: {{ .Values.adapterConfig.hyperfleetApi.auth.tokenCacheTtl | quote }}
{{- end }}
Expand Down
13 changes: 9 additions & 4 deletions charts/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,20 @@ adapterConfig:
baseUrl: http://hyperfleet-api:8000
# -- API version (`HYPERFLEET_API_VERSION`)
version: v1
# -- JWT bearer token authentication via Kubernetes projected ServiceAccount token
# -- Token-based authentication via a Kubernetes projected service account token
auth:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Needs a scheme: Bearer value here, surfaced as HYPERFLEET_API_AUTH_SCHEME alongside the token path and cache TTL env vars so the helmfile can set it.

# -- Enable bearer token auth (`HYPERFLEET_API_AUTH_TOKEN_PATH`)
# -- Enable token auth (`HYPERFLEET_API_AUTH_TOKEN_PATH`)
enabled: false
# -- ServiceAccount token audience (used for the projected volume)
# -- Service account token audience (used for the projected volume)
audience: hyperfleet-api
# -- Absolute path where the token file is mounted
tokenPath: /var/run/secrets/hyperfleet/token
# -- Token lifetime in seconds for the projected ServiceAccount token
# -- Authorization header scheme used when sending the token
# (`HYPERFLEET_API_AUTH_SCHEME`). Defaults to `Bearer`; set to
# `ServiceAccount` when fronted by a gateway that differentiates
# machine callers from human-jwt callers.
scheme: Bearer
# -- Token lifetime in seconds for the projected service account token
expirationSeconds: 3600
# -- How long the token is cached in memory (`HYPERFLEET_API_AUTH_TOKEN_CACHE_TTL`).
# Zero means re-read on every request.
Expand Down
2 changes: 1 addition & 1 deletion cmd/adapter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ func createAPIClient(apiConfig configloader.HyperfleetAPIConfig) (hyperfleetapi.
opts = append(opts, hyperfleetapi.WithDefaultHeader(key, value))
}

// Configure bearer token auth if set
// Configure token-based auth if set
if apiConfig.Auth != nil {
opts = append(opts, hyperfleetapi.WithAuth(apiConfig.Auth))
}
Expand Down
10 changes: 6 additions & 4 deletions configs/adapter-config-template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,14 @@ clients:
timeout: 2s
retry_attempts: 3
retry_backoff: exponential
# Optional JWT bearer token authentication via a file (e.g. Kubernetes projected ServiceAccount token).
# When configured, the token is read from token_path and attached as Authorization: Bearer <token>.
# token_path must be an absolute path.
# Environment variables: HYPERFLEET_API_AUTH_TOKEN_PATH, HYPERFLEET_API_AUTH_TOKEN_CACHE_TTL
# Optional token-based authentication via a token file (e.g. Kubernetes projected service account token).
# When configured, the token is read from token_path and attached as Authorization: <scheme> <token>.
# scheme defaults to Bearer; set to ServiceAccount when fronted by a gateway that
# differentiates human-jwt callers from machine callers. token_path must be an absolute path.
# Environment variables: HYPERFLEET_API_AUTH_TOKEN_PATH, HYPERFLEET_API_AUTH_SCHEME, HYPERFLEET_API_AUTH_TOKEN_CACHE_TTL
# auth:
# token_path: "/var/run/secrets/hyperfleet/token"
# scheme: Bearer
# token_cache_ttl: "30s" # 0 = re-read on every request

# Broker consumer configuration (adapter-level)
Expand Down
5 changes: 4 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ clients:
X-Example: "value"
auth:
token_path: "/var/run/secrets/hyperfleet/token"
scheme: "Bearer"
token_cache_ttl: "30s"
broker:
subscription_id: "example-subscription"
Expand Down Expand Up @@ -115,7 +116,8 @@ clients:
- `base_delay` (duration string): Initial retry delay. Default: `1s`.
- `max_delay` (duration string): Maximum retry delay. Default: `30s`.
- `default_headers` (map[string]string): Headers added to all API requests.
- `auth.token_path` (string): Absolute path to a file containing a JWT bearer token. When set, the token is read from this file and attached as `Authorization: Bearer <token>` on every request. Typically a Kubernetes projected ServiceAccount token. Must be an absolute path.
- `auth.token_path` (string): Absolute path to a file containing a service account token. When set, the token is read from this file and attached as `Authorization: <scheme> <token>` on every request. Typically a Kubernetes projected service account token. Must be an absolute path.
- `auth.scheme` (string): Authorization header scheme used when sending the token. Defaults to `Bearer`. Set to `ServiceAccount` when fronted by a gateway that differentiates human-jwt callers from machine callers.
- `auth.token_cache_ttl` (duration string): How long the token is cached in memory before re-reading the file. Zero (default) means re-read on every request.

### Broker (`clients.broker`)
Expand Down Expand Up @@ -293,6 +295,7 @@ All deployment overrides use the `HYPERFLEET_` prefix unless noted.
- `HYPERFLEET_API_BASE_DELAY` -> `clients.hyperfleet_api.base_delay`
- `HYPERFLEET_API_MAX_DELAY` -> `clients.hyperfleet_api.max_delay`
- `HYPERFLEET_API_AUTH_TOKEN_PATH` -> `clients.hyperfleet_api.auth.token_path`
- `HYPERFLEET_API_AUTH_SCHEME` -> `clients.hyperfleet_api.auth.scheme`
- `HYPERFLEET_API_AUTH_TOKEN_CACHE_TTL` -> `clients.hyperfleet_api.auth.token_cache_ttl`

**Broker**
Expand Down
15 changes: 9 additions & 6 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,12 @@ These fields have first-class Helm values that the chart injects as environment
| `adapterConfig.log.level` | Log level (`debug`, `info`, `warn`, `error`) | `LOG_LEVEL` | `info` |
| `adapterConfig.hyperfleetApi.baseUrl` | HyperFleet API base URL | `HYPERFLEET_API_BASE_URL` | `http://hyperfleet-api:8000` |
| `adapterConfig.hyperfleetApi.version` | API version | `HYPERFLEET_API_VERSION` | `v1` |
| `adapterConfig.hyperfleetApi.auth.enabled` | Enable JWT bearer token auth | — (controls volume + env vars) | `false` |
| `adapterConfig.hyperfleetApi.auth.enabled` | Enable token auth | — (controls volume + env vars) | `false` |
| `adapterConfig.hyperfleetApi.auth.tokenPath` | Absolute path to the token file | `HYPERFLEET_API_AUTH_TOKEN_PATH` | `/var/run/secrets/hyperfleet/token` |
| `adapterConfig.hyperfleetApi.auth.scheme` | Authorization header scheme sent with the token | `HYPERFLEET_API_AUTH_SCHEME` | `Bearer` |
| `adapterConfig.hyperfleetApi.auth.tokenCacheTtl` | In-memory token cache TTL | `HYPERFLEET_API_AUTH_TOKEN_CACHE_TTL` | `30s` |
| `adapterConfig.hyperfleetApi.auth.audience` | ServiceAccount token audience | — (used in projected volume) | `hyperfleet-api` |
| `adapterConfig.hyperfleetApi.auth.expirationSeconds` | ServiceAccount token lifetime (seconds) | — (used in projected volume) | `3600` |
| `adapterConfig.hyperfleetApi.auth.audience` | Service account token audience | — (used in projected volume) | `hyperfleet-api` |
| `adapterConfig.hyperfleetApi.auth.expirationSeconds` | Service account token lifetime (seconds) | — (used in projected volume) | `3600` |

### Fields settable via the `env` list

Expand Down Expand Up @@ -147,12 +148,12 @@ When using individual properties, `broker.type` must be set to `googlepubsub` or

## HyperFleet API Authentication

The adapter can authenticate to the HyperFleet API using a Kubernetes projected ServiceAccount token (JWT bearer token). Authentication is **disabled by default** — existing deployments are unaffected.
The adapter can authenticate to the HyperFleet API using a Kubernetes projected service account token. Authentication is **disabled by default** — existing deployments are unaffected.

When enabled, the Helm chart:
1. Mounts a projected `serviceAccountToken` volume at the configured `tokenPath` directory.
2. Sets `HYPERFLEET_API_AUTH_TOKEN_PATH` and `HYPERFLEET_API_AUTH_TOKEN_CACHE_TTL` env vars.
3. The adapter reads the token file and attaches `Authorization: Bearer <token>` to every HyperFleet API request.
2. Sets `HYPERFLEET_API_AUTH_TOKEN_PATH`, `HYPERFLEET_API_AUTH_SCHEME`, and `HYPERFLEET_API_AUTH_TOKEN_CACHE_TTL` env vars.
3. The adapter reads the token file and attaches `Authorization: <scheme> <token>` to every HyperFleet API request. `scheme` defaults to `Bearer`; set it to `ServiceAccount` when fronted by a gateway that differentiates human-jwt callers from machine callers.

```yaml
adapterConfig:
Expand All @@ -161,6 +162,7 @@ adapterConfig:
enabled: true
audience: hyperfleet-api # token audience claimed by the API server
tokenPath: /var/run/secrets/hyperfleet/token
scheme: Bearer # defaults to Bearer; use ServiceAccount behind a gateway that expects it
expirationSeconds: 3600 # kubelet rotates the token before expiry
tokenCacheTtl: 30s # re-read file every 30s; 0 = re-read per request
```
Expand Down Expand Up @@ -191,6 +193,7 @@ The chart automatically sets these environment variables from Helm values:
| `HYPERFLEET_API_BASE_URL` | `adapterConfig.hyperfleetApi.baseUrl` | Always |
| `HYPERFLEET_API_VERSION` | `adapterConfig.hyperfleetApi.version` | Always |
| `HYPERFLEET_API_AUTH_TOKEN_PATH` | `adapterConfig.hyperfleetApi.auth.tokenPath` | When `auth.enabled` is `true` |
| `HYPERFLEET_API_AUTH_SCHEME` | `adapterConfig.hyperfleetApi.auth.scheme` | When `auth.enabled` is `true` |
| `HYPERFLEET_API_AUTH_TOKEN_CACHE_TTL` | `adapterConfig.hyperfleetApi.auth.tokenCacheTtl` | When `auth.enabled` is `true` |
| `BROKER_CONFIG_FILE` | Hardcoded `/etc/broker/broker.yaml` | Always |
| `HYPERFLEET_BROKER_SUBSCRIPTION_ID` | `broker.googlepubsub.subscriptionId` | When broker type is `googlepubsub` |
Expand Down
1 change: 1 addition & 0 deletions internal/configloader/viper_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ var viperKeyMappings = map[string]string{
"clients::hyperfleet_api::base_delay": "API_BASE_DELAY",
"clients::hyperfleet_api::max_delay": "API_MAX_DELAY",
"clients::hyperfleet_api::auth::token_path": "API_AUTH_TOKEN_PATH",
"clients::hyperfleet_api::auth::scheme": "API_AUTH_SCHEME",
"clients::hyperfleet_api::auth::token_cache_ttl": "API_AUTH_TOKEN_CACHE_TTL",
"clients::broker::subscription_id": "BROKER_SUBSCRIPTION_ID",
"clients::broker::topic": "BROKER_TOPIC",
Expand Down
13 changes: 9 additions & 4 deletions internal/hyperfleetapi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type httpClient struct {
client *http.Client
config *ClientConfig
tokenSource *fileTokenSource
authScheme string
}

// ClientOption is a functional option for configuring the client
Expand Down Expand Up @@ -111,7 +112,7 @@ func WithBaseURL(baseURL string) ClientOption {
}
}

// WithAuth configures JWT bearer token authentication from a file.
// WithAuth configures token-based authentication from a token file.
func WithAuth(auth *AuthConfig) ClientOption {
return func(c *httpClient) {
c.config.Auth = auth
Expand Down Expand Up @@ -157,9 +158,13 @@ func NewClient(opts ...ClientOption) (Client, error) {
}
}

// Initialize token source for bearer token auth if configured
// Initialize the token source if auth is configured
if c.config.Auth != nil && c.config.Auth.TokenPath != "" {
c.tokenSource = newFileTokenSource(c.config.Auth.TokenPath, c.config.Auth.TokenCacheTTL)
c.authScheme = c.config.Auth.Scheme
if c.authScheme == "" {
c.authScheme = DefaultAuthScheme
}
}

return c, nil
Expand Down Expand Up @@ -334,13 +339,13 @@ func (c *httpClient) doRequest(ctx context.Context, req *Request) (*Response, er
httpReq.Header.Set(k, v)
}

// Inject bearer token auth header
// Inject the auth header
if c.tokenSource != nil {
tok, authErr := c.tokenSource.get()
if authErr != nil {
return nil, fmt.Errorf("getting auth token: %w", authErr)
}
httpReq.Header.Set("Authorization", "Bearer "+tok)
httpReq.Header.Set("Authorization", c.authScheme+" "+tok)
}

// Set default Content-Type for requests with body
Expand Down
42 changes: 35 additions & 7 deletions internal/hyperfleetapi/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ func TestClientWithHeaders(t *testing.T) {
defer server.Close()

client, err := NewClient(WithBaseURL(server.URL),
WithDefaultHeader("Authorization", "Bearer default-token"))
WithDefaultHeader("Authorization", "ServiceAccount default-token"))
require.NoError(t, err, "failed to create client")
ctx := context.Background()

Expand All @@ -218,8 +218,8 @@ func TestClientWithHeaders(t *testing.T) {
)
require.NoError(t, err, "unexpected error")

if receivedAuth != "Bearer default-token" {
t.Errorf("expected Authorization header 'Bearer default-token', got %q", receivedAuth)
if receivedAuth != "ServiceAccount default-token" {
t.Errorf("expected Authorization header 'ServiceAccount default-token', got %q", receivedAuth)
}

if receivedCustom != "custom-value" {
Expand Down Expand Up @@ -667,10 +667,10 @@ func TestAPIErrorInRetryExhausted(t *testing.T) {
}
}

func TestClientBearerTokenAuth(t *testing.T) {
func TestClientDefaultBearerScheme(t *testing.T) {
dir := t.TempDir()
tokenFile := filepath.Join(dir, "token")
if err := os.WriteFile(tokenFile, []byte("test-jwt-token"), 0600); err != nil {
if err := os.WriteFile(tokenFile, []byte("test-token"), 0600); err != nil {
t.Fatal(err)
}

Expand All @@ -690,8 +690,36 @@ func TestClientBearerTokenAuth(t *testing.T) {
_, err = client.Get(context.Background(), "/test")
require.NoError(t, err)

if receivedAuth != "Bearer test-jwt-token" {
t.Errorf("Authorization = %q, want %q", receivedAuth, "Bearer test-jwt-token")
if receivedAuth != "Bearer test-token" {
t.Errorf("Authorization = %q, want %q", receivedAuth, "Bearer test-token")
}
}

func TestClientConfiguredServiceAccountScheme(t *testing.T) {
dir := t.TempDir()
tokenFile := filepath.Join(dir, "token")
if err := os.WriteFile(tokenFile, []byte("test-token"), 0600); err != nil {
t.Fatal(err)
}

var receivedAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

client, err := NewClient(
WithBaseURL(server.URL),
WithAuth(&AuthConfig{TokenPath: tokenFile, Scheme: "ServiceAccount", TokenCacheTTL: 0}),
)
require.NoError(t, err)

_, err = client.Get(context.Background(), "/test")
require.NoError(t, err)

if receivedAuth != "ServiceAccount test-token" {
t.Errorf("Authorization = %q, want %q", receivedAuth, "ServiceAccount test-token")
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/hyperfleetapi/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"time"
)

// fileTokenSource reads a bearer token from disk on every call, or caches it
// fileTokenSource reads a 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 {
Expand Down
Loading