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:
niksis02
2026-08-25 01:03:22 +04:00
parent c9ce6ab37c
commit ec579ca841
23 changed files with 3266 additions and 56 deletions
+190
View File
@@ -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
}
+21
View File
@@ -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 {
+292
View File
@@ -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