Skip to content
Open
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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
ARG BASE_IMAGE=registry.access.redhat.com/ubi9-micro:latest

FROM registry.access.redhat.com/ubi9/go-toolset:1.26.2-1777889793 AS builder
FROM registry.access.redhat.com/ubi9/go-toolset:1.26.2-1779719578 AS builder

ARG GIT_SHA=unknown
ARG GIT_DIRTY=""
Expand Down
2 changes: 1 addition & 1 deletion charts/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ apiVersion: v2
name: hyperfleet-api
description: HyperFleet API - Cluster Lifecycle Management Service
type: application
version: 1.0.0
version: 1.1.0
appVersion: "0.0.0-dev"
maintainers:
- name: HyperFleet Team
Expand Down
25 changes: 25 additions & 0 deletions charts/templates/NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,30 @@ Validation schema validation is ENABLED.
The API will fail to start if the schema is missing or invalid.
{{- end }}

Caller identity (audit attribution):
config.server.jwt.identity_claim: {{ .Values.config.server.jwt.identity_claim | default "email" | quote }}
config.server.identity_header.enabled: {{ .Values.config.server.identity_header.enabled }} (chart default: false)
config.server.identity_header.name: {{ .Values.config.server.identity_header.name | default "X-HyperFleet-Identity" | quote }}

When identity_header.enabled is true and a request includes a non-empty
{{ .Values.config.server.identity_header.name | default "X-HyperFleet-Identity" }} header,
that value overrides the JWT claim for audit fields (created_by, updated_by).
JWT authentication behavior is unchanged when config.server.jwt.enabled is true.
Only trusted gateways should set the identity header in production.

Override in values.yaml:
config:
server:
jwt:
identity_claim: preferred_username
identity_header:
enabled: true
name: X-HyperFleet-Identity

Or at install/upgrade:
--set config.server.jwt.identity_claim=preferred_username
--set config.server.identity_header.enabled=true
--set config.server.identity_header.name=X-HyperFleet-Identity

Documentation:
https://github.com/openshift-hyperfleet/hyperfleet-api/blob/main/docs/deployment.md
5 changes: 5 additions & 0 deletions charts/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ data:
enabled: {{ .Values.config.server.jwt.enabled }}
issuer_url: {{ .Values.config.server.jwt.issuer_url | quote }}
audience: {{ .Values.config.server.jwt.audience | quote }}
identity_claim: {{ .Values.config.server.jwt.identity_claim | quote }}

identity_header:
enabled: {{ .Values.config.server.identity_header.enabled }}
name: {{ .Values.config.server.identity_header.name | quote }}

jwk:
cert_file: {{ .Values.config.server.jwk.cert_file | quote }}
Expand Down
6 changes: 6 additions & 0 deletions charts/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ config:
enabled: false
issuer_url: ""
audience: ""
identity_claim: email

identity_header:
enabled: false
name: X-HyperFleet-Identity
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

jwk:
cert_file: ""
Expand Down Expand Up @@ -104,6 +109,7 @@ config:
- Cookie
- X-Auth-Token
- X-Forwarded-Authorization
- X-HyperFleet-Identity
fields:
- password
- secret
Expand Down
5 changes: 5 additions & 0 deletions cmd/hyperfleet-api/environments/e_integration_testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ func (e *integrationTestingEnvImpl) OverrideConfig(c *config.ApplicationConfig)
c.Database.SSL.Mode = SSLModeDisable
}

// Avoid clashing with a local dev server on default ports
c.Server.Port = 8777
c.Metrics.Port = 19090
c.Health.Port = 18080

return nil
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/hyperfleet-api/environments/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type Database struct {
}

type Handlers struct {
AuthMiddleware auth.JWTMiddleware
CallerIdentityMiddleware auth.CallerIdentityMiddleware
}

type Services struct {
Expand Down
28 changes: 13 additions & 15 deletions cmd/hyperfleet-api/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ type ServicesInterface interface {
type RouteRegistrationFunc func(
apiV1Router *mux.Router,
services ServicesInterface,
authMiddleware auth.JWTMiddleware,
)

var routeRegistry = make(map[string]RouteRegistrationFunc)
Expand All @@ -40,10 +39,9 @@ func RegisterRoutes(name string, registrationFunc RouteRegistrationFunc) {
func LoadDiscoveredRoutes(
apiV1Router *mux.Router,
services ServicesInterface,
authMiddleware auth.JWTMiddleware,
) {
for name, registrationFunc := range routeRegistry {
registrationFunc(apiV1Router, services, authMiddleware)
registrationFunc(apiV1Router, services)
_ = name // prevent unused variable warning
}
}
Expand All @@ -53,17 +51,6 @@ func (s *apiServer) routes(tracingEnabled bool) *mux.Router {

metadataHandler := handlers.NewMetadataHandler()

var authMiddleware auth.JWTMiddleware
authMiddleware = &auth.MiddlewareMock{}
if env().Config.Server.JWT.Enabled {
var err error
authMiddleware, err = auth.NewAuthMiddleware()
check(err, "Unable to create auth middleware")
}
if authMiddleware == nil {
check(fmt.Errorf("auth middleware is nil"), "Unable to create auth middleware: missing middleware")
}

// mainRouter is top level "/"
mainRouter := mux.NewRouter()
mainRouter.NotFoundHandler = http.HandlerFunc(api.SendNotFound)
Expand Down Expand Up @@ -99,8 +86,19 @@ func (s *apiServer) routes(tracingEnabled bool) *mux.Router {
err = registerAPIMiddleware(apiV1Router)
check(err, "Failed to initialize API middleware")

if env().Config.Server.JWT.Enabled || env().Config.Server.IdentityHeader.Enabled {
callerIdentityMW, mwErr := auth.NewCallerIdentityMiddleware(auth.CallerIdentityConfig{
JWTEnabled: env().Config.Server.JWT.Enabled,
JWTIdentityClaim: env().Config.Server.JWT.IdentityClaim,
HeaderEnabled: env().Config.Server.IdentityHeader.Enabled,
HeaderName: env().Config.Server.IdentityHeader.Name,
})
check(mwErr, "Unable to create caller identity middleware")
apiV1Router.Use(callerIdentityMW.ResolveCallerIdentity)
}

// Auto-discovered routes (no manual editing needed)
LoadDiscoveredRoutes(apiV1Router, services, authMiddleware)
LoadDiscoveredRoutes(apiV1Router, services)

return mainRouter
}
Expand Down
6 changes: 6 additions & 0 deletions configs/config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ server:
enabled: true # Enable JWT authentication
issuer_url: "" # JWT issuer URL (required when jwt.enabled=true)
audience: "" # JWT audience claim (optional)
identity_claim: email # JWT claim used as request identity for audit (e.g. email, preferred_username, sub)

identity_header:
enabled: false # Use HTTP header as caller identity (overrides JWT claim when set)
name: X-HyperFleet-Identity # Header name (must be set by a trusted gateway in production)

jwk:
cert_file: "" # JWK certificate file path
Expand Down Expand Up @@ -67,6 +72,7 @@ logging:
- Cookie
- X-Auth-Token
- X-Forwarded-Authorization
- X-HyperFleet-Identity

fields: # Sensitive JSON fields to mask
- password
Expand Down
37 changes: 37 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,43 @@ curl -H "Authorization: Bearer ${TOKEN}" \
http://localhost:8000/api/hyperfleet/v1/clusters
```

## Caller identity for audit

Authentication (JWT validation) and caller identity (audit attribution) are separate:

| Layer | Component | Responsibility |
|-------|-----------|----------------|
| Outer | `JWTHandler` | Validates `Authorization: Bearer` token |
| Inner | `ResolveCallerIdentity` middleware | Resolves who is recorded as the actor (`CreatedBy`, force-delete logs) |

### JWT claim

Configure which JWT claim is used when no identity header is present:

```yaml
server:
jwt:
identity_claim: email # or preferred_username, sub, etc.
```

### HTTP identity header (optional)

When enabled, a trusted gateway can set the caller identity via HTTP header. **If the header is present and non-empty, it overrides the JWT claim** for audit fields. JWT validation is still required when `jwt.enabled=true`.

```yaml
server:
identity_header:
enabled: true
name: X-HyperFleet-Identity
```

**Security:** Clients must not be able to set this header directly. Configure your ingress/gateway to strip the header from external requests and set it from the authenticated upstream user.

```bash
export HYPERFLEET_SERVER_IDENTITY_HEADER_ENABLED=true
export HYPERFLEET_SERVER_IDENTITY_HEADER_NAME=X-HyperFleet-Identity
```

## Configuration

### Environment Variables
Expand Down
9 changes: 9 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,9 @@ HTTP server settings for the API endpoint.
| `server.jwt.enabled` | bool | `true` | Enable JWT authentication |
| `server.jwt.issuer_url` | string | `""` | Expected JWT issuer URL for token validation (required when JWT is enabled) |
| `server.jwt.audience` | string | `""` | Expected JWT audience claim (optional) |
| `server.jwt.identity_claim` | string | `email` | JWT claim used as request identity for audit (e.g. `email`, `preferred_username`, `sub`) |
| `server.identity_header.enabled` | bool | `false` | Enable HTTP header as caller identity source for audit |
| `server.identity_header.name` | string | `X-HyperFleet-Identity` | Header name; when set and non-empty, overrides JWT claim for audit attribution |
| `server.jwk.cert_file` | string | `""` | JWK certificate file path (optional) |
| `server.jwk.cert_url` | string | `""` | JWK certificate URL (required when JWT is enabled and cert_file is not set) |

Expand Down Expand Up @@ -351,6 +354,9 @@ Complete table of all configuration properties, their environment variables, and
| `server.jwt.enabled` | `HYPERFLEET_SERVER_JWT_ENABLED` | bool | `true` |
| `server.jwt.issuer_url` | `HYPERFLEET_SERVER_JWT_ISSUER_URL` | string | `""` |
| `server.jwt.audience` | `HYPERFLEET_SERVER_JWT_AUDIENCE` | string | `""` |
| `server.jwt.identity_claim` | `HYPERFLEET_SERVER_JWT_IDENTITY_CLAIM` | string | `email` |
| `server.identity_header.enabled` | `HYPERFLEET_SERVER_IDENTITY_HEADER_ENABLED` | bool | `false` |
| `server.identity_header.name` | `HYPERFLEET_SERVER_IDENTITY_HEADER_NAME` | string | `X-HyperFleet-Identity` |
| `server.jwk.cert_file` | `HYPERFLEET_SERVER_JWK_CERT_FILE` | string | `""` |
| `server.jwk.cert_url` | `HYPERFLEET_SERVER_JWK_CERT_URL` | string | `""` |
| **Database** | | | |
Expand Down Expand Up @@ -413,6 +419,9 @@ All CLI flags and their corresponding configuration paths.
| `--server-jwt-enabled` | `server.jwt.enabled` | bool |
| `--server-jwt-issuer-url` | `server.jwt.issuer_url` | string |
| `--server-jwt-audience` | `server.jwt.audience` | string |
| `--server-jwt-identity-claim` | `server.jwt.identity_claim` | string |
| `--server-identity-header-enabled` | `server.identity_header.enabled` | bool |
| `--server-identity-header-name` | `server.identity_header.name` | string |
| `--server-jwk-cert-file` | `server.jwk.cert_file` | string |
| `--server-jwk-cert-url` | `server.jwk.cert_url` | string |
| **Database** | | |
Expand Down
59 changes: 45 additions & 14 deletions pkg/auth/auth_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,68 @@ import (
"net/http"

"github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/validation"
)

type JWTMiddleware interface {
AuthenticateAccountJWT(next http.Handler) http.Handler
// CallerIdentityMiddleware resolves and attaches the caller identity used for audit fields.
type CallerIdentityMiddleware interface {
ResolveCallerIdentity(next http.Handler) http.Handler
}

type Middleware struct{}
type callerIdentityMiddleware struct {
cfg CallerIdentityConfig
}

var _ JWTMiddleware = &Middleware{}
var _ CallerIdentityMiddleware = &callerIdentityMiddleware{}

func NewAuthMiddleware() (*Middleware, error) {
middleware := Middleware{}
return &middleware, nil
func NewCallerIdentityMiddleware(cfg CallerIdentityConfig) (CallerIdentityMiddleware, error) {
if cfg.JWTIdentityClaim == "" {
cfg.JWTIdentityClaim = DefaultJWTIdentityClaim
}
if cfg.HeaderEnabled {
if cfg.HeaderName == "" {
return nil, fmt.Errorf("identity header name is required when identity header is enabled")
}
if validation.IsForbiddenIdentityHeaderName(cfg.HeaderName) {
return nil, fmt.Errorf("identity header name %q is not allowed", cfg.HeaderName)
}
}
return &callerIdentityMiddleware{cfg: cfg}, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// AuthenticateAccountJWT Middleware handler to validate JWT tokens and authenticate users
func (a *Middleware) AuthenticateAccountJWT(next http.Handler) http.Handler {
// ResolveCallerIdentity attaches the resolved caller identity to the request context.
// JWT validation is performed by JWTHandler; this middleware only resolves attribution.
func (m *callerIdentityMiddleware) ResolveCallerIdentity(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if shouldSkipCallerIdentity(r.URL.Path) {
next.ServeHTTP(w, r)
return
}

ctx := r.Context()
payload, err := GetAuthPayload(r)
identity, err := CallerIdentityFromRequest(ctx, r, m.cfg)
if err != nil {
handleError(
ctx, w, r, errors.CodeAuthNoCredentials,
fmt.Sprintf("Unable to get payload details from JWT token: %s", err),
fmt.Sprintf("Unable to resolve caller identity: %s", err),
)
return
}

// Append the username to the request context
ctx = SetUsernameContext(ctx, payload.Username)
*r = *r.WithContext(ctx)
if identity != "" {
ctx = SetUsernameContext(ctx, identity)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
return
}

if m.cfg.JWTEnabled {
handleError(
ctx, w, r, errors.CodeAuthNoCredentials,
"Unable to resolve caller identity from JWT token or identity header",
)
return
}

next.ServeHTTP(w, r)
})
Expand Down
16 changes: 0 additions & 16 deletions pkg/auth/auth_middleware_mock.go

This file was deleted.

Loading