diff --git a/cmd/internal/gwcli/iam.go b/cmd/internal/gwcli/iam.go index 705ad80d..183aa5f0 100644 --- a/cmd/internal/gwcli/iam.go +++ b/cmd/internal/gwcli/iam.go @@ -109,6 +109,11 @@ func IAMCommand() *cli.Command { EnvVars: []string{"VGW_QUIET"}, Aliases: []string{"q"}, }, + &cli.BoolFlag{ + Name: "disable-oidc-thumbprint-autofetch", + Usage: "reject CreateOpenIDConnectProvider requests that omit ThumbprintList instead of auto-fetching it over an outbound TLS connection", + EnvVars: []string{"VGW_IAM_DISABLE_OIDC_THUMBPRINT_AUTOFETCH"}, + }, }, } } diff --git a/cmd/versitygw/iam.go b/cmd/versitygw/iam.go index c80d098b..5b246165 100644 --- a/cmd/versitygw/iam.go +++ b/cmd/versitygw/iam.go @@ -34,34 +34,35 @@ func runIAM(ctx *cli.Context) error { } return embedgw.RunIAMAPI(ctx.Context, &embedgw.IAMConfig{ - RootUserAccess: gwcli.RootUserAccess, - RootUserSecret: gwcli.RootUserSecret, - Ports: ports, - MaxConnections: maxConnections, - MaxRequests: maxRequests, - CertFile: certFile, - KeyFile: keyFile, - Debug: debug, - Quiet: quiet || ctx.Bool("quiet"), - KeepAlive: keepAlive, - HealthPath: healthPath, - SocketPerm: socketPerm, - IAMDir: ctx.String("dir"), - VaultEndpointURL: ctx.String("vault-endpoint-url"), - VaultNamespace: ctx.String("vault-namespace"), - VaultSecretStoragePath: ctx.String("vault-secret-storage-path"), - VaultSecretStorageNamespace: ctx.String("vault-secret-storage-namespace"), - VaultAuthMethod: ctx.String("vault-auth-method"), - VaultAuthNamespace: ctx.String("vault-auth-namespace"), - VaultMountPath: ctx.String("vault-mount-path"), - VaultRootToken: ctx.String("vault-root-token"), - VaultRoleID: ctx.String("vault-role-id"), - VaultRoleSecret: ctx.String("vault-role-secret"), - VaultServerCert: ctx.String("vault-server-cert"), - VaultClientCert: ctx.String("vault-client-cert"), - VaultClientCertKey: ctx.String("vault-client-cert-key"), - Version: Version, - Build: Build, - BuildTime: BuildTime, + RootUserAccess: gwcli.RootUserAccess, + RootUserSecret: gwcli.RootUserSecret, + Ports: ports, + MaxConnections: maxConnections, + MaxRequests: maxRequests, + CertFile: certFile, + KeyFile: keyFile, + Debug: debug, + Quiet: quiet || ctx.Bool("quiet"), + KeepAlive: keepAlive, + HealthPath: healthPath, + SocketPerm: socketPerm, + IAMDir: ctx.String("dir"), + VaultEndpointURL: ctx.String("vault-endpoint-url"), + VaultNamespace: ctx.String("vault-namespace"), + VaultSecretStoragePath: ctx.String("vault-secret-storage-path"), + VaultSecretStorageNamespace: ctx.String("vault-secret-storage-namespace"), + VaultAuthMethod: ctx.String("vault-auth-method"), + VaultAuthNamespace: ctx.String("vault-auth-namespace"), + VaultMountPath: ctx.String("vault-mount-path"), + VaultRootToken: ctx.String("vault-root-token"), + VaultRoleID: ctx.String("vault-role-id"), + VaultRoleSecret: ctx.String("vault-role-secret"), + VaultServerCert: ctx.String("vault-server-cert"), + VaultClientCert: ctx.String("vault-client-cert"), + VaultClientCertKey: ctx.String("vault-client-cert-key"), + DisableOIDCThumbprintAutoFetch: ctx.Bool("disable-oidc-thumbprint-autofetch"), + Version: Version, + Build: Build, + BuildTime: BuildTime, }) } diff --git a/embedgw/iam.go b/embedgw/iam.go index 959217c7..f6f087ef 100644 --- a/embedgw/iam.go +++ b/embedgw/iam.go @@ -120,6 +120,13 @@ type IAMConfig struct { Version string Build string BuildTime string + + // DisableOIDCThumbprintAutoFetch disables CreateOpenIDConnectProvider's + // TLS auto-fetch fallback for when ThumbprintList is omitted. When set, + // an omitted ThumbprintList is rejected instead of the IAM API making an + // outbound TLS connection to the caller-supplied URL — for restricted + // or air-gapped deployments. + DisableOIDCThumbprintAutoFetch bool } var iamAPIRunning atomic.Bool @@ -198,6 +205,9 @@ func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error { if cfg.Quiet { opts = append(opts, iamapi.WithQuiet()) } + if cfg.DisableOIDCThumbprintAutoFetch { + opts = append(opts, iamapi.WithOIDCThumbprintAutoFetchDisabled()) + } if cfg.Debug { debuglogger.SetDebugEnabled() } diff --git a/iamapi/controller.go b/iamapi/controller.go index 0cd44868..0fc3a5a3 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -30,10 +30,19 @@ import ( type IAMApiController struct { store storage.Storer + // oidcThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's + // TLS auto-fetch fallback when ThumbprintList is omitted (operational + // safety valve for restricted/air-gapped deployments); set via + // iamapi.WithOIDCThumbprintAutoFetchDisabled(). Defaults to false + // (auto-fetch enabled), matching real AWS behavior. + oidcThumbprintAutoFetchDisabled bool } -func NewController(store storage.Storer) IAMApiController { - return IAMApiController{store: store} +func NewController(store storage.Storer, oidcThumbprintAutoFetchDisabled bool) IAMApiController { + return IAMApiController{ + store: store, + oidcThumbprintAutoFetchDisabled: oidcThumbprintAutoFetchDisabled, + } } func (c IAMApiController) CreateUser(ctx fiber.Ctx) (*Response, error) { @@ -848,3 +857,188 @@ func (c IAMApiController) ListRolePolicies(ctx fiber.Ctx) (*Response, error) { }, }}, nil } + +func (c IAMApiController) CreateOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + rawURL, ok := iamutil.RequestParam(ctx, "Url") + if !ok || rawURL == "" { + debuglogger.Logf("missing required CreateOpenIDConnectProvider parameter: Url") + return nil, iamerr.MissingValue("url") + } + url, err := iamutil.ValidateOIDCProviderURL(rawURL) + if err != nil { + return nil, err + } + + clientIDs := iamutil.ParseStringList(ctx, "ClientIDList") + if len(clientIDs) > storage.MaxClientIDsPerOIDCProvider { + return nil, iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(storage.MaxClientIDsPerOIDCProvider) + } + for _, id := range clientIDs { + if len(id) > iamutil.MaxOIDCClientIDLen { + return nil, iamerr.ValueTooLong("clientID", iamutil.MaxOIDCClientIDLen) + } + } + + thumbprints := iamutil.ParseStringList(ctx, "ThumbprintList") + if len(thumbprints) == 0 { + if c.oidcThumbprintAutoFetchDisabled { + debuglogger.Logf("CreateOpenIDConnectProvider: ThumbprintList omitted and auto-fetch is disabled") + return nil, iamerr.MissingValue("thumbprintList") + } + fetched, err := iamutil.FetchThumbprint(ctx.Context(), url) + if err != nil { + debuglogger.Logf("failed to auto-fetch OIDC thumbprint for url %q: %v", url, err) + return nil, err + } + thumbprints = []string{fetched} + } else { + if err := iamutil.ValidateThumbprintList(thumbprints, false); err != nil { + return nil, err + } + thumbprints = iamutil.NormalizeThumbprintList(thumbprints) + } + + tags, err := iamutil.ParseTags(ctx) + if err != nil { + return nil, err + } + + provider := types.OIDCProvider{ + Arn: iamutil.BuildOIDCProviderArn(iamutil.DefaultAccountID, url), + Url: url, + ClientIDList: clientIDs, + ThumbprintList: thumbprints, + CreateDate: time.Now().UTC().Truncate(time.Second), + Tags: tags, + } + + stored, err := c.store.CreateOIDCProvider(ctx.Context(), provider) + if err != nil { + debuglogger.Logf("failed to create IAM OIDC provider for url %q: %v", url, err) + return nil, err + } + + return &Response{Data: &types.CreateOpenIDConnectProviderResponse{ + Result: types.CreateOpenIDConnectProviderResult{ + OpenIDConnectProviderArn: stored.Arn, + Tags: stored.Tags, + }, + }}, nil +} + +func (c IAMApiController) GetOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "GetOpenIDConnectProvider") + if err != nil { + return nil, err + } + + provider, err := c.store.GetOIDCProvider(ctx.Context(), arn) + if err != nil { + debuglogger.Logf("failed to get IAM OIDC provider %q: %v", arn, err) + return nil, err + } + + return &Response{Data: &types.GetOpenIDConnectProviderResponse{ + Result: types.GetOpenIDConnectProviderResult{ + Url: provider.Url, + ClientIDList: provider.ClientIDList, + ThumbprintList: provider.ThumbprintList, + CreateDate: provider.CreateDate, + Tags: provider.Tags, + }, + }}, nil +} + +func (c IAMApiController) ListOpenIDConnectProviders(ctx fiber.Ctx) (*Response, error) { + out, err := c.store.ListOIDCProviders(ctx.Context()) + if err != nil { + debuglogger.Logf("failed to list IAM OIDC providers: %v", err) + return nil, err + } + + return &Response{Data: &types.ListOpenIDConnectProvidersResponse{ + Result: types.ListOpenIDConnectProvidersResult{ + OpenIDConnectProviderList: types.OpenIDConnectProviderList{Members: out.Providers}, + }, + }}, nil +} + +func (c IAMApiController) DeleteOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "DeleteOpenIDConnectProvider") + if err != nil { + return nil, err + } + + if err := c.store.DeleteOIDCProvider(ctx.Context(), arn); err != nil { + debuglogger.Logf("failed to delete IAM OIDC provider %q: %v", arn, err) + return nil, err + } + + return &Response{Data: &types.DeleteOpenIDConnectProviderResponse{}}, nil +} + +func (c IAMApiController) AddClientIDToOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "AddClientIDToOpenIDConnectProvider") + if err != nil { + return nil, err + } + + clientID, ok := iamutil.RequestParam(ctx, "ClientID") + if !ok || clientID == "" { + debuglogger.Logf("missing required AddClientIDToOpenIDConnectProvider parameter: ClientID") + return nil, iamerr.MissingValue("clientID") + } + if len(clientID) > iamutil.MaxOIDCClientIDLen { + return nil, iamerr.ValueTooLong("clientID", iamutil.MaxOIDCClientIDLen) + } + + if err := c.store.AddClientIDToOIDCProvider(ctx.Context(), arn, clientID); err != nil { + debuglogger.Logf("failed to add client id %q to IAM OIDC provider %q: %v", clientID, arn, err) + return nil, err + } + + return &Response{Data: &types.AddClientIDToOpenIDConnectProviderResponse{}}, nil +} + +func (c IAMApiController) RemoveClientIDFromOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "RemoveClientIDFromOpenIDConnectProvider") + if err != nil { + return nil, err + } + + clientID, ok := iamutil.RequestParam(ctx, "ClientID") + if !ok || clientID == "" { + debuglogger.Logf("missing required RemoveClientIDFromOpenIDConnectProvider parameter: ClientID") + return nil, iamerr.MissingValue("clientID") + } + if len(clientID) > iamutil.MaxOIDCClientIDLen { + return nil, iamerr.ValueTooLong("clientID", iamutil.MaxOIDCClientIDLen) + } + + if err := c.store.RemoveClientIDFromOIDCProvider(ctx.Context(), arn, clientID); err != nil { + debuglogger.Logf("failed to remove client id %q from IAM OIDC provider %q: %v", clientID, arn, err) + return nil, err + } + + return &Response{Data: &types.RemoveClientIDFromOpenIDConnectProviderResponse{}}, nil +} + +func (c IAMApiController) UpdateOpenIDConnectProviderThumbprint(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "UpdateOpenIDConnectProviderThumbprint") + if err != nil { + return nil, err + } + + thumbprints := iamutil.ParseStringList(ctx, "ThumbprintList") + if err := iamutil.ValidateThumbprintList(thumbprints, true); err != nil { + return nil, err + } + thumbprints = iamutil.NormalizeThumbprintList(thumbprints) + + if err := c.store.UpdateOIDCProviderThumbprint(ctx.Context(), arn, thumbprints); err != nil { + debuglogger.Logf("failed to update IAM OIDC provider thumbprint for %q: %v", arn, err) + return nil, err + } + + return &Response{Data: &types.UpdateOpenIDConnectProviderThumbprintResponse{}}, nil +} diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 3c0cefa8..0f80d9d1 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -18,6 +18,7 @@ import ( "net/http" "net/url" "regexp" + "slices" "strings" "testing" "time" @@ -1671,6 +1672,335 @@ func TestIAMApiControllerPutRolePolicyExceedsQuota(t *testing.T) { requireIAMError(t, resp, http.StatusConflict, "Sender", "LimitExceeded", "Maximum policy size of 10240 bytes exceeded for role my-role") } +func TestIAMApiControllerOIDCProviderLifecycle(t *testing.T) { + server := newIAMControllerTestServer(t) + + create := doIAMAction(t, server, url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://token.actions.githubusercontent.com"}, + "ClientIDList.member.1": {"sts.amazonaws.com"}, + "ThumbprintList.member.1": {"6938FD4D98BAB03FAADB97B34396831E3780AEA1"}, + "Tags.member.1.Key": {"env"}, + "Tags.member.1.Value": {"test"}, + }) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateOpenIDConnectProvider status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + createBody := readBody(t, create) + var createOut iamtypes.CreateOpenIDConnectProviderResponse + unmarshalXML(t, createBody, &createOut) + if createOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || createOut.XMLName.Local != "CreateOpenIDConnectProviderResponse" { + t.Fatalf("CreateOpenIDConnectProvider XMLName = %#v", createOut.XMLName) + } + wantArn := "arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com" + if createOut.Result.OpenIDConnectProviderArn != wantArn { + t.Fatalf("OpenIDConnectProviderArn = %q, want %q", createOut.Result.OpenIDConnectProviderArn, wantArn) + } + if len(createOut.Result.Tags) != 1 || createOut.Result.Tags[0].Key != "env" || createOut.Result.Tags[0].Value != "test" { + t.Fatalf("Tags = %#v", createOut.Result.Tags) + } + if createOut.ResponseMetadata.RequestID == "" { + t.Fatal("CreateOpenIDConnectProvider missing RequestId") + } + + duplicate := doIAMAction(t, server, url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://token.actions.githubusercontent.com"}, + "ThumbprintList.member.1": {"6938fd4d98bab03faadb97b34396831e3780aea1"}, + }) + requireIAMError(t, duplicate, http.StatusConflict, "Sender", "EntityAlreadyExists", + "Provider with url https://token.actions.githubusercontent.com already exists.") + + get := doIAMAction(t, server, url.Values{ + "Action": {"GetOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + if get.StatusCode != http.StatusOK { + t.Fatalf("GetOpenIDConnectProvider status = %d, body=%s", get.StatusCode, readBody(t, get)) + } + var getOut iamtypes.GetOpenIDConnectProviderResponse + unmarshalXML(t, readBody(t, get), &getOut) + if getOut.Result.Url != "token.actions.githubusercontent.com" { + t.Fatalf("Url = %q, want scheme stripped", getOut.Result.Url) + } + if len(getOut.Result.ClientIDList) != 1 || getOut.Result.ClientIDList[0] != "sts.amazonaws.com" { + t.Fatalf("ClientIDList = %#v", getOut.Result.ClientIDList) + } + // Submitted uppercase; AWS lowercases whatever is stored. + if len(getOut.Result.ThumbprintList) != 1 || getOut.Result.ThumbprintList[0] != "6938fd4d98bab03faadb97b34396831e3780aea1" { + t.Fatalf("ThumbprintList = %#v, want lowercased", getOut.Result.ThumbprintList) + } + if getOut.Result.CreateDate.IsZero() { + t.Fatal("CreateDate is zero") + } + + list := doIAMAction(t, server, url.Values{"Action": {"ListOpenIDConnectProviders"}}) + if list.StatusCode != http.StatusOK { + t.Fatalf("ListOpenIDConnectProviders status = %d, body=%s", list.StatusCode, readBody(t, list)) + } + var listOut iamtypes.ListOpenIDConnectProvidersResponse + unmarshalXML(t, readBody(t, list), &listOut) + if len(listOut.Result.OpenIDConnectProviderList.Members) != 1 || listOut.Result.OpenIDConnectProviderList.Members[0].Arn != wantArn { + t.Fatalf("ListOpenIDConnectProviders = %#v, want [%s]", listOut.Result.OpenIDConnectProviderList.Members, wantArn) + } + + addClientID := doIAMAction(t, server, url.Values{ + "Action": {"AddClientIDToOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + "ClientID": {"another-client"}, + }) + if addClientID.StatusCode != http.StatusOK { + t.Fatalf("AddClientIDToOpenIDConnectProvider status = %d, body=%s", addClientID.StatusCode, readBody(t, addClientID)) + } + + // Idempotent: adding an already-present client ID succeeds silently. + addDuplicate := doIAMAction(t, server, url.Values{ + "Action": {"AddClientIDToOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + "ClientID": {"another-client"}, + }) + if addDuplicate.StatusCode != http.StatusOK { + t.Fatalf("AddClientIDToOpenIDConnectProvider (duplicate) status = %d, body=%s", addDuplicate.StatusCode, readBody(t, addDuplicate)) + } + + removeClientID := doIAMAction(t, server, url.Values{ + "Action": {"RemoveClientIDFromOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + "ClientID": {"another-client"}, + }) + if removeClientID.StatusCode != http.StatusOK { + t.Fatalf("RemoveClientIDFromOpenIDConnectProvider status = %d, body=%s", removeClientID.StatusCode, readBody(t, removeClientID)) + } + + // Idempotent: removing an absent client ID succeeds silently. + removeAbsent := doIAMAction(t, server, url.Values{ + "Action": {"RemoveClientIDFromOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + "ClientID": {"never-existed"}, + }) + if removeAbsent.StatusCode != http.StatusOK { + t.Fatalf("RemoveClientIDFromOpenIDConnectProvider (absent) status = %d, body=%s", removeAbsent.StatusCode, readBody(t, removeAbsent)) + } + + getAfterClientIDChanges := doIAMAction(t, server, url.Values{ + "Action": {"GetOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + var getAfterClientIDOut iamtypes.GetOpenIDConnectProviderResponse + unmarshalXML(t, readBody(t, getAfterClientIDChanges), &getAfterClientIDOut) + if len(getAfterClientIDOut.Result.ClientIDList) != 1 || getAfterClientIDOut.Result.ClientIDList[0] != "sts.amazonaws.com" { + t.Fatalf("ClientIDList after add+remove = %#v, want [sts.amazonaws.com]", getAfterClientIDOut.Result.ClientIDList) + } + + updateThumbprint := doIAMAction(t, server, url.Values{ + "Action": {"UpdateOpenIDConnectProviderThumbprint"}, + "OpenIDConnectProviderArn": {wantArn}, + "ThumbprintList.member.1": {strings.Repeat("a", 40)}, + "ThumbprintList.member.2": {strings.Repeat("B", 40)}, + }) + if updateThumbprint.StatusCode != http.StatusOK { + t.Fatalf("UpdateOpenIDConnectProviderThumbprint status = %d, body=%s", updateThumbprint.StatusCode, readBody(t, updateThumbprint)) + } + + getAfterThumbprintUpdate := doIAMAction(t, server, url.Values{ + "Action": {"GetOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + var getAfterThumbprintOut iamtypes.GetOpenIDConnectProviderResponse + unmarshalXML(t, readBody(t, getAfterThumbprintUpdate), &getAfterThumbprintOut) + wantThumbprints := []string{strings.Repeat("a", 40), strings.Repeat("b", 40)} + if !slices.Equal(getAfterThumbprintOut.Result.ThumbprintList, wantThumbprints) { + t.Fatalf("ThumbprintList after update = %#v, want %#v (full replace, lowercased)", getAfterThumbprintOut.Result.ThumbprintList, wantThumbprints) + } + + deleteResp := doIAMAction(t, server, url.Values{ + "Action": {"DeleteOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + if deleteResp.StatusCode != http.StatusOK { + t.Fatalf("DeleteOpenIDConnectProvider status = %d, body=%s", deleteResp.StatusCode, readBody(t, deleteResp)) + } + + // DeleteOpenIDConnectProvider is NOT idempotent, contradicting AWS's own + // published docs - a second delete of the same ARN must fail. + deleteAgain := doIAMAction(t, server, url.Values{ + "Action": {"DeleteOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + requireIAMError(t, deleteAgain, http.StatusNotFound, "Sender", "NoSuchEntity", + "OpenId connect Provider "+wantArn+" cannot be found.") + + missing := doIAMAction(t, server, url.Values{ + "Action": {"GetOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {wantArn}, + }) + requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", + "OpenIDConnect Provider not found for arn "+wantArn) +} + +func TestIAMApiControllerCreateOIDCProviderValidationErrors(t *testing.T) { + tests := []struct { + name string + params url.Values + status int + code string + message string + }{ + { + name: "missing url", + params: url.Values{"Action": {"CreateOpenIDConnectProvider"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'url' failed to satisfy constraint: Member must not be null", + }, + { + name: "no scheme at all", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"example.com"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "Invalid Open ID Connect Provider URL", + }, + { + name: "wrong scheme", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"http://example.com"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Invalid Open ID Connect Provider URL. The URL must begin with https://.", + }, + { + name: "query params", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com?foo=1"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Invalid Open ID Connect Provider URL.", + }, + { + name: "explicit port", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com:8443"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Invalid Open ID Connect Provider URL.", + }, + { + name: "url too long", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://" + strings.Repeat("a", 250) + ".com"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'url' failed to satisfy constraint: Member must have length less than or equal to 255", + }, + { + name: "client id too long", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com"}, + "ClientIDList.member.1": {strings.Repeat("c", 256)}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'clientID' failed to satisfy constraint: Member must have length less than or equal to 255", + }, + { + name: "thumbprint wrong length", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com"}, + "ThumbprintList.member.1": {strings.Repeat("a", 39)}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Thumbprint must be exactly 40 characters.", + }, + { + name: "thumbprint too many", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com"}, + "ThumbprintList.member.1": {strings.Repeat("1", 40)}, + "ThumbprintList.member.2": {strings.Repeat("2", 40)}, + "ThumbprintList.member.3": {strings.Repeat("3", 40)}, + "ThumbprintList.member.4": {strings.Repeat("4", 40)}, + "ThumbprintList.member.5": {strings.Repeat("5", 40)}, + "ThumbprintList.member.6": {strings.Repeat("6", 40)}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Thumbprint list must contain fewer than 5 entries.", + }, + { + name: "duplicate tag keys", + params: url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com"}, + "ThumbprintList.member.1": {strings.Repeat("a", 40)}, + "Tags.member.1.Key": {"key"}, + "Tags.member.1.Value": {"one"}, + "Tags.member.2.Key": {"KEY"}, + "Tags.member.2.Value": {"two"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + resp := doIAMAction(t, server, tt.params) + requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message) + }) + } +} + +func TestIAMApiControllerOIDCThumbprintAutoFetchDisabled(t *testing.T) { + store, err := storage.New(storage.Config{Dir: t.TempDir()}) + if err != nil { + t.Fatalf("storage.New: %v", err) + } + server, err := New(store, WithQuiet(), WithRootUserCreds(testRoot), WithOIDCThumbprintAutoFetchDisabled()) + if err != nil { + t.Fatalf("New: %v", err) + } + + resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://example.com"}, + }) + requireIAMError(t, resp, http.StatusBadRequest, "Sender", "ValidationError", + "1 validation error detected: Value at 'thumbprintList' failed to satisfy constraint: Member must not be null") +} + +// TestIAMApiControllerCreateOIDCProviderAutoFetchSSRFGuard confirms the +// auto-fetch fallback's SSRF guard is wired all the way through the HTTP +// action handler: an omitted ThumbprintList against a loopback URL must be +// rejected before any real network attempt, deterministically and without +// requiring outbound network access from the test environment. +func TestIAMApiControllerCreateOIDCProviderAutoFetchSSRFGuard(t *testing.T) { + server := newIAMControllerTestServer(t) + + resp := doIAMAction(t, server, url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Url": {"https://127.0.0.1"}, + }) + requireIAMError(t, resp, http.StatusBadRequest, "Sender", "OpenIdIdpCommunicationError", + "Could not connect to https://127.0.0.1") +} + func newIAMControllerTestServer(t *testing.T) *IAMApiServer { t.Helper() diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 1df3877e..14d24010 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -425,6 +425,10 @@ func ValueTooLong(field string, maxLength int) Error { return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must have length less than or equal to %d", field, maxLength)) } +func ValueTooShort(field string, minLength int) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must have length greater than or equal to %d", field, minLength)) +} + func InvalidCharset(field string) Error { return ValidationError(fmt.Sprintf("The specified value for %s is invalid. It must contain only printable ASCII characters.", field)) } @@ -461,6 +465,47 @@ func InlinePolicyQuotaExceeded(entityKind, entityName string, maxBytes int) Erro return newSenderError("LimitExceeded", fmt.Sprintf("Maximum policy size of %d bytes exceeded for %s %s", maxBytes, entityKind, entityName), http.StatusConflict) } +func EntityAlreadyExistsOIDCProvider(url string) Error { + return newSenderError("EntityAlreadyExists", fmt.Sprintf("Provider with url %s already exists.", url), http.StatusConflict) +} + +func NoSuchEntityOIDCProviderGet(arn string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("OpenIDConnect Provider not found for arn %s", arn), http.StatusNotFound) +} + +func NoSuchEntityOIDCProviderDelete(arn string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("OpenId connect Provider %s cannot be found.", arn), http.StatusNotFound) +} + +// AccessDeniedOIDCProvider is returned when a well-formed OIDC provider ARN +// references an account id other than callerAccountID. +func AccessDeniedOIDCProvider(callerAccountID, resourceArn string) Error { + return newSenderError("AccessDenied", fmt.Sprintf( + "User: arn:aws:iam::%s:root is not authorized to perform this action on resource: %s", + callerAccountID, resourceArn, + ), http.StatusForbidden) +} + +func ClientIdsPerOpenIdConnectProviderLimitExceeded(max int) Error { + return newSenderError("LimitExceeded", fmt.Sprintf("Cannot exceed quota for ClientIdsPerOpenIdConnectProvider: %d", max), http.StatusConflict) +} + +func ThumbprintListTooLong(max int) Error { + return newSenderError("InvalidInput", fmt.Sprintf("Thumbprint list must contain fewer than %d entries.", max), http.StatusBadRequest) +} + +func ThumbprintListEmpty() Error { + return newSenderError("InvalidInput", "Thumbprint list must contain at least one entry.", http.StatusBadRequest) +} + +func OIDCProvidersPerAccountLimitExceeded(max int) Error { + return newSenderError("LimitExceeded", fmt.Sprintf("Cannot exceed quota for OpenIDConnectProvidersPerAccount: %d", max), http.StatusConflict) +} + +func OpenIdIdpCommunicationError(url string) Error { + return newSenderError("OpenIdIdpCommunicationError", fmt.Sprintf("Could not connect to %s", url), http.StatusBadRequest) +} + func newSenderError(code, message string, statusCode int) Error { return Error{ Type: TypeSender, diff --git a/iamapi/internal/iamutil/oidc.go b/iamapi/internal/iamutil/oidc.go new file mode 100644 index 00000000..36f9e365 --- /dev/null +++ b/iamapi/internal/iamutil/oidc.go @@ -0,0 +1,225 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package iamutil + +import ( + "fmt" + "net" + "net/url" + "regexp" + "strings" + + "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/iamapi/iamerr" +) + +const ( + MinOIDCProviderArnLen = 20 + MaxOIDCProviderArnLen = 2048 + MaxOIDCProviderURLLen = 255 + MaxOIDCClientIDLen = 255 + MaxThumbprintsPerOIDCProvider = 5 + OIDCThumbprintLen = 40 + + oidcProviderResourceType = "oidc-provider" +) + +var oidcHostLabelPattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`) + +// ParseStringList reads flat indexed list members ".member.1", +// ".member.2", ... — the AWS Query-protocol wire form for a bare +// []string (distinct from ParseTags's Key/Value-pair member form, used by +// ClientIDList/ThumbprintList) — stopping at the first missing index. +// Returns nil if no entries are present. +func ParseStringList(ctx fiber.Ctx, paramName string) []string { + var values []string + for i := 1; ; i++ { + value, ok := RequestParam(ctx, fmt.Sprintf("%s.member.%d", paramName, i)) + if !ok { + break + } + values = append(values, value) + } + return values +} + +// BuildOIDCProviderArn constructs the ARN for an IAM OIDC identity +// provider. url must already have its "https://" scheme stripped. +func BuildOIDCProviderArn(accountID, url string) string { + return fmt.Sprintf("arn:aws:iam::%s:oidc-provider/%s", accountID, url) +} + +// ParseOIDCProviderArn validates arn's overall length and structural shape +// (arn:aws:iam:::/) and, on success, +// returns the resource segment — the provider's Url with "https://" already +// stripped, exactly as stored. The account-id segment must match +// DefaultAccountID; any other value is rejected with AccessDenied, matching +// real AWS's behavior for a well-formed ARN referencing a foreign account. +// +// Beyond the length and account-id checks, real AWS produces several more +// specific messages for structurally-malformed ARNs this function does not +// reproduce byte-for-byte — e.g. "Invalid service in ARN" for a non-iam +// service segment (a check this function does not perform at all), and a +// bare "Invalid ARN" (no echoed value) for a present-but-empty resource — +// this function falls back to a generic "Invalid ARN: %s" for those cases +// instead. +func ParseOIDCProviderArn(arn string) (string, error) { + if len(arn) < MinOIDCProviderArnLen { + debuglogger.Logf("invalid OpenIDConnectProviderArn length: %d", len(arn)) + return "", iamerr.ValueTooShort("openIDConnectProviderArn", MinOIDCProviderArnLen) + } + if len(arn) > MaxOIDCProviderArnLen { + debuglogger.Logf("invalid OpenIDConnectProviderArn length: %d", len(arn)) + return "", iamerr.ValueTooLong("openIDConnectProviderArn", MaxOIDCProviderArnLen) + } + + const prefix = "arn:aws:iam::" + if !strings.HasPrefix(arn, prefix) { + debuglogger.Logf("malformed OpenIDConnectProviderArn: %q", arn) + return "", iamerr.ValidationError(fmt.Sprintf("Invalid ARN: %s", arn)) + } + + rest := strings.SplitN(arn[len(prefix):], ":", 2) + if len(rest) != 2 || rest[0] == "" { + debuglogger.Logf("malformed OpenIDConnectProviderArn: %q", arn) + return "", iamerr.ValidationError(fmt.Sprintf("Invalid ARN: %s", arn)) + } + if rest[0] != DefaultAccountID { + debuglogger.Logf("OpenIDConnectProviderArn account id mismatch: %q", arn) + return "", iamerr.AccessDeniedOIDCProvider(DefaultAccountID, arn) + } + + resourceType, resource, ok := strings.Cut(rest[1], "/") + if !ok || resource == "" { + debuglogger.Logf("malformed OpenIDConnectProviderArn: %q", arn) + return "", iamerr.ValidationError(fmt.Sprintf("Invalid ARN: %s", arn)) + } + if resourceType != oidcProviderResourceType { + debuglogger.Logf("wrong resource type in ARN: %q", arn) + return "", iamerr.ValidationError("Invalid resource type in ARN") + } + + return resource, nil +} + +// GetOIDCProviderArn resolves the OpenIDConnectProviderArn request +// parameter, validates its shape via ParseOIDCProviderArn, and returns the +// ARN exactly as supplied by the caller (used verbatim in NoSuchEntity +// messages, which echo the full ARN, not just the url). A missing +// parameter is rejected with iamerr.MissingValue — every OIDC action +// taking this parameter reports it identically. +func GetOIDCProviderArn(ctx fiber.Ctx, operation string) (string, error) { + arn, ok := RequestParam(ctx, "OpenIDConnectProviderArn") + if !ok || arn == "" { + debuglogger.Logf("missing required %s parameter: OpenIDConnectProviderArn", operation) + return "", iamerr.MissingValue("openIDConnectProviderArn") + } + if _, err := ParseOIDCProviderArn(arn); err != nil { + return "", err + } + return arn, nil +} + +// ValidateOIDCProviderURL validates the Url parameter of +// CreateOpenIDConnectProvider and returns it with its "https://" scheme +// stripped (the canonical form used for ARN construction, storage keys, and +// GetOpenIDConnectProvider's own Url response field). +// +// This implements a pragmatic subset of AWS's real validation: scheme must +// be exactly "https", no userinfo/port/query/fragment, host must be a +// syntactically plausible RFC-1123-ish hostname or IP literal, overall +// length <= MaxOIDCProviderURLLen. It does not attempt to reproduce every +// hostname-shape check AWS performs; it returns clear InvalidInput/ +// ValidationError messages instead of chasing every malformed edge case. +func ValidateOIDCProviderURL(rawURL string) (string, error) { + if rawURL == "" { + return "", iamerr.MissingValue("url") + } + if len(rawURL) > MaxOIDCProviderURLLen { + return "", iamerr.ValueTooLong("url", MaxOIDCProviderURLLen) + } + // A URL with no scheme delimiter at all (e.g. "example.com") is + // rejected as ValidationError; one with a scheme other than https + // (e.g. "http://example.com") is rejected as InvalidInput — distinct + // error codes for distinct malformed inputs. + if !strings.Contains(rawURL, "://") { + return "", iamerr.ValidationError("Invalid Open ID Connect Provider URL") + } + if !strings.HasPrefix(rawURL, "https://") { + return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL. The URL must begin with https://.") + } + + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return "", iamerr.ValidationError("Invalid Open ID Connect Provider URL") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Port() != "" { + return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.") + } + if !isValidOIDCHostname(parsed.Hostname()) { + return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.") + } + + return strings.TrimPrefix(rawURL, "https://"), nil +} + +func isValidOIDCHostname(host string) bool { + if net.ParseIP(host) != nil { + return true + } + if host == "" || len(host) > 253 { + return false + } + for _, label := range strings.Split(host, ".") { + if !oidcHostLabelPattern.MatchString(label) { + return false + } + } + return true +} + +// ValidateThumbprintList validates a parsed ThumbprintList: at most +// MaxThumbprintsPerOIDCProvider entries, each exactly OIDCThumbprintLen +// characters (no hex-charset check — any 40-char string is accepted). If +// required is true, an empty list is rejected +// (UpdateOpenIDConnectProviderThumbprint, no auto-fetch fallback exists +// there); if false, an empty list passes through untouched +// (CreateOpenIDConnectProvider, whose caller handles empty via auto-fetch +// before calling this). +func ValidateThumbprintList(thumbprints []string, required bool) error { + if required && len(thumbprints) == 0 { + return iamerr.ThumbprintListEmpty() + } + if len(thumbprints) > MaxThumbprintsPerOIDCProvider { + return iamerr.ThumbprintListTooLong(MaxThumbprintsPerOIDCProvider) + } + for _, tp := range thumbprints { + if len(tp) != OIDCThumbprintLen { + return iamerr.InvalidInput(fmt.Sprintf("Thumbprint must be exactly %d characters.", OIDCThumbprintLen)) + } + } + return nil +} + +// NormalizeThumbprintList lowercases every entry: AWS stores/returns +// thumbprints lowercased regardless of submitted case. +func NormalizeThumbprintList(thumbprints []string) []string { + out := make([]string, len(thumbprints)) + for i, tp := range thumbprints { + out[i] = strings.ToLower(tp) + } + return out +} diff --git a/iamapi/internal/iamutil/oidc_thumbprint.go b/iamapi/internal/iamutil/oidc_thumbprint.go new file mode 100644 index 00000000..11ff9881 --- /dev/null +++ b/iamapi/internal/iamutil/oidc_thumbprint.go @@ -0,0 +1,127 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package iamutil + +import ( + "context" + "crypto/sha1" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "errors" + "net" + "strings" + "time" + + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/iamapi/iamerr" +) + +const oidcThumbprintFetchTimeout = 8 * time.Second + +// FetchThumbprint implements CreateOpenIDConnectProvider's auto-fetch +// behavior: it opens a raw TLS handshake (crypto/tls, not a full +// HTTP GET) to host:443, where host is derived from providerURL (a +// scheme-stripped OIDC provider Url), and returns the SHA-1 thumbprint of +// the last (top-most/intermediate CA) certificate in the peer's presented +// chain. +// +// SSRF hardening (mandatory): the hostname is resolved once via +// net.DefaultResolver.LookupIP; if any resolved address is +// loopback/private/link-local/unspecified/multicast (this range covers +// 169.254.169.254 and other cloud metadata endpoints), the fetch is +// rejected before any connection attempt. The TLS dial then targets one of +// the pre-validated IPs directly (never re-resolving the hostname at dial +// time, closing the DNS-rebinding TOCTOU gap) while presenting the original +// hostname via tls.Config.ServerName for SNI/certificate purposes. +// +// tls.Config.InsecureSkipVerify is deliberately set: this handshake exists +// solely to observe whatever certificate chain the peer presents — that is +// the entire point of AWS's thumbprint-pinning feature (trusting an +// operator-established fingerprint for IDPs whose certs may not pass +// standard verification). No application data is sent or received over +// this connection, so skipping chain verification does not expose any real +// traffic to a MITM. +func FetchThumbprint(ctx context.Context, providerURL string) (string, error) { + host := hostFromOIDCUrl(providerURL) + displayURL := "https://" + providerURL + + ctx, cancel := context.WithTimeout(ctx, oidcThumbprintFetchTimeout) + defer cancel() + + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil || len(ips) == 0 { + debuglogger.Logf("oidc thumbprint fetch: dns lookup failed for %q: %v", host, err) + return "", iamerr.OpenIdIdpCommunicationError(displayURL) + } + for _, ip := range ips { + if isDisallowedFetchTarget(ip) { + debuglogger.Logf("oidc thumbprint fetch: refusing to dial disallowed address %q for host %q", ip, host) + return "", iamerr.OpenIdIdpCommunicationError(displayURL) + } + } + + dialer := &tls.Dialer{Config: &tls.Config{ServerName: host, InsecureSkipVerify: true}} + conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ips[0].String(), "443")) + if err != nil { + debuglogger.Logf("oidc thumbprint fetch: tls dial failed for %q (%s): %v", host, ips[0], err) + return "", iamerr.OpenIdIdpCommunicationError(displayURL) + } + defer conn.Close() + + tlsConn, ok := conn.(*tls.Conn) + if !ok { + return "", iamerr.OpenIdIdpCommunicationError(displayURL) + } + + thumbprint, err := ThumbprintFromChain(tlsConn.ConnectionState().PeerCertificates) + if err != nil { + debuglogger.Logf("oidc thumbprint fetch: %v", err) + return "", iamerr.OpenIdIdpCommunicationError(displayURL) + } + return thumbprint, nil +} + +// ThumbprintFromChain computes AWS's documented OIDC thumbprint: the SHA-1 +// hash of the DER bytes of the last (top-most/intermediate CA) certificate +// in chain, hex-encoded and lowercased. Split out from FetchThumbprint as a +// pure function specifically so it is unit-testable (e.g. against a chain +// obtained from httptest.NewTLSServer) without going through +// FetchThumbprint's SSRF guard, which must always reject loopback targets +// and therefore can never itself be exercised against a same-process test +// server. +func ThumbprintFromChain(chain []*x509.Certificate) (string, error) { + if len(chain) == 0 { + return "", errors.New("iamutil: empty certificate chain") + } + top := chain[len(chain)-1] + sum := sha1.Sum(top.Raw) + return hex.EncodeToString(sum[:]), nil +} + +func isDisallowedFetchTarget(ip net.IP) bool { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() +} + +// hostFromOIDCUrl extracts the host (no scheme, no path — OIDC provider +// URLs are validated to disallow explicit ports) from a scheme-stripped +// provider Url. +func hostFromOIDCUrl(providerURL string) string { + if before, _, ok := strings.Cut(providerURL, "/"); ok { + return before + } + return providerURL +} diff --git a/iamapi/internal/iamutil/oidc_thumbprint_test.go b/iamapi/internal/iamutil/oidc_thumbprint_test.go new file mode 100644 index 00000000..39d65a9c --- /dev/null +++ b/iamapi/internal/iamutil/oidc_thumbprint_test.go @@ -0,0 +1,122 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package iamutil + +import ( + "context" + "crypto/sha1" + "crypto/tls" + "encoding/hex" + "net" + "net/http/httptest" + "testing" +) + +// TestThumbprintFromChain exercises the pure cert-chain-hashing logic +// (AWS's OIDC thumbprint is the SHA-1 hash of the DER bytes of the +// last/top-most certificate in the peer's presented chain, hex encoded and +// lowercased) against a real TLS handshake with a locally generated +// self-signed certificate. +// +// This deliberately dials httptest.NewTLSServer directly with tls.Dial +// rather than going through FetchThumbprint, whose SSRF guard must always +// reject loopback targets — exactly what a local test server is. +func TestThumbprintFromChain(t *testing.T) { + srv := httptest.NewTLSServer(nil) + defer srv.Close() + + conn, err := tls.Dial("tcp", srv.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true}) + if err != nil { + t.Fatalf("tls.Dial: %v", err) + } + defer conn.Close() + + chain := conn.ConnectionState().PeerCertificates + if len(chain) == 0 { + t.Fatal("expected at least one peer certificate") + } + + got, err := ThumbprintFromChain(chain) + if err != nil { + t.Fatalf("ThumbprintFromChain: %v", err) + } + + sum := sha1.Sum(chain[len(chain)-1].Raw) + want := hex.EncodeToString(sum[:]) + if got != want { + t.Fatalf("ThumbprintFromChain = %q, want %q", got, want) + } + if len(got) != OIDCThumbprintLen { + t.Fatalf("thumbprint length = %d, want %d", len(got), OIDCThumbprintLen) + } +} + +func TestThumbprintFromChainEmptyChain(t *testing.T) { + if _, err := ThumbprintFromChain(nil); err == nil { + t.Fatal("expected error for empty certificate chain") + } +} + +// TestFetchThumbprintSSRFGuard confirms FetchThumbprint refuses to dial +// loopback/private targets before any network attempt, matching the +// mandatory SSRF hardening design: 127.0.0.1 is exactly the kind of +// address a malicious CreateOpenIDConnectProvider caller could supply to +// probe the gateway's own local network. +func TestFetchThumbprintSSRFGuard(t *testing.T) { + tests := []string{ + "127.0.0.1", + "169.254.169.254", // cloud metadata endpoint + "::1", + } + for _, host := range tests { + t.Run(host, func(t *testing.T) { + _, err := FetchThumbprint(context.Background(), host) + if err == nil { + t.Fatalf("FetchThumbprint(%q): expected SSRF guard error, got nil", host) + } + }) + } +} + +func TestFetchThumbprintDNSFailure(t *testing.T) { + _, err := FetchThumbprint(context.Background(), "this-host-should-not-resolve.invalid") + if err == nil { + t.Fatal("expected error for unresolvable host") + } +} + +func TestIsDisallowedFetchTarget(t *testing.T) { + tests := []struct { + ip string + disallowed bool + }{ + {"127.0.0.1", true}, + {"169.254.169.254", true}, + {"10.0.0.5", true}, + {"192.168.1.1", true}, + {"::1", true}, + {"8.8.8.8", false}, + {"1.1.1.1", false}, + } + for _, tt := range tests { + ip := net.ParseIP(tt.ip) + if ip == nil { + t.Fatalf("invalid test IP %q", tt.ip) + } + if got := isDisallowedFetchTarget(ip); got != tt.disallowed { + t.Errorf("isDisallowedFetchTarget(%q) = %v, want %v", tt.ip, got, tt.disallowed) + } + } +} diff --git a/iamapi/router.go b/iamapi/router.go index ddb39bd2..430398fb 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -38,41 +38,51 @@ type IAMApiRouter struct { Ctrl IAMApiController actions map[string]ActionHandler rootCreds *RootCredentials + // oidcThumbprintAutoFetchDisabled is threaded into the controller; + // see IAMApiController.oidcThumbprintAutoFetchDisabled. + oidcThumbprintAutoFetchDisabled bool } func (r *IAMApiRouter) Init() { - ctrl := NewController(r.store) - r.Ctrl = ctrl + r.Ctrl = NewController(r.store, r.oidcThumbprintAutoFetchDisabled) r.actions = map[string]ActionHandler{ // User CRUD - "CreateUser": ctrl.CreateUser, - "DeleteUser": ctrl.DeleteUser, - "GetUser": ctrl.GetUser, - "ListUsers": ctrl.ListUsers, - "UpdateUser": ctrl.UpdateUser, + "CreateUser": r.Ctrl.CreateUser, + "DeleteUser": r.Ctrl.DeleteUser, + "GetUser": r.Ctrl.GetUser, + "ListUsers": r.Ctrl.ListUsers, + "UpdateUser": r.Ctrl.UpdateUser, // User Access Key CRUD - "CreateAccessKey": ctrl.CreateAccessKey, - "UpdateAccessKey": ctrl.UpdateAccessKey, - "DeleteAccessKey": ctrl.DeleteAccessKey, - "GetAccessKeyLastUsed": ctrl.GetAccessKeyLastUsed, - "ListAccessKeys": ctrl.ListAccessKeys, + "CreateAccessKey": r.Ctrl.CreateAccessKey, + "UpdateAccessKey": r.Ctrl.UpdateAccessKey, + "DeleteAccessKey": r.Ctrl.DeleteAccessKey, + "GetAccessKeyLastUsed": r.Ctrl.GetAccessKeyLastUsed, + "ListAccessKeys": r.Ctrl.ListAccessKeys, // User Inline Policy CRUD - "PutUserPolicy": ctrl.PutUserPolicy, - "GetUserPolicy": ctrl.GetUserPolicy, - "DeleteUserPolicy": ctrl.DeleteUserPolicy, - "ListUserPolicies": ctrl.ListUserPolicies, + "PutUserPolicy": r.Ctrl.PutUserPolicy, + "GetUserPolicy": r.Ctrl.GetUserPolicy, + "DeleteUserPolicy": r.Ctrl.DeleteUserPolicy, + "ListUserPolicies": r.Ctrl.ListUserPolicies, // Role CRUD - "CreateRole": ctrl.CreateRole, - "GetRole": ctrl.GetRole, - "ListRoles": ctrl.ListRoles, - "DeleteRole": ctrl.DeleteRole, - "UpdateAssumeRolePolicy": ctrl.UpdateAssumeRolePolicy, + "CreateRole": r.Ctrl.CreateRole, + "GetRole": r.Ctrl.GetRole, + "ListRoles": r.Ctrl.ListRoles, + "DeleteRole": r.Ctrl.DeleteRole, + "UpdateAssumeRolePolicy": r.Ctrl.UpdateAssumeRolePolicy, // Role Inline Policy CRUD - "PutRolePolicy": ctrl.PutRolePolicy, - "GetRolePolicy": ctrl.GetRolePolicy, - "DeleteRolePolicy": ctrl.DeleteRolePolicy, - "ListRolePolicies": ctrl.ListRolePolicies, + "PutRolePolicy": r.Ctrl.PutRolePolicy, + "GetRolePolicy": r.Ctrl.GetRolePolicy, + "DeleteRolePolicy": r.Ctrl.DeleteRolePolicy, + "ListRolePolicies": r.Ctrl.ListRolePolicies, + // OIDC Provider CRUD + "CreateOpenIDConnectProvider": r.Ctrl.CreateOpenIDConnectProvider, + "GetOpenIDConnectProvider": r.Ctrl.GetOpenIDConnectProvider, + "ListOpenIDConnectProviders": r.Ctrl.ListOpenIDConnectProviders, + "DeleteOpenIDConnectProvider": r.Ctrl.DeleteOpenIDConnectProvider, + "AddClientIDToOpenIDConnectProvider": r.Ctrl.AddClientIDToOpenIDConnectProvider, + "RemoveClientIDFromOpenIDConnectProvider": r.Ctrl.RemoveClientIDFromOpenIDConnectProvider, + "UpdateOpenIDConnectProviderThumbprint": r.Ctrl.UpdateOpenIDConnectProviderThumbprint, } actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) diff --git a/iamapi/server.go b/iamapi/server.go index c5ccbe47..43660610 100644 --- a/iamapi/server.go +++ b/iamapi/server.go @@ -58,6 +58,9 @@ type IAMApiServer struct { maxRequests int socketPerm os.FileMode onListen func() + // oidcThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's + // TLS auto-fetch fallback; see WithOIDCThumbprintAutoFetchDisabled. + oidcThumbprintAutoFetchDisabled bool } func New(store storage.Storer, opts ...Option) (*IAMApiServer, error) { @@ -89,6 +92,7 @@ func New(store storage.Storer, opts ...Option) (*IAMApiServer, error) { server.app = app server.Router.app = app server.Router.rootCreds = server.rootCreds + server.Router.oidcThumbprintAutoFetchDisabled = server.oidcThumbprintAutoFetchDisabled app.Use("*", recover.New(recover.Config{ EnableStackTrace: true, @@ -161,6 +165,15 @@ func WithRootUserCreds(root RootCredentials) Option { } } +// WithOIDCThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's +// TLS auto-fetch fallback for when ThumbprintList is omitted. When set, an +// omitted ThumbprintList is rejected with a MissingValue error instead of +// the gateway making an outbound TLS connection to the caller-supplied URL +// — an operational safety valve for restricted/air-gapped deployments. +func WithOIDCThumbprintAutoFetchDisabled() Option { + return func(s *IAMApiServer) { s.oidcThumbprintAutoFetchDisabled = true } +} + func (s *IAMApiServer) ServeMultiPort(ports []string) error { if len(ports) == 0 { return fmt.Errorf("no ports specified") diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 536418d8..70cc7ac1 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -24,6 +24,7 @@ import ( "time" "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/internal/iamutil" "github.com/versity/versitygw/iamapi/types" "github.com/versity/versitygw/internal/iamstore" ) @@ -63,6 +64,11 @@ type iamConfig struct { Roles map[string]types.Role `json:"roles"` // RoleNameIndex is UserNameIndex's counterpart for roles. RoleNameIndex map[string]string `json:"roleNameIndex"` + + // OIDCProviders is keyed directly by the provider's Url (scheme + // stripped, exactly as given at creation — no index needed since + // lookup is by exact string, not a case-insensitive human name). + OIDCProviders map[string]types.OIDCProvider `json:"oidcProviders"` } func defaultIAMConfig() iamConfig { @@ -72,6 +78,7 @@ func defaultIAMConfig() iamConfig { UserNameIndex: map[string]string{}, Roles: map[string]types.Role{}, RoleNameIndex: map[string]string{}, + OIDCProviders: map[string]types.OIDCProvider{}, } } @@ -104,6 +111,10 @@ func normalizeIAMConfig(conf *iamConfig) { conf.RoleNameIndex[key] = name } } + + if conf.OIDCProviders == nil { + conf.OIDCProviders = make(map[string]types.OIDCProvider) + } } // lookupUser resolves name to the canonical stored user name and entry, @@ -983,3 +994,182 @@ func cloneRole(role types.Role) *types.Role { cloned.Policies.Inline = slices.Clone(role.Policies.Inline) return &cloned } + +func (s *InternalStore) CreateOIDCProvider(_ context.Context, provider types.OIDCProvider) (*types.OIDCProvider, error) { + s.Lock() + defer s.Unlock() + + if err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + if _, ok := conf.OIDCProviders[provider.Url]; ok { + return nil, iamerr.EntityAlreadyExistsOIDCProvider("https://" + provider.Url) + } + if len(conf.OIDCProviders) >= MaxOIDCProvidersPerAccount { + return nil, iamerr.OIDCProvidersPerAccountLimitExceeded(MaxOIDCProvidersPerAccount) + } + + conf.OIDCProviders[provider.Url] = provider + return json.Marshal(conf) + }); err != nil { + return nil, unwrapAPIError(err) + } + + return cloneOIDCProvider(provider), nil +} + +func (s *InternalStore) GetOIDCProvider(_ context.Context, arn string) (*types.OIDCProvider, error) { + s.RLock() + defer s.RUnlock() + + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + provider, ok := conf.OIDCProviders[url] + if !ok { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + return cloneOIDCProvider(provider), nil +} + +func (s *InternalStore) ListOIDCProviders(_ context.Context) (*ListOIDCProvidersOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + entries := make([]types.OpenIDConnectProviderListEntry, 0, len(conf.OIDCProviders)) + for _, p := range conf.OIDCProviders { + entries = append(entries, types.OpenIDConnectProviderListEntry{Arn: p.Arn}) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Arn < entries[j].Arn }) + + return &ListOIDCProvidersOutput{Providers: entries}, nil +} + +func (s *InternalStore) DeleteOIDCProvider(_ context.Context, arn string) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + if _, ok := conf.OIDCProviders[url]; !ok { + return nil, iamerr.NoSuchEntityOIDCProviderDelete(arn) + } + delete(conf.OIDCProviders, url) + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) AddClientIDToOIDCProvider(_ context.Context, arn, clientID string) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + provider, ok := conf.OIDCProviders[url] + if !ok { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + + if slices.Contains(provider.ClientIDList, clientID) { + return json.Marshal(conf) + } + if len(provider.ClientIDList) >= MaxClientIDsPerOIDCProvider { + return nil, iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(MaxClientIDsPerOIDCProvider) + } + provider.ClientIDList = append(provider.ClientIDList, clientID) + conf.OIDCProviders[url] = provider + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) RemoveClientIDFromOIDCProvider(_ context.Context, arn, clientID string) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + provider, ok := conf.OIDCProviders[url] + if !ok { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + + idx := slices.Index(provider.ClientIDList, clientID) + if idx == -1 { + return json.Marshal(conf) + } + provider.ClientIDList = slices.Delete(provider.ClientIDList, idx, idx+1) + conf.OIDCProviders[url] = provider + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) UpdateOIDCProviderThumbprint(_ context.Context, arn string, thumbprints []string) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + provider, ok := conf.OIDCProviders[url] + if !ok { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + provider.ThumbprintList = thumbprints + conf.OIDCProviders[url] = provider + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func cloneOIDCProvider(p types.OIDCProvider) *types.OIDCProvider { + cloned := p + cloned.ClientIDList = slices.Clone(p.ClientIDList) + cloned.ThumbprintList = slices.Clone(p.ThumbprintList) + cloned.Tags = slices.Clone(p.Tags) + return &cloned +} diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index aa915c19..7f0b9a73 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -37,6 +37,14 @@ const MaxInlinePolicyBytesPerUser = 2048 // all of a single IAM role's inline policy documents combined const MaxInlinePolicyBytesPerRole = 10240 +// MaxClientIDsPerOIDCProvider is the maximum number of client IDs a single +// OIDC provider may hold at once +const MaxClientIDsPerOIDCProvider = 100 + +// MaxOIDCProvidersPerAccount is the maximum number of OIDC providers a +// single account may hold +const MaxOIDCProvidersPerAccount = 100 + var ( ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") @@ -148,6 +156,10 @@ type ListRolePoliciesOutput struct { Marker string } +type ListOIDCProvidersOutput struct { + Providers []types.OpenIDConnectProviderListEntry +} + // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -177,6 +189,15 @@ type Storer interface { GetRolePolicy(ctx context.Context, roleName, policyName string) (*types.PolicyEntry, error) DeleteRolePolicy(ctx context.Context, roleName, policyName string) error ListRolePolicies(ctx context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error) + + // OIDC Provider CRUD + CreateOIDCProvider(ctx context.Context, provider types.OIDCProvider) (*types.OIDCProvider, error) + GetOIDCProvider(ctx context.Context, arn string) (*types.OIDCProvider, error) + ListOIDCProviders(ctx context.Context) (*ListOIDCProvidersOutput, error) + DeleteOIDCProvider(ctx context.Context, arn string) error + AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error + RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error + UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index 9c914d85..5b9d9694 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -16,6 +16,7 @@ package storage import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -28,6 +29,7 @@ import ( vault "github.com/hashicorp/vault-client-go" "github.com/hashicorp/vault-client-go/schema" "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/internal/iamutil" "github.com/versity/versitygw/iamapi/types" ) @@ -1119,6 +1121,296 @@ func parseVaultRole(data map[string]any, roleName string) (types.Role, error) { return role, nil } +// oidcProvidersPath is the KV prefix under which OIDC providers are stored, +// kept distinct from secretStoragePath/rolesPath. +func (s *VaultStore) oidcProvidersPath() string { + return s.secretStoragePath + "/oidc-providers" +} + +// oidcProviderPathSegment returns the literal KV path segment for a +// provider identified by its scheme-stripped url. OIDC provider URLs may +// themselves contain "/" (e.g. "host/" and "host/path" are distinct valid +// providers) and Vault KV paths treat "/" as a path +// separator, so — unlike RoleName/UserName, which never contain "/" and are +// used as literal path segments directly — the raw url cannot safely be +// used as a KV path segment. base64url-encoding (RawURLEncoding: lossless, +// produces only [A-Za-z0-9_-], no "/" or "=" padding) collapses it to one +// opaque, path-safe segment. The same segment is reused as the single outer +// JSON key inside the KV secret body (a deliberate deviation from +// roleToVaultMap/userToVaultMap's convention of keying on the +// human-readable name — simpler here since only one identifier needs to be +// tracked for read-back, not two). +func oidcProviderPathSegment(url string) string { + return base64.RawURLEncoding.EncodeToString([]byte(url)) +} + +func (s *VaultStore) CreateOIDCProvider(_ context.Context, provider types.OIDCProvider) (*types.OIDCProvider, error) { + segment := oidcProviderPathSegment(provider.Url) + path := s.oidcProvidersPath() + "/" + segment + displayURL := "https://" + provider.Url + + resp, err := s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...) + if err != nil && !vault.IsErrorStatus(err, http.StatusNotFound) { + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...) + if err != nil && !vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, err + } + } + if resp != nil { + if slices.Contains(resp.Data.Keys, segment) { + return nil, iamerr.EntityAlreadyExistsOIDCProvider(displayURL) + } + if len(resp.Data.Keys) >= MaxOIDCProvidersPerAccount { + return nil, iamerr.OIDCProvidersPerAccountLimitExceeded(MaxOIDCProvidersPerAccount) + } + } + + providerMap, err := oidcProviderToVaultMap(provider) + if err != nil { + return nil, fmt.Errorf("serialize oidc provider: %w", err) + } + req := schema.KvV2WriteRequest{ + Data: map[string]any{segment: providerMap}, + Options: map[string]any{"cas": 0}, + } + + _, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, iamerr.EntityAlreadyExistsOIDCProvider(displayURL) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + _, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, iamerr.EntityAlreadyExistsOIDCProvider(displayURL) + } + if vault.IsErrorStatus(err, http.StatusForbidden) { + return nil, fmt.Errorf("vault 403 permission denied on path %q. check KV mount path and policy. original: %w", path, err) + } + return nil, err + } + } + return cloneOIDCProvider(provider), nil +} + +func (s *VaultStore) GetOIDCProvider(_ context.Context, arn string) (*types.OIDCProvider, error) { + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + segment := oidcProviderPathSegment(url) + path := s.oidcProvidersPath() + "/" + segment + + resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityOIDCProviderGet(arn) + } + return nil, err + } + } + + provider, err := parseVaultOIDCProvider(resp.Data.Data, segment) + if err != nil { + return nil, err + } + return cloneOIDCProvider(provider), nil +} + +func (s *VaultStore) ListOIDCProviders(_ context.Context) (*ListOIDCProvidersOutput, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return &ListOIDCProvidersOutput{Providers: []types.OpenIDConnectProviderListEntry{}}, nil + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return &ListOIDCProvidersOutput{Providers: []types.OpenIDConnectProviderListEntry{}}, nil + } + return nil, err + } + } + + entries := make([]types.OpenIDConnectProviderListEntry, 0, len(resp.Data.Keys)) + for _, segment := range resp.Data.Keys { + // Read each secret by its already-known key rather than decoding + // segment back to a url, populating the list from each secret's own + // stored fields. + path := s.oidcProvidersPath() + "/" + segment + secretResp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + secretResp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + return nil, err + } + } + provider, err := parseVaultOIDCProvider(secretResp.Data.Data, segment) + if err != nil { + return nil, err + } + entries = append(entries, types.OpenIDConnectProviderListEntry{Arn: provider.Arn}) + } + + sort.Slice(entries, func(i, j int) bool { return entries[i].Arn < entries[j].Arn }) + return &ListOIDCProvidersOutput{Providers: entries}, nil +} + +func (s *VaultStore) DeleteOIDCProvider(_ context.Context, arn string) error { + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return err + } + path := s.oidcProvidersPath() + "/" + oidcProviderPathSegment(url) + + // Existence check first: unlike deleteRoleByPath (only reached after + // DeleteRole's own prior GetRole existence check), Delete's own + // not-found path is load-bearing here (NOT idempotent). + if _, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...); err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return iamerr.NoSuchEntityOIDCProviderDelete(arn) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return reauthErr + } + if _, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...); err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return iamerr.NoSuchEntityOIDCProviderDelete(arn) + } + return err + } + } + + return s.deleteOIDCProviderByURL(url) +} + +func (s *VaultStore) deleteOIDCProviderByURL(url string) error { + path := s.oidcProvidersPath() + "/" + oidcProviderPathSegment(url) + _, err := s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...) + if err != nil { + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return reauthErr + } + _, err = s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...) + if err != nil { + return err + } + } + return nil +} + +// AddClientIDToOIDCProvider / RemoveClientIDFromOIDCProvider / +// UpdateOIDCProviderThumbprint use non-atomic get-then-replace, mirroring +// the existing consistency model of UpdateAssumeRolePolicy/PutRolePolicy's +// Vault implementations — this codebase has no CAS-protected +// read-modify-write for Vault mutations today, and this does not introduce +// one. + +func (s *VaultStore) AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error { + provider, err := s.GetOIDCProvider(ctx, arn) + if err != nil { + return err + } + if slices.Contains(provider.ClientIDList, clientID) { + return nil + } + if len(provider.ClientIDList) >= MaxClientIDsPerOIDCProvider { + return iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(MaxClientIDsPerOIDCProvider) + } + provider.ClientIDList = append(provider.ClientIDList, clientID) + return s.replaceOIDCProvider(ctx, *provider) +} + +func (s *VaultStore) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error { + provider, err := s.GetOIDCProvider(ctx, arn) + if err != nil { + return err + } + idx := slices.Index(provider.ClientIDList, clientID) + if idx == -1 { + return nil + } + provider.ClientIDList = slices.Delete(provider.ClientIDList, idx, idx+1) + return s.replaceOIDCProvider(ctx, *provider) +} + +func (s *VaultStore) UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error { + provider, err := s.GetOIDCProvider(ctx, arn) + if err != nil { + return err + } + provider.ThumbprintList = thumbprints + return s.replaceOIDCProvider(ctx, *provider) +} + +// replaceOIDCProvider overwrites the stored document for provider.Url by +// deleting all existing versions and recreating with CAS=0. +func (s *VaultStore) replaceOIDCProvider(ctx context.Context, provider types.OIDCProvider) error { + if err := s.deleteOIDCProviderByURL(provider.Url); err != nil { + return err + } + _, err := s.CreateOIDCProvider(ctx, provider) + return err +} + +var errInvalidVaultOIDCProvider = errors.New("invalid oidc provider entry in vault secrets engine") + +func oidcProviderToVaultMap(provider types.OIDCProvider) (map[string]any, error) { + b, err := json.Marshal(provider) + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + return m, nil +} + +// parseVaultOIDCProvider reconstructs an OIDCProvider from the raw +// map[string]any vault returns. The outer key is the base64url path +// segment used at write time (oidcProviderPathSegment), not a +// human-readable value — unlike parseVaultRole/parseVaultUser. +func parseVaultOIDCProvider(data map[string]any, segment string) (types.OIDCProvider, error) { + raw, ok := data[segment] + if !ok { + return types.OIDCProvider{}, errInvalidVaultOIDCProvider + } + providerMap, ok := raw.(map[string]any) + if !ok { + return types.OIDCProvider{}, errInvalidVaultOIDCProvider + } + b, err := json.Marshal(providerMap) + if err != nil { + return types.OIDCProvider{}, fmt.Errorf("re-marshal vault oidc provider: %w", err) + } + var provider types.OIDCProvider + if err := json.Unmarshal(b, &provider); err != nil { + return types.OIDCProvider{}, fmt.Errorf("unmarshal vault oidc provider: %w", err) + } + return provider, nil +} + var errInvalidVaultUser = errors.New("invalid user entry in vault secrets engine") // userToVaultMap round-trips User through JSON to produce a map[string]any diff --git a/iamapi/types/oidc.go b/iamapi/types/oidc.go new file mode 100644 index 00000000..d8ee2e84 --- /dev/null +++ b/iamapi/types/oidc.go @@ -0,0 +1,129 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package types + +import ( + "encoding/xml" + "time" +) + +// OIDCProvider is the storage-layer representation of an IAM OIDC identity +// provider. Unlike Role, it is never marshaled to XML directly — each real +// IAM action returns a different subset of its fields — so it is copied +// field-by-field into the narrower XML result types +type OIDCProvider struct { + // Arn is the full arn:aws:iam:::oidc-provider/ ARN. + Arn string `json:"arn"` + // Url is stored WITHOUT the "https://" scheme prefix. This is both the + // ARN's resource-path suffix and the exact string + // GetOpenIDConnectProvider echoes back in its own Url field. It is never + // case-folded or otherwise normalized + Url string `json:"url"` + ClientIDList []string `json:"clientIDList,omitempty"` + ThumbprintList []string `json:"thumbprintList,omitempty"` + CreateDate time.Time `json:"createDate"` + Tags []Tag `json:"tags,omitempty"` +} + +type CreateOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateOpenIDConnectProviderResponse"` + Result CreateOpenIDConnectProviderResult `xml:"CreateOpenIDConnectProviderResult"` + ResponseMetadata ResponseMetadata +} + +func (r *CreateOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type CreateOpenIDConnectProviderResult struct { + OpenIDConnectProviderArn string `xml:"OpenIDConnectProviderArn"` + Tags []Tag `xml:"Tags>member,omitempty"` +} + +type GetOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetOpenIDConnectProviderResponse"` + Result GetOpenIDConnectProviderResult `xml:"GetOpenIDConnectProviderResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetOpenIDConnectProviderResult struct { + Url string `xml:",omitempty"` + ClientIDList []string `xml:"ClientIDList>member,omitempty"` + ThumbprintList []string `xml:"ThumbprintList>member,omitempty"` + CreateDate time.Time `xml:"CreateDate"` + Tags []Tag `xml:"Tags>member,omitempty"` +} + +type ListOpenIDConnectProvidersResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListOpenIDConnectProvidersResponse"` + Result ListOpenIDConnectProvidersResult `xml:"ListOpenIDConnectProvidersResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListOpenIDConnectProvidersResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListOpenIDConnectProvidersResult struct { + OpenIDConnectProviderList OpenIDConnectProviderList +} + +type OpenIDConnectProviderList struct { + Members []OpenIDConnectProviderListEntry `xml:"member"` +} + +type OpenIDConnectProviderListEntry struct { + Arn string `xml:"Arn"` +} + +type DeleteOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteOpenIDConnectProviderResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *DeleteOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type AddClientIDToOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AddClientIDToOpenIDConnectProviderResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *AddClientIDToOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type RemoveClientIDFromOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ RemoveClientIDFromOpenIDConnectProviderResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *RemoveClientIDFromOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type UpdateOpenIDConnectProviderThumbprintResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateOpenIDConnectProviderThumbprintResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *UpdateOpenIDConnectProviderThumbprintResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 3d3fdd18..f27ae157 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1385,6 +1385,70 @@ func TestIAMListRolePolicies(ts *TestState) { ts.Run(IAMListRolePolicies_pagination) } +func TestIAMCreateOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMCreateOpenIDConnectProvider_missing_url) + ts.Run(IAMCreateOpenIDConnectProvider_invalid_url) + ts.Run(IAMCreateOpenIDConnectProvider_client_id_too_long) + ts.Run(IAMCreateOpenIDConnectProvider_too_many_client_ids) + ts.Run(IAMCreateOpenIDConnectProvider_invalid_thumbprint) + ts.Run(IAMCreateOpenIDConnectProvider_duplicate_tag_keys) + ts.Run(IAMCreateOpenIDConnectProvider_already_exists) + ts.Run(IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error) + ts.Run(IAMCreateOpenIDConnectProvider_quota_exceeded) + ts.Run(IAMCreateOpenIDConnectProvider_success) + ts.Run(IAMCreateOpenIDConnectProvider_defaults) + ts.Run(IAMCreateOpenIDConnectProvider_ip_literal_host) + ts.Run(IAMCreateOpenIDConnectProvider_thumbprint_edge_cases) + ts.Run(IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity) +} + +func TestIAMGetOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMGetOpenIDConnectProvider_missing_arn) + ts.Run(IAMGetOpenIDConnectProvider_invalid_arn) + ts.Run(IAMGetOpenIDConnectProvider_non_existing) + ts.Run(IAMGetOpenIDConnectProvider_success) +} + +func TestIAMListOpenIDConnectProviders(ts *TestState) { + ts.Run(IAMListOpenIDConnectProviders_success) +} + +func TestIAMDeleteOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMDeleteOpenIDConnectProvider_missing_arn) + ts.Run(IAMDeleteOpenIDConnectProvider_non_existing) + ts.Run(IAMDeleteOpenIDConnectProvider_success) + ts.Run(IAMDeleteOpenIDConnectProvider_not_idempotent) +} + +func TestIAMAddClientIDToOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMAddClientIDToOpenIDConnectProvider_missing_arn) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_missing_client_id) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_client_id_too_long) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_non_existing_provider) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_limit_exceeded) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_success) + ts.Run(IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate) +} + +func TestIAMRemoveClientIDFromOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn) + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id) + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long) + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider) + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_success) + ts.Run(IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent) +} + +func TestIAMUpdateOpenIDConnectProviderThumbprint(ts *TestState) { + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_missing_arn) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_success) + ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1411,6 +1475,13 @@ func TestIAM(ts *TestState) { TestIAMGetRolePolicy(ts) TestIAMDeleteRolePolicy(ts) TestIAMListRolePolicies(ts) + TestIAMCreateOpenIDConnectProvider(ts) + TestIAMGetOpenIDConnectProvider(ts) + TestIAMListOpenIDConnectProviders(ts) + TestIAMDeleteOpenIDConnectProvider(ts) + TestIAMAddClientIDToOpenIDConnectProvider(ts) + TestIAMRemoveClientIDFromOpenIDConnectProvider(ts) + TestIAMUpdateOpenIDConnectProviderThumbprint(ts) } func TestAccessControl(ts *TestState) { @@ -1956,6 +2027,49 @@ func GetIntTests() IntTests { "IAMListRolePolicies_empty_result": IAMListRolePolicies_empty_result, "IAMListRolePolicies_success": IAMListRolePolicies_success, "IAMListRolePolicies_pagination": IAMListRolePolicies_pagination, + "IAMCreateOpenIDConnectProvider_missing_url": IAMCreateOpenIDConnectProvider_missing_url, + "IAMCreateOpenIDConnectProvider_invalid_url": IAMCreateOpenIDConnectProvider_invalid_url, + "IAMCreateOpenIDConnectProvider_client_id_too_long": IAMCreateOpenIDConnectProvider_client_id_too_long, + "IAMCreateOpenIDConnectProvider_too_many_client_ids": IAMCreateOpenIDConnectProvider_too_many_client_ids, + "IAMCreateOpenIDConnectProvider_invalid_thumbprint": IAMCreateOpenIDConnectProvider_invalid_thumbprint, + "IAMCreateOpenIDConnectProvider_duplicate_tag_keys": IAMCreateOpenIDConnectProvider_duplicate_tag_keys, + "IAMCreateOpenIDConnectProvider_already_exists": IAMCreateOpenIDConnectProvider_already_exists, + "IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error": IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error, + "IAMCreateOpenIDConnectProvider_quota_exceeded": IAMCreateOpenIDConnectProvider_quota_exceeded, + "IAMCreateOpenIDConnectProvider_success": IAMCreateOpenIDConnectProvider_success, + "IAMCreateOpenIDConnectProvider_defaults": IAMCreateOpenIDConnectProvider_defaults, + "IAMCreateOpenIDConnectProvider_ip_literal_host": IAMCreateOpenIDConnectProvider_ip_literal_host, + "IAMCreateOpenIDConnectProvider_thumbprint_edge_cases": IAMCreateOpenIDConnectProvider_thumbprint_edge_cases, + "IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity": IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity, + "IAMGetOpenIDConnectProvider_missing_arn": IAMGetOpenIDConnectProvider_missing_arn, + "IAMGetOpenIDConnectProvider_invalid_arn": IAMGetOpenIDConnectProvider_invalid_arn, + "IAMGetOpenIDConnectProvider_non_existing": IAMGetOpenIDConnectProvider_non_existing, + "IAMGetOpenIDConnectProvider_success": IAMGetOpenIDConnectProvider_success, + "IAMListOpenIDConnectProviders_success": IAMListOpenIDConnectProviders_success, + "IAMDeleteOpenIDConnectProvider_missing_arn": IAMDeleteOpenIDConnectProvider_missing_arn, + "IAMDeleteOpenIDConnectProvider_non_existing": IAMDeleteOpenIDConnectProvider_non_existing, + "IAMDeleteOpenIDConnectProvider_success": IAMDeleteOpenIDConnectProvider_success, + "IAMDeleteOpenIDConnectProvider_not_idempotent": IAMDeleteOpenIDConnectProvider_not_idempotent, + "IAMAddClientIDToOpenIDConnectProvider_missing_arn": IAMAddClientIDToOpenIDConnectProvider_missing_arn, + "IAMAddClientIDToOpenIDConnectProvider_missing_client_id": IAMAddClientIDToOpenIDConnectProvider_missing_client_id, + "IAMAddClientIDToOpenIDConnectProvider_client_id_too_long": IAMAddClientIDToOpenIDConnectProvider_client_id_too_long, + "IAMAddClientIDToOpenIDConnectProvider_non_existing_provider": IAMAddClientIDToOpenIDConnectProvider_non_existing_provider, + "IAMAddClientIDToOpenIDConnectProvider_limit_exceeded": IAMAddClientIDToOpenIDConnectProvider_limit_exceeded, + "IAMAddClientIDToOpenIDConnectProvider_success": IAMAddClientIDToOpenIDConnectProvider_success, + "IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate": IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate, + "IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn": IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn, + "IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id": IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id, + "IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long": IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long, + "IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider": IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider, + "IAMRemoveClientIDFromOpenIDConnectProvider_success": IAMRemoveClientIDFromOpenIDConnectProvider_success, + "IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent": IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent, + "IAMUpdateOpenIDConnectProviderThumbprint_missing_arn": IAMUpdateOpenIDConnectProviderThumbprint_missing_arn, + "IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list": IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list, + "IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints, + "IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint": IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint, + "IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider": IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider, + "IAMUpdateOpenIDConnectProviderThumbprint_success": IAMUpdateOpenIDConnectProviderThumbprint_success, + "IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints, "PresignedAuth_security_token_not_supported": PresignedAuth_security_token_not_supported, "PresignedAuth_unsupported_algorithm": PresignedAuth_unsupported_algorithm, "PresignedAuth_ECDSA_not_supported": PresignedAuth_ECDSA_not_supported, diff --git a/tests/integration/iam_add_client_id_to_oidc_provider.go b/tests/integration/iam_add_client_id_to_oidc_provider.go new file mode 100644 index 00000000..8f6bd2e5 --- /dev/null +++ b/tests/integration/iam_add_client_id_to_oidc_provider.go @@ -0,0 +1,204 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/storage" +) + +func IAMAddClientIDToOpenIDConnectProvider_missing_arn(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_missing_arn" + body := []byte(url.Values{ + "Action": {"AddClientIDToOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "ClientID": {"sts.amazonaws.com"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMAddClientIDToOpenIDConnectProvider_missing_client_id(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_missing_client_id" + body := []byte(url.Values{ + "Action": {"AddClientIDToOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "OpenIDConnectProviderArn": {"arn:aws:iam::000000000000:oidc-provider/example.com"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("clientID")) + }) +} + +func IAMAddClientIDToOpenIDConnectProvider_client_id_too_long(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_client_id_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := checkIAMApiErr(addClientIDToOIDCProvider(client, arn, strings.Repeat("c", 256)), iamerr.ValueTooLong("clientID", 255)) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMAddClientIDToOpenIDConnectProvider_non_existing_provider(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_non_existing_provider" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + err := addClientIDToOIDCProvider(client, arn, "sts.amazonaws.com") + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderGet(arn)) + }) +} + +func IAMAddClientIDToOpenIDConnectProvider_limit_exceeded(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + clientIDs := make([]string, storage.MaxClientIDsPerOIDCProvider) + for i := range clientIDs { + clientIDs[i] = fmt.Sprintf("client-%d", i) + } + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ClientIDList: clientIDs, + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return err + } + arn := aws.ToString(out.OpenIDConnectProviderArn) + + checkErr := checkIAMApiErr( + addClientIDToOIDCProvider(client, arn, "one-too-many"), + iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(storage.MaxClientIDsPerOIDCProvider), + ) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMAddClientIDToOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + if err := addClientIDToOIDCProvider(client, arn, "sts.amazonaws.com"); err != nil { + return err + } + out, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + if len(out.ClientIDList) != 1 || out.ClientIDList[0] != "sts.amazonaws.com" { + return fmt.Errorf("expected ClientIDList [sts.amazonaws.com], instead got %#v", out.ClientIDList) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate confirms +// adding an already-present client ID succeeds silently rather than +// erroring or creating a duplicate entry. +func IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate(s *S3Conf) error { + testName := "IAMAddClientIDToOpenIDConnectProvider_idempotent_duplicate" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + if err := addClientIDToOIDCProvider(client, arn, "sts.amazonaws.com"); err != nil { + return err + } + if err := addClientIDToOIDCProvider(client, arn, "sts.amazonaws.com"); err != nil { + return err + } + out, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + if len(out.ClientIDList) != 1 || out.ClientIDList[0] != "sts.amazonaws.com" { + return fmt.Errorf("expected ClientIDList [sts.amazonaws.com] (no duplicate), instead got %#v", out.ClientIDList) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func addClientIDToOIDCProvider(client *iam.Client, arn, clientID string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.AddClientIDToOpenIDConnectProvider(ctx, &iam.AddClientIDToOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + ClientID: &clientID, + }) + return err +} diff --git a/tests/integration/iam_create_oidc_provider.go b/tests/integration/iam_create_oidc_provider.go new file mode 100644 index 00000000..66832078 --- /dev/null +++ b/tests/integration/iam_create_oidc_provider.go @@ -0,0 +1,481 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/storage" +) + +// validOIDCThumbprint is a syntactically valid (40 hex chars) thumbprint +// used whenever a test needs a ThumbprintList entry but isn't specifically +// exercising thumbprint validation. +const validOIDCThumbprint = "6938fd4d98bab03faadb97b34396831e3780aea1" + +func IAMCreateOpenIDConnectProvider_missing_url(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_missing_url" + body := []byte(url.Values{ + "Action": {"CreateOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("url")) + }) +} + +func IAMCreateOpenIDConnectProvider_invalid_url(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_invalid_url" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, tt := range []struct { + name string + url string + want iamerr.Error + }{ + {"no_scheme", "example.com", iamerr.ValidationError("Invalid Open ID Connect Provider URL")}, + {"wrong_scheme", "http://example.com", iamerr.InvalidInput("Invalid Open ID Connect Provider URL. The URL must begin with https://.")}, + {"empty_host", "https://", iamerr.ValidationError("Invalid Open ID Connect Provider URL")}, + {"userinfo", "https://user:pass@example.com", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")}, + {"query_params", "https://example.com?foo=1", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")}, + {"fragment", "https://example.com#frag", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")}, + {"explicit_port", "https://example.com:8443", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")}, + {"invalid_hostname_chars", "https://exa_mple.com", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")}, + {"too_long", "https://" + strings.Repeat("a", 250) + ".com", iamerr.ValueTooLong("url", 255)}, + } { + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{Url: aws.String(tt.url)}) + if checkErr := checkIAMApiErr(err, tt.want); checkErr != nil { + return fmt.Errorf("%s: %w", tt.name, checkErr) + } + } + return nil + }) +} + +func IAMCreateOpenIDConnectProvider_client_id_too_long(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_client_id_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ClientIDList: []string{strings.Repeat("c", 256)}, + }) + return checkIAMApiErr(err, iamerr.ValueTooLong("clientID", 255)) + }) +} + +func IAMCreateOpenIDConnectProvider_too_many_client_ids(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_too_many_client_ids" + return iamActionHandler(s, testName, func(client *iam.Client) error { + clientIDs := make([]string, storage.MaxClientIDsPerOIDCProvider+1) + for i := range clientIDs { + clientIDs[i] = fmt.Sprintf("client-%d", i) + } + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ClientIDList: clientIDs, + ThumbprintList: []string{validOIDCThumbprint}, + }) + return checkIAMApiErr(err, iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(storage.MaxClientIDsPerOIDCProvider)) + }) +} + +func IAMCreateOpenIDConnectProvider_invalid_thumbprint(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_invalid_thumbprint" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: []string{strings.Repeat("a", 39)}, + }) + if checkErr := checkIAMApiErr(err, iamerr.InvalidInput("Thumbprint must be exactly 40 characters.")); checkErr != nil { + return fmt.Errorf("wrong_length: %w", checkErr) + } + + _, err = createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: []string{strings.Repeat("1", 40), strings.Repeat("2", 40), strings.Repeat("3", 40), strings.Repeat("4", 40), strings.Repeat("5", 40), strings.Repeat("6", 40)}, + }) + if checkErr := checkIAMApiErr(err, iamerr.ThumbprintListTooLong(5)); checkErr != nil { + return fmt.Errorf("too_many: %w", checkErr) + } + return nil + }) +} + +func IAMCreateOpenIDConnectProvider_duplicate_tag_keys(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_duplicate_tag_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: []string{validOIDCThumbprint}, + Tags: []iamtypes.Tag{ + {Key: aws.String("key"), Value: aws.String("one")}, + {Key: aws.String("KEY"), Value: aws.String("two")}, + }, + }) + return checkIAMApiErr(err, iamerr.InvalidInput("Duplicate tag keys found. Please note that Tag keys are case insensitive.")) + }) +} + +func IAMCreateOpenIDConnectProvider_already_exists(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_already_exists" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + arn, err := createTestOIDCProviderWithURL(client, providerURL) + if err != nil { + return err + } + + _, dupErr := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ThumbprintList: []string{validOIDCThumbprint}, + }) + checkErr := checkIAMApiErr(dupErr, iamerr.EntityAlreadyExistsOIDCProvider(providerURL)) + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error +// confirms the network-dependent auto-fetch fallback (triggered by +// omitting ThumbprintList) is wired all the way through the real HTTP +// action handler: a loopback URL is rejected by the fetch's mandatory +// SSRF guard before any real network attempt, deterministically and +// without requiring outbound network access from the test environment. +func IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_thumbprint_autofetch_communication_error" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String("https://127.0.0.1"), + }) + return checkIAMApiErr(err, iamerr.OpenIdIdpCommunicationError("https://127.0.0.1")) + }) +} + +// IAMCreateOpenIDConnectProvider_quota_exceeded tops the account up to +// storage.MaxOIDCProvidersPerAccount from whatever baseline count already +// exists, then confirms one more Create is rejected. It only ever creates +// (and cleans up) providers relative to the observed baseline, so it +// tolerates a non-empty account, but — like any test of a truly +// account-global, unscoped quota — it assumes no other test is +// concurrently creating/deleting OIDC providers, which holds for this +// suite's default sequential execution (not necessarily under --parallel). +func IAMCreateOpenIDConnectProvider_quota_exceeded(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_quota_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) (err error) { + baseline, err := listIAMOIDCProviders(client) + if err != nil { + return err + } + + var created []string + defer func() { + for _, arn := range created { + if deleteErr := deleteOIDCProvider(client, arn); deleteErr != nil { + err = errors.Join(err, fmt.Errorf("delete IAM OIDC provider %q: %w", arn, deleteErr)) + } + } + }() + + for i := len(baseline.OpenIDConnectProviderList); i < storage.MaxOIDCProvidersPerAccount; i++ { + arn, createErr := createTestOIDCProvider(client) + if createErr != nil { + return fmt.Errorf("topping up to quota: %w", createErr) + } + created = append(created, arn) + } + + _, overErr := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: []string{validOIDCThumbprint}, + }) + return checkIAMApiErr(overErr, iamerr.OIDCProvidersPerAccountLimitExceeded(storage.MaxOIDCProvidersPerAccount)) + }) +} + +func IAMCreateOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ClientIDList: []string{"sts.amazonaws.com"}, + ThumbprintList: []string{strings.ToUpper(validOIDCThumbprint)}, + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("test")}, + }, + }) + if err != nil { + return err + } + + checkErr := func() error { + wantArn := oidcProviderArn(providerURL) + if aws.ToString(out.OpenIDConnectProviderArn) != wantArn { + return fmt.Errorf("expected OpenIDConnectProviderArn %q, instead got %q", wantArn, aws.ToString(out.OpenIDConnectProviderArn)) + } + if len(out.Tags) != 1 || aws.ToString(out.Tags[0].Key) != "env" || aws.ToString(out.Tags[0].Value) != "test" { + return fmt.Errorf("expected create output tag env=test, instead got %#v", out.Tags) + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected CreateOpenIDConnectProvider response request id") + } + + get, getErr := getIAMOIDCProvider(client, aws.ToString(out.OpenIDConnectProviderArn)) + if getErr != nil { + return getErr + } + wantURL := strings.TrimPrefix(providerURL, "https://") + if aws.ToString(get.Url) != wantURL { + return fmt.Errorf("expected Url %q (scheme stripped), instead got %q", wantURL, aws.ToString(get.Url)) + } + if len(get.ClientIDList) != 1 || get.ClientIDList[0] != "sts.amazonaws.com" { + return fmt.Errorf("expected ClientIDList [sts.amazonaws.com], instead got %#v", get.ClientIDList) + } + // Submitted uppercase; AWS lowercases whatever is stored. + if len(get.ThumbprintList) != 1 || get.ThumbprintList[0] != validOIDCThumbprint { + return fmt.Errorf("expected ThumbprintList [%s] (lowercased), instead got %#v", validOIDCThumbprint, get.ThumbprintList) + } + if get.CreateDate == nil || get.CreateDate.IsZero() { + return fmt.Errorf("expected CreateDate to be set") + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, aws.ToString(out.OpenIDConnectProviderArn)) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateOpenIDConnectProvider_defaults(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_defaults" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return err + } + + checkErr := func() error { + if len(out.Tags) != 0 { + return fmt.Errorf("expected no tags in create output, instead got %#v", out.Tags) + } + get, getErr := getIAMOIDCProvider(client, aws.ToString(out.OpenIDConnectProviderArn)) + if getErr != nil { + return getErr + } + if len(get.ClientIDList) != 0 { + return fmt.Errorf("expected no client ids, instead got %#v", get.ClientIDList) + } + if len(get.Tags) != 0 { + return fmt.Errorf("expected no tags, instead got %#v", get.Tags) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, aws.ToString(out.OpenIDConnectProviderArn)) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMCreateOpenIDConnectProvider_ip_literal_host confirms an IP-literal +// host is accepted by exercising isValidOIDCHostname's net.ParseIP branch +// end-to-end. +func IAMCreateOpenIDConnectProvider_ip_literal_host(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_ip_literal_host" + return iamActionHandler(s, testName, func(client *iam.Client) error { + host := newIAMOIDCProviderIPHost() + arn, err := createTestOIDCProviderWithURL(client, "https://"+host) + if err != nil { + return err + } + + get, getErr := getIAMOIDCProvider(client, arn) + checkErr := getErr + if getErr == nil && aws.ToString(get.Url) != host { + checkErr = fmt.Errorf("expected Url %q, instead got %q", host, aws.ToString(get.Url)) + } + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMCreateOpenIDConnectProvider_thumbprint_edge_cases exercises two +// success-path ThumbprintList edge cases in one pass: exactly +// MaxThumbprintsPerOIDCProvider entries (the limit message says "fewer +// than 5", but 5 itself is accepted), and a 40-character entry outside the +// hex charset (AWS does not check for a hex charset). +func IAMCreateOpenIDConnectProvider_thumbprint_edge_cases(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_thumbprint_edge_cases" + return iamActionHandler(s, testName, func(client *iam.Client) error { + checkThumbprints := func(thumbprints []string) error { + arn, err := createOIDCProviderReturningArn(client, thumbprints) + if err != nil { + return err + } + return deleteOIDCProvider(client, arn) + } + + if err := checkThumbprints([]string{ + strings.Repeat("1", 40), strings.Repeat("2", 40), strings.Repeat("3", 40), + strings.Repeat("4", 40), strings.Repeat("5", 40), + }); err != nil { + return fmt.Errorf("max_thumbprints_boundary: %w", err) + } + + if err := checkThumbprints([]string{strings.Repeat("z", 40)}); err != nil { + return fmt.Errorf("non_hex_thumbprint: %w", err) + } + return nil + }) +} + +// IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity confirms +// that a trailing slash is part of a provider's identity: "https://host" +// and "https://host/" register as two distinct providers, not a +// collision. +func IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity(s *S3Conf) error { + testName := "IAMCreateOpenIDConnectProvider_trailing_slash_distinct_identity" + return iamActionHandler(s, testName, func(client *iam.Client) (err error) { + host := "oidc-test-" + genRandString(16) + ".example.com" + withoutSlash, err := createTestOIDCProviderWithURL(client, "https://"+host) + if err != nil { + return err + } + defer func() { + if deleteErr := deleteOIDCProvider(client, withoutSlash); deleteErr != nil { + err = errors.Join(err, deleteErr) + } + }() + + withSlash, err := createTestOIDCProviderWithURL(client, "https://"+host+"/") + if err != nil { + return err + } + defer func() { + if deleteErr := deleteOIDCProvider(client, withSlash); deleteErr != nil { + err = errors.Join(err, deleteErr) + } + }() + + if withoutSlash == withSlash { + return fmt.Errorf("expected distinct ARNs for %q and %q, both got %q", host, host+"/", withoutSlash) + } + return nil + }) +} + +// newIAMOIDCProviderURL returns a fresh https:// URL for a throwaway OIDC +// provider. Provider identity is the URL itself (there is no separate +// name), so genRandString's collision-free counter is what keeps +// concurrent/repeated test runs from colliding with each other or with any +// provider left over from a prior run. +func newIAMOIDCProviderURL() string { + return "https://oidc-test-" + genRandString(16) + ".example.com" +} + +// newIAMOIDCProviderIPHost returns a host string within the TEST-NET-2 +// documentation range (RFC 5737, 198.51.100.0/24 — never publicly +// routable), used to exercise CreateOpenIDConnectProvider's IP-literal +// hostname path without depending on any real, reachable host. +func newIAMOIDCProviderIPHost() string { + suffix := genRandString(1) + return fmt.Sprintf("198.51.100.%d", int(suffix[0])%254+1) +} + +func createOIDCProvider(client *iam.Client, input *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.CreateOpenIDConnectProvider(ctx, input) +} + +// createTestOIDCProvider creates a provider at a fresh random URL with a +// single explicit valid thumbprint (bypassing the network-dependent +// auto-fetch path) and returns its ARN. +func createTestOIDCProvider(client *iam.Client) (string, error) { + return createTestOIDCProviderWithURL(client, newIAMOIDCProviderURL()) +} + +func createTestOIDCProviderWithURL(client *iam.Client, providerURL string) (string, error) { + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return "", err + } + return aws.ToString(out.OpenIDConnectProviderArn), nil +} + +func deleteOIDCProvider(client *iam.Client, arn string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.DeleteOpenIDConnectProvider(ctx, &iam.DeleteOpenIDConnectProviderInput{OpenIDConnectProviderArn: &arn}) + return err +} + +// oidcProviderArn builds the expected ARN for a provider created at +// providerURL, mirroring iamutil.BuildOIDCProviderArn without importing an +// internal package from this external test tree. +func oidcProviderArn(providerURL string) string { + return "arn:aws:iam::000000000000:oidc-provider/" + strings.TrimPrefix(providerURL, "https://") +} + +func createOIDCProviderReturningArn(client *iam.Client, thumbprints []string) (string, error) { + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: thumbprints, + }) + if err != nil { + return "", err + } + return aws.ToString(out.OpenIDConnectProviderArn), nil +} diff --git a/tests/integration/iam_delete_oidc_provider.go b/tests/integration/iam_delete_oidc_provider.go new file mode 100644 index 00000000..47526ece --- /dev/null +++ b/tests/integration/iam_delete_oidc_provider.go @@ -0,0 +1,84 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "net/http" + "time" + + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMDeleteOpenIDConnectProvider_missing_arn(s *S3Conf) error { + testName := "IAMDeleteOpenIDConnectProvider_missing_arn" + body := []byte("Action=DeleteOpenIDConnectProvider&Version=2010-05-08") + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMDeleteOpenIDConnectProvider_non_existing(s *S3Conf) error { + testName := "IAMDeleteOpenIDConnectProvider_non_existing" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + err := deleteOIDCProvider(client, arn) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderDelete(arn)) + }) +} + +func IAMDeleteOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMDeleteOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + if err := deleteOIDCProvider(client, arn); err != nil { + return err + } + + _, err = getIAMOIDCProvider(client, arn) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderGet(arn)) + }) +} + +// IAMDeleteOpenIDConnectProvider_not_idempotent confirms a second delete +// of the same ARN fails. +func IAMDeleteOpenIDConnectProvider_not_idempotent(s *S3Conf) error { + testName := "IAMDeleteOpenIDConnectProvider_not_idempotent" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + if err := deleteOIDCProvider(client, arn); err != nil { + return err + } + + err = deleteOIDCProvider(client, arn) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderDelete(arn)) + }) +} diff --git a/tests/integration/iam_get_oidc_provider.go b/tests/integration/iam_get_oidc_provider.go new file mode 100644 index 00000000..22469f2e --- /dev/null +++ b/tests/integration/iam_get_oidc_provider.go @@ -0,0 +1,143 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMGetOpenIDConnectProvider_missing_arn(s *S3Conf) error { + testName := "IAMGetOpenIDConnectProvider_missing_arn" + body := []byte("Action=GetOpenIDConnectProvider&Version=2010-05-08") + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMGetOpenIDConnectProvider_invalid_arn(s *S3Conf) error { + testName := "IAMGetOpenIDConnectProvider_invalid_arn" + return iamActionHandler(s, testName, func(client *iam.Client) error { + tests := []struct { + name string + arn string + want iamerr.Error + }{ + {"too_short", strings.Repeat("a", 19), iamerr.ValueTooShort("openIDConnectProviderArn", 20)}, + {"too_long", strings.Repeat("a", 2049), iamerr.ValueTooLong("openIDConnectProviderArn", 2048)}, + {"wrong_resource_type", "arn:aws:iam::000000000000:role/some-role", iamerr.ValidationError("Invalid resource type in ARN")}, + {"foreign_account_id", "arn:aws:iam::123456789012:oidc-provider/example.com", iamerr.AccessDeniedOIDCProvider("000000000000", "arn:aws:iam::123456789012:oidc-provider/example.com")}, + } + for _, tt := range tests { + _, err := getIAMOIDCProvider(client, tt.arn) + if checkErr := checkIAMApiErr(err, tt.want); checkErr != nil { + return fmt.Errorf("%s: %w", tt.name, checkErr) + } + } + return nil + }) +} + +func IAMGetOpenIDConnectProvider_non_existing(s *S3Conf) error { + testName := "IAMGetOpenIDConnectProvider_non_existing" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + _, err := getIAMOIDCProvider(client, arn) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderGet(arn)) + }) +} + +func IAMGetOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMGetOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + providerURL := newIAMOIDCProviderURL() + created, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(providerURL), + ClientIDList: []string{"sts.amazonaws.com", "another-client"}, + ThumbprintList: []string{validOIDCThumbprint}, + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("test")}, + }, + }) + if err != nil { + return err + } + arn := aws.ToString(created.OpenIDConnectProviderArn) + + checkErr := func() error { + out, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + wantURL := strings.TrimPrefix(providerURL, "https://") + if aws.ToString(out.Url) != wantURL { + return fmt.Errorf("expected Url %q, instead got %q", wantURL, aws.ToString(out.Url)) + } + wantClientIDs := []string{"sts.amazonaws.com", "another-client"} + if len(out.ClientIDList) != len(wantClientIDs) { + return fmt.Errorf("expected ClientIDList %#v, instead got %#v", wantClientIDs, out.ClientIDList) + } + for i, id := range wantClientIDs { + if out.ClientIDList[i] != id { + return fmt.Errorf("expected ClientIDList %#v, instead got %#v", wantClientIDs, out.ClientIDList) + } + } + if len(out.ThumbprintList) != 1 || out.ThumbprintList[0] != validOIDCThumbprint { + return fmt.Errorf("expected ThumbprintList [%s], instead got %#v", validOIDCThumbprint, out.ThumbprintList) + } + if out.CreateDate == nil || out.CreateDate.IsZero() { + return fmt.Errorf("expected CreateDate to be set") + } + if len(out.Tags) != 1 || aws.ToString(out.Tags[0].Key) != "env" || aws.ToString(out.Tags[0].Value) != "test" { + return fmt.Errorf("expected tag env=test, instead got %#v", out.Tags) + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected GetOpenIDConnectProvider response request id") + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func getIAMOIDCProvider(client *iam.Client, arn string) (*iam.GetOpenIDConnectProviderOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetOpenIDConnectProvider(ctx, &iam.GetOpenIDConnectProviderInput{OpenIDConnectProviderArn: &arn}) +} diff --git a/tests/integration/iam_list_oidc_providers.go b/tests/integration/iam_list_oidc_providers.go new file mode 100644 index 00000000..b5d2b5b3 --- /dev/null +++ b/tests/integration/iam_list_oidc_providers.go @@ -0,0 +1,122 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "errors" + "fmt" + + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" +) + +func IAMListOpenIDConnectProviders_success(s *S3Conf) error { + testName := "IAMListOpenIDConnectProviders_success" + return iamActionHandler(s, testName, func(client *iam.Client) (err error) { + before, err := listIAMOIDCProviders(client) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(before.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListOpenIDConnectProviders response request id") + } + baseline := oidcProviderArnSet(before) + + arnA, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + arnB, err := createTestOIDCProvider(client) + if err != nil { + delErr := deleteOIDCProvider(client, arnA) + return errors.Join(err, delErr) + } + + cleanup := func(arns ...string) error { + var errs error + for _, arn := range arns { + if delErr := deleteOIDCProvider(client, arn); delErr != nil { + errs = errors.Join(errs, delErr) + } + } + return errs + } + + afterCreate, err := listIAMOIDCProviders(client) + if err != nil { + return errors.Join(err, cleanup(arnA, arnB)) + } + createdSet := oidcProviderArnSet(afterCreate) + if _, ok := createdSet[arnA]; !ok { + return errors.Join(fmt.Errorf("expected %q in ListOpenIDConnectProviders after create", arnA), cleanup(arnA, arnB)) + } + if _, ok := createdSet[arnB]; !ok { + return errors.Join(fmt.Errorf("expected %q in ListOpenIDConnectProviders after create", arnB), cleanup(arnA, arnB)) + } + for arn := range baseline { + if _, ok := createdSet[arn]; !ok { + return errors.Join(fmt.Errorf("expected pre-existing %q to still be listed", arn), cleanup(arnA, arnB)) + } + } + + if err := deleteOIDCProvider(client, arnA); err != nil { + return errors.Join(err, cleanup(arnB)) + } + + afterDeleteA, err := listIAMOIDCProviders(client) + if err != nil { + return errors.Join(err, cleanup(arnB)) + } + afterDeleteASet := oidcProviderArnSet(afterDeleteA) + if _, ok := afterDeleteASet[arnA]; ok { + return errors.Join(fmt.Errorf("expected %q to be absent after delete", arnA), cleanup(arnB)) + } + if _, ok := afterDeleteASet[arnB]; !ok { + return errors.Join(fmt.Errorf("expected %q still listed", arnB), cleanup(arnB)) + } + + if err := deleteOIDCProvider(client, arnB); err != nil { + return err + } + + afterDeleteB, err := listIAMOIDCProviders(client) + if err != nil { + return err + } + if _, ok := oidcProviderArnSet(afterDeleteB)[arnB]; ok { + return fmt.Errorf("expected %q to be absent after delete", arnB) + } + + return nil + }) +} + +func listIAMOIDCProviders(client *iam.Client) (*iam.ListOpenIDConnectProvidersOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListOpenIDConnectProviders(ctx, &iam.ListOpenIDConnectProvidersInput{}) +} + +func oidcProviderArnSet(out *iam.ListOpenIDConnectProvidersOutput) map[string]struct{} { + set := make(map[string]struct{}, len(out.OpenIDConnectProviderList)) + for _, p := range out.OpenIDConnectProviderList { + if p.Arn != nil { + set[*p.Arn] = struct{}{} + } + } + return set +} diff --git a/tests/integration/iam_remove_client_id_from_oidc_provider.go b/tests/integration/iam_remove_client_id_from_oidc_provider.go new file mode 100644 index 00000000..fa214b1c --- /dev/null +++ b/tests/integration/iam_remove_client_id_from_oidc_provider.go @@ -0,0 +1,163 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_missing_arn" + body := []byte(url.Values{ + "Action": {"RemoveClientIDFromOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "ClientID": {"sts.amazonaws.com"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_missing_client_id" + body := []byte(url.Values{ + "Action": {"RemoveClientIDFromOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "OpenIDConnectProviderArn": {"arn:aws:iam::000000000000:oidc-provider/example.com"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("clientID")) + }) +} + +func IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_client_id_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := checkIAMApiErr(removeClientIDFromOIDCProvider(client, arn, strings.Repeat("c", 256)), iamerr.ValueTooLong("clientID", 255)) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_non_existing_provider" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + err := removeClientIDFromOIDCProvider(client, arn, "sts.amazonaws.com") + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderGet(arn)) + }) +} + +func IAMRemoveClientIDFromOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ClientIDList: []string{"sts.amazonaws.com", "another-client"}, + ThumbprintList: []string{validOIDCThumbprint}, + }) + if err != nil { + return err + } + arn := aws.ToString(out.OpenIDConnectProviderArn) + + checkErr := func() error { + if err := removeClientIDFromOIDCProvider(client, arn, "sts.amazonaws.com"); err != nil { + return err + } + got, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + if len(got.ClientIDList) != 1 || got.ClientIDList[0] != "another-client" { + return fmt.Errorf("expected ClientIDList [another-client], instead got %#v", got.ClientIDList) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent confirms +// removing a client ID that was never added succeeds silently rather than +// erroring. +func IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent(s *S3Conf) error { + testName := "IAMRemoveClientIDFromOpenIDConnectProvider_idempotent_absent" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := removeClientIDFromOIDCProvider(client, arn, "never-added") + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func removeClientIDFromOIDCProvider(client *iam.Client, arn, clientID string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.RemoveClientIDFromOpenIDConnectProvider(ctx, &iam.RemoveClientIDFromOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + ClientID: &clientID, + }) + return err +} diff --git a/tests/integration/iam_update_oidc_provider_thumbprint.go b/tests/integration/iam_update_oidc_provider_thumbprint.go new file mode 100644 index 00000000..143e54ae --- /dev/null +++ b/tests/integration/iam_update_oidc_provider_thumbprint.go @@ -0,0 +1,185 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMUpdateOpenIDConnectProviderThumbprint_missing_arn(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_missing_arn" + body := []byte(url.Values{ + "Action": {"UpdateOpenIDConnectProviderThumbprint"}, + "Version": {"2010-05-08"}, + "ThumbprintList.member.1": {validOIDCThumbprint}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_missing_thumbprint_list" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := checkIAMApiErr(updateOIDCProviderThumbprint(client, arn, []string{}), iamerr.ThumbprintListEmpty()) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_too_many_thumbprints" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + thumbprints := []string{ + strings.Repeat("1", 40), strings.Repeat("2", 40), strings.Repeat("3", 40), + strings.Repeat("4", 40), strings.Repeat("5", 40), strings.Repeat("6", 40), + } + checkErr := checkIAMApiErr(updateOIDCProviderThumbprint(client, arn, thumbprints), iamerr.ThumbprintListTooLong(5)) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_wrong_length_thumbprint" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := checkIAMApiErr( + updateOIDCProviderThumbprint(client, arn, []string{strings.Repeat("a", 39)}), + iamerr.InvalidInput("Thumbprint must be exactly 40 characters."), + ) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + err := updateOIDCProviderThumbprint(client, arn, []string{validOIDCThumbprint}) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderGet(arn)) + }) +} + +func IAMUpdateOpenIDConnectProviderThumbprint_success(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + newThumbprints := []string{strings.Repeat("A", 40), strings.Repeat("B", 40)} + if err := updateOIDCProviderThumbprint(client, arn, newThumbprints); err != nil { + return err + } + out, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + // Full replace (the original validOIDCThumbprint must be gone), + // lowercased (submitted uppercase). + want := []string{strings.Repeat("a", 40), strings.Repeat("b", 40)} + if !slices.Equal(out.ThumbprintList, want) { + return fmt.Errorf("expected ThumbprintList %#v, instead got %#v", want, out.ThumbprintList) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints +// confirms exactly MaxThumbprintsPerOIDCProvider entries succeeds — the +// limit message says "fewer than 5", but 5 itself is accepted. +func IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints(s *S3Conf) error { + testName := "IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + thumbprints := []string{ + strings.Repeat("1", 40), strings.Repeat("2", 40), strings.Repeat("3", 40), + strings.Repeat("4", 40), strings.Repeat("5", 40), + } + checkErr := updateOIDCProviderThumbprint(client, arn, thumbprints) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func updateOIDCProviderThumbprint(client *iam.Client, arn string, thumbprints []string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.UpdateOpenIDConnectProviderThumbprint(ctx, &iam.UpdateOpenIDConnectProviderThumbprintInput{ + OpenIDConnectProviderArn: &arn, + ThumbprintList: thumbprints, + }) + return err +}