diff --git a/AGENTS.md b/AGENTS.md index e0f3bc7..3ef1baa 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,39 @@ 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. +### Caller identity (audit attribution) + +The API enforces caller identity on mutating requests (production default). E2E tests must send identity headers — without them, mutating requests are rejected with `401 Unauthorized`. + +```bash +# Header mode (simplest) +HYPERFLEET_IDENTITY_HEADER=X-HyperFleet-Identity \ +HYPERFLEET_IDENTITY_VALUE=e2e-test@hyperfleet.local \ +./bin/hyperfleet-e2e test + +# Or via CLI flags +./bin/hyperfleet-e2e test \ + --identity-header X-HyperFleet-Identity \ + --identity-value e2e-test@hyperfleet.local + +# JWT mode (for Prow CI with GCP SA) +HYPERFLEET_IDENTITY_TOKEN=$TOKEN ./bin/hyperfleet-e2e test +``` + +Identity injection uses `openapi.WithRequestEditorFn` — headers are added to every request from the generated OpenAPI client. The `identity.value` must be email-formatted (the API validates `created_by` as email type). + +To verify audit fields in tests, use `h.ExpectedIdentity()` and `HaveAuditIdentity`: + +```go +if expected := h.ExpectedIdentity(); expected != "" { + Expect(cluster).To(helper.HaveAuditIdentity(expected)) +} +``` + +When no identity is configured, `ExpectedIdentity()` returns `""` and audit assertions are skipped. However, if the API enforces identity, tests will fail with `401` before reaching any assertions. + +For local kind setup, `deploy-scripts/lib/api.sh` automatically deploys the API with `config.server.identity_header=X-HyperFleet-Identity` via helm. + ## Boundaries ### DON'T @@ -137,3 +171,5 @@ 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/` +- Identity config is optional — when all identity fields are empty, no headers are injected and audit assertions are skipped. The `identity.value` must be email-formatted (e.g., `user@domain.local`) because the API's `created_by` field is typed as `openapi_types.Email` +- Identity header name must match the API's `server.identity_header` config — conventional value is `X-HyperFleet-Identity` diff --git a/cmd/hyperfleet-e2e/main.go b/cmd/hyperfleet-e2e/main.go index 04c0e26..f6ebf82 100644 --- a/cmd/hyperfleet-e2e/main.go +++ b/cmd/hyperfleet-e2e/main.go @@ -30,6 +30,8 @@ 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)") + pfs.StringVar(&identityHeader, "identity-header", "", "HTTP header name for caller identity (e.g. X-HyperFleet-Identity)") + pfs.StringVar(&identityValue, "identity-value", "", "Caller identity value sent in the identity header") // Flags are bound in subcommand run() after config loading (osde2e pattern) @@ -37,11 +39,13 @@ func init() { } var ( - configFile string - apiURL string - logLevel string - logFormat string - logOutput string + configFile string + apiURL string + logLevel string + logFormat string + logOutput string + identityHeader string + identityValue string ) func main() { diff --git a/cmd/hyperfleet-e2e/test/cmd.go b/cmd/hyperfleet-e2e/test/cmd.go index 8d5f0e9..2241c0d 100644 --- a/cmd/hyperfleet-e2e/test/cmd.go +++ b/cmd/hyperfleet-e2e/test/cmd.go @@ -65,12 +65,14 @@ func run(cmd *cobra.Command, argv []string) { _ = viper.BindPFlag(config.Tests.GinkgoDryRun, pfs.Lookup("dry-run")) _ = viper.BindPFlag(config.Tests.FlakeAttempts, pfs.Lookup("flake-attempts")) - // Bind parent command flags (api-url, logging flags) + // Bind parent command flags (api-url, logging flags, identity) parentFlags := cmd.Parent().PersistentFlags() _ = viper.BindPFlag(config.API.URL, parentFlags.Lookup("api-url")) _ = viper.BindPFlag(config.Log.Level, parentFlags.Lookup("log-level")) _ = viper.BindPFlag(config.Log.Format, parentFlags.Lookup("log-format")) _ = viper.BindPFlag(config.Log.Output, parentFlags.Lookup("log-output")) + _ = viper.BindPFlag(config.Identity.Header, parentFlags.Lookup("identity-header")) + _ = viper.BindPFlag(config.Identity.Value, parentFlags.Lookup("identity-value")) // Bind test environment variables _ = viper.BindEnv(config.Tests.GinkgoLabelFilter, "GINKGO_LABEL_FILTER") diff --git a/configs/config.yaml b/configs/config.yaml index 9ca6146..23b4720 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -118,3 +118,23 @@ adapters: # - API_ADAPTERS_NODEPOOL nodepool: - "np-configmap" + +# ============================================================================ +# Caller Identity Configuration +# ============================================================================ + +identity: + # HTTP header name for caller identity (must match API's server.identity_header) + # Conventional value: X-HyperFleet-Identity + # Can be overridden by: HYPERFLEET_IDENTITY_HEADER + header: "" + + # Identity value sent in the header (e.g. e2e-test@hyperfleet.local) + # Can be overridden by: HYPERFLEET_IDENTITY_VALUE + value: "" + + # JWT bearer token for authenticated requests + # When set, sent as Authorization: Bearer + # Can be overridden by: HYPERFLEET_IDENTITY_TOKEN + # NOTE: Use environment variable for sensitive tokens, not this config file + token: "" diff --git a/docs/getting-started.md b/docs/getting-started.md index 020609f..647bf4d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -38,7 +38,16 @@ export MAESTRO_URL= export NAMESPACE= ``` -**Step 2**: Run tier0 tests +**Step 2**: Configure caller identity + +The API enforces caller identity on mutating requests (production default). Without identity configured, tests will get `401 Unauthorized`. + +```bash +export HYPERFLEET_IDENTITY_HEADER=X-HyperFleet-Identity +export HYPERFLEET_IDENTITY_VALUE=dev-user@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/config/config.go b/pkg/config/config.go index 8c69e4e..7d1f37a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -81,6 +81,25 @@ var Tests = struct { FlakeAttempts: "tests.flakeAttempts", } +// Identity config keys +var Identity = struct { + // Header is the HTTP header name for caller identity + // Env: HYPERFLEET_IDENTITY_HEADER + Header string + + // Value is the identity header value + // Env: HYPERFLEET_IDENTITY_VALUE + Value string + + // Token is the JWT bearer token + // Env: HYPERFLEET_IDENTITY_TOKEN + Token string +}{ + Header: "identity.header", + Value: "identity.value", + Token: "identity.token", +} + // Log config keys var Log = struct { // Level is the minimum log level @@ -124,6 +143,14 @@ type APIDeploymentConfig struct { ChartPath string `yaml:"chartPath" mapstructure:"chartPath"` } +// IdentityConfig contains caller identity configuration for audit attribution. +// When configured, identity headers are injected into all API requests. +type IdentityConfig struct { + Header string `yaml:"header" mapstructure:"header"` // HTTP header name (e.g. X-HyperFleet-Identity) + Value string `yaml:"value" mapstructure:"value"` // Header value (e.g. e2e-test@hyperfleet.local) + Token string `yaml:"token" mapstructure:"token"` // JWT bearer token +} + // Config represents the e2e test configuration type Config struct { Namespace string `yaml:"namespace" mapstructure:"namespace"` @@ -137,6 +164,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 @@ -450,6 +478,11 @@ 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 config: header and value must both be set or both be empty + if (c.Identity.Header != "") != (c.Identity.Value != "") { + return fmt.Errorf("configuration validation failed: identity.header and identity.value must both be set or both be empty (got header=%q, value=%q)", c.Identity.Header, c.Identity.Value) + } + return nil } @@ -481,6 +514,9 @@ 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_header", valueOrNotSet(c.Identity.Header), + "identity_value", redactToken(c.Identity.Value), + "identity_token", redactToken(c.Identity.Token), ) } @@ -492,6 +528,14 @@ func valueOrNotSet(value string) string { return value } +// redactToken returns RedactedPlaceholder if the token is non-empty, otherwise NotSetPlaceholder +func redactToken(token string) string { + if token == "" { + return NotSetPlaceholder + } + return RedactedPlaceholder +} + // redactURL redacts credentials from URLs func redactURL(rawURL string) string { if rawURL == "" { diff --git a/pkg/helper/helper.go b/pkg/helper/helper.go index 53b04f8..c6bfcad 100644 --- a/pkg/helper/helper.go +++ b/pkg/helper/helper.go @@ -182,6 +182,13 @@ func (h *Helper) CleanupTestWifConfig(ctx context.Context, wifConfigID string) e return nil } +// ExpectedIdentity returns the configured caller identity value. +// Returns empty string if no identity is configured, signalling callers to skip +// audit assertions. +func (h *Helper) ExpectedIdentity() string { + return h.Cfg.Identity.Value +} + // 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..637ca37 100644 --- a/pkg/helper/suite.go +++ b/pkg/helper/suite.go @@ -1,9 +1,12 @@ package helper import ( + "context" "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,7 +56,22 @@ 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) + var opts []openapi.ClientOption + if cfg.Identity.Header != "" || cfg.Identity.Token != "" { + header, value, token := cfg.Identity.Header, cfg.Identity.Value, cfg.Identity.Token + opts = append(opts, openapi.WithRequestEditorFn( + func(_ context.Context, req *http.Request) error { + if header != "" && value != "" { + req.Header.Set(header, value) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + return nil + })) + } + + cl, err := client.NewHyperFleetClient(cfg.API.URL, nil, opts...) if err != nil { return nil, err }