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:
Josh Siviour
2026-06-06 11:32:51 +10:00
committed by GitHub
parent a22bc2f716
commit ca2418ffa7
17 changed files with 1023 additions and 23 deletions
@@ -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
View File
@@ -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
}
+14
View File
@@ -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 {
+34
View File
@@ -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
}
+116
View File
@@ -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)
}
}
+123
View File
@@ -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
View File
@@ -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
}
+49
View File
@@ -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
}
+105
View File
@@ -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)
})
}
+5 -1
View File
@@ -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)
}
+251 -1
View File
@@ -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")
}
}
+8
View File
@@ -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
+5
View File
@@ -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
}