mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 08:24:17 +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:
+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
|
||||
}
|
||||
Reference in New Issue
Block a user