Skip to content

Commit 5d8c6b8

Browse files
committed
HYPERFLEET-1147 - feat: add caller identity support for audit attribution
Inject caller identity headers into all E2E API requests so tests work against an API with identity enforcement enabled (production default). - Add IdentityConfig (header/value/token) to config with CLI flags, env vars (HYPERFLEET_IDENTITY_*), and config.yaml support - Inject identity via OpenAPI RequestEditorFn in helper.newHelper() - Add HaveAuditIdentity matcher and ExpectedIdentity() helper - Add created_by assertion in cluster creation, deleted_by in deletion - Deploy API with identity_header enabled in local kind setup - Update AGENTS.md, getting-started, and local-kind-setup docs
1 parent 0e5ebcc commit 5d8c6b8

14 files changed

Lines changed: 216 additions & 11 deletions

File tree

AGENTS.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Pre-flight order: `make check` then `make build`.
4343
| Labels | `pkg/labels/labels.go` |
4444
| Condition type constants | `pkg/client/constants.go` |
4545
| Config file | `configs/config.yaml` |
46+
| Identity config & transport | `pkg/config/config.go` (`IdentityConfig`), `pkg/helper/suite.go` (RequestEditorFn wiring) |
4647

4748
## Test Conventions
4849

@@ -87,7 +88,7 @@ Eventually(h.PollNamespacesByPrefix(ctx, clusterID), timeout, h.Cfg.Polling.Inte
8788

8889
Available pollers: `PollCluster`, `PollNodePool`, `PollClusterAdapterStatuses`, `PollNodePoolAdapterStatuses`, `PollClusterHTTPStatus`, `PollNodePoolHTTPStatus`, `PollNamespacesByPrefix`.
8990

90-
Available matchers: `HaveResourceCondition`, `HaveAllAdaptersWithCondition`, `HaveAllAdaptersAtGeneration`.
91+
Available matchers: `HaveResourceCondition`, `HaveAllAdaptersWithCondition`, `HaveAllAdaptersAtGeneration`, `HaveAuditIdentity`.
9192

9293
For one-off complex assertions, use `Eventually(func(g Gomega) { ... }).Should(Succeed())` with `g.Expect()` (not bare `Expect()`).
9394

@@ -118,6 +119,39 @@ Use `ginkgo.By()` for major steps. **IMPORTANT:** Never use `ginkgo.By()` inside
118119

119120
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.
120121

122+
### Caller identity (audit attribution)
123+
124+
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`.
125+
126+
```bash
127+
# Header mode (simplest)
128+
HYPERFLEET_IDENTITY_HEADER=X-HyperFleet-Identity \
129+
HYPERFLEET_IDENTITY_VALUE=e2e-test@hyperfleet.local \
130+
./bin/hyperfleet-e2e test
131+
132+
# Or via CLI flags
133+
./bin/hyperfleet-e2e test \
134+
--identity-header X-HyperFleet-Identity \
135+
--identity-value e2e-test@hyperfleet.local
136+
137+
# JWT mode (for Prow CI with GCP SA)
138+
HYPERFLEET_IDENTITY_TOKEN=$TOKEN ./bin/hyperfleet-e2e test
139+
```
140+
141+
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).
142+
143+
To verify audit fields in tests, use `h.ExpectedIdentity()` and `HaveAuditIdentity`:
144+
145+
```go
146+
if expected := h.ExpectedIdentity(); expected != "" {
147+
Expect(cluster).To(helper.HaveAuditIdentity(expected))
148+
}
149+
```
150+
151+
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.
152+
153+
For local kind setup, `deploy-scripts/lib/api.sh` automatically deploys the API with `config.server.identity_header=X-HyperFleet-Identity` via helm.
154+
121155
## Boundaries
122156

123157
### DON'T
@@ -136,3 +170,5 @@ Always use config values: `h.Cfg.Timeouts.Cluster.Reconciled`, `h.Cfg.Timeouts.N
136170
- Config file path priority: `--config` flag > `HYPERFLEET_CONFIG` env > `./configs/config.yaml` auto-detect
137171
- 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`)
138172
- `e2e-ci` Makefile target sets `TESTDATA_DIR` to absolute path and writes JUnit XML to `output/`
173+
- 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`
174+
- Identity header name must match the API's `server.identity_header` config — conventional value is `X-HyperFleet-Identity`

cmd/hyperfleet-e2e/main.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,22 @@ func init() {
3030
pfs.StringVar(&logLevel, "log-level", config.DefaultLogLevel, "Log level (debug, info, warn, error)")
3131
pfs.StringVar(&logFormat, "log-format", config.DefaultLogFormat, "Log format (text, json)")
3232
pfs.StringVar(&logOutput, "log-output", config.DefaultLogOutput, "Log output (stdout, stderr)")
33+
pfs.StringVar(&identityHeader, "identity-header", "", "HTTP header name for caller identity (e.g. X-HyperFleet-Identity)")
34+
pfs.StringVar(&identityValue, "identity-value", "", "Caller identity value sent in the identity header")
3335

3436
// Flags are bound in subcommand run() after config loading (osde2e pattern)
3537

3638
root.AddCommand(test.Cmd)
3739
}
3840

3941
var (
40-
configFile string
41-
apiURL string
42-
logLevel string
43-
logFormat string
44-
logOutput string
42+
configFile string
43+
apiURL string
44+
logLevel string
45+
logFormat string
46+
logOutput string
47+
identityHeader string
48+
identityValue string
4549
)
4650

4751
func main() {

cmd/hyperfleet-e2e/test/cmd.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,14 @@ func run(cmd *cobra.Command, argv []string) {
5757
_ = viper.BindPFlag(config.Tests.GinkgoSkip, pfs.Lookup("skip"))
5858
_ = viper.BindPFlag(config.Tests.JUnitReportPath, pfs.Lookup("junit-report"))
5959

60-
// Bind parent command flags (api-url, logging flags)
60+
// Bind parent command flags (api-url, logging flags, identity)
6161
parentFlags := cmd.Parent().PersistentFlags()
6262
_ = viper.BindPFlag(config.API.URL, parentFlags.Lookup("api-url"))
6363
_ = viper.BindPFlag(config.Log.Level, parentFlags.Lookup("log-level"))
6464
_ = viper.BindPFlag(config.Log.Format, parentFlags.Lookup("log-format"))
6565
_ = viper.BindPFlag(config.Log.Output, parentFlags.Lookup("log-output"))
66+
_ = viper.BindPFlag(config.Identity.Header, parentFlags.Lookup("identity-header"))
67+
_ = viper.BindPFlag(config.Identity.Value, parentFlags.Lookup("identity-value"))
6668

6769
// Bind test environment variables
6870
_ = viper.BindEnv(config.Tests.GinkgoLabelFilter, "GINKGO_LABEL_FILTER")

configs/config.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,23 @@ adapters:
114114
# - API_ADAPTERS_NODEPOOL
115115
nodepool:
116116
- "np-configmap"
117+
118+
# ============================================================================
119+
# Caller Identity Configuration
120+
# ============================================================================
121+
122+
identity:
123+
# HTTP header name for caller identity (must match API's server.identity_header)
124+
# Conventional value: X-HyperFleet-Identity
125+
# Can be overridden by: HYPERFLEET_IDENTITY_HEADER
126+
header: ""
127+
128+
# Identity value sent in the header (e.g. e2e-test@hyperfleet.local)
129+
# Can be overridden by: HYPERFLEET_IDENTITY_VALUE
130+
value: ""
131+
132+
# JWT bearer token for authenticated requests
133+
# When set, sent as Authorization: Bearer <token>
134+
# Can be overridden by: HYPERFLEET_IDENTITY_TOKEN
135+
# NOTE: Use environment variable for sensitive tokens, not this config file
136+
token: ""

deploy-scripts/lib/api.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ install_api() {
5555
--set "service.type=${API_SERVICE_TYPE}"
5656
)
5757

58+
# Enable caller identity via HTTP header for audit attribution
59+
local identity_header="${API_IDENTITY_HEADER:-X-HyperFleet-Identity}"
60+
helm_cmd+=(--set "config.server.identity_header=${identity_header}")
61+
log_verbose "Identity header: ${identity_header}"
62+
5863
# Add adapter configurations (always set both, use empty if not discovered)
5964
# The API chart requires both config.adapters.required.cluster and config.adapters.required.nodepool to be set
6065
if [[ -n "${cluster_adapters}" ]]; then

docs/getting-started.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,16 @@ You should see the command help output.
3434
export HYPERFLEET_API_URL=https://api.hyperfleet.example.com
3535
```
3636

37-
**Step 2**: Run tests
37+
**Step 2**: Configure caller identity
38+
39+
The API enforces caller identity on mutating requests (production default). Without identity configured, tests will get `401 Unauthorized`.
40+
41+
```bash
42+
export HYPERFLEET_IDENTITY_HEADER=X-HyperFleet-Identity
43+
export HYPERFLEET_IDENTITY_VALUE=dev-user@hyperfleet.local
44+
```
45+
46+
**Step 3**: Run tests
3847

3948
```bash
4049
./bin/hyperfleet-e2e test --label-filter=tier0

docs/local-kind-setup.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ Local kind settings are at the bottom of the file:
9292
| `NAMESPACE` | `hyperfleet-local` | Kubernetes namespace |
9393
| `HYPERFLEET_API_URL` || API URL for tests (`http://localhost:8000`) |
9494
| `MAESTRO_URL` || Maestro URL for tests (`http://localhost:8100`) |
95+
| `HYPERFLEET_IDENTITY_HEADER` || Identity header name (`X-HyperFleet-Identity`) |
96+
| `HYPERFLEET_IDENTITY_VALUE` || Identity value sent in the header (must be email format) |
97+
| `API_IDENTITY_HEADER` | `X-HyperFleet-Identity` | Header name passed to API helm chart via `config.server.identity_header` |
9598

9699
## Troubleshooting
97100

e2e/cluster/creation.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ var _ = ginkgo.Describe("[Suite: cluster][baseline] Cluster Resource Type Lifecy
3131
clusterName = cluster.Name
3232
ginkgo.GinkgoWriter.Printf("Created cluster ID: %s, Name: %s\n", clusterID, clusterName)
3333

34+
if expected := h.ExpectedIdentity(); expected != "" {
35+
Expect(cluster).To(helper.HaveAuditIdentity(expected))
36+
}
37+
3438
ginkgo.DeferCleanup(func(ctx context.Context) {
3539
if err := h.CleanupTestCluster(ctx, clusterID); err != nil {
3640
ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup cluster %s: %v\n", clusterID, err)

e2e/cluster/delete.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ var _ = ginkgo.Describe("[Suite: cluster][delete] Cluster Deletion Lifecycle",
5353
Expect(deletedCluster.DeletedTime).NotTo(BeNil(), "soft-deleted cluster should have deleted_time set")
5454
Expect(deletedCluster.Generation).To(Equal(clusterBefore.Generation+1), "generation should increment after soft-delete")
5555

56+
if expected := h.ExpectedIdentity(); expected != "" {
57+
Expect(deletedCluster.DeletedBy).NotTo(BeNil(), "deleted_by should be set on soft-delete")
58+
Expect(string(*deletedCluster.DeletedBy)).To(Equal(expected), "deleted_by should match configured identity")
59+
}
60+
5661
ginkgo.By("waiting for cluster adapters to finalize and cluster to be hard-deleted")
5762
// Hard-delete executes atomically within the POST /adapter_statuses request that
5863
// computes Reconciled=True, so there is no observable window to see Finalized=True

pkg/client/client.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@ type HyperFleetClient struct {
2020
}
2121

2222
// NewHyperFleetClient creates a new HyperFleet API client.
23-
func NewHyperFleetClient(baseURL string, httpClient *http.Client) (*HyperFleetClient, error) {
23+
func NewHyperFleetClient(baseURL string, httpClient *http.Client, opts ...openapi.ClientOption) (*HyperFleetClient, error) {
2424
if httpClient == nil {
2525
httpClient = &http.Client{Timeout: 30 * time.Second}
2626
}
2727

28-
client, err := openapi.NewClient(baseURL, openapi.WithHTTPClient(httpClient))
28+
clientOpts := append([]openapi.ClientOption{openapi.WithHTTPClient(httpClient)}, opts...)
29+
client, err := openapi.NewClient(baseURL, clientOpts...)
2930
if err != nil {
3031
return nil, fmt.Errorf("failed to create client: %w", err)
3132
}

0 commit comments

Comments
 (0)