mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
feat(eks): OIDC provider CRUD + IRSA discovery + anonymous STS (6e) (#301)
* feat(iam,awsgw): OIDC provider CRUD + IRSA discovery endpoints (6e) Complete the AWS-SDK-facing half of IRSA: - IAM Create/Get/List/DeleteOpenIDConnectProvider, backed by the per-account oidc-providers registry that STS AssumeRoleWithWebIdentity already reads to authorise an issuer. Create writes the key STS checks; the provider ARN is the scheme-stripped issuer per AWS convention. - awsgw serves the per-cluster OIDC discovery document (/.well-known/openid-configuration) and JWKS (/keys) as unauthenticated public endpoints, mounted ahead of the SigV4 surface in their own route group. The discovery issuer is the cluster's stored OIDCIssuer, byte-exact with the apiserver iss claim; JWKS is served from the EKS account bucket. Unit + route tests cover CRUD happy/idempotent/not-found/invalid-url, the ARN<->issuer round-trip, discovery issuer/jwks_uri equality, JWKS bytes, 404s, and the no-auth bypass. make preflight green. * fix(awsgw): route anonymous AssumeRoleWithWebIdentity past SigV4 (6e) AssumeRoleWithWebIdentity is the IRSA credential-bootstrap call: the AWS SDK sends it unsigned because the caller holds no AWS credentials, only a projected ServiceAccount JWT in the body. The gateway ran the SigV4 middleware first and rejected it with MissingAuthenticationToken, so the true anonymous path never reached the handler. Add anonymousSTSInterceptor, a pre-auth middleware mounted ahead of the SigV4 surface. For an unsigned POST it body-peeks the Action and, for the anonymousSTSActions set, dispatches straight to STS_Request; STS_Request now skips SigV4-caller resolution for those actions (the handler authenticates the JWT and gates on the role's web-identity trust policy). Signed requests carry an Authorization header and pass through untouched, off the body-buffering path. Surfaced by the 6e live exit gate: a real in-pod assume-role-with-web-identity would otherwise have been rejected before reaching token validation.
This commit is contained in:
@@ -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
|
||||
}
|
||||
+29
-11
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+21
-10
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user