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

### Added

- Tenant enforcement middleware that resolves caller tenant identity from trusted gateway-injected headers; configurable via `server.tenant` (`enabled`, `system_header`, `dimensions` with header, key, and required flag); system callers receive unscoped context, non-system callers missing required dimensions or resolving zero dimensions receive 403 problem+json
- `HYPERFLEET-AUZ-001` Permission Denied error code for tenant identity rejection responses
- Grafana dashboard for API and database metrics (`charts/dashboards/hyperfleet-api.json`) — covers HTTP request rate/latency, reconciliation pending/stuck gauges, DB query duration/errors, connection pool, and build info ([#311](https://github.com/openshift-hyperfleet/hyperfleet-api/pull/311))
- JWT authentication handler using `golang-jwt/jwt/v5` and `MicahParks/keyfunc/v3` with RS256 validation, configurable issuer and audience, and JWKS key rotation support ([#120](https://github.com/openshift-hyperfleet/hyperfleet-api/pull/120))
- Hard deletion for Clusters and NodePools: resources and their adapter statuses are permanently removed from the database once all required adapters report `Finalized=True` and no child resources remain ([#119](https://github.com/openshift-hyperfleet/hyperfleet-api/pull/119))
Expand Down
7 changes: 4 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -167,16 +167,17 @@ DB_FLAGS = --db-host localhost --db-port $(db_port) --db-name $(db_name) \
--db-username $(db_user) --db-password $(db_password)

DEV_TOKEN_FILE := /tmp/hf-dev-token.txt

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.

I find a bit confusing that we generate:

  • the token at /tmp
  • the dev-jwks.json at ./configs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is pre-existing behavior, not introduced here. The distinction: the token is ephemeral (regenerated on each make run, 8-hour expiry) so /tmp fits; the JWKS is referenced by dev.yaml and can persist across runs, so it lives in ./configs. Happy to revisit the locations in a follow-up if you feel strongly.

export DEV_TOKEN_FILE

.PHONY: run
run: db/migrate ## Run the application with JWT auth
@install -m 600 /dev/null $(DEV_TOKEN_FILE) && configs/gen-dev-token.sh --new-key > $(DEV_TOKEN_FILE)
@install -m 600 /dev/null "$${DEV_TOKEN_FILE}" && configs/gen-dev-token.sh --new-key > "$${DEV_TOKEN_FILE}"
./bin/hyperfleet-api serve $(DB_FLAGS) --config configs/dev.yaml

.PHONY: dev-token
dev-token: ## Generate a fresh JWT using the existing dev key (no server restart needed)
@install -m 600 /dev/null $(DEV_TOKEN_FILE) && configs/gen-dev-token.sh > $(DEV_TOKEN_FILE)
@echo 'Usage: curl -H "Authorization: Bearer $$(cat $(DEV_TOKEN_FILE))" http://localhost:8000/api/hyperfleet/v1/clusters'
@install -m 600 /dev/null "$${DEV_TOKEN_FILE}" && configs/gen-dev-token.sh > "$${DEV_TOKEN_FILE}"
@echo "Token saved to $${DEV_TOKEN_FILE}"

.PHONY: run-no-auth
run-no-auth: db/migrate ## Run the application without auth or TLS (local dev)
Expand Down
5 changes: 5 additions & 0 deletions cmd/hyperfleet-api/servecmd/api_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/middleware"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/services"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/tenant"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/validators"
)

Expand Down Expand Up @@ -60,6 +61,10 @@ func BuildAPIServer(
callerIdentityMiddleware.ResolveCallerIdentity,
)
}
if cfg.Server.Tenant.Enabled {
tenantResolver := tenant.NewResolver(cfg.Server.Tenant)
authMiddleware = append(authMiddleware, tenantResolver.ResolveTenant)
}

registrars := []server.RouteRegistrar{
server.NewEntityRouteRegistrar(resourceService, adapterStatusService, schemaValidator),
Expand Down
74 changes: 74 additions & 0 deletions cmd/hyperfleet-api/servecmd/api_server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package servecmd

import (
"context"
"net/http"
"testing"
"time"

. "github.com/onsi/gomega"

"github.com/openshift-hyperfleet/hyperfleet-api/pkg/config"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry"
)

// TestBuildAPIServer_TenantMiddlewareWiredWhenEnabled guards the tenant
// middleware's wiring into the composition root, since pkg/tenant's own tests
// never exercise it there and wouldn't catch a dropped/reordered append.
//
// resourceService, adapterStatusService, schemaValidator, and sessionFactory
// are nil: tenant middleware runs ahead of the handlers that would need them,
// so a rejected request never reaches code that dereferences them.
func TestBuildAPIServer_TenantMiddlewareWiredWhenEnabled(t *testing.T) {
RegisterTestingT(t)
registry.Reset()
t.Cleanup(registry.Reset)
registry.Register(registry.EntityDescriptor{Kind: "Channel", Plural: "channels"})

cfg := config.NewApplicationConfig()
cfg.Server.Host = "127.0.0.1"
cfg.Server.Port = 0 // ephemeral port
cfg.Server.JWT.Enabled = false
cfg.Server.Tenant = config.TenantConfig{
Enabled: true,
SystemHeader: "X-HyperFleet-System",
Dimensions: []config.TenantDimension{
{Header: "X-HyperFleet-Org", Key: "org", Required: true},
},
}

apiServer, err := BuildAPIServer(cfg, nil, nil, nil, nil, nil)
Expect(err).NotTo(HaveOccurred())

listener, err := apiServer.Listen()
Expect(err).NotTo(HaveOccurred())

// served closes once Serve returns, so cleanup can block until the
// goroutine has actually exited instead of racing the next test.
served := make(chan struct{})
go func() {
defer close(served)
apiServer.Serve(listener)
}()
t.Cleanup(func() {
shutdownCtx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
Expect(apiServer.Shutdown(shutdownCtx)).To(Succeed())
<-served
})

baseURL := "http://" + listener.Addr().String()

// Missing the required tenant dimension header: tenant middleware must
// reject with 403 before the request reaches any entity handler.
var resp *http.Response
Eventually(func() error {
var getErr error
resp, getErr = http.Get(baseURL + "/api/hyperfleet/v1/channels")
return getErr
}, "2s", "25ms").Should(Succeed())
defer resp.Body.Close()

Expect(resp.StatusCode).To(Equal(http.StatusForbidden),
"request missing a required tenant header must be rejected by the tenant middleware")
}
5 changes: 5 additions & 0 deletions configs/config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ server:
identity_claim_pattern: "" # Regex pattern to validate the identity claim value (optional)
identity_header: "" # Per-issuer HTTP header for caller identity; overrides JWT claim when set (e.g. X-HyperFleet-Identity)

tenant:
enabled: false # Enable tenant enforcement middleware
system_header: "" # Header marking system callers (required when enabled=true)
dimensions: [] # Tenant dimension headers (header, key, required); at least one required=true if enabled

# Database Configuration
database:
dialect: postgres # Database dialect (postgres, mysql)
Expand Down
43 changes: 43 additions & 0 deletions configs/dev.yaml

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.

I wonder about the need of this file.
I mean we already have config.yaml.example, could we use that one for development purposes and avoid the risk of drifting?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

dev.yaml and config.yaml.example serve different purposes: dev.yaml is the runnable config so make run works out of the box; config.yaml.example is the reference template with all options documented. Keeping both means contributors don't have to maintain their own local config, since dev.yaml stays current as config evolves. Updating both together when adding config (as done here) keeps them in sync.

Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,46 @@ server:
jwk_cert_file: configs/dev-jwks.json
header: Authorization
identity_claim: email
tenant:
enabled: true
system_header: X-HyperFleet-System
dimensions:
- header: X-HyperFleet-Org
key: org
required: true
- header: X-HyperFleet-Project
key: project
required: false

entities:
- kind: Cluster
plural: clusters
spec_schema_name: ClusterSpec
required_adapters: [validation, dns, pullsecret, hypershift]
name_min_len: 3
name_max_len: 53
require_spec_schema: true

- kind: NodePool
plural: nodepools
parent_kind: Cluster
on_parent_delete: cascade
spec_schema_name: NodePoolSpec
required_adapters: [validation, hypershift]
name_min_len: 3
name_max_len: 15
require_spec_schema: true

- kind: Channel
plural: channels
spec_schema_name: ChannelSpec

- kind: Version
plural: versions
parent_kind: Channel
on_parent_delete: restrict
spec_schema_name: VersionSpec

- kind: WifConfig
plural: wifconfigs
spec_schema_name: WifConfigSpec
27 changes: 27 additions & 0 deletions pkg/api/response/service_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package response

import (
"context"
"net/http"

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

// WriteServiceErrorResponse writes err as an RFC 9457 Problem Details response,
// resolving the trace ID from ctx (via logger.GetRequestID) and using the
// request path as the problem instance. ctx is taken explicitly rather than
// derived from r.Context() so callers can still resolve a trace ID when r is
// nil. Callers are responsible for logging the error themselves beforehand,
// since the appropriate log level (e.g. Warn vs Info vs Error) varies by caller.
func WriteServiceErrorResponse(ctx context.Context, w http.ResponseWriter, r *http.Request, err *errors.ServiceError) {
traceID, ok := logger.GetRequestID(ctx)
if !ok {
traceID = "unknown"
}
instance := ""
if r != nil {
instance = r.URL.Path
}
WriteProblemDetailsResponse(w, r, err.HTTPCode, err.AsProblemDetails(instance, traceID))
}
5 changes: 5 additions & 0 deletions pkg/auth/auth_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ func NewCallerIdentityMiddleware() CallerIdentityMiddleware {
// If an identity header is configured, it takes precedence over JWT claims.
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()
identity, err := CallerIdentityFromRequest(ctx, r)

Expand Down
8 changes: 8 additions & 0 deletions pkg/auth/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ func normalizeIdentity(raw string, source string) (string, error) {
return value, nil
}

// ShouldSkipCallerIdentity reports whether path bypasses caller identity
// resolution. Exported so pkg/tenant can reuse the same skip list for tenant
// enforcement instead of maintaining a second copy of these path prefixes.
func ShouldSkipCallerIdentity(path string) bool {
return strings.HasPrefix(path, "/api/hyperfleet/v1/openapi") ||
strings.HasPrefix(path, "/api/hyperfleet/v1/errors")
}

func isMutatingMethod(method string) bool {
return method == http.MethodPost || method == http.MethodPatch || method == http.MethodDelete ||
method == http.MethodPut
Expand Down
3 changes: 3 additions & 0 deletions pkg/config/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ func AddServerFlags(cmd *cobra.Command) {
cmd.Flags().String("server-https-key-file", defaults.TLS.KeyFile, "Path to TLS key file")
cmd.Flags().Bool("server-https-enabled", defaults.TLS.Enabled, "Enable HTTPS rather than HTTP")
cmd.Flags().Bool("server-jwt-enabled", defaults.JWT.Enabled, "Enable JWT authentication")
cmd.Flags().Bool("server-tenant-enabled", defaults.Tenant.Enabled, "Enable tenant enforcement middleware")
cmd.Flags().String("server-tenant-system-header", defaults.Tenant.SystemHeader,
"Trusted header identifying system callers (bypasses tenant scoping)")
}

// AddDatabaseFlags adds database configuration flags following standard naming
Expand Down
10 changes: 10 additions & 0 deletions pkg/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ func (l *ConfigLoader) validateConfig(config *ApplicationConfig) error {
if valErr := config.Server.JWT.Validate(); valErr != nil {
return fmt.Errorf("server JWT validation failed: %w", valErr)
}
if valErr := config.Server.Tenant.Validate(); valErr != nil {
return fmt.Errorf("server tenant validation failed: %w", valErr)
}
if valErr := config.Health.Validate(); valErr != nil {
return fmt.Errorf("health config validation failed: %w", valErr)
}
Expand Down Expand Up @@ -299,6 +302,10 @@ func (l *ConfigLoader) bindAllEnvVars() {
l.bindEnv("server.jwt.enabled")
// server.jwt.configs is a list of structs — loaded from YAML config only.
// Viper cannot bind env vars to individual list elements.
l.bindEnv("server.tenant.enabled")
l.bindEnv("server.tenant.system_header")
// server.tenant.dimensions is a list of structs — loaded from YAML config only,
// same reason as server.jwt.configs above.
// Database config
l.bindEnv("database.dialect")
l.bindEnv("database.host")
Expand Down Expand Up @@ -377,6 +384,9 @@ func (l *ConfigLoader) bindFlags(cmd *cobra.Command) {
l.bindPFlag("server.tls.enabled", cmd.Flags().Lookup("server-https-enabled"))
l.bindPFlag("server.jwt.enabled", cmd.Flags().Lookup("server-jwt-enabled"))
// server.jwt.configs: no CLI flags — per-issuer config is YAML-only
l.bindPFlag("server.tenant.enabled", cmd.Flags().Lookup("server-tenant-enabled"))
l.bindPFlag("server.tenant.system_header", cmd.Flags().Lookup("server-tenant-system-header"))
// server.tenant.dimensions: no CLI flags — per-dimension config is YAML-only
// Database flags: --db-* -> database.*
l.bindPFlag("database.host", cmd.Flags().Lookup("db-host"))
l.bindPFlag("database.port", cmd.Flags().Lookup("db-port"))
Expand Down
4 changes: 4 additions & 0 deletions pkg/config/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type ServerConfig struct {
OpenAPISchemaPath string `mapstructure:"openapi_schema_path" json:"openapi_schema_path"`
TLS TLSConfig `mapstructure:"tls" json:"tls" validate:"required"`
JWT JWTConfig `mapstructure:"jwt" json:"jwt" validate:"required"`
Tenant TenantConfig `mapstructure:"tenant" json:"tenant" validate:"required"`
Timeouts TimeoutsConfig `mapstructure:"timeouts" json:"timeouts" validate:"required"`
Port int `mapstructure:"port" json:"port" validate:"required,min=1,max=65535"`
}
Expand Down Expand Up @@ -193,6 +194,9 @@ func NewServerConfig() *ServerConfig {
JWT: JWTConfig{
Enabled: true,
},
Tenant: TenantConfig{
Enabled: false,
},
}
}

Expand Down
Loading