diff --git a/AGENTS.md b/AGENTS.md index e0f3bc7..66d6466 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ Pre-flight order: `make check` then `make build`. | Labels | `pkg/labels/labels.go` | | Condition type constants | `pkg/client/constants.go` | | Config file | `configs/config.yaml` | +| Identity config & transport | `pkg/config/config.go` (`IdentityConfig`), `pkg/helper/suite.go` (RequestEditorFn wiring) | ## Test Conventions @@ -88,7 +89,7 @@ Eventually(h.PollNamespacesByPrefix(ctx, clusterID), timeout, h.Cfg.Polling.Inte Available pollers: `PollCluster`, `PollNodePool`, `PollClusterAdapterStatuses`, `PollNodePoolAdapterStatuses`, `PollClusterHTTPStatus`, `PollNodePoolHTTPStatus`, `PollNamespacesByPrefix`. -Available matchers: `HaveResourceCondition`, `HaveAllAdaptersWithCondition`, `HaveAllAdaptersAtGeneration`. +Available matchers: `HaveResourceCondition`, `HaveAllAdaptersWithCondition`, `HaveAllAdaptersAtGeneration`, `HaveAuditIdentity`. For one-off complex assertions, use `Eventually(func(g Gomega) { ... }).Should(Succeed())` with `g.Expect()` (not bare `Expect()`). @@ -119,6 +120,38 @@ Use `ginkgo.By()` for major steps. **IMPORTANT:** Never use `ginkgo.By()` inside Always use config values: `h.Cfg.Timeouts.Cluster.Reconciled`, `h.Cfg.Timeouts.NodePool.Reconciled`, `h.Cfg.Timeouts.Adapter.Processing`, `h.Cfg.Polling.Interval`. Never hardcode durations. +### JWT authentication (caller identity) + +The API authenticates requests via JWT. The E2E framework acquires a token from K8s using the TokenRequest API — no static tokens or secrets to manage. + +```yaml +# configs/config.yaml +identity: + expectedIdentity: "e2e@hyperfleet.local" + tokenRequest: + serviceAccountName: "hyperfleet-e2e-sa" + namespace: "hyperfleet" +``` + +Or via env vars: +```bash +HYPERFLEET_IDENTITY_TOKENREQUEST_SERVICEACCOUNTNAME=hyperfleet-e2e-sa \ +HYPERFLEET_IDENTITY_TOKENREQUEST_NAMESPACE=hyperfleet \ +HYPERFLEET_IDENTITY_EXPECTEDIDENTITY=e2e@hyperfleet.local \ +./bin/hyperfleet-e2e test +``` + +The acquired token is injected as `Authorization: Bearer` on every API request via `openapi.WithRequestEditorFn`. + +The `expectedIdentity` field is the audit identity the API resolves from the JWT claim. It's used by test assertions to verify `created_by` / `deleted_by` fields: + +```go +if expected := h.ExpectedIdentity(); expected != "" { + Expect(cluster).To(helper.HaveAuditIdentity(expected)) +} +``` + +When `expectedIdentity` is empty, audit assertions are skipped. ## Boundaries ### DON'T @@ -137,3 +170,4 @@ Always use config values: `h.Cfg.Timeouts.Cluster.Reconciled`, `h.Cfg.Timeouts.N - Config file path priority: `--config` flag > `HYPERFLEET_CONFIG` env > `./configs/config.yaml` auto-detect - Adapter names come from `h.Cfg.Adapters.Cluster` and `h.Cfg.Adapters.NodePool` at runtime — never hardcode adapter names. Values in `configs/config.yaml` (e.g., `cl-namespace`) override compiled defaults in `pkg/config/defaults.go` (e.g., `clusters-namespace`) - `e2e-ci` Makefile target sets `TESTDATA_DIR` to absolute path and writes JUnit XML to `output/` +- JWT auth is required when the API has `server.jwt.enabled=true` (production default). Set `identity.tokenRequest.serviceAccountName` and `.namespace` — the framework acquires a token via the K8s TokenRequest API at startup. diff --git a/cmd/hyperfleet-e2e/main.go b/cmd/hyperfleet-e2e/main.go index 04c0e26..a2d028c 100644 --- a/cmd/hyperfleet-e2e/main.go +++ b/cmd/hyperfleet-e2e/main.go @@ -30,7 +30,6 @@ func init() { pfs.StringVar(&logLevel, "log-level", config.DefaultLogLevel, "Log level (debug, info, warn, error)") pfs.StringVar(&logFormat, "log-format", config.DefaultLogFormat, "Log format (text, json)") pfs.StringVar(&logOutput, "log-output", config.DefaultLogOutput, "Log output (stdout, stderr)") - // Flags are bound in subcommand run() after config loading (osde2e pattern) root.AddCommand(test.Cmd) diff --git a/configs/config.yaml b/configs/config.yaml index 9ca6146..7fe8bce 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -118,3 +118,33 @@ adapters: # - API_ADAPTERS_NODEPOOL nodepool: - "np-configmap" + +# ============================================================================ +# JWT Authentication Configuration +# ============================================================================ + +identity: + # Expected audit identity for test assertions (e.g. "e2e@hyperfleet.local"). + # The API resolves caller identity from the JWT claim — this field tells + # the E2E framework what value to expect in created_by / deleted_by fields. + # Can be overridden by: HYPERFLEET_IDENTITY_EXPECTEDIDENTITY + expectedIdentity: "" + + # Token Request Configuration (K8s TokenRequest API) + # When enabled (serviceAccountName is set), the E2E framework acquires + # a JWT from the K8s cluster at startup using the TokenRequest API. + # This is the recommended auth method for JWT-enabled API deployments. + # + # Required: serviceAccountName and namespace + # The SA must exist in the target namespace before tests run. + # + # Can be overridden by environment variables: + # HYPERFLEET_IDENTITY_TOKENREQUEST_SERVICEACCOUNTNAME + # HYPERFLEET_IDENTITY_TOKENREQUEST_NAMESPACE + # HYPERFLEET_IDENTITY_TOKENREQUEST_AUDIENCE + # HYPERFLEET_IDENTITY_TOKENREQUEST_EXPIRATIONSECONDS + tokenRequest: + serviceAccountName: "" + namespace: "" + audience: "hyperfleet-api" + expirationSeconds: 3600 diff --git a/docs/getting-started.md b/docs/getting-started.md index 020609f..ee1ed43 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -38,7 +38,17 @@ export MAESTRO_URL= export NAMESPACE= ``` -**Step 2**: Run tier0 tests +**Step 2**: Configure JWT authentication + +The API authenticates requests via JWT. The E2E framework acquires a token from K8s using the TokenRequest API: + +```bash +export HYPERFLEET_IDENTITY_TOKENREQUEST_SERVICEACCOUNTNAME=hyperfleet-e2e-sa +export HYPERFLEET_IDENTITY_TOKENREQUEST_NAMESPACE=hyperfleet +export HYPERFLEET_IDENTITY_EXPECTEDIDENTITY=e2e@hyperfleet.local +``` + +**Step 3**: Run tier0 tests ```bash ./bin/hyperfleet-e2e test --label-filter=tier0 diff --git a/e2e/cluster/creation.go b/e2e/cluster/creation.go index 705f8b7..2f65a6c 100644 --- a/e2e/cluster/creation.go +++ b/e2e/cluster/creation.go @@ -31,6 +31,10 @@ var _ = ginkgo.Describe("[Suite: cluster][baseline] Cluster Resource Type Lifecy clusterName = cluster.Name ginkgo.GinkgoWriter.Printf("Created cluster ID: %s, Name: %s\n", clusterID, clusterName) + if expected := h.ExpectedIdentity(); expected != "" { + Expect(cluster).To(helper.HaveAuditIdentity(expected)) + } + ginkgo.DeferCleanup(func(ctx context.Context) { if err := h.CleanupTestCluster(ctx, clusterID); err != nil { ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup cluster %s: %v\n", clusterID, err) diff --git a/e2e/cluster/delete.go b/e2e/cluster/delete.go index 3c47e7a..a9d0f19 100644 --- a/e2e/cluster/delete.go +++ b/e2e/cluster/delete.go @@ -53,6 +53,11 @@ var _ = ginkgo.Describe("[Suite: cluster][delete] Cluster Deletion Lifecycle", Expect(deletedCluster.DeletedTime).NotTo(BeNil(), "soft-deleted cluster should have deleted_time set") Expect(deletedCluster.Generation).To(Equal(clusterBefore.Generation+1), "generation should increment after soft-delete") + if expected := h.ExpectedIdentity(); expected != "" { + Expect(deletedCluster.DeletedBy).NotTo(BeNil(), "deleted_by should be set on soft-delete") + Expect(string(*deletedCluster.DeletedBy)).To(Equal(expected), "deleted_by should match configured identity") + } + ginkgo.By("waiting for cluster adapters to finalize and cluster to be hard-deleted") // Hard-delete executes atomically within the POST /adapter_statuses request that // computes Reconciled=True, so there is no observable window to see Finalized=True diff --git a/pkg/client/client.go b/pkg/client/client.go index 52ee35a..fc57de5 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -20,12 +20,13 @@ type HyperFleetClient struct { } // NewHyperFleetClient creates a new HyperFleet API client. -func NewHyperFleetClient(baseURL string, httpClient *http.Client) (*HyperFleetClient, error) { +func NewHyperFleetClient(baseURL string, httpClient *http.Client, opts ...openapi.ClientOption) (*HyperFleetClient, error) { if httpClient == nil { httpClient = &http.Client{Timeout: 30 * time.Second} } - client, err := openapi.NewClient(baseURL, openapi.WithHTTPClient(httpClient)) + clientOpts := append([]openapi.ClientOption{openapi.WithHTTPClient(httpClient)}, opts...) + client, err := openapi.NewClient(baseURL, clientOpts...) if err != nil { return nil, fmt.Errorf("failed to create client: %w", err) } diff --git a/pkg/client/kubernetes/client.go b/pkg/client/kubernetes/client.go index 017fad7..2fab856 100644 --- a/pkg/client/kubernetes/client.go +++ b/pkg/client/kubernetes/client.go @@ -7,6 +7,7 @@ import ( "time" appsv1 "k8s.io/api/apps/v1" + authenticationv1 "k8s.io/api/authentication/v1" autoscalingv1 "k8s.io/api/autoscaling/v1" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" @@ -371,3 +372,20 @@ func HasDeploymentCondition(deploy *appsv1.Deployment, condType appsv1.Deploymen } return false } + +// CreateToken requests a token for a service account using the TokenRequest API +func (c *Client) CreateToken(ctx context.Context, namespace, serviceAccountName, audience string, expirationSeconds int64) (string, error) { + tokenRequest := &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{audience}, + ExpirationSeconds: &expirationSeconds, + }, + } + + tokenResp, err := c.CoreV1().ServiceAccounts(namespace).CreateToken(ctx, serviceAccountName, tokenRequest, metav1.CreateOptions{}) + if err != nil { + return "", fmt.Errorf("failed to create token for service account %s/%s: %w", namespace, serviceAccountName, err) + } + + return tokenResp.Status.Token, nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 4e5e8c2..6d528f1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -85,6 +85,36 @@ var Tests = struct { FlakeAttempts: "tests.flakeAttempts", } +// Identity config keys +var Identity = struct { + // ExpectedIdentity is the expected audit identity for test assertions + // (e.g. the email the API resolves from the JWT claim) + // Env: HYPERFLEET_IDENTITY_EXPECTEDIDENTITY + ExpectedIdentity string + + // TokenRequestServiceAccountName is the K8s service account for TokenRequest + // Env: HYPERFLEET_IDENTITY_TOKENREQUEST_SERVICEACCOUNTNAME + TokenRequestServiceAccountName string + + // TokenRequestNamespace is the namespace of the service account + // Env: HYPERFLEET_IDENTITY_TOKENREQUEST_NAMESPACE + TokenRequestNamespace string + + // TokenRequestAudience is the audience for the requested token + // Env: HYPERFLEET_IDENTITY_TOKENREQUEST_AUDIENCE + TokenRequestAudience string + + // TokenRequestExpirationSeconds is the token lifetime in seconds + // Env: HYPERFLEET_IDENTITY_TOKENREQUEST_EXPIRATIONSECONDS + TokenRequestExpirationSeconds string +}{ + ExpectedIdentity: "identity.expectedIdentity", + TokenRequestServiceAccountName: "identity.tokenRequest.serviceAccountName", + TokenRequestNamespace: "identity.tokenRequest.namespace", + TokenRequestAudience: "identity.tokenRequest.audience", + TokenRequestExpirationSeconds: "identity.tokenRequest.expirationSeconds", +} + // Log config keys var Log = struct { // Level is the minimum log level @@ -128,6 +158,40 @@ type APIDeploymentConfig struct { ChartPath string `yaml:"chartPath" mapstructure:"chartPath"` } +// TokenRequestConfig contains configuration for K8s TokenRequest-based JWT acquisition. +// When enabled (ServiceAccountName is set), a JWT is acquired via the K8s TokenRequest API +// at helper creation time and used for API authentication. +type TokenRequestConfig struct { + ServiceAccountName string `yaml:"serviceAccountName" mapstructure:"serviceAccountName"` + Namespace string `yaml:"namespace" mapstructure:"namespace"` + Audience string `yaml:"audience" mapstructure:"audience"` + ExpirationSeconds int64 `yaml:"expirationSeconds" mapstructure:"expirationSeconds"` +} + +// IsEnabled returns true when TokenRequest-based authentication is configured. +func (t TokenRequestConfig) IsEnabled() bool { + return t.ServiceAccountName != "" +} + +// IdentityConfig contains JWT authentication and audit identity configuration. +// token is acquired via TokenRequest at runtime and injected as Authorization: Bearer. +// ExpectedIdentity is used by test assertions to verify audit fields (created_by, deleted_by). +type IdentityConfig struct { + token string // JWT bearer token (acquired via TokenRequest, not user-configurable) + ExpectedIdentity string `yaml:"expectedIdentity" mapstructure:"expectedIdentity"` // Expected audit identity for assertions + TokenRequest TokenRequestConfig `yaml:"tokenRequest" mapstructure:"tokenRequest"` // K8s TokenRequest JWT acquisition +} + +// Token returns the JWT bearer token. +func (c *IdentityConfig) Token() string { + return c.token +} + +// SetToken sets the JWT bearer token (called by TokenRequest acquisition). +func (c *IdentityConfig) SetToken(t string) { + c.token = t +} + // Config represents the e2e test configuration type Config struct { Namespace string `yaml:"namespace" mapstructure:"namespace"` @@ -142,6 +206,7 @@ type Config struct { Adapters AdaptersConfig `yaml:"adapters" mapstructure:"adapters"` AdapterDeployment AdapterDeploymentConfig `yaml:"adapterDeployment" mapstructure:"adapterDeployment"` APIDeployment APIDeploymentConfig `yaml:"apiDeployment" mapstructure:"apiDeployment"` + Identity IdentityConfig `yaml:"identity" mapstructure:"identity"` } // APIConfig contains API-related configuration @@ -424,6 +489,16 @@ func (c *Config) applyDefaults() { if c.APIDeployment.ChartPath == "" { c.APIDeployment.ChartPath = os.Getenv("API_CHART_PATH") } + + // Apply tokenRequest defaults when enabled + if c.Identity.TokenRequest.IsEnabled() { + if c.Identity.TokenRequest.ExpirationSeconds == 0 { + c.Identity.TokenRequest.ExpirationSeconds = 3600 + } + if c.Identity.TokenRequest.Audience == "" { + c.Identity.TokenRequest.Audience = "hyperfleet-api" + } + } } // Validate validates configuration with detailed error messages @@ -470,6 +545,13 @@ func (c *Config) Validate() error { return fmt.Errorf("configuration validation failed: polling.interval must be a positive duration, got %v", c.Polling.Interval) } + // Validate identity tokenRequest config + if c.Identity.TokenRequest.IsEnabled() { + if c.Identity.TokenRequest.Namespace == "" { + return fmt.Errorf("configuration validation failed: identity.tokenRequest.namespace is required when tokenRequest is enabled") + } + } + return nil } @@ -502,6 +584,10 @@ func (c *Config) Display() { "api_chart_repo", redactURL(c.APIDeployment.ChartRepo), "api_chart_ref", valueOrNotSet(c.APIDeployment.ChartRef), "api_chart_path", valueOrNotSet(c.APIDeployment.ChartPath), + "identity_expected", valueOrNotSet(c.Identity.ExpectedIdentity), + "identity_token_request_sa", valueOrNotSet(c.Identity.TokenRequest.ServiceAccountName), + "identity_token_request_ns", valueOrNotSet(c.Identity.TokenRequest.Namespace), + "identity_token_request_audience", valueOrNotSet(c.Identity.TokenRequest.Audience), ) } diff --git a/pkg/helper/helper.go b/pkg/helper/helper.go index 53b04f8..0c8d770 100644 --- a/pkg/helper/helper.go +++ b/pkg/helper/helper.go @@ -182,6 +182,12 @@ func (h *Helper) CleanupTestWifConfig(ctx context.Context, wifConfigID string) e return nil } +// ExpectedIdentity returns the configured expected audit identity value. +// Returns empty string if not configured, signalling callers to skip audit assertions. +func (h *Helper) ExpectedIdentity() string { + return h.Cfg.Identity.ExpectedIdentity +} + // GetMaestroClient returns the Maestro client, initializing it lazily on first access // This avoids the overhead of K8s service discovery for test suites that don't use Maestro func (h *Helper) GetMaestroClient() *maestro.Client { diff --git a/pkg/helper/matchers.go b/pkg/helper/matchers.go index 4799347..ea03500 100644 --- a/pkg/helper/matchers.go +++ b/pkg/helper/matchers.go @@ -157,6 +157,53 @@ func (m *allAdaptersGenerationMatcher) NegatedFailureMessage(_ any) string { return fmt.Sprintf("expected adapters NOT at generation %d", m.generation) } +// HaveAuditIdentity matches a *Cluster or *Resource whose CreatedBy field equals the expected identity. +func HaveAuditIdentity(expected string) types.GomegaMatcher { + return &auditIdentityMatcher{expected: expected} +} + +type auditIdentityMatcher struct { + expected string + actual string +} + +func (m *auditIdentityMatcher) Match(actual any) (bool, error) { + identity, err := extractCreatedBy(actual) + if err != nil { + return false, err + } + m.actual = identity + return identity == m.expected, nil +} + +func (m *auditIdentityMatcher) FailureMessage(_ any) string { + return fmt.Sprintf("expected created_by=%q but got %q", m.expected, m.actual) +} + +func (m *auditIdentityMatcher) NegatedFailureMessage(_ any) string { + return fmt.Sprintf("expected created_by NOT to be %q", m.expected) +} + +func extractCreatedBy(actual any) (string, error) { + switch v := actual.(type) { + case *openapi.Cluster: + if v == nil { + return "", fmt.Errorf("HaveAuditIdentity expects non-nil *Cluster") + } + return string(v.CreatedBy), nil + case *client.Resource: + if v == nil { + return "", fmt.Errorf("HaveAuditIdentity expects non-nil *Resource") + } + if v.CreatedBy == nil { + return "", nil + } + return *v.CreatedBy, nil + default: + return "", fmt.Errorf("HaveAuditIdentity expects *Cluster or *Resource, got %T", actual) + } +} + func hasAdapterCond(conditions []openapi.AdapterCondition, condType string, status openapi.AdapterConditionStatus) bool { for _, c := range conditions { if c.Type == condType && c.Status == status { diff --git a/pkg/helper/suite.go b/pkg/helper/suite.go index 0e61b14..e22c936 100644 --- a/pkg/helper/suite.go +++ b/pkg/helper/suite.go @@ -1,9 +1,13 @@ package helper import ( + "context" + "fmt" "log" + "net/http" "sync" + "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/api/openapi" "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/client" k8sclient "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/client/kubernetes" "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/config" @@ -53,12 +57,41 @@ func New() *Helper { // newHelper creates a new Helper instance (internal use) func newHelper(cfg *config.Config) (*Helper, error) { - cl, err := client.NewHyperFleetClient(cfg.API.URL, nil) + k8sClient, err := k8sclient.NewClient() if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create k8s client: %w", err) } - k8sClient, err := k8sclient.NewClient() + // Acquire JWT via K8s TokenRequest API if configured + if cfg.Identity.TokenRequest.IsEnabled() { + token, err := k8sClient.CreateToken( + context.Background(), + cfg.Identity.TokenRequest.Namespace, + cfg.Identity.TokenRequest.ServiceAccountName, + cfg.Identity.TokenRequest.Audience, + cfg.Identity.TokenRequest.ExpirationSeconds, + ) + if err != nil { + return nil, fmt.Errorf("failed to acquire JWT via TokenRequest: %w", err) + } + cfg.Identity.SetToken(token) + log.Printf("Acquired JWT for SA %s/%s (audience: %s, expires: %ds)", + cfg.Identity.TokenRequest.Namespace, + cfg.Identity.TokenRequest.ServiceAccountName, + cfg.Identity.TokenRequest.Audience, + cfg.Identity.TokenRequest.ExpirationSeconds) + } + + var opts []openapi.ClientOption + if token := cfg.Identity.Token(); token != "" { + opts = append(opts, openapi.WithRequestEditorFn( + func(_ context.Context, req *http.Request) error { + req.Header.Set("Authorization", "Bearer "+token) + return nil + })) + } + + cl, err := client.NewHyperFleetClient(cfg.API.URL, nil, opts...) if err != nil { return nil, err }