diff --git a/spinifex/gateway/ec2/instance/oidc_provider_mock_test.go b/spinifex/gateway/ec2/instance/oidc_provider_mock_test.go new file mode 100644 index 000000000..77884d123 --- /dev/null +++ b/spinifex/gateway/ec2/instance/oidc_provider_mock_test.go @@ -0,0 +1,19 @@ +package gateway_ec2_instance + +import "github.com/aws/aws-sdk-go/service/iam" + +// OIDC identity-provider registry stubs so fakeIAMService satisfies the +// IAMService interface; EC2 instance-profile paths never call these. + +func (f *fakeIAMService) CreateOpenIDConnectProvider(string, *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) { + return nil, nil +} +func (f *fakeIAMService) GetOpenIDConnectProvider(string, *iam.GetOpenIDConnectProviderInput) (*iam.GetOpenIDConnectProviderOutput, error) { + return nil, nil +} +func (f *fakeIAMService) ListOpenIDConnectProviders(string, *iam.ListOpenIDConnectProvidersInput) (*iam.ListOpenIDConnectProvidersOutput, error) { + return nil, nil +} +func (f *fakeIAMService) DeleteOpenIDConnectProvider(string, *iam.DeleteOpenIDConnectProviderInput) (*iam.DeleteOpenIDConnectProviderOutput, error) { + return nil, nil +} diff --git a/spinifex/gateway/gateway.go b/spinifex/gateway/gateway.go index 7072465cb..4423f9e80 100644 --- a/spinifex/gateway/gateway.go +++ b/spinifex/gateway/gateway.go @@ -135,19 +135,37 @@ func (gw *GatewayConfig) SetupRoutes() http.Handler { r.Use(slogRequestLogger) } - // AWS SigV4 authentication middleware - r.Use(gw.SigV4AuthMiddleware()) + // Anonymous STS bootstrap (AssumeRoleWithWebIdentity) is dispatched here, + // ahead of the SigV4 surface — these calls hold no AWS credentials, only a + // web-identity JWT in the body. Signed requests pass straight through. + r.Use(gw.anonymousSTSInterceptor) - // API request throttling (post-auth, per-account+action token bucket) - if gw.Throttler != nil { - r.Use(gw.Throttler.Middleware( - gw.throttleKeyFuncs(), - gw.writeThrottleError, - )) - } + // Public, unauthenticated OIDC discovery endpoints (IRSA). They serve the + // per-cluster issuer discovery document + JWKS so AWS STS / SDKs can + // validate ServiceAccount tokens. These carry no SigV4 and must bypass the + // auth + throttle middleware, so they live in their own group registered + // ahead of the authenticated surface. + r.Group(func(pub chi.Router) { + pub.Get("/oidc/eks/{region}/{accountID}/{clusterName}/.well-known/openid-configuration", gw.OIDCDiscoveryDocument) + pub.Get("/oidc/eks/{region}/{accountID}/{clusterName}/keys", gw.OIDCJWKS) + }) - // Catch-all routes - r.HandleFunc("/*", gw.Request) + // Authenticated AWS API surface. + r.Group(func(auth chi.Router) { + // AWS SigV4 authentication middleware + auth.Use(gw.SigV4AuthMiddleware()) + + // API request throttling (post-auth, per-account+action token bucket) + if gw.Throttler != nil { + auth.Use(gw.Throttler.Middleware( + gw.throttleKeyFuncs(), + gw.writeThrottleError, + )) + } + + // Catch-all routes + auth.HandleFunc("/*", gw.Request) + }) return r } diff --git a/spinifex/gateway/iam.go b/spinifex/gateway/iam.go index 5cb344834..444035d0f 100644 --- a/spinifex/gateway/iam.go +++ b/spinifex/gateway/iam.go @@ -154,6 +154,20 @@ var iamActions = map[string]IAMHandler{ "RemoveRoleFromInstanceProfile": iamHandler(func(accountID string, input *iam.RemoveRoleFromInstanceProfileInput, gw *GatewayConfig) (any, error) { return gateway_iam.RemoveRoleFromInstanceProfile(accountID, input, gw.IAMService) }), + + // OIDC identity-provider registry (IRSA) + "CreateOpenIDConnectProvider": iamHandler(func(accountID string, input *iam.CreateOpenIDConnectProviderInput, gw *GatewayConfig) (any, error) { + return gateway_iam.CreateOpenIDConnectProvider(accountID, input, gw.IAMService) + }), + "GetOpenIDConnectProvider": iamHandler(func(accountID string, input *iam.GetOpenIDConnectProviderInput, gw *GatewayConfig) (any, error) { + return gateway_iam.GetOpenIDConnectProvider(accountID, input, gw.IAMService) + }), + "ListOpenIDConnectProviders": iamHandler(func(accountID string, input *iam.ListOpenIDConnectProvidersInput, gw *GatewayConfig) (any, error) { + return gateway_iam.ListOpenIDConnectProviders(accountID, input, gw.IAMService) + }), + "DeleteOpenIDConnectProvider": iamHandler(func(accountID string, input *iam.DeleteOpenIDConnectProviderInput, gw *GatewayConfig) (any, error) { + return gateway_iam.DeleteOpenIDConnectProvider(accountID, input, gw.IAMService) + }), } func (gw *GatewayConfig) IAM_Request(w http.ResponseWriter, r *http.Request) error { diff --git a/spinifex/gateway/iam/oidc_provider.go b/spinifex/gateway/iam/oidc_provider.go new file mode 100644 index 000000000..e57310c63 --- /dev/null +++ b/spinifex/gateway/iam/oidc_provider.go @@ -0,0 +1,34 @@ +package gateway_iam + +import ( + "errors" + + "github.com/aws/aws-sdk-go/service/iam" + "github.com/mulgadc/spinifex/spinifex/awserrors" + handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" +) + +func CreateOpenIDConnectProvider(accountID string, input *iam.CreateOpenIDConnectProviderInput, svc handlers_iam.IAMService) (*iam.CreateOpenIDConnectProviderOutput, error) { + if input.Url == nil || *input.Url == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + return svc.CreateOpenIDConnectProvider(accountID, input) +} + +func GetOpenIDConnectProvider(accountID string, input *iam.GetOpenIDConnectProviderInput, svc handlers_iam.IAMService) (*iam.GetOpenIDConnectProviderOutput, error) { + if input.OpenIDConnectProviderArn == nil || *input.OpenIDConnectProviderArn == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + return svc.GetOpenIDConnectProvider(accountID, input) +} + +func ListOpenIDConnectProviders(accountID string, input *iam.ListOpenIDConnectProvidersInput, svc handlers_iam.IAMService) (*iam.ListOpenIDConnectProvidersOutput, error) { + return svc.ListOpenIDConnectProviders(accountID, input) +} + +func DeleteOpenIDConnectProvider(accountID string, input *iam.DeleteOpenIDConnectProviderInput, svc handlers_iam.IAMService) (*iam.DeleteOpenIDConnectProviderOutput, error) { + if input.OpenIDConnectProviderArn == nil || *input.OpenIDConnectProviderArn == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + return svc.DeleteOpenIDConnectProvider(accountID, input) +} diff --git a/spinifex/gateway/iam/oidc_provider_mock_test.go b/spinifex/gateway/iam/oidc_provider_mock_test.go new file mode 100644 index 000000000..c08575a62 --- /dev/null +++ b/spinifex/gateway/iam/oidc_provider_mock_test.go @@ -0,0 +1,19 @@ +package gateway_iam + +import "github.com/aws/aws-sdk-go/service/iam" + +// OIDC identity-provider registry stubs so stubIAMService satisfies the +// IAMService interface. Dedicated CRUD coverage lives in handlers/iam. + +func (s *stubIAMService) CreateOpenIDConnectProvider(string, *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) { + return nil, nil +} +func (s *stubIAMService) GetOpenIDConnectProvider(string, *iam.GetOpenIDConnectProviderInput) (*iam.GetOpenIDConnectProviderOutput, error) { + return nil, nil +} +func (s *stubIAMService) ListOpenIDConnectProviders(string, *iam.ListOpenIDConnectProvidersInput) (*iam.ListOpenIDConnectProvidersOutput, error) { + return nil, nil +} +func (s *stubIAMService) DeleteOpenIDConnectProvider(string, *iam.DeleteOpenIDConnectProviderInput) (*iam.DeleteOpenIDConnectProviderOutput, error) { + return nil, nil +} diff --git a/spinifex/gateway/oidc_discovery.go b/spinifex/gateway/oidc_discovery.go new file mode 100644 index 000000000..c7c5ec598 --- /dev/null +++ b/spinifex/gateway/oidc_discovery.go @@ -0,0 +1,116 @@ +package gateway + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks" + "github.com/nats-io/nats.go" +) + +// oidcDiscoveryDocument is the subset of the OpenID Connect Discovery metadata +// (RFC 8414) that AWS STS and the AWS SDKs consume to validate IRSA +// ServiceAccount tokens. Served unauthenticated at the per-cluster issuer URL. +type oidcDiscoveryDocument struct { + Issuer string `json:"issuer"` + JWKSURI string `json:"jwks_uri"` + ResponseTypesSupported []string `json:"response_types_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` + ClaimsSupported []string `json:"claims_supported"` +} + +// eksAccountKV opens the read-only per-account EKS bucket. Returns +// nats.ErrBucketNotFound (unwrapped) when the account has no clusters, which +// the OIDC handlers translate to 404. +func (gw *GatewayConfig) eksAccountKV(accountID string) (nats.KeyValue, error) { + if gw.NATSConn == nil { + return nil, errors.New("gateway NATS connection not initialised") + } + js, err := gw.NATSConn.JetStream() + if err != nil { + return nil, err + } + return js.KeyValue(handlers_eks.AccountBucketName(accountID)) +} + +// OIDCDiscoveryDocument serves the cluster's OpenID discovery document. The +// `issuer` it returns is the cluster's stored IssuerURL — byte-identical to the +// apiserver's `iss` claim — so an AWS SDK that fetches this document while +// registering the provider sees a matching issuer. +func (gw *GatewayConfig) OIDCDiscoveryDocument(w http.ResponseWriter, r *http.Request) { + accountID := chi.URLParam(r, "accountID") + clusterName := chi.URLParam(r, "clusterName") + + kv, err := gw.eksAccountKV(accountID) + if err != nil { + if errors.Is(err, nats.ErrBucketNotFound) { + http.NotFound(w, r) + return + } + slog.Error("OIDC discovery: open account bucket", "accountID", accountID, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + meta, err := handlers_eks.GetClusterMeta(kv, clusterName) + if err != nil || meta == nil || meta.OIDCIssuer == "" { + http.NotFound(w, r) + return + } + + gw.writeOIDCJSON(w, oidcDiscoveryDocument{ + Issuer: meta.OIDCIssuer, + JWKSURI: meta.OIDCIssuer + "/keys", + ResponseTypesSupported: []string{"id_token"}, + SubjectTypesSupported: []string{"public"}, + IDTokenSigningAlgValuesSupported: []string{"ES256"}, + ClaimsSupported: []string{"sub", "iss", "aud", "exp", "iat"}, + }) +} + +// OIDCJWKS serves the cluster's JWKS document (the public verification key) as +// referenced by the discovery document's jwks_uri. +func (gw *GatewayConfig) OIDCJWKS(w http.ResponseWriter, r *http.Request) { + accountID := chi.URLParam(r, "accountID") + clusterName := chi.URLParam(r, "clusterName") + + kv, err := gw.eksAccountKV(accountID) + if err != nil { + if errors.Is(err, nats.ErrBucketNotFound) { + http.NotFound(w, r) + return + } + slog.Error("OIDC JWKS: open account bucket", "accountID", accountID, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + entry, err := kv.Get(handlers_eks.OIDCJWKSKey(clusterName)) + if err != nil { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=300") + if _, err := w.Write(entry.Value()); err != nil { + slog.Debug("OIDC JWKS: write response", "err", err) + } +} + +func (gw *GatewayConfig) writeOIDCJSON(w http.ResponseWriter, v any) { + body, err := json.Marshal(v) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=300") + if _, err := w.Write(body); err != nil { + slog.Debug("OIDC discovery: write response", "err", err) + } +} diff --git a/spinifex/gateway/oidc_discovery_test.go b/spinifex/gateway/oidc_discovery_test.go new file mode 100644 index 000000000..a8203ed81 --- /dev/null +++ b/spinifex/gateway/oidc_discovery_test.go @@ -0,0 +1,123 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks" + "github.com/mulgadc/spinifex/spinifex/testutil" +) + +const ( + testDiscAccount = "000000000001" + testDiscCluster = "h1" + testDiscIssuer = "https://10.0.0.1:9999/oidc/eks/ap-southeast-2/000000000001/h1" +) + +// seedDiscoveryCluster writes a ClusterMeta (with OIDCIssuer) and a JWKS doc +// into the per-account EKS bucket, returning a router wired to the same NATS. +func seedDiscoveryCluster(t *testing.T) http.Handler { + t.Helper() + _, nc, js := testutil.StartTestJetStream(t) + + kv, err := handlers_eks.GetOrCreateAccountBucket(js, testDiscAccount) + if err != nil { + t.Fatalf("account bucket: %v", err) + } + if err := handlers_eks.PutClusterMeta(kv, &handlers_eks.ClusterMeta{ + Name: testDiscCluster, + Status: handlers_eks.ClusterStatusActive, + OIDCIssuer: testDiscIssuer, + }); err != nil { + t.Fatalf("put cluster meta: %v", err) + } + if _, err := kv.Put(handlers_eks.OIDCJWKSKey(testDiscCluster), []byte(`{"keys":[{"kty":"EC","kid":"abc"}]}`)); err != nil { + t.Fatalf("put jwks: %v", err) + } + + gw := &GatewayConfig{DisableLogging: true, NATSConn: nc} + return gw.SetupRoutes() +} + +func TestOIDCDiscoveryDocument(t *testing.T) { + h := seedDiscoveryCluster(t) + + req := httptest.NewRequest(http.MethodGet, + "/oidc/eks/ap-southeast-2/"+testDiscAccount+"/"+testDiscCluster+"/.well-known/openid-configuration", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + var doc oidcDiscoveryDocument + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if doc.Issuer != testDiscIssuer { + t.Fatalf("issuer = %q, want %q", doc.Issuer, testDiscIssuer) + } + if doc.JWKSURI != testDiscIssuer+"/keys" { + t.Fatalf("jwks_uri = %q, want issuer+/keys", doc.JWKSURI) + } + if len(doc.IDTokenSigningAlgValuesSupported) != 1 || doc.IDTokenSigningAlgValuesSupported[0] != "ES256" { + t.Fatalf("alg = %v, want [ES256]", doc.IDTokenSigningAlgValuesSupported) + } +} + +func TestOIDCJWKS(t *testing.T) { + h := seedDiscoveryCluster(t) + + req := httptest.NewRequest(http.MethodGet, + "/oidc/eks/ap-southeast-2/"+testDiscAccount+"/"+testDiscCluster+"/keys", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("content-type = %q", ct) + } + var jwks struct { + Keys []map[string]string `json:"keys"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &jwks); err != nil { + t.Fatalf("unmarshal jwks: %v", err) + } + if len(jwks.Keys) != 1 || jwks.Keys[0]["kid"] != "abc" { + t.Fatalf("jwks = %s", rec.Body.String()) + } +} + +func TestOIDCDiscovery_UnknownCluster404(t *testing.T) { + h := seedDiscoveryCluster(t) + + for _, path := range []string{ + "/oidc/eks/ap-southeast-2/" + testDiscAccount + "/nope/.well-known/openid-configuration", + "/oidc/eks/ap-southeast-2/" + testDiscAccount + "/nope/keys", + "/oidc/eks/ap-southeast-2/999999999999/h1/keys", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("path %s: status = %d, want 404", path, rec.Code) + } + } +} + +// TestOIDCDiscovery_NoAuth confirms the discovery routes bypass SigV4 — they +// return 200 with no Authorization header, unlike the authenticated surface. +func TestOIDCDiscovery_NoAuth(t *testing.T) { + h := seedDiscoveryCluster(t) + req := httptest.NewRequest(http.MethodGet, + "/oidc/eks/ap-southeast-2/"+testDiscAccount+"/"+testDiscCluster+"/keys", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unauthenticated discovery status = %d, want 200", rec.Code) + } +} diff --git a/spinifex/gateway/oidc_provider_mock_test.go b/spinifex/gateway/oidc_provider_mock_test.go new file mode 100644 index 000000000..404926706 --- /dev/null +++ b/spinifex/gateway/oidc_provider_mock_test.go @@ -0,0 +1,33 @@ +package gateway + +import "github.com/aws/aws-sdk-go/service/iam" + +// OIDC identity-provider registry methods — IRSA stubs so the mock IAM +// services satisfy the IAMService interface. No gateway test exercises these +// paths directly (covered by handlers/iam unit tests). + +func (m *mockIAMService) CreateOpenIDConnectProvider(string, *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) { + return nil, nil +} +func (m *mockIAMService) GetOpenIDConnectProvider(string, *iam.GetOpenIDConnectProviderInput) (*iam.GetOpenIDConnectProviderOutput, error) { + return nil, nil +} +func (m *mockIAMService) ListOpenIDConnectProviders(string, *iam.ListOpenIDConnectProvidersInput) (*iam.ListOpenIDConnectProvidersOutput, error) { + return nil, nil +} +func (m *mockIAMService) DeleteOpenIDConnectProvider(string, *iam.DeleteOpenIDConnectProviderInput) (*iam.DeleteOpenIDConnectProviderOutput, error) { + return nil, nil +} + +func (m *flexMockIAMService) CreateOpenIDConnectProvider(string, *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) { + return nil, nil +} +func (m *flexMockIAMService) GetOpenIDConnectProvider(string, *iam.GetOpenIDConnectProviderInput) (*iam.GetOpenIDConnectProviderOutput, error) { + return nil, nil +} +func (m *flexMockIAMService) ListOpenIDConnectProviders(string, *iam.ListOpenIDConnectProvidersInput) (*iam.ListOpenIDConnectProvidersOutput, error) { + return nil, nil +} +func (m *flexMockIAMService) DeleteOpenIDConnectProvider(string, *iam.DeleteOpenIDConnectProviderInput) (*iam.DeleteOpenIDConnectProviderOutput, error) { + return nil, nil +} diff --git a/spinifex/gateway/sts.go b/spinifex/gateway/sts.go index a29e79c8a..1cac7d1e4 100644 --- a/spinifex/gateway/sts.go +++ b/spinifex/gateway/sts.go @@ -58,13 +58,11 @@ var stsActions = map[string]STSHandler{ "AssumeRole": stsHandler(func(c stsCaller, input *sts.AssumeRoleInput, gw *GatewayConfig) (any, error) { return gateway_sts.AssumeRole(c.accountID, c.arn, c.identity, input, gw.STSService) }), - // TODO: AssumeRoleWithWebIdentity is anonymous on AWS — the caller is - // authenticated by the JWT body, not by a SigV4 envelope. The current - // dispatch path runs SigV4 first, then enters this handler with a synthetic - // stsCaller. That works for in-cluster callers that already hold SigV4 - // credentials but rejects the true anonymous code path (no AWS creds, only - // a projected ServiceAccount token). The AWS gateway mux needs a pre-auth - // route for this action so the JWT can be the sole authenticator. + // AssumeRoleWithWebIdentity is anonymous on AWS — the caller is authenticated + // by the JWT body, not by a SigV4 envelope. anonymousSTSInterceptor routes it + // to this dispatcher ahead of the SigV4 middleware, so STS_Request enters with + // a zero stsCaller (see anonymousSTSActions); the handler ignores it and + // gates the request on the token + the role's web-identity trust policy. "AssumeRoleWithWebIdentity": stsHandler(func(_ stsCaller, input *sts.AssumeRoleWithWebIdentityInput, gw *GatewayConfig) (any, error) { return gateway_sts.AssumeRoleWithWebIdentity(input, gw.STSService) }), @@ -85,6 +83,14 @@ var stsSkipPolicyCheck = map[string]bool{ "GetCallerIdentity": true, } +// anonymousSTSActions lists STS actions AWS accepts without SigV4: the caller +// holds no AWS credentials and is authenticated solely by a web-identity JWT in +// the request body. anonymousSTSInterceptor routes these ahead of the SigV4 +// middleware, and STS_Request skips SigV4-caller resolution for them. +var anonymousSTSActions = map[string]bool{ + "AssumeRoleWithWebIdentity": true, +} + func (gw *GatewayConfig) STS_Request(w http.ResponseWriter, r *http.Request) error { queryArgs, err := readQueryArgs(r) if err != nil { @@ -113,9 +119,14 @@ func (gw *GatewayConfig) STS_Request(w http.ResponseWriter, r *http.Request) err } } - caller, err := gw.resolveSTSCaller(r) - if err != nil { - return err + // Anonymous actions (AssumeRoleWithWebIdentity) carry no SigV4 envelope, so + // there is no caller to resolve — the handler ignores it. + var caller stsCaller + if !anonymousSTSActions[action] { + caller, err = gw.resolveSTSCaller(r) + if err != nil { + return err + } } xmlOutput, err := handler(action, queryArgs, gw, caller) diff --git a/spinifex/gateway/sts/oidc_provider_mock_test.go b/spinifex/gateway/sts/oidc_provider_mock_test.go new file mode 100644 index 000000000..f42503d02 --- /dev/null +++ b/spinifex/gateway/sts/oidc_provider_mock_test.go @@ -0,0 +1,19 @@ +package gateway_sts + +import "github.com/aws/aws-sdk-go/service/iam" + +// OIDC identity-provider registry stubs so stubIAMService satisfies the +// IAMService interface; STS gateway tests never call these. + +func (s *stubIAMService) CreateOpenIDConnectProvider(string, *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) { + return nil, nil +} +func (s *stubIAMService) GetOpenIDConnectProvider(string, *iam.GetOpenIDConnectProviderInput) (*iam.GetOpenIDConnectProviderOutput, error) { + return nil, nil +} +func (s *stubIAMService) ListOpenIDConnectProviders(string, *iam.ListOpenIDConnectProvidersInput) (*iam.ListOpenIDConnectProvidersOutput, error) { + return nil, nil +} +func (s *stubIAMService) DeleteOpenIDConnectProvider(string, *iam.DeleteOpenIDConnectProviderInput) (*iam.DeleteOpenIDConnectProviderOutput, error) { + return nil, nil +} diff --git a/spinifex/gateway/sts_anonymous.go b/spinifex/gateway/sts_anonymous.go new file mode 100644 index 000000000..c6a183215 --- /dev/null +++ b/spinifex/gateway/sts_anonymous.go @@ -0,0 +1,49 @@ +package gateway + +import ( + "bytes" + "context" + "io" + "net/http" +) + +// anonymousSTSInterceptor routes the credential-bootstrap STS actions +// (AssumeRoleWithWebIdentity) to the STS dispatcher ahead of the SigV4 +// middleware. These calls carry no AWS credentials — the caller is +// authenticated by the projected ServiceAccount JWT in the request body — so +// the signed surface would reject them as unauthenticated. Requests bearing an +// Authorization header skip this path untouched, keeping the signed hot path +// free of body buffering. +func (gw *GatewayConfig) anonymousSTSInterceptor(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.Header.Get("Authorization") == "" { + if args, ok := gw.anonymousSTSArgs(r); ok { + ctx := context.WithValue(r.Context(), ctxQueryArgs, args) + ctx = context.WithValue(ctx, ctxService, "sts") + r = r.WithContext(ctx) + if err := gw.STS_Request(w, r); err != nil { + gw.ErrorHandler(w, r, err) + } + return + } + } + next.ServeHTTP(w, r) + }) +} + +// anonymousSTSArgs buffers and restores the request body, parses the AWS query +// args, and reports whether Action is an STS action permitted without SigV4. A +// non-anonymous request flows on unchanged with its body intact. +func (gw *GatewayConfig) anonymousSTSArgs(r *http.Request) (map[string]string, bool) { + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, false + } + r.Body = io.NopCloser(bytes.NewReader(body)) + + args, err := ParseAWSQueryArgs(string(body)) + if err != nil || !anonymousSTSActions[args["Action"]] { + return nil, false + } + return args, true +} diff --git a/spinifex/gateway/sts_anonymous_test.go b/spinifex/gateway/sts_anonymous_test.go new file mode 100644 index 000000000..b7dd1ef32 --- /dev/null +++ b/spinifex/gateway/sts_anonymous_test.go @@ -0,0 +1,105 @@ +package gateway + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAnonymousSTS_WebIdentityNoAuthReachesHandler proves the IRSA bootstrap +// call — AssumeRoleWithWebIdentity carrying no Authorization header — is routed +// to the STS dispatcher ahead of the SigV4 middleware, rather than rejected as +// unauthenticated. The mock asserts the parsed token reached the handler. +func TestAnonymousSTS_WebIdentityNoAuthReachesHandler(t *testing.T) { + var seenToken string + gw := &GatewayConfig{ + DisableLogging: true, + IAMService: &flexMockIAMService{}, + STSService: &flexMockSTSService{ + assumeWebIdentityFn: func(in *sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error) { + seenToken = aws.StringValue(in.WebIdentityToken) + return &sts.AssumeRoleWithWebIdentityOutput{ + Credentials: &sts.Credentials{ + AccessKeyId: aws.String("ASIAIRSA"), + SecretAccessKey: aws.String("secret"), + SessionToken: aws.String("token"), + }, + }, nil + }, + }, + } + handler := gw.SetupRoutes() + + body := "Action=AssumeRoleWithWebIdentity&Version=2011-06-15" + + "&RoleArn=arn:aws:iam::000000000001:role/demo&RoleSessionName=s1" + + "&WebIdentityToken=header.payload.sig" + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // No Authorization header — this is the unsigned bootstrap path. + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + b, _ := io.ReadAll(rec.Body) + assert.Contains(t, string(b), "AssumeRoleWithWebIdentityResult") + assert.Contains(t, string(b), "ASIAIRSA") + assert.Equal(t, "header.payload.sig", seenToken) +} + +// TestAnonymousSTS_SignedRequestFallsThrough confirms a request bearing an +// Authorization header is NOT intercepted — it flows to the signed surface, so +// the anonymous dispatcher never runs and the body is left for the SigV4 path. +func TestAnonymousSTS_SignedRequestFallsThrough(t *testing.T) { + gw := &GatewayConfig{ + DisableLogging: true, + STSService: &flexMockSTSService{ + assumeWebIdentityFn: func(*sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error) { + t.Fatal("signed request must not reach the anonymous STS dispatcher") + return nil, nil + }, + }, + } + + nextCalled := false + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { nextCalled = true }) + + req := httptest.NewRequest(http.MethodPost, "/", + strings.NewReader("Action=AssumeRoleWithWebIdentity&WebIdentityToken=x")) + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=...") + + rec := httptest.NewRecorder() + gw.anonymousSTSInterceptor(next).ServeHTTP(rec, req) + assert.True(t, nextCalled, "signed request must pass through to next handler") +} + +// TestAnonymousSTSArgs_Classification covers the body-peek helper: an anonymous +// action is recognised and the body is restored for the downstream handler; a +// non-anonymous action is rejected and the body left intact for SigV4. +func TestAnonymousSTSArgs_Classification(t *testing.T) { + gw := &GatewayConfig{DisableLogging: true} + + t.Run("anonymous action recognised, body restored", func(t *testing.T) { + const body = "Action=AssumeRoleWithWebIdentity&WebIdentityToken=tok" + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + args, ok := gw.anonymousSTSArgs(req) + require.True(t, ok) + assert.Equal(t, "AssumeRoleWithWebIdentity", args["Action"]) + + restored, _ := io.ReadAll(req.Body) + assert.Equal(t, body, string(restored), "body must be restored for downstream read") + }) + + t.Run("non-anonymous action rejected", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetCallerIdentity")) + _, ok := gw.anonymousSTSArgs(req) + assert.False(t, ok) + }) +} diff --git a/spinifex/gateway/sts_request_test.go b/spinifex/gateway/sts_request_test.go index 639804387..47f56fb55 100644 --- a/spinifex/gateway/sts_request_test.go +++ b/spinifex/gateway/sts_request_test.go @@ -26,6 +26,7 @@ type flexMockSTSService struct { assumeRoleFn func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) getCallerIdentityFn func(string, string, string, *sts.GetCallerIdentityInput) (*sts.GetCallerIdentityOutput, error) lookupSessionFn func(string) (*handlers_sts.SessionCredential, error) + assumeWebIdentityFn func(*sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error) } var _ handlers_sts.STSService = (*flexMockSTSService)(nil) @@ -65,7 +66,10 @@ func (m *flexMockSTSService) VerifySessionToken(*handlers_sts.SessionCredential, return true } -func (m *flexMockSTSService) AssumeRoleWithWebIdentity(*sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error) { +func (m *flexMockSTSService) AssumeRoleWithWebIdentity(input *sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error) { + if m.assumeWebIdentityFn != nil { + return m.assumeWebIdentityFn(input) + } return nil, errors.New(awserrors.ErrorNotImplemented) } diff --git a/spinifex/handlers/iam/oidc_providers.go b/spinifex/handlers/iam/oidc_providers.go index 8ddd65ccf..c1e8109fa 100644 --- a/spinifex/handlers/iam/oidc_providers.go +++ b/spinifex/handlers/iam/oidc_providers.go @@ -3,13 +3,25 @@ package handlers_iam import ( "crypto/sha256" "encoding/hex" + "encoding/json" + "errors" "fmt" + "log/slog" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/mulgadc/spinifex/spinifex/awserrors" + "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/nats-io/nats.go" ) // Per-account IAM JetStream KV bucket holds resources that are inherently // account-scoped and not appropriate for the global IAM buckets — currently // the OIDC identity-provider registry consumed by STS AssumeRoleWithWebIdentity -// and (in a later sprint) by IAM's CreateOpenIDConnectProvider CRUD. +// and by IAM's CreateOpenIDConnectProvider CRUD. // // Lazy creation: callers that read MUST tolerate ErrBucketNotFound (no provider // has been registered for the account yet); callers that write MUST use @@ -52,3 +64,241 @@ func OIDCProviderKey(issuer string) string { func OIDCProviderARN(accountID, issuerHostPath string) string { return fmt.Sprintf("arn:aws:iam::%s:oidc-provider/%s", accountID, issuerHostPath) } + +// GetOrCreateIAMAccountBucket opens the per-account IAM bucket, creating it on +// first use. Writers (CreateOpenIDConnectProvider) MUST use this; readers that +// must tolerate a not-yet-created bucket should call js.KeyValue directly and +// treat nats.ErrBucketNotFound as "empty". +func GetOrCreateIAMAccountBucket(js nats.JetStreamContext, accountID string, replicas int) (nats.KeyValue, error) { + return getOrCreateBucket(js, IAMAccountBucketName(accountID), 1, max(replicas, 1)) +} + +// OIDCProviderRecord is the stored shape of a registered OIDC identity provider. +// Url carries the full issuer URL including scheme (the exact string the +// cluster apiserver places in the token `iss` claim); the KV key is its hash, +// and the provider ARN is derived by stripping the scheme. +type OIDCProviderRecord struct { + Url string `json:"url"` + ClientIDList []string `json:"clientIDList,omitempty"` + ThumbprintList []string `json:"thumbprintList,omitempty"` + CreatedAt string `json:"createdAt"` + Tags []Tag `json:"tags,omitempty"` +} + +// issuerFromOIDCProviderARN reverses OIDCProviderARN: it extracts the +// scheme-less host/path suffix and prepends https:// to reconstruct the issuer +// URL (and therefore its KV key). v1 issuers are always https, so this +// round-trips Create's input.Url. +func issuerFromOIDCProviderARN(arn string) (string, error) { + _, hostPath, ok := strings.Cut(arn, ":oidc-provider/") + if !ok { + return "", errors.New("not an oidc-provider ARN") + } + if hostPath == "" { + return "", errors.New("oidc-provider ARN missing host/path") + } + return "https://" + hostPath, nil +} + +// validateOIDCProviderURL enforces the v1 issuer shape: a parseable https URL +// with a host. We are the IdP, so no thumbprint/cert-chain validation applies. +func validateOIDCProviderURL(raw string) error { + if raw == "" { + return errors.New("url is required") + } + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("parse url: %w", err) + } + if u.Scheme != "https" { + return fmt.Errorf("url scheme must be https, got %q", u.Scheme) + } + if u.Host == "" { + return errors.New("url missing host") + } + return nil +} + +func awsStrings(in []*string) []string { + out := make([]string, 0, len(in)) + for _, s := range in { + if s != nil { + out = append(out, *s) + } + } + return out +} + +func (s *IAMServiceImpl) CreateOpenIDConnectProvider(accountID string, input *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) { + issuer := aws.StringValue(input.Url) + if err := validateOIDCProviderURL(issuer); err != nil { + slog.Debug("CreateOpenIDConnectProvider: invalid Url", "url", issuer, "err", err) + return nil, errors.New(awserrors.ErrorIAMInvalidInput) + } + + record := OIDCProviderRecord{ + Url: issuer, + ClientIDList: awsStrings(input.ClientIDList), + ThumbprintList: awsStrings(input.ThumbprintList), + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Tags: copyTags(input.Tags), + } + data, err := json.Marshal(record) + if err != nil { + return nil, fmt.Errorf("marshal OIDC provider: %w", err) + } + + kv, err := GetOrCreateIAMAccountBucket(s.js, accountID, s.replicas) + if err != nil { + return nil, fmt.Errorf("open IAM account bucket: %w", err) + } + + if _, err := kv.Create(OIDCProviderKey(issuer), data); err != nil { + if errors.Is(err, nats.ErrKeyExists) { + return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists) + } + return nil, fmt.Errorf("store OIDC provider: %w", err) + } + + arn := OIDCProviderARN(accountID, stripIssuerScheme(issuer)) + slog.Info("IAM OIDC provider created", "accountID", accountID, "url", issuer, "arn", arn) + return &iam.CreateOpenIDConnectProviderOutput{ + OpenIDConnectProviderArn: aws.String(arn), + Tags: tagsToSDK(record.Tags), + }, nil +} + +func (s *IAMServiceImpl) GetOpenIDConnectProvider(accountID string, input *iam.GetOpenIDConnectProviderInput) (*iam.GetOpenIDConnectProviderOutput, error) { + record, err := s.getOIDCProvider(accountID, aws.StringValue(input.OpenIDConnectProviderArn)) + if err != nil { + return nil, err + } + return &iam.GetOpenIDConnectProviderOutput{ + Url: aws.String(stripIssuerScheme(record.Url)), + ClientIDList: aws.StringSlice(record.ClientIDList), + ThumbprintList: aws.StringSlice(record.ThumbprintList), + CreateDate: aws.Time(parseCreatedAt(record.CreatedAt)), + Tags: tagsToSDK(record.Tags), + }, nil +} + +func (s *IAMServiceImpl) ListOpenIDConnectProviders(accountID string, _ *iam.ListOpenIDConnectProvidersInput) (*iam.ListOpenIDConnectProvidersOutput, error) { + out := &iam.ListOpenIDConnectProvidersOutput{ + OpenIDConnectProviderList: []*iam.OpenIDConnectProviderListEntry{}, + } + + kv, err := s.js.KeyValue(IAMAccountBucketName(accountID)) + if err != nil { + if errors.Is(err, nats.ErrBucketNotFound) { + return out, nil + } + return nil, fmt.Errorf("open IAM account bucket: %w", err) + } + + keys, err := kv.Keys() + if err != nil { + if errors.Is(err, nats.ErrNoKeysFound) { + return out, nil + } + return nil, fmt.Errorf("list OIDC provider keys: %w", err) + } + + for _, key := range keys { + if key == utils.VersionKey || !strings.HasPrefix(key, oidcProvidersKeyPrefix) { + continue + } + entry, err := kv.Get(key) + if err != nil { + if errors.Is(err, nats.ErrKeyNotFound) { + continue + } + slog.Warn("ListOpenIDConnectProviders: get failed", "key", key, "err", err) + continue + } + var record OIDCProviderRecord + if err := json.Unmarshal(entry.Value(), &record); err != nil { + slog.Warn("ListOpenIDConnectProviders: unmarshal failed", "key", key, "err", err) + continue + } + out.OpenIDConnectProviderList = append(out.OpenIDConnectProviderList, + &iam.OpenIDConnectProviderListEntry{ + Arn: aws.String(OIDCProviderARN(accountID, stripIssuerScheme(record.Url))), + }) + } + return out, nil +} + +func (s *IAMServiceImpl) DeleteOpenIDConnectProvider(accountID string, input *iam.DeleteOpenIDConnectProviderInput) (*iam.DeleteOpenIDConnectProviderOutput, error) { + arn := aws.StringValue(input.OpenIDConnectProviderArn) + issuer, err := issuerFromOIDCProviderARN(arn) + if err != nil { + return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) + } + + kv, err := s.js.KeyValue(IAMAccountBucketName(accountID)) + if err != nil { + if errors.Is(err, nats.ErrBucketNotFound) { + return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) + } + return nil, fmt.Errorf("open IAM account bucket: %w", err) + } + + key := OIDCProviderKey(issuer) + if _, err := kv.Get(key); err != nil { + if errors.Is(err, nats.ErrKeyNotFound) { + return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) + } + return nil, fmt.Errorf("get OIDC provider: %w", err) + } + if err := kv.Delete(key); err != nil { + return nil, fmt.Errorf("delete OIDC provider: %w", err) + } + + slog.Info("IAM OIDC provider deleted", "accountID", accountID, "arn", arn) + return &iam.DeleteOpenIDConnectProviderOutput{}, nil +} + +func (s *IAMServiceImpl) getOIDCProvider(accountID, arn string) (*OIDCProviderRecord, error) { + issuer, err := issuerFromOIDCProviderARN(arn) + if err != nil { + return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) + } + kv, err := s.js.KeyValue(IAMAccountBucketName(accountID)) + if err != nil { + if errors.Is(err, nats.ErrBucketNotFound) { + return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) + } + return nil, fmt.Errorf("open IAM account bucket: %w", err) + } + entry, err := kv.Get(OIDCProviderKey(issuer)) + if err != nil { + if errors.Is(err, nats.ErrKeyNotFound) { + return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) + } + return nil, fmt.Errorf("get OIDC provider: %w", err) + } + var record OIDCProviderRecord + if err := json.Unmarshal(entry.Value(), &record); err != nil { + return nil, fmt.Errorf("unmarshal OIDC provider: %w", err) + } + return &record, nil +} + +// stripIssuerScheme drops the https:// prefix to produce the scheme-less +// host/path form AWS uses in the oidc-provider ARN suffix. +func stripIssuerScheme(issuer string) string { + return strings.TrimPrefix(issuer, "https://") +} + +// tagsToSDK converts stored Tags into the SDK shape used by OIDC-provider +// responses. +func tagsToSDK(tags []Tag) []*iam.Tag { + if len(tags) == 0 { + return nil + } + out := make([]*iam.Tag, 0, len(tags)) + for _, t := range tags { + out = append(out, &iam.Tag{Key: aws.String(t.Key), Value: aws.String(t.Value)}) + } + return out +} diff --git a/spinifex/handlers/iam/oidc_providers_test.go b/spinifex/handlers/iam/oidc_providers_test.go new file mode 100644 index 000000000..f1f886a6a --- /dev/null +++ b/spinifex/handlers/iam/oidc_providers_test.go @@ -0,0 +1,173 @@ +package handlers_iam + +import ( + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/mulgadc/spinifex/spinifex/awserrors" +) + +const testIssuer = "https://10.0.0.1:9999/oidc/eks/ap-southeast-2/000000000001/h1" + +func TestCreateOpenIDConnectProvider(t *testing.T) { + svc := setupTestIAMService(t) + acct := "000000000001" + + out, err := svc.CreateOpenIDConnectProvider(acct, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(testIssuer), + ClientIDList: aws.StringSlice([]string{"sts.amazonaws.com"}), + }) + if err != nil { + t.Fatalf("CreateOpenIDConnectProvider: %v", err) + } + + wantARN := OIDCProviderARN(acct, stripIssuerScheme(testIssuer)) + if aws.StringValue(out.OpenIDConnectProviderArn) != wantARN { + t.Fatalf("ARN = %q, want %q", aws.StringValue(out.OpenIDConnectProviderArn), wantARN) + } + + // The registry key STS reads must now exist for this exact issuer. + kv, err := svc.js.KeyValue(IAMAccountBucketName(acct)) + if err != nil { + t.Fatalf("open account bucket: %v", err) + } + if _, err := kv.Get(OIDCProviderKey(testIssuer)); err != nil { + t.Fatalf("provider key not written for issuer: %v", err) + } +} + +func TestCreateOpenIDConnectProvider_Idempotent(t *testing.T) { + svc := setupTestIAMService(t) + acct := "000000000001" + in := &iam.CreateOpenIDConnectProviderInput{Url: aws.String(testIssuer)} + + if _, err := svc.CreateOpenIDConnectProvider(acct, in); err != nil { + t.Fatalf("first create: %v", err) + } + _, err := svc.CreateOpenIDConnectProvider(acct, in) + if err == nil || err.Error() != awserrors.ErrorIAMEntityAlreadyExists { + t.Fatalf("duplicate create err = %v, want EntityAlreadyExists", err) + } +} + +func TestCreateOpenIDConnectProvider_InvalidURL(t *testing.T) { + svc := setupTestIAMService(t) + for _, bad := range []string{"", "http://insecure.example", "://nohost", "ftp://x"} { + _, err := svc.CreateOpenIDConnectProvider("000000000001", + &iam.CreateOpenIDConnectProviderInput{Url: aws.String(bad)}) + if err == nil || err.Error() != awserrors.ErrorIAMInvalidInput { + t.Fatalf("url %q: err = %v, want InvalidInput", bad, err) + } + } +} + +func TestGetOpenIDConnectProvider(t *testing.T) { + svc := setupTestIAMService(t) + acct := "000000000001" + created, err := svc.CreateOpenIDConnectProvider(acct, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(testIssuer), + ClientIDList: aws.StringSlice([]string{"sts.amazonaws.com"}), + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + got, err := svc.GetOpenIDConnectProvider(acct, &iam.GetOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: created.OpenIDConnectProviderArn, + }) + if err != nil { + t.Fatalf("get: %v", err) + } + if aws.StringValue(got.Url) != stripIssuerScheme(testIssuer) { + t.Fatalf("Url = %q, want %q", aws.StringValue(got.Url), stripIssuerScheme(testIssuer)) + } + if len(got.ClientIDList) != 1 || aws.StringValue(got.ClientIDList[0]) != "sts.amazonaws.com" { + t.Fatalf("ClientIDList = %v", aws.StringValueSlice(got.ClientIDList)) + } +} + +func TestGetOpenIDConnectProvider_NotFound(t *testing.T) { + svc := setupTestIAMService(t) + _, err := svc.GetOpenIDConnectProvider("000000000001", &iam.GetOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: aws.String(OIDCProviderARN("000000000001", "10.0.0.1:9999/oidc/eks/x/y/z")), + }) + if err == nil || err.Error() != awserrors.ErrorIAMNoSuchEntity { + t.Fatalf("err = %v, want NoSuchEntity", err) + } +} + +func TestListOpenIDConnectProviders(t *testing.T) { + svc := setupTestIAMService(t) + acct := "000000000001" + + // Empty account (bucket not yet created) lists nothing, not an error. + empty, err := svc.ListOpenIDConnectProviders(acct, &iam.ListOpenIDConnectProvidersInput{}) + if err != nil { + t.Fatalf("list empty: %v", err) + } + if len(empty.OpenIDConnectProviderList) != 0 { + t.Fatalf("empty list = %d entries", len(empty.OpenIDConnectProviderList)) + } + + issuers := []string{testIssuer, "https://10.0.0.1:9999/oidc/eks/ap-southeast-2/000000000001/h2"} + for _, iss := range issuers { + if _, err := svc.CreateOpenIDConnectProvider(acct, &iam.CreateOpenIDConnectProviderInput{Url: aws.String(iss)}); err != nil { + t.Fatalf("create %s: %v", iss, err) + } + } + + out, err := svc.ListOpenIDConnectProviders(acct, &iam.ListOpenIDConnectProvidersInput{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(out.OpenIDConnectProviderList) != 2 { + t.Fatalf("list = %d entries, want 2", len(out.OpenIDConnectProviderList)) + } + want := map[string]bool{ + OIDCProviderARN(acct, stripIssuerScheme(issuers[0])): true, + OIDCProviderARN(acct, stripIssuerScheme(issuers[1])): true, + } + for _, e := range out.OpenIDConnectProviderList { + if !want[aws.StringValue(e.Arn)] { + t.Fatalf("unexpected ARN %q", aws.StringValue(e.Arn)) + } + } +} + +func TestDeleteOpenIDConnectProvider(t *testing.T) { + svc := setupTestIAMService(t) + acct := "000000000001" + created, err := svc.CreateOpenIDConnectProvider(acct, &iam.CreateOpenIDConnectProviderInput{Url: aws.String(testIssuer)}) + if err != nil { + t.Fatalf("create: %v", err) + } + + if _, err := svc.DeleteOpenIDConnectProvider(acct, &iam.DeleteOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: created.OpenIDConnectProviderArn, + }); err != nil { + t.Fatalf("delete: %v", err) + } + + // Second delete is NoSuchEntity. + _, err = svc.DeleteOpenIDConnectProvider(acct, &iam.DeleteOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: created.OpenIDConnectProviderArn, + }) + if err == nil || err.Error() != awserrors.ErrorIAMNoSuchEntity { + t.Fatalf("second delete err = %v, want NoSuchEntity", err) + } +} + +func TestIssuerProviderARNRoundTrip(t *testing.T) { + arn := OIDCProviderARN("000000000001", stripIssuerScheme(testIssuer)) + got, err := issuerFromOIDCProviderARN(arn) + if err != nil { + t.Fatalf("reverse: %v", err) + } + if got != testIssuer { + t.Fatalf("round-trip issuer = %q, want %q", got, testIssuer) + } + if OIDCProviderKey(got) != OIDCProviderKey(testIssuer) { + t.Fatal("round-trip key mismatch") + } +} diff --git a/spinifex/handlers/iam/service.go b/spinifex/handlers/iam/service.go index 257e7a0e7..4fb5e969a 100644 --- a/spinifex/handlers/iam/service.go +++ b/spinifex/handlers/iam/service.go @@ -54,6 +54,14 @@ type IAMService interface { AddRoleToInstanceProfile(accountID string, input *iam.AddRoleToInstanceProfileInput) (*iam.AddRoleToInstanceProfileOutput, error) RemoveRoleFromInstanceProfile(accountID string, input *iam.RemoveRoleFromInstanceProfileInput) (*iam.RemoveRoleFromInstanceProfileOutput, error) + // OIDC identity-provider registry — account-scoped. Registers a cluster + // issuer as a trusted federated IdP so STS AssumeRoleWithWebIdentity will + // honour tokens it signs (IRSA). + CreateOpenIDConnectProvider(accountID string, input *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) + GetOpenIDConnectProvider(accountID string, input *iam.GetOpenIDConnectProviderInput) (*iam.GetOpenIDConnectProviderOutput, error) + ListOpenIDConnectProviders(accountID string, input *iam.ListOpenIDConnectProvidersInput) (*iam.ListOpenIDConnectProvidersOutput, error) + DeleteOpenIDConnectProvider(accountID string, input *iam.DeleteOpenIDConnectProviderInput) (*iam.DeleteOpenIDConnectProviderOutput, error) + // ResolveInstanceProfile dereferences a RunInstancesInput.IamInstanceProfile // reference (name or ARN) to the canonical InstanceProfile record. Used by // EC2 paths only. Cross-account ARNs are rejected as a defence-in-depth diff --git a/spinifex/handlers/iam/service_impl.go b/spinifex/handlers/iam/service_impl.go index 133b63214..23609d22b 100644 --- a/spinifex/handlers/iam/service_impl.go +++ b/spinifex/handlers/iam/service_impl.go @@ -87,6 +87,10 @@ type IAMServiceImpl struct { instanceProfilesBucket nats.KeyValue masterKey []byte decrypter *Decrypter + // replicas is the JetStream replication factor for lazily-created + // per-account buckets (the OIDC-provider registry). Matches the factor + // the eagerly-created buckets above were built with. + replicas int } var _ IAMService = (*IAMServiceImpl)(nil) @@ -189,6 +193,7 @@ func NewIAMServiceImpl(natsConn *nats.Conn, masterKey []byte, clusterSize int) ( instanceProfilesBucket: instanceProfilesBucket, masterKey: masterKey, decrypter: decrypter, + replicas: replicas, }, nil }