Skip to content
Open
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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,21 @@ hyperfleet-e2e/
├── cmd/ - CLI entry point
│ └── hyperfleet-e2e/
├── pkg/ - Core packages
│ ├── api/ - OpenAPI generated client
│ ├── client/ - HyperFleet API client wrapper
│ ├── client/ - HyperFleet API client
│ ├── config/ - Configuration loading and validation
│ ├── e2e/ - Test execution engine (Ginkgo)
│ ├── helper/ - Test helper utilities
│ ├── labels/ - Test label definitions
│ └── logger/ - Structured logging (slog)
│ ├── logger/ - Structured logging (slog)
│ └── util/ - Shared utility functions
├── e2e/ - Test suites
│ ├── adapter/ - Adapter lifecycle tests
│ ├── auth/ - JWT authentication tests
│ ├── channel/ - Channel management tests
│ ├── cluster/ - Cluster lifecycle tests
│ ├── nodepool/ - NodePool management tests
│ └── version/ - Version management tests
│ ├── version/ - Version management tests
│ └── wifconfig/ - WIF config management tests
├── testdata/ - Test payloads and fixtures
│ ├── adapter-configs/ - Adapter configuration files
│ └── payloads/
Expand All @@ -94,7 +96,6 @@ hyperfleet-e2e/
├── env/ - Environment configuration files
├── hack/ - Build and development scripts
├── images/ - Container image definitions
├── openapi/ - OpenAPI spec and generation config
└── scripts/ - Utility scripts
```

Expand Down
2 changes: 1 addition & 1 deletion docs/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ The environment guide covers:
### Build the E2E Binary

```bash
# Generate API client from OpenAPI spec and build
# Build the E2E binary
make build

# Verify the build
Expand Down
85 changes: 85 additions & 0 deletions e2e/auth/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package auth

import (
"context"
"crypto/rand"
"crypto/rsa"
"fmt"
"net/http"
"strings"
"time"

"github.com/golang-jwt/jwt/v5"
)

const apiPrefix = "/api/hyperfleet/v1/"

// rawRequest makes an HTTP request to the clusters API endpoint.
// If token is non-empty, it's sent as a Bearer token; otherwise unauthenticated.
func rawRequest(ctx context.Context, apiURL, method, token string) (*http.Response, error) {
fullURL := strings.TrimRight(apiURL, "/") + apiPrefix + "clusters"
req, err := http.NewRequestWithContext(ctx, method, fullURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Accept", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
return (&http.Client{Timeout: 30 * time.Second}).Do(req)
}

// craftJWT creates a self-signed JWT with arbitrary claims, signed with a random RSA key.
// Useful for testing rejection of tokens with invalid signatures, expired tokens, etc.
func craftJWT(claims jwt.MapClaims) (string, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return "", fmt.Errorf("generating RSA key: %w", err)
}

token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signed, err := token.SignedString(key)
if err != nil {
return "", fmt.Errorf("signing JWT: %w", err)
}
return signed, nil
}

// craftExpiredJWT creates a self-signed JWT that has already expired.
// Random-key signed, so it can't isolate exp-checking from signature rejection (see AUT-002 note in jwt_enforcement.go).
func craftExpiredJWT() (string, error) {
now := time.Now()
return craftJWT(jwt.MapClaims{
"iss": "https://expired-issuer.example.com",
"sub": "system:serviceaccount:test:expired-sa",
"aud": "hyperfleet-api",
"exp": jwt.NewNumericDate(now.Add(-1 * time.Hour)),
"iat": jwt.NewNumericDate(now.Add(-2 * time.Hour)),
})
Comment thread
kuudori marked this conversation as resolved.
}

// craftInvalidSignatureJWT creates a structurally valid JWT signed with a random key
// that the API's JWKS endpoint won't recognize.
func craftInvalidSignatureJWT() (string, error) {
now := time.Now()
return craftJWT(jwt.MapClaims{
"iss": "https://kubernetes.default.svc",
"sub": "system:serviceaccount:hyperfleet:fake-sa",
"aud": "hyperfleet-api",
"exp": jwt.NewNumericDate(now.Add(1 * time.Hour)),
"iat": jwt.NewNumericDate(now),
})
}

// craftUnconfiguredIssuerJWT creates a JWT with an issuer URL not in the API's configured issuer list.
// Random-key signed, so it can't isolate iss-checking from signature rejection either.
func craftUnconfiguredIssuerJWT() (string, error) {
now := time.Now()
return craftJWT(jwt.MapClaims{
"iss": "https://unconfigured-issuer.example.com",
"sub": "user@unconfigured.example.com",
"aud": "hyperfleet-api",
"exp": jwt.NewNumericDate(now.Add(1 * time.Hour)),
"iat": jwt.NewNumericDate(now),
})
}
97 changes: 97 additions & 0 deletions e2e/auth/jwt_enforcement.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package auth

import (
"context"
"net/http"

"github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" //nolint:staticcheck // dot import for test readability

"github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/helper"
"github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/labels"
)

var _ = ginkgo.Describe("[Suite: auth][enforcement] JWT Authentication Enforcement",
ginkgo.Label(labels.Tier0, labels.Auth, labels.Negative),
func() {
var h *helper.Helper

ginkgo.BeforeEach(func(_ context.Context) {
h = helper.New()
})

ginkgo.It("rejects GET request without Authorization header with 401 and AUT-001",
func(ctx context.Context) {
ginkgo.By("sending GET without Authorization header")
resp, err := rawRequest(ctx, h.Cfg.API.URL, http.MethodGet, "")
Expect(err).NotTo(HaveOccurred(), "HTTP request should succeed at transport level")
defer func() { _ = resp.Body.Close() }()

Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized),
"unauthenticated GET should return 401")
Expect(resp).To(helper.HaveRFC9457Error("HYPERFLEET-AUT-001"))
})

ginkgo.It("rejects POST request without Authorization header with 401 and AUT-001",
func(ctx context.Context) {
ginkgo.By("sending POST without Authorization header")
resp, err := rawRequest(ctx, h.Cfg.API.URL, http.MethodPost, "")
Expect(err).NotTo(HaveOccurred(), "HTTP request should succeed at transport level")
defer func() { _ = resp.Body.Close() }()

Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized),
"unauthenticated POST should return 401")
Expect(resp).To(helper.HaveRFC9457Error("HYPERFLEET-AUT-001"))
})

ginkgo.It("rejects requests with invalid JWT signature with 401 and AUT-002",
func(ctx context.Context) {
ginkgo.By("crafting a JWT signed with an unknown key")
token, err := craftInvalidSignatureJWT()
Expect(err).NotTo(HaveOccurred(), "crafting JWT should succeed")

ginkgo.By("sending GET with invalid-signature bearer token")
resp, err := rawRequest(ctx, h.Cfg.API.URL, http.MethodGet, token)
Expect(err).NotTo(HaveOccurred(), "HTTP request should succeed at transport level")
defer func() { _ = resp.Body.Close() }()

Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized),
"invalid signature should return 401")
Expect(resp).To(helper.HaveRFC9457Error("HYPERFLEET-AUT-002"))
})

// Note: the error model defines AUT-003 for expired tokens, but the API's JWT middleware
// currently returns AUT-002 (invalid credentials) for all unverifiable tokens.
// This test validates the deployed behavior.
ginkgo.It("rejects requests with expired token with 401",
func(ctx context.Context) {
ginkgo.By("crafting a self-signed expired JWT")
token, err := craftExpiredJWT()
Expect(err).NotTo(HaveOccurred(), "crafting expired JWT should succeed")

ginkgo.By("sending GET with expired bearer token")
resp, err := rawRequest(ctx, h.Cfg.API.URL, http.MethodGet, token)
Expect(err).NotTo(HaveOccurred(), "HTTP request should succeed at transport level")
defer func() { _ = resp.Body.Close() }()

Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized),
"expired token should return 401")
Expect(resp).To(helper.HaveRFC9457Error("HYPERFLEET-AUT-002"))
})

// Note: the error model defines AUT-004 for malformed tokens, but the API's JWT middleware
// currently returns AUT-002 (invalid credentials) for all unverifiable tokens.
// This test validates the deployed behavior.
ginkgo.It("rejects requests with malformed token with 401",
func(ctx context.Context) {
ginkgo.By("sending GET with a non-JWT string as bearer token")
resp, err := rawRequest(ctx, h.Cfg.API.URL, http.MethodGet, "not-a-jwt")
Expect(err).NotTo(HaveOccurred(), "HTTP request should succeed at transport level")
defer func() { _ = resp.Body.Close() }()

Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized),
"malformed token should return 401")
Expect(resp).To(helper.HaveRFC9457Error("HYPERFLEET-AUT-002"))
})
},
)
142 changes: 142 additions & 0 deletions e2e/auth/multi_issuer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package auth

import (
"context"
"fmt"
"net/http"

"github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" //nolint:staticcheck // dot import for test readability

"github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/client"
"github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/helper"
"github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/labels"
)

var _ = ginkgo.Describe("[Suite: auth][multi-issuer] JWT Identity and Issuer Validation",
ginkgo.Label(labels.Tier0, labels.Auth),
func() {
var h *helper.Helper

ginkgo.BeforeEach(func(_ context.Context) {
h = helper.New()
})

ginkgo.It("populates audit fields on write requests from JWT sub claim",
func(ctx context.Context) {
expected := h.ExpectedIdentity()
if expected == "" {
ginkgo.Skip("identity.expectedIdentity not configured - skipping audit field assertion")
}

ginkgo.By("creating a cluster and verifying created_by")
cluster, err := h.Client.CreateClusterFromPayload(ctx, h.TestDataPath("payloads/clusters/cluster-request.json"))
Expect(err).NotTo(HaveOccurred(), "cluster creation should succeed")
Expect(cluster.Id).NotTo(BeNil())
clusterID := *cluster.Id

h.DeferClusterCleanup(clusterID)

Expect(cluster.CreatedBy).To(HaveValue(Equal(expected)),
"created_by should contain the JWT sub claim identity")

ginkgo.By("patching the cluster and verifying updated_by")
patched, err := h.Client.PatchClusterFromPayload(ctx, clusterID, h.TestDataPath("payloads/clusters/cluster-patch.json"))
Expect(err).NotTo(HaveOccurred(), "cluster PATCH should succeed")
Expect(patched.UpdatedBy).To(HaveValue(Equal(expected)),
"updated_by should contain the JWT sub claim identity")

ginkgo.By("waiting for cluster to reconcile before delete")
Eventually(h.PollCluster(ctx, clusterID), h.Cfg.Timeouts.Cluster.Reconciled, h.Cfg.Polling.Interval).
Should(helper.HaveResourceCondition(client.ConditionTypeReconciled, client.ResourceConditionStatusTrue))

ginkgo.By("deleting the cluster and verifying deleted_by")
deleted, err := h.Client.DeleteCluster(ctx, clusterID)
Expect(err).NotTo(HaveOccurred(), "cluster DELETE should succeed")
Expect(deleted.DeletedBy).To(HaveValue(Equal(expected)),
"deleted_by should contain the JWT sub claim identity")
})

ginkgo.It("rejects tokens from unconfigured issuers with 401 and AUT-002",
func(ctx context.Context) {
ginkgo.By("crafting a JWT from an unconfigured issuer")
token, err := craftUnconfiguredIssuerJWT()
Expect(err).NotTo(HaveOccurred(), "crafting unconfigured-issuer JWT should succeed")

ginkgo.By("sending request with unconfigured issuer token")
resp, err := rawRequest(ctx, h.Cfg.API.URL, http.MethodGet, token)
Expect(err).NotTo(HaveOccurred(), "HTTP request should succeed at transport level")
defer func() { _ = resp.Body.Close() }()

Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized),
"unconfigured issuer should return 401")
Expect(resp).To(helper.HaveRFC9457Error("HYPERFLEET-AUT-002"))
})

ginkgo.It("accepts tokens from different service accounts within the configured issuer",
func(ctx context.Context) {
if !h.Cfg.Identity.TokenRequest.IsEnabled() {
ginkgo.Skip("TokenRequest not configured - cannot acquire token for a different SA")
}

// Use a different SA name in the same namespace to prove the API accepts
// tokens from any SA under the configured K8s issuer, not just the E2E SA.
altSAName := h.Cfg.Identity.TokenRequest.ServiceAccountName + "-alt"
ns := h.Cfg.Identity.TokenRequest.Namespace
audience := h.Cfg.Identity.TokenRequest.Audience
expiration := h.Cfg.Identity.TokenRequest.ExpirationSeconds

ginkgo.By("provisioning the alternative service account")
Expect(h.EnsureServiceAccount(ctx, ns, altSAName)).To(Succeed(),
"provisioning alt SA should succeed (requires RBAC to create service accounts in %s)", ns)
ginkgo.DeferCleanup(func(cleanupCtx context.Context) {
_ = h.DeleteServiceAccount(cleanupCtx, ns, altSAName)
})

ginkgo.By("acquiring a token for the alternative service account")
altToken, err := h.K8sClient.CreateToken(ctx, ns, altSAName, audience, expiration)
Expect(err).NotTo(HaveOccurred(), "acquiring a token for the alt SA should succeed")

ginkgo.By("creating a cluster with the alternative SA token")
altClient, err := client.NewHyperFleetClient(h.Cfg.API.URL, nil, client.WithBearerToken(altToken))
Expect(err).NotTo(HaveOccurred(), "creating alt client should succeed")

cluster, err := altClient.CreateClusterFromPayload(ctx, h.TestDataPath("payloads/clusters/cluster-request.json"))
Expect(err).NotTo(HaveOccurred(), "cluster creation with alt SA token should succeed")
Expect(cluster.Id).NotTo(BeNil())
clusterID := *cluster.Id

h.DeferClusterCleanup(clusterID)

ginkgo.By("verifying audit field shows the alternative SA identity")
expectedAltIdentity := fmt.Sprintf("system:serviceaccount:%s:%s", ns, altSAName)
Expect(cluster.CreatedBy).To(HaveValue(Equal(expectedAltIdentity)),
"created_by should reflect the alternative SA identity, not the primary E2E SA")
})

ginkgo.It("confirms adapters authenticate with JWT when reporting cluster status",
func(ctx context.Context) {
ginkgo.By("creating a cluster and waiting for Reconciled")
clusterID, err := h.GetTestCluster(ctx, h.TestDataPath("payloads/clusters/cluster-request.json"))
Expect(err).NotTo(HaveOccurred(), "cluster creation should succeed")

h.DeferClusterCleanup(clusterID)

Eventually(h.PollCluster(ctx, clusterID), h.Cfg.Timeouts.Cluster.Reconciled, h.Cfg.Polling.Interval).
Should(helper.HaveResourceCondition(client.ConditionTypeReconciled, client.ResourceConditionStatusTrue))

ginkgo.By("verifying adapter status reports were recorded")
statuses, err := h.Client.GetClusterStatuses(ctx, clusterID)
Expect(err).NotTo(HaveOccurred(), "getting cluster statuses should succeed")
Expect(statuses.Items).NotTo(BeEmpty(), "at least one adapter should have reported")

// AdapterStatus has no audit-identity field, so this only proves the
// JWT-authenticated PUT was accepted, not which identity it used.
for _, status := range statuses.Items {
Expect(status.CreatedTime).NotTo(BeZero(),
"adapter %s status should have created_time set (proving JWT-authenticated PUT succeeded)",
status.Adapter)
}
})
},
)
Loading