mirror of
https://github.com/versity/versitygw.git
synced 2026-08-17 12:46:23 +00:00
feat: add IAM OIDC provider CRUD
Add support for `CreateOpenIDConnectProvider`, `GetOpenIDConnectProvider`, `ListOpenIDConnectProviders`, `DeleteOpenIDConnectProvider`, `AddClientIDToOpenIDConnectProvider`, `RemoveClientIDFromOpenIDConnectProvider`, and `UpdateOpenIDConnectProviderThumbprint` on both the internal and Vault storage backends, rounding out the standalone IAM service with the same OIDC identity provider management AWS IAM exposes. CreateOpenIDConnectProvider validates the issuer URL, enforces the client ID and per-provider client ID list limits, and accepts an optional ThumbprintList. When the caller omits ThumbprintList, the provider auto-fetches the thumbprint by opening an outbound TLS connection to the issuer URL and hashing its top-level CA certificate, matching real AWS behavior. This auto-fetch is configurable: it can be turned off with the `--disable-oidc-thumbprint-autofetch` CLI flag (or the `VGW_IAM_DISABLE_OIDC_THUMBPRINT_AUTOFETCH` environment variable) for restricted or air-gapped deployments where the IAM server shouldn't make outbound connections, in which case an omitted ThumbprintList is rejected instead. AddClientIDToOpenIDConnectProvider and RemoveClientIDFromOpenIDConnectProvider manage a provider's client ID list, and UpdateOpenIDConnectProviderThumbprint replaces its thumbprint list, all with the same length and format validation applied at creation time. Provider ARNs are derived from the issuer URL, and GetOpenIDConnectProvider and DeleteOpenIDConnectProvider resolve providers by ARN, returning NoSuchEntity when a provider doesn't exist. ListOpenIDConnectProviders returns the full set of stored providers. These actions are wired into the IAM API router and given their own XML response types under iamapi/types, with a new iamapi/internal/iamutil package handling URL validation, thumbprint fetching and normalization, and ARN construction shared across the controller methods.
This commit is contained in:
@@ -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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+30
-29
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+196
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "<paramName>.member.1",
|
||||
// "<paramName>.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::<account>:<resource-type>/<resource>) 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
-25
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::<account>:oidc-provider/<url> 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user