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
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
20 changes: 20 additions & 0 deletions charts/templates/NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,25 @@ 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 "" | quote }}
config.server.identity_header: {{ .Values.config.server.identity_header | default "" | quote }}

When identity_header is set and a request includes a non-empty header with that name,
the header value overrides the JWT claim for audit fields (created_by, updated_by).
When identity_claim is set, the named JWT claim is used as the caller identity.
Only trusted gateways should set the identity header in production.

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

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

Documentation:
https://github.com/openshift-hyperfleet/hyperfleet-api/blob/main/docs/deployment.md
3 changes: 3 additions & 0 deletions charts/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ 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: {{ .Values.config.server.identity_header | quote }}

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

identity_header: ""

jwk:
cert_file: ""
Expand Down Expand Up @@ -104,6 +107,7 @@ config:
- Cookie
- X-Auth-Token
- X-Forwarded-Authorization
- X-HyperFleet-Identity
fields:
- password
- secret
Expand Down
5 changes: 1 addition & 4 deletions cmd/hyperfleet-api/environments/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package environments
import (
"sync"

"github.com/openshift-hyperfleet/hyperfleet-api/pkg/auth"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/config"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/db"
)
Expand Down Expand Up @@ -37,9 +36,7 @@ type Database struct {
SessionFactory db.SessionFactory
}

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

type Services struct {
serviceRegistry map[string]interface{}
Expand Down
29 changes: 14 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,20 @@ func (s *apiServer) routes(tracingEnabled bool) *mux.Router {
err = registerAPIMiddleware(apiV1Router)
check(err, "Failed to initialize API middleware")

identityCfg := auth.CallerIdentityConfig{
HeaderName: env().Config.Server.IdentityHeader,
}
if env().Config.Server.JWT.Enabled && env().Config.Server.JWT.IdentityClaim != "" {
identityCfg.JWTIdentityClaim = env().Config.Server.JWT.IdentityClaim
}
if identityCfg.JWTIdentityClaim != "" || identityCfg.HeaderName != "" {
callerIdentityMW, mwErr := auth.NewCallerIdentityMiddleware(identityCfg)
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
3 changes: 3 additions & 0 deletions configs/config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ 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: "" # HTTP header name for caller identity; leave empty to disable (e.g. X-HyperFleet-Identity)

jwk:
cert_file: "" # JWK certificate file path
Expand Down
138 changes: 135 additions & 3 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ This document describes authentication mechanisms for the HyperFleet API.

## Overview

HyperFleet API supports two authentication modes:
HyperFleet API supports the following authentication modes:

1. **Development Mode (No Auth)**: For local development and testing
2. **Production Mode (JWT Auth)**: JWT-based authentication with configurable issuer
1. **Development Mode (No Auth)**: For local development and testing without authentication
2. **Development with JWT (Google Cloud)**: Local development with real JWT validation using Google identity tokens
3. **Production Mode (JWT Auth)**: JWT-based authentication with configurable issuer

## Development Mode (No Auth)

Expand All @@ -30,8 +31,99 @@ export HYPERFLEET_SERVER_JWT_ENABLED=false
./bin/hyperfleet-api serve
```

### Caller identity in development mode

When JWT is disabled and no `identity_header` is configured, caller identity resolution is inactive. Audit fields (`created_by`, `updated_by`, `deleted_by`) fall back to `system@hyperfleet.local`.

To get proper caller attribution without JWT, configure an identity header:

```bash
./bin/hyperfleet-api serve \
--server-jwt-enabled=false \
--server-identity-header=X-HyperFleet-Identity
```

Then pass the header in requests:

```bash
# Create with attribution
curl -X POST http://localhost:8000/api/hyperfleet/v1/clusters \
-H "Content-Type: application/json" \
-H "X-HyperFleet-Identity: dev-user@local" \
-d '{"kind":"Cluster","name":"my-cluster","spec":{}}'

# Read requests work without the header
curl http://localhost:8000/api/hyperfleet/v1/clusters | jq
```

When `identity_header` or `identity_claim` is configured, mutating requests (POST, PATCH, PUT, DELETE) that cannot resolve a caller identity are rejected with `401 Unauthorized`. Read requests (GET, LIST) are allowed without identity.

**Important**: Never disable authentication in production environments.

## Development with JWT (Google Cloud)

For local development with real JWT validation, you can use Google Cloud identity tokens. This gives you proper authentication and caller identity without deploying a dedicated identity provider.

### Prerequisites

- [Google Cloud SDK](https://cloud.google.com/sdk/docs/install) installed
- Authenticated with `gcloud auth login`

### Start the server

```bash
./bin/hyperfleet-api serve \
--server-jwt-enabled=true \
--server-jwt-issuer-url="https://accounts.google.com" \
--server-jwk-cert-url="https://www.googleapis.com/oauth2/v3/certs" \
--server-jwt-audience="32555940559.apps.googleusercontent.com" \
--server-jwt-identity-claim="email" \
--server-identity-header=X-HyperFleet-Identity \
--db-host localhost --db-port 5432 --db-name hyperfleet --db-username hyperfleet
```

The audience `32555940559.apps.googleusercontent.com` is the default gcloud CLI OAuth client ID. It matches the `aud` claim in tokens generated by `gcloud auth print-identity-token`.

### Generate a token and make requests

```bash
# Generate an identity token (valid for ~1 hour)
TOKEN=$(gcloud auth print-identity-token)

# List clusters
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8000/api/hyperfleet/v1/clusters | jq

# Create a cluster (created_by will be your Google email)
curl -X POST http://localhost:8000/api/hyperfleet/v1/clusters \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind":"Cluster","name":"my-cluster","spec":{}}'

# Override identity via header (header takes precedence over JWT)
curl -X POST http://localhost:8000/api/hyperfleet/v1/clusters \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "X-HyperFleet-Identity: gateway-user@corp.com" \
-d '{"kind":"Cluster","name":"my-cluster-2","spec":{}}'
```

### How it works

Google identity tokens are standard OIDC JWTs signed by Google's keys. The server validates them like any other JWT:

1. Fetches Google's public keys from the `jwk_cert_url`
2. Verifies the RS256 signature
3. Checks `iss` matches `https://accounts.google.com`
4. Checks `aud` matches the configured audience
5. Extracts the `email` claim for caller identity

You can inspect your token with:

```bash
gcloud auth print-identity-token | cut -d. -f2 | base64 -d 2>/dev/null | jq
```

## Production Mode (JWT Auth)

Production deployments use JWT-based authentication with a configurable issuer.
Expand Down Expand Up @@ -69,6 +161,46 @@ 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 concerns. Identity resolution is enabled by setting `identity_claim` (in the JWT config) and/or `identity_header`. When neither is set, no identity middleware is registered and audit fields fall back to `system@hyperfleet.local`.

| Layer | Component | Responsibility |
|-------|-----------|----------------|
| Outer | `JWTHandler` | Validates `Authorization: Bearer` token |
| Inner | `ResolveCallerIdentity` middleware | Resolves who is recorded as the actor |

The resolved identity is written to `created_by` on create, `updated_by` on update, and `deleted_by` on delete. Precedence: identity header > JWT claim.

When identity resolution is configured, mutating requests (POST, PATCH, PUT, DELETE) that cannot resolve a caller identity are rejected with `401 Unauthorized`. Read requests (GET, LIST) are allowed without identity.

### JWT claim

Configure which JWT claim is used as the caller identity:

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

### HTTP identity header (optional)

When set, 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: 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=X-HyperFleet-Identity
```

Identity values from both sources are validated: trimmed of whitespace, limited to 256 characters, and rejected if they contain control characters.

## Configuration

### Environment Variables
Expand Down
6 changes: 6 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ 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` | string | `""` | HTTP header name for caller identity; 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 +353,8 @@ 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` | `HYPERFLEET_SERVER_IDENTITY_HEADER` | string | `""` |
| `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 +417,8 @@ 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` | `server.identity_header` | string |
| `--server-jwk-cert-file` | `server.jwk.cert_file` | string |
| `--server-jwk-cert-url` | `server.jwk.cert_url` | string |
| **Database** | | |
Expand Down
Loading