feat(iam): OIDC provider mutations + multi-client + TLS thumbprints (Phase 2b) (#9320)
* feat(iam): OIDC provider mutations + multi-client + TLS thumbprints - Mutating IAM actions: CreateOpenIDConnectProvider, DeleteOpenIDConnectProvider, AddClientIDToOpenIDConnectProvider, RemoveClientIDFromOpenIDConnectProvider, UpdateOpenIDConnectProviderThumbprint, TagOpenIDConnectProvider, UntagOpenIDConnectProvider. Each enforces AWS-shape input bounds and the read-only mode rejects all mutations. - Multiple client_ids per provider in OIDCConfig (clientIds list, plural) with full backward compatibility — singular clientId still works and is merged into the audience allowlist. Provider factory accepts both. - AWS-compatible TLS thumbprint pinning: when OIDCConfig.Thumbprints is non-empty, JWKS fetches enforce that the negotiated TLS chain contains a certificate whose SHA-1 hex matches the allowlist. Empty list keeps the existing system-trust path. * fix(iam): factor Tags.member.N parser into a helper CreateOpenIDConnectProvider and TagOpenIDConnectProvider were both walking the AWS Tags.member.N.Key / Tags.member.N.Value query-string convention with copy-pasted loops. Factor into extractTags so the parsing rules and the "no tags present" semantics live in one place. Addresses gemini medium review on PR #9320. * fix(iam): sentinel errors for OIDC provider not-found / already-exists The s3api dispatcher was using strings.Contains(err.Error(), "not found") and "already exists" to map IAM-manager errors back to AWS error codes. Substring matching on a formatted message couples the API error code to the exact wording of the upstream message — touching the message silently changes the IAM API contract. Define ErrOIDCProviderNotFound and ErrOIDCProviderAlreadyExists in the integration package, fmt.Errorf("%w: ...") them at the four return sites in iam_manager.go and oidc_provider_store.go, and use errors.Is at the s3api call sites. Same control flow, no string-match fragility. Addresses gemini medium review on PR #9320. * fix(iam): surface non-NotFound errors from CreateOIDCProvider lookup Previously CreateOIDCProvider only treated GetProviderByARN's success path as "exists" and silently fell through on any error, including transient backend failures. That hid real problems and still attempted a write. Distinguish ErrOIDCProviderNotFound (the only "safe to create" case) from other errors so we don't mask filer outages or partition issues. * fix(iam): enforce 100-client-ID cap on AddClientIDToOIDCProvider CreateOIDCProvider and the implicit update path through validateOIDC- ProviderRecord both reject lists with more than 100 client IDs, but AddClientIDToOIDCProvider could grow the list past that bound one ID at a time. Refuse the add when the list is already at the cap so the invariant holds across every mutation entry point. * feat(iam): IAM-managed OIDC provider live view in STS service Add a separate, mutex-guarded map of admin-managed OIDC providers on the STS service. The map can be atomically replaced via SetIAMManagedOIDCProvidersByIssuer; AssumeRoleWithWebIdentity lookups consult it first and fall back to the existing static-config map, so records persisted through the IAM API can shadow bootstrap entries without a restart. This is the runtime hook the IAM API and the metadata-subscribe path will both call when the OIDCProviderStore changes (next two commits). * feat(iam): refresh STS service runtime view after OIDC mutations Add IAMManager.RefreshOIDCProvidersFromStore: lists every persisted OIDCProviderRecord, builds a runtime OIDCProvider for each, and atomically publishes the issuer-keyed map into the STS service. Each mutating IAM API call (Create / Delete / AddClientID / RemoveClientID / UpdateThumbprints) now triggers this refresh inline so the local instance picks up the change without waiting for a metadata-subscribe round trip. Tag mutations skip the refresh because tags do not affect token validation. Refresh failures only log; the persisted write has already succeeded by that point, so a transient list error must not surface to the API caller. The peer-instance update path (filer metadata subscription) is added in a follow-up commit. * feat(iam): subscribe to OIDC provider changes on the filer Watch /etc/iam/oidc-providers under the existing s3 metadata-subscribe loop and call RefreshOIDCProvidersFromStore on any create / update / delete / rename. This is the cross-instance update path: S3 server A writes via the IAM API, the filer fans out the metadata change, and S3 servers B..N pick up the new runtime view without a restart. Mirrors the existing onIamConfigChange / onCircuitBreakerConfigChange pattern. The handler short-circuits when the path is unrelated, and when no IAMManager is wired in (static-only configurations).
This commit is contained in:
parent
6141222ab0
commit
f8973b3ed6
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -11,6 +12,7 @@ import (
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/oidc"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
@ -67,6 +69,211 @@ func (m *IAMManager) ListOIDCProviders(ctx context.Context) ([]*OIDCProviderReco
|
||||
return m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress())
|
||||
}
|
||||
|
||||
// CreateOIDCProvider persists a new IAM-managed OIDC provider record. Refuses
|
||||
// to overwrite an existing record so callers see EntityAlreadyExists semantics.
|
||||
func (m *IAMManager) CreateOIDCProvider(ctx context.Context, rec *OIDCProviderRecord) error {
|
||||
if m.oidcProviderStore == nil {
|
||||
return fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
if rec == nil {
|
||||
return fmt.Errorf("record cannot be nil")
|
||||
}
|
||||
if err := validateOIDCProviderRecord(rec); err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), rec.ARN)
|
||||
if err == nil && existing != nil {
|
||||
return fmt.Errorf("%w: %s", ErrOIDCProviderAlreadyExists, rec.ARN)
|
||||
}
|
||||
if err != nil && !errors.Is(err, ErrOIDCProviderNotFound) {
|
||||
return fmt.Errorf("lookup existing OIDC provider %q: %w", rec.ARN, err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
rec.CreatedAt = now
|
||||
rec.UpdatedAt = now
|
||||
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
||||
return err
|
||||
}
|
||||
m.refreshOIDCProvidersBestEffort(ctx, "CreateOIDCProvider", rec.ARN)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOIDCProvider removes the IAM-managed record. Idempotent.
|
||||
func (m *IAMManager) DeleteOIDCProvider(ctx context.Context, arn string) error {
|
||||
if m.oidcProviderStore == nil {
|
||||
return fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
if err := m.oidcProviderStore.DeleteProvider(ctx, m.getFilerAddress(), arn); err != nil {
|
||||
return err
|
||||
}
|
||||
m.refreshOIDCProvidersBestEffort(ctx, "DeleteOIDCProvider", arn)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddClientIDToOIDCProvider appends `clientID` to the provider's allowed
|
||||
// audience list. Adding an existing client ID is a no-op (AWS-compat).
|
||||
func (m *IAMManager) AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error {
|
||||
if m.oidcProviderStore == nil {
|
||||
return fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
if clientID == "" {
|
||||
return fmt.Errorf("ClientID cannot be empty")
|
||||
}
|
||||
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, existing := range rec.ClientIDs {
|
||||
if existing == clientID {
|
||||
return nil // idempotent
|
||||
}
|
||||
}
|
||||
if len(rec.ClientIDs) >= 100 {
|
||||
return fmt.Errorf("ClientIDList must contain at most 100 entries")
|
||||
}
|
||||
rec.ClientIDs = append(rec.ClientIDs, clientID)
|
||||
rec.UpdatedAt = time.Now().UTC()
|
||||
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
||||
return err
|
||||
}
|
||||
m.refreshOIDCProvidersBestEffort(ctx, "AddClientIDToOIDCProvider", arn)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveClientIDFromOIDCProvider drops `clientID` from the allowed audience
|
||||
// list. Removing a missing client ID is a no-op.
|
||||
func (m *IAMManager) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error {
|
||||
if m.oidcProviderStore == nil {
|
||||
return fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pruned := make([]string, 0, len(rec.ClientIDs))
|
||||
for _, existing := range rec.ClientIDs {
|
||||
if existing != clientID {
|
||||
pruned = append(pruned, existing)
|
||||
}
|
||||
}
|
||||
if len(pruned) == len(rec.ClientIDs) {
|
||||
return nil // not present; no-op
|
||||
}
|
||||
rec.ClientIDs = pruned
|
||||
rec.UpdatedAt = time.Now().UTC()
|
||||
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
||||
return err
|
||||
}
|
||||
m.refreshOIDCProvidersBestEffort(ctx, "RemoveClientIDFromOIDCProvider", arn)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateOIDCProviderThumbprints replaces the entire thumbprint list. AWS
|
||||
// constrains the list to 1..5 entries when non-empty; we mirror that bound.
|
||||
func (m *IAMManager) UpdateOIDCProviderThumbprints(ctx context.Context, arn string, thumbprints []string) error {
|
||||
if m.oidcProviderStore == nil {
|
||||
return fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
if len(thumbprints) > 5 {
|
||||
return fmt.Errorf("ThumbprintList must contain at most 5 entries, got %d", len(thumbprints))
|
||||
}
|
||||
for _, tp := range thumbprints {
|
||||
if !isValidSHA1Thumbprint(tp) {
|
||||
return fmt.Errorf("invalid thumbprint %q: must be 40-character SHA-1 hex", tp)
|
||||
}
|
||||
}
|
||||
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rec.Thumbprints = append([]string(nil), thumbprints...)
|
||||
rec.UpdatedAt = time.Now().UTC()
|
||||
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
||||
return err
|
||||
}
|
||||
m.refreshOIDCProvidersBestEffort(ctx, "UpdateOIDCProviderThumbprints", arn)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TagOIDCProvider merges the supplied tags into the provider's tag set.
|
||||
func (m *IAMManager) TagOIDCProvider(ctx context.Context, arn string, tags map[string]string) error {
|
||||
if m.oidcProviderStore == nil {
|
||||
return fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rec.Tags == nil {
|
||||
rec.Tags = make(map[string]string, len(tags))
|
||||
}
|
||||
for k, v := range tags {
|
||||
rec.Tags[k] = v
|
||||
}
|
||||
rec.UpdatedAt = time.Now().UTC()
|
||||
return m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec)
|
||||
}
|
||||
|
||||
// UntagOIDCProvider removes the named tags from the provider's tag set.
|
||||
func (m *IAMManager) UntagOIDCProvider(ctx context.Context, arn string, keys []string) error {
|
||||
if m.oidcProviderStore == nil {
|
||||
return fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
delete(rec.Tags, k)
|
||||
}
|
||||
rec.UpdatedAt = time.Now().UTC()
|
||||
return m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec)
|
||||
}
|
||||
|
||||
// validateOIDCProviderRecord enforces the invariants AWS imposes on the
|
||||
// underlying CreateOpenIDConnectProvider call.
|
||||
func validateOIDCProviderRecord(rec *OIDCProviderRecord) error {
|
||||
if rec.URL == "" {
|
||||
return fmt.Errorf("Url is required")
|
||||
}
|
||||
if rec.ARN == "" {
|
||||
return fmt.Errorf("ARN is required")
|
||||
}
|
||||
if len(rec.ClientIDs) == 0 {
|
||||
return fmt.Errorf("ClientIDList must contain at least one entry")
|
||||
}
|
||||
if len(rec.ClientIDs) > 100 {
|
||||
return fmt.Errorf("ClientIDList must contain at most 100 entries")
|
||||
}
|
||||
if len(rec.Thumbprints) > 5 {
|
||||
return fmt.Errorf("ThumbprintList must contain at most 5 entries")
|
||||
}
|
||||
for _, tp := range rec.Thumbprints {
|
||||
if !isValidSHA1Thumbprint(tp) {
|
||||
return fmt.Errorf("invalid thumbprint %q: must be 40-character SHA-1 hex", tp)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidSHA1Thumbprint returns true iff `s` is exactly 40 hex characters,
|
||||
// matching the SHA-1 digest format AWS expects.
|
||||
func isValidSHA1Thumbprint(s string) bool {
|
||||
if len(s) != 40 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
case r >= 'a' && r <= 'f':
|
||||
case r >= 'A' && r <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IAMConfig holds configuration for all IAM components
|
||||
type IAMConfig struct {
|
||||
// STS service configuration
|
||||
@ -310,6 +517,70 @@ func (m *IAMManager) initOIDCProviderStore(config *IAMConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshOIDCProvidersBestEffort calls RefreshOIDCProvidersFromStore and
|
||||
// logs a warning on failure. The IAM API call has already succeeded by the
|
||||
// time we get here, so a refresh failure must not surface to the caller —
|
||||
// the worst case is that the local instance keeps the stale runtime view
|
||||
// until a peer's metadata-subscribe event triggers another refresh.
|
||||
func (m *IAMManager) refreshOIDCProvidersBestEffort(ctx context.Context, op, arn string) {
|
||||
if err := m.RefreshOIDCProvidersFromStore(ctx); err != nil {
|
||||
glog.Warningf("refresh OIDC providers after %s on %s: %v", op, arn, err)
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshOIDCProvidersFromStore reloads every OIDCProviderRecord from the
|
||||
// configured store and pushes the resulting runtime providers into the STS
|
||||
// service so AssumeRoleWithWebIdentity sees the latest set without a
|
||||
// restart. Safe to call when no store is configured (returns nil) and when
|
||||
// the store is empty (clears the IAM-managed map). Records with empty URLs
|
||||
// or invalid configuration are logged and skipped so a single bad entry
|
||||
// does not stop the rest from refreshing.
|
||||
func (m *IAMManager) RefreshOIDCProvidersFromStore(ctx context.Context) error {
|
||||
if m.oidcProviderStore == nil || m.stsService == nil {
|
||||
return nil
|
||||
}
|
||||
records, err := m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress())
|
||||
if err != nil {
|
||||
return fmt.Errorf("list OIDC providers: %w", err)
|
||||
}
|
||||
byIssuer := make(map[string]providers.IdentityProvider, len(records))
|
||||
for _, rec := range records {
|
||||
if rec == nil || rec.URL == "" {
|
||||
continue
|
||||
}
|
||||
provider, err := buildOIDCProviderFromRecord(rec)
|
||||
if err != nil {
|
||||
glog.Warningf("skip refreshing OIDC provider %s: %v", rec.ARN, err)
|
||||
continue
|
||||
}
|
||||
// Last write wins on issuer collision; the store is the source of
|
||||
// truth, and an operator who has two records with the same issuer
|
||||
// has already accepted one will shadow the other.
|
||||
byIssuer[rec.URL] = provider
|
||||
}
|
||||
m.stsService.SetIAMManagedOIDCProvidersByIssuer(byIssuer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildOIDCProviderFromRecord turns a stored record into a runtime
|
||||
// OIDCProvider. The provider name is the ARN so re-registration is
|
||||
// idempotent and never collides with static-config entries.
|
||||
func buildOIDCProviderFromRecord(rec *OIDCProviderRecord) (*oidc.OIDCProvider, error) {
|
||||
if rec == nil {
|
||||
return nil, fmt.Errorf("record cannot be nil")
|
||||
}
|
||||
cfg := &oidc.OIDCConfig{
|
||||
Issuer: rec.URL,
|
||||
ClientIDs: append([]string(nil), rec.ClientIDs...),
|
||||
Thumbprints: append([]string(nil), rec.Thumbprints...),
|
||||
}
|
||||
provider := oidc.NewOIDCProvider(rec.ARN)
|
||||
if err := provider.Initialize(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// createOIDCProviderStore selects an OIDCProviderStore implementation. Defaults
|
||||
// to memory; "filer" requires a filerAddressProvider to be configured.
|
||||
func (m *IAMManager) createOIDCProviderStore(cfg *OIDCProviderStoreConfig) (OIDCProviderStore, error) {
|
||||
|
||||
@ -20,6 +20,15 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the IAM manager and OIDCProviderStore. Callers
|
||||
// in the s3api layer use errors.Is rather than substring-matching the
|
||||
// formatted message, so the message can be edited freely without changing
|
||||
// the IAM error code surfaced to the API caller.
|
||||
var (
|
||||
ErrOIDCProviderNotFound = errors.New("OIDC provider not found")
|
||||
ErrOIDCProviderAlreadyExists = errors.New("OIDC provider already exists")
|
||||
)
|
||||
|
||||
// OIDCProviderRecord is the persisted, IAM-managed view of an OIDC identity
|
||||
// provider. It is the source of truth consulted at AssumeRoleWithWebIdentity
|
||||
// time. Static configuration entries are loaded into this same store at
|
||||
@ -109,7 +118,7 @@ func (m *MemoryOIDCProviderStore) GetProviderByARN(ctx context.Context, _ string
|
||||
defer m.mu.RUnlock()
|
||||
rec, ok := m.providers[arn]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("OIDC provider not found: %s", arn)
|
||||
return nil, fmt.Errorf("%w: %s", ErrOIDCProviderNotFound, arn)
|
||||
}
|
||||
return copyOIDCProviderRecord(rec), nil
|
||||
}
|
||||
@ -239,7 +248,7 @@ func (f *FilerOIDCProviderStore) GetProviderByARN(ctx context.Context, filerAddr
|
||||
Name: f.fileName(arn),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("OIDC provider not found: %v", err)
|
||||
return fmt.Errorf("%w: %v", ErrOIDCProviderNotFound, err)
|
||||
}
|
||||
if resp.Entry == nil {
|
||||
return fmt.Errorf("OIDC provider not found: %s", arn)
|
||||
|
||||
@ -5,9 +5,11 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@ -48,8 +50,15 @@ type OIDCConfig struct {
|
||||
// Issuer is the OIDC issuer URL
|
||||
Issuer string `json:"issuer"`
|
||||
|
||||
// ClientID is the OAuth2 client ID
|
||||
ClientID string `json:"clientId"`
|
||||
// ClientID is the OAuth2 client ID. Either ClientID or ClientIDs is
|
||||
// required; when both are present, ClientID is appended to the audience
|
||||
// allowlist.
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
|
||||
// ClientIDs is the AWS-compatible audience allowlist. Tokens are accepted
|
||||
// when any of `aud` or `azp` matches any entry. Mutually compatible with
|
||||
// ClientID for backward compatibility.
|
||||
ClientIDs []string `json:"clientIds,omitempty"`
|
||||
|
||||
// ClientSecret is the OAuth2 client secret (optional for public clients)
|
||||
ClientSecret string `json:"clientSecret,omitempty"`
|
||||
@ -72,6 +81,12 @@ type OIDCConfig struct {
|
||||
// JWKSCacheTTLSeconds sets how long to cache JWKS before refresh (default 3600 seconds)
|
||||
JWKSCacheTTLSeconds int `json:"jwksCacheTTLSeconds,omitempty"`
|
||||
|
||||
// Thumbprints, when non-empty, pins the issuer's TLS certificate against
|
||||
// this allowlist of SHA-1 hex digests. Matches the AWS IAM
|
||||
// CreateOpenIDConnectProvider semantics. Empty means "trust the system
|
||||
// root store" (or whatever TLSCACert configures).
|
||||
Thumbprints []string `json:"thumbprints,omitempty"`
|
||||
|
||||
// TLSCACert is the path to the CA certificate file for custom/self-signed certificates
|
||||
TLSCACert string `json:"tlsCaCert,omitempty"`
|
||||
|
||||
@ -80,6 +95,64 @@ type OIDCConfig struct {
|
||||
TLSInsecureSkipVerify bool `json:"tlsInsecureSkipVerify,omitempty"`
|
||||
}
|
||||
|
||||
// normalizeThumbprints lowercases and de-duplicates the configured allowlist.
|
||||
// Returns a set keyed by lowercase hex for O(1) lookup during TLS verification.
|
||||
func normalizeThumbprints(in []string) map[string]struct{} {
|
||||
out := make(map[string]struct{}, len(in))
|
||||
for _, t := range in {
|
||||
t = strings.ToLower(strings.TrimSpace(t))
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
out[t] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// verifyThumbprintMatch checks that some certificate in the negotiated chain
|
||||
// hashes to a thumbprint in `expected`. AWS pins the certificate immediately
|
||||
// below the root in the chain; we accept any chain certificate to also cover
|
||||
// self-signed deployments and skip-verify test setups. When the chain has not
|
||||
// been built (InsecureSkipVerify), we fall back to PeerCertificates.
|
||||
func verifyThumbprintMatch(cs tls.ConnectionState, expected map[string]struct{}) error {
|
||||
candidates := collectThumbprintCandidates(cs)
|
||||
for _, c := range candidates {
|
||||
sum := sha1.Sum(c.Raw)
|
||||
hexSum := hex.EncodeToString(sum[:])
|
||||
if _, ok := expected[hexSum]; ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("OIDC TLS thumbprint did not match any configured allowlist entry")
|
||||
}
|
||||
|
||||
// collectThumbprintCandidates flattens the verified chains and peer cert list
|
||||
// into a single slice of unique certificates worth checking.
|
||||
func collectThumbprintCandidates(cs tls.ConnectionState) []*x509.Certificate {
|
||||
var out []*x509.Certificate
|
||||
seen := map[string]struct{}{}
|
||||
add := func(c *x509.Certificate) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
k := string(c.Raw)
|
||||
if _, ok := seen[k]; ok {
|
||||
return
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
out = append(out, c)
|
||||
}
|
||||
for _, chain := range cs.VerifiedChains {
|
||||
for _, c := range chain {
|
||||
add(c)
|
||||
}
|
||||
}
|
||||
for _, c := range cs.PeerCertificates {
|
||||
add(c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// JWKS represents JSON Web Key Set
|
||||
type JWKS struct {
|
||||
Keys []JWK `json:"keys"`
|
||||
@ -154,6 +227,20 @@ func (p *OIDCProvider) Initialize(config interface{}) error {
|
||||
glog.Warningf("OIDC provider %q is configured to skip TLS verification. This is insecure and should not be used in production.", p.name)
|
||||
}
|
||||
|
||||
// Thumbprint pinning: when configured, every TLS handshake to the IDP must
|
||||
// present a chain whose terminal certificate (the cert just below the root,
|
||||
// matching AWS IAM semantics) hashes to one of the listed SHA-1 digests.
|
||||
// VerifyConnection runs after the chain build, so cs.VerifiedChains is
|
||||
// populated when InsecureSkipVerify is false; we additionally pin against
|
||||
// PeerCertificates so self-signed test setups work too.
|
||||
if len(oidcConfig.Thumbprints) > 0 {
|
||||
expected := normalizeThumbprints(oidcConfig.Thumbprints)
|
||||
tlsConfig.VerifyConnection = func(cs tls.ConnectionState) error {
|
||||
return verifyThumbprintMatch(cs, expected)
|
||||
}
|
||||
glog.V(2).Infof("OIDC provider %q: TLS thumbprint pinning enabled (%d allowed)", p.name, len(expected))
|
||||
}
|
||||
|
||||
if oidcConfig.TLSCACert != "" {
|
||||
// Validate that the CA cert path is absolute to prevent reading unintended files
|
||||
if !filepath.IsAbs(oidcConfig.TLSCACert) {
|
||||
@ -193,7 +280,7 @@ func (p *OIDCProvider) validateConfig(config *OIDCConfig) error {
|
||||
return fmt.Errorf("issuer is required")
|
||||
}
|
||||
|
||||
if config.ClientID == "" {
|
||||
if config.ClientID == "" && len(config.ClientIDs) == 0 {
|
||||
return fmt.Errorf("client ID is required")
|
||||
}
|
||||
|
||||
@ -205,6 +292,32 @@ func (p *OIDCProvider) validateConfig(config *OIDCConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// allowedAudiences returns the merged list of acceptable audiences for this
|
||||
// provider. Both the singular ClientID and the plural ClientIDs are honoured;
|
||||
// duplicates collapse silently.
|
||||
func (p *OIDCProvider) allowedAudiences() []string {
|
||||
if p.config == nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
add := func(s string) {
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
return
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
add(p.config.ClientID)
|
||||
for _, c := range p.config.ClientIDs {
|
||||
add(c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Authenticate authenticates a user with an OIDC token
|
||||
func (p *OIDCProvider) Authenticate(ctx context.Context, token string) (*providers.ExternalIdentity, error) {
|
||||
if !p.initialized {
|
||||
@ -432,33 +545,45 @@ func (p *OIDCProvider) ValidateToken(ctx context.Context, token string) (*provid
|
||||
return nil, fmt.Errorf("%w: expected %s, got %s", providers.ErrProviderInvalidIssuer, p.config.Issuer, issuer)
|
||||
}
|
||||
|
||||
// Check audience claim (aud) or authorized party (azp) - Keycloak uses azp
|
||||
// Per RFC 7519, aud can be either a string or an array of strings
|
||||
// Check audience claim (aud) or authorized party (azp) — Keycloak uses azp.
|
||||
// Per RFC 7519, aud can be either a string or an array of strings.
|
||||
// Multiple client IDs are supported per AWS IAM CreateOpenIDConnectProvider
|
||||
// semantics: any one match in the allowlist accepts the token.
|
||||
allowed := p.allowedAudiences()
|
||||
allowedSet := make(map[string]struct{}, len(allowed))
|
||||
for _, a := range allowed {
|
||||
allowedSet[a] = struct{}{}
|
||||
}
|
||||
|
||||
var audienceMatched bool
|
||||
if audClaim, ok := claims["aud"]; ok {
|
||||
switch aud := audClaim.(type) {
|
||||
case string:
|
||||
if aud == p.config.ClientID {
|
||||
if _, ok := allowedSet[aud]; ok {
|
||||
audienceMatched = true
|
||||
}
|
||||
case []interface{}:
|
||||
for _, a := range aud {
|
||||
if str, ok := a.(string); ok && str == p.config.ClientID {
|
||||
audienceMatched = true
|
||||
break
|
||||
if str, ok := a.(string); ok {
|
||||
if _, ok := allowedSet[str]; ok {
|
||||
audienceMatched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !audienceMatched {
|
||||
if azp, ok := claims["azp"].(string); ok && azp == p.config.ClientID {
|
||||
audienceMatched = true
|
||||
if azp, ok := claims["azp"].(string); ok {
|
||||
if _, ok := allowedSet[azp]; ok {
|
||||
audienceMatched = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !audienceMatched {
|
||||
return nil, fmt.Errorf("%w: expected client ID %s", providers.ErrProviderInvalidAudience, p.config.ClientID)
|
||||
return nil, fmt.Errorf("%w: token audience matches none of the configured client IDs", providers.ErrProviderInvalidAudience)
|
||||
}
|
||||
|
||||
subject, ok := claims["sub"].(string)
|
||||
|
||||
153
weed/iam/oidc/thumbprint_test.go
Normal file
153
weed/iam/oidc/thumbprint_test.go
Normal file
@ -0,0 +1,153 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// thumbprintFromTLS returns the SHA-1 hex of the first peer certificate the
|
||||
// given TLS server presents. It mirrors the pinning algorithm used by the
|
||||
// OIDC provider so the test is hashing the cert that will actually be
|
||||
// verified at runtime.
|
||||
func thumbprintFromTLS(t *testing.T, server *httptest.Server) string {
|
||||
t.Helper()
|
||||
if server.TLS == nil || len(server.Certificate().Raw) == 0 {
|
||||
t.Fatal("server has no TLS certificate")
|
||||
}
|
||||
sum := sha1.Sum(server.Certificate().Raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// newTLSIDP starts a TLS-only test IDP that always serves a static JWKS.
|
||||
// httptest TLS servers use a self-signed certificate by default, which means
|
||||
// callers who want a successful handshake must either trust that cert or
|
||||
// use TLSInsecureSkipVerify.
|
||||
func newTLSIDP(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(JWKS{Keys: []JWK{{Kty: "RSA", Kid: "k1", Use: "sig", Alg: "RS256", N: "AQAB", E: "AQAB"}}})
|
||||
})
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r) // force fallback path
|
||||
})
|
||||
server := httptest.NewTLSServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
// pinnedProvider configures an OIDC provider that pins TLS to the given
|
||||
// thumbprint allowlist and trusts only the test server's self-signed cert.
|
||||
func pinnedProvider(t *testing.T, server *httptest.Server, thumbprints []string) *OIDCProvider {
|
||||
t.Helper()
|
||||
p := NewOIDCProvider("thumbprint-test")
|
||||
cfg := &OIDCConfig{
|
||||
Issuer: server.URL,
|
||||
ClientID: "anything",
|
||||
Thumbprints: thumbprints,
|
||||
TLSInsecureSkipVerify: true, // we're doing the trust decision via thumbprint
|
||||
}
|
||||
if err := p.Initialize(cfg); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
// Replace the inner transport's RootCAs with the server's cert so the chain
|
||||
// builder also accepts it; this exercises the verified-chain path.
|
||||
if transport, ok := p.httpClient.Transport.(*http.Transport); ok && transport.TLSClientConfig != nil {
|
||||
transport.TLSClientConfig.InsecureSkipVerify = false
|
||||
transport.TLSClientConfig.RootCAs = server.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestThumbprintMatchAccepted(t *testing.T) {
|
||||
server := newTLSIDP(t)
|
||||
tp := thumbprintFromTLS(t, server)
|
||||
p := pinnedProvider(t, server, []string{tp})
|
||||
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS with matching thumbprint should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThumbprintMismatchRejected(t *testing.T) {
|
||||
server := newTLSIDP(t)
|
||||
// Flip the last byte so the digest can't match anything legitimate.
|
||||
bad := "0000000000000000000000000000000000000000"
|
||||
p := pinnedProvider(t, server, []string{bad})
|
||||
|
||||
err := p.fetchJWKS(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("fetchJWKS with mismatched thumbprint should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThumbprintAllowlistAcceptsMixedCase(t *testing.T) {
|
||||
server := newTLSIDP(t)
|
||||
tp := thumbprintFromTLS(t, server)
|
||||
// Mix uppercase + whitespace to ensure normalization is applied.
|
||||
p := pinnedProvider(t, server, []string{" " + uppercase(tp) + " "})
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("normalized thumbprint should match: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// uppercase returns s with letters uppercased — cheap helper avoiding a
|
||||
// dependency on strings.ToUpper at the package level for clarity.
|
||||
func uppercase(s string) string {
|
||||
out := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'a' && c <= 'z' {
|
||||
c -= 32
|
||||
}
|
||||
out[i] = c
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// guard against a future refactor that drops VerifyConnection by checking
|
||||
// that omitting Thumbprints leaves the connection unverified-by-pin.
|
||||
func TestThumbprintEmptyAllowlistSkipsCheck(t *testing.T) {
|
||||
server := newTLSIDP(t)
|
||||
p := pinnedProvider(t, server, nil)
|
||||
transport, ok := p.httpClient.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatal("expected *http.Transport")
|
||||
}
|
||||
if transport.TLSClientConfig.VerifyConnection != nil {
|
||||
t.Fatal("VerifyConnection must be nil when no thumbprints are configured")
|
||||
}
|
||||
// And the full TLS handshake still succeeds normally.
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS with no thumbprints: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the underlying tls.Config still rejects truly invalid handshakes
|
||||
// when InsecureSkipVerify is on but no thumbprint allowlist is configured —
|
||||
// in that case we explicitly want to *trust* the connection (skip-verify),
|
||||
// just as the existing test code did.
|
||||
func TestThumbprintSkipVerifyHonoured(t *testing.T) {
|
||||
server := newTLSIDP(t)
|
||||
p := NewOIDCProvider("skip")
|
||||
if err := p.Initialize(&OIDCConfig{
|
||||
Issuer: server.URL,
|
||||
ClientID: "x",
|
||||
TLSInsecureSkipVerify: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
transport := p.httpClient.Transport.(*http.Transport)
|
||||
if !transport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("InsecureSkipVerify should be set")
|
||||
}
|
||||
if got := transport.TLSClientConfig.MinVersion; got != tls.VersionTLS12 {
|
||||
t.Fatalf("MinVersion = %v want TLS12", got)
|
||||
}
|
||||
}
|
||||
@ -46,6 +46,52 @@ type GetOpenIDConnectProviderResponse struct {
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// CreateOpenIDConnectProviderResponse is the response for CreateOpenIDConnectProvider.
|
||||
type CreateOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateOpenIDConnectProviderResponse"`
|
||||
CreateOpenIDConnectProviderResult struct {
|
||||
OpenIDConnectProviderArn string `xml:"OpenIDConnectProviderArn"`
|
||||
Tags []*IAMTag `xml:"Tags>member,omitempty"`
|
||||
} `xml:"CreateOpenIDConnectProviderResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeleteOpenIDConnectProviderResponse is the response for DeleteOpenIDConnectProvider.
|
||||
type DeleteOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteOpenIDConnectProviderResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// AddClientIDToOpenIDConnectProviderResponse is the response for AddClientIDToOpenIDConnectProvider.
|
||||
type AddClientIDToOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AddClientIDToOpenIDConnectProviderResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// RemoveClientIDFromOpenIDConnectProviderResponse is the response for RemoveClientIDFromOpenIDConnectProvider.
|
||||
type RemoveClientIDFromOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ RemoveClientIDFromOpenIDConnectProviderResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// UpdateOpenIDConnectProviderThumbprintResponse is the response for UpdateOpenIDConnectProviderThumbprint.
|
||||
type UpdateOpenIDConnectProviderThumbprintResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateOpenIDConnectProviderThumbprintResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// TagOpenIDConnectProviderResponse is the response for TagOpenIDConnectProvider.
|
||||
type TagOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ TagOpenIDConnectProviderResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// UntagOpenIDConnectProviderResponse is the response for UntagOpenIDConnectProvider.
|
||||
type UntagOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UntagOpenIDConnectProviderResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// SetRequestId stores the request ID generated for the current HTTP request.
|
||||
func (r *CommonResponse) SetRequestId(requestID string) {
|
||||
r.ResponseMetadata.RequestId = requestID
|
||||
|
||||
@ -46,6 +46,8 @@ const (
|
||||
ConfigFieldBasePath = "basePath"
|
||||
ConfigFieldIssuer = "issuer"
|
||||
ConfigFieldClientID = "clientId"
|
||||
ConfigFieldClientIDs = "clientIds"
|
||||
ConfigFieldThumbprints = "thumbprints"
|
||||
ConfigFieldClientSecret = "clientSecret"
|
||||
ConfigFieldJWKSUri = "jwksUri"
|
||||
ConfigFieldScopes = "scopes"
|
||||
|
||||
@ -91,9 +91,20 @@ func (f *ProviderFactory) convertToOIDCConfig(configMap map[string]interface{})
|
||||
return nil, fmt.Errorf(ErrIssuerRequired)
|
||||
}
|
||||
|
||||
if clientID, ok := configMap[ConfigFieldClientID].(string); ok {
|
||||
clientIDPresent := false
|
||||
if clientID, ok := configMap[ConfigFieldClientID].(string); ok && clientID != "" {
|
||||
config.ClientID = clientID
|
||||
} else {
|
||||
clientIDPresent = true
|
||||
}
|
||||
// Accept the AWS-compatible plural form. Either field satisfies the
|
||||
// "at least one client id" requirement; both can be set together.
|
||||
if rawList, ok := configMap[ConfigFieldClientIDs]; ok {
|
||||
if list, err := f.convertToStringSlice(rawList); err == nil && len(list) > 0 {
|
||||
config.ClientIDs = list
|
||||
clientIDPresent = true
|
||||
}
|
||||
}
|
||||
if !clientIDPresent {
|
||||
return nil, fmt.Errorf(ErrClientIDRequired)
|
||||
}
|
||||
|
||||
@ -123,6 +134,12 @@ func (f *ProviderFactory) convertToOIDCConfig(configMap map[string]interface{})
|
||||
config.TLSCACert = tlsCaCert
|
||||
}
|
||||
|
||||
if rawThumbprints, ok := configMap[ConfigFieldThumbprints]; ok {
|
||||
if list, err := f.convertToStringSlice(rawThumbprints); err == nil {
|
||||
config.Thumbprints = list
|
||||
}
|
||||
}
|
||||
|
||||
if tlsInsecureSkipVerify, ok := configMap[ConfigFieldTLSInsecureSkipVerify].(bool); ok {
|
||||
config.TLSInsecureSkipVerify = tlsInsecureSkipVerify
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@ -79,6 +80,16 @@ type STSService struct {
|
||||
issuerToProvider map[string]providers.IdentityProvider // Efficient issuer-based provider lookup
|
||||
tokenGenerator *TokenGenerator
|
||||
trustPolicyValidator TrustPolicyValidator // Interface for trust policy validation
|
||||
|
||||
// iamManagedOIDCMu guards iamManagedOIDCByIssuer. The map is the live view
|
||||
// of providers persisted in the IAM-managed OIDCProviderStore; it is
|
||||
// atomically replaced by SetIAMManagedOIDCProvidersByIssuer whenever the
|
||||
// store changes (either via a local IAM API call or a metadata-subscribe
|
||||
// event from a peer). Lookups consult this map first and fall back to the
|
||||
// static-config issuerToProvider so admin-managed entries always take
|
||||
// precedence over the bootstrap config.
|
||||
iamManagedOIDCMu sync.RWMutex
|
||||
iamManagedOIDCByIssuer map[string]providers.IdentityProvider
|
||||
}
|
||||
|
||||
// GetTokenGenerator returns the token generator used by the STS service.
|
||||
@ -424,6 +435,40 @@ func (s *STSService) GetProviders() map[string]providers.IdentityProvider {
|
||||
return s.providers
|
||||
}
|
||||
|
||||
// SetIAMManagedOIDCProvidersByIssuer atomically replaces the IAM-managed
|
||||
// OIDC provider map. Pass nil or an empty map to clear all managed entries.
|
||||
// The caller passes a fully-built map keyed by issuer URL; the STS service
|
||||
// copies the reference and serves AssumeRoleWithWebIdentity lookups from it
|
||||
// in preference to the static-config issuerToProvider map.
|
||||
func (s *STSService) SetIAMManagedOIDCProvidersByIssuer(byIssuer map[string]providers.IdentityProvider) {
|
||||
// Defensively copy so callers can keep mutating their map without affecting
|
||||
// in-flight lookups. A nil input becomes an empty map for cheap reads.
|
||||
cp := make(map[string]providers.IdentityProvider, len(byIssuer))
|
||||
for k, v := range byIssuer {
|
||||
if k == "" || v == nil {
|
||||
continue
|
||||
}
|
||||
cp[k] = v
|
||||
}
|
||||
s.iamManagedOIDCMu.Lock()
|
||||
s.iamManagedOIDCByIssuer = cp
|
||||
s.iamManagedOIDCMu.Unlock()
|
||||
}
|
||||
|
||||
// lookupOIDCProviderByIssuer returns the provider that should validate tokens
|
||||
// from `issuer`, consulting the IAM-managed map first and falling back to the
|
||||
// static-config map. Returns ok=false when no provider is registered.
|
||||
func (s *STSService) lookupOIDCProviderByIssuer(issuer string) (providers.IdentityProvider, bool) {
|
||||
s.iamManagedOIDCMu.RLock()
|
||||
if p, ok := s.iamManagedOIDCByIssuer[issuer]; ok {
|
||||
s.iamManagedOIDCMu.RUnlock()
|
||||
return p, true
|
||||
}
|
||||
s.iamManagedOIDCMu.RUnlock()
|
||||
p, ok := s.issuerToProvider[issuer]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// SetTrustPolicyValidator sets the trust policy validator for role assumption validation
|
||||
func (s *STSService) SetTrustPolicyValidator(validator TrustPolicyValidator) {
|
||||
s.trustPolicyValidator = validator
|
||||
@ -715,8 +760,11 @@ func (s *STSService) validateWebIdentityToken(ctx context.Context, token string)
|
||||
return nil, nil, fmt.Errorf("web identity token must be a valid JWT token: %w", err)
|
||||
}
|
||||
|
||||
// Look up the specific provider for this issuer
|
||||
provider, exists := s.issuerToProvider[issuer]
|
||||
// Look up the specific provider for this issuer. IAM-managed records
|
||||
// (admin-controlled, mutable at runtime) take precedence over the
|
||||
// static-config map so an operator's CreateOpenIDConnectProvider call
|
||||
// can shadow a bootstrap entry without requiring a restart.
|
||||
provider, exists := s.lookupOIDCProviderByIssuer(issuer)
|
||||
if !exists {
|
||||
// SECURITY: If no provider is registered for this issuer, fail immediately
|
||||
// This prevents JWT tokens from being validated by unintended providers
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -12,6 +13,8 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
const oidcProvidersDir = filer.IamConfigDirectory + "/oidc-providers"
|
||||
|
||||
func (s3a *S3ApiServer) subscribeMetaEvents(clientName string, lastTsNs int64, prefix string, directoriesToWatch []string) {
|
||||
|
||||
processEventFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
|
||||
@ -30,12 +33,14 @@ func (s3a *S3ApiServer) subscribeMetaEvents(clientName string, lastTsNs int64, p
|
||||
// These handlers check for nil entries internally
|
||||
_ = s3a.onBucketMetadataChange(dir, message.OldEntry, message.NewEntry)
|
||||
_ = s3a.onIamConfigChange(dir, message.OldEntry, message.NewEntry)
|
||||
_ = s3a.onOIDCProviderChange(dir, message.OldEntry, message.NewEntry)
|
||||
_ = s3a.onCircuitBreakerConfigChange(dir, message.OldEntry, message.NewEntry)
|
||||
|
||||
// For moves across directories, replay a delete event for the source directory
|
||||
if message.NewParentPath != "" && resp.Directory != message.NewParentPath {
|
||||
_ = s3a.onBucketMetadataChange(resp.Directory, message.OldEntry, nil)
|
||||
_ = s3a.onIamConfigChange(resp.Directory, message.OldEntry, nil)
|
||||
_ = s3a.onOIDCProviderChange(resp.Directory, message.OldEntry, nil)
|
||||
_ = s3a.onCircuitBreakerConfigChange(resp.Directory, message.OldEntry, nil)
|
||||
}
|
||||
|
||||
@ -123,6 +128,33 @@ func (s3a *S3ApiServer) onIamConfigChange(dir string, oldEntry *filer_pb.Entry,
|
||||
return nil
|
||||
}
|
||||
|
||||
// onOIDCProviderChange refreshes the IAM-managed OIDC provider runtime view
|
||||
// whenever the persisted store under /etc/iam/oidc-providers changes — both
|
||||
// for mutations originated on this S3 server (the local IAM API also calls
|
||||
// RefreshOIDCProvidersFromStore inline, but the subscribe path costs nothing
|
||||
// extra) and for mutations originated on peer S3 servers, which this is the
|
||||
// only mechanism to learn about. A single refresh covers create, update,
|
||||
// delete, and rename because the store is small and a full reload is the
|
||||
// safest way to reach a consistent view.
|
||||
func (s3a *S3ApiServer) onOIDCProviderChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error {
|
||||
if dir != oidcProvidersDir && !strings.HasPrefix(dir, oidcProvidersDir+"/") {
|
||||
return nil
|
||||
}
|
||||
if s3a.iam == nil || s3a.iam.iamIntegration == nil {
|
||||
return nil
|
||||
}
|
||||
s3iam, ok := s3a.iam.iamIntegration.(*S3IAMIntegration)
|
||||
if !ok || s3iam.iamManager == nil {
|
||||
return nil
|
||||
}
|
||||
if err := s3iam.iamManager.RefreshOIDCProvidersFromStore(context.Background()); err != nil {
|
||||
glog.Warningf("OIDC provider refresh after %s change failed: %v", dir, err)
|
||||
return err
|
||||
}
|
||||
glog.V(2).Infof("Refreshed IAM-managed OIDC providers after %s change", dir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// onCircuitBreakerConfigChange handles circuit breaker config file changes (create, update, delete)
|
||||
func (s3a *S3ApiServer) onCircuitBreakerConfigChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error {
|
||||
if dir != s3_constants.CircuitBreakerConfigDir {
|
||||
|
||||
@ -12,12 +12,17 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
|
||||
)
|
||||
|
||||
// OIDC provider IAM actions handled by this file. Mutating actions are
|
||||
// reserved for Phase 2b; the read-only set lands now so existing static-config
|
||||
// providers become discoverable through the AWS IAM API.
|
||||
// OIDC provider IAM actions handled by this file.
|
||||
const (
|
||||
actionGetOpenIDConnectProvider = "GetOpenIDConnectProvider"
|
||||
actionListOpenIDConnectProviders = "ListOpenIDConnectProviders"
|
||||
actionGetOpenIDConnectProvider = "GetOpenIDConnectProvider"
|
||||
actionListOpenIDConnectProviders = "ListOpenIDConnectProviders"
|
||||
actionCreateOpenIDConnectProvider = "CreateOpenIDConnectProvider"
|
||||
actionDeleteOpenIDConnectProvider = "DeleteOpenIDConnectProvider"
|
||||
actionAddClientIDToOpenIDConnectProvider = "AddClientIDToOpenIDConnectProvider"
|
||||
actionRemoveClientIDFromOpenIDConnectProvider = "RemoveClientIDFromOpenIDConnectProvider"
|
||||
actionUpdateOpenIDConnectProviderThumbprint = "UpdateOpenIDConnectProviderThumbprint"
|
||||
actionTagOpenIDConnectProvider = "TagOpenIDConnectProvider"
|
||||
actionUntagOpenIDConnectProvider = "UntagOpenIDConnectProvider"
|
||||
)
|
||||
|
||||
// isOIDCProviderAction reports whether an action belongs to the OIDC provider
|
||||
@ -26,7 +31,14 @@ const (
|
||||
func isOIDCProviderAction(action string) bool {
|
||||
switch action {
|
||||
case actionGetOpenIDConnectProvider,
|
||||
actionListOpenIDConnectProviders:
|
||||
actionListOpenIDConnectProviders,
|
||||
actionCreateOpenIDConnectProvider,
|
||||
actionDeleteOpenIDConnectProvider,
|
||||
actionAddClientIDToOpenIDConnectProvider,
|
||||
actionRemoveClientIDFromOpenIDConnectProvider,
|
||||
actionUpdateOpenIDConnectProviderThumbprint,
|
||||
actionTagOpenIDConnectProvider,
|
||||
actionUntagOpenIDConnectProvider:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@ -56,10 +68,221 @@ func (e *EmbeddedIamApi) dispatchOIDCProviderAction(ctx context.Context, values
|
||||
case actionGetOpenIDConnectProvider:
|
||||
resp, err := e.getOpenIDConnectProvider(ctx, mgr, values)
|
||||
return resp, err, true
|
||||
case actionCreateOpenIDConnectProvider:
|
||||
resp, err := e.createOpenIDConnectProvider(ctx, mgr, values)
|
||||
return resp, err, true
|
||||
case actionDeleteOpenIDConnectProvider:
|
||||
resp, err := e.deleteOpenIDConnectProvider(ctx, mgr, values)
|
||||
return resp, err, true
|
||||
case actionAddClientIDToOpenIDConnectProvider:
|
||||
resp, err := e.addClientIDToOpenIDConnectProvider(ctx, mgr, values)
|
||||
return resp, err, true
|
||||
case actionRemoveClientIDFromOpenIDConnectProvider:
|
||||
resp, err := e.removeClientIDFromOpenIDConnectProvider(ctx, mgr, values)
|
||||
return resp, err, true
|
||||
case actionUpdateOpenIDConnectProviderThumbprint:
|
||||
resp, err := e.updateOpenIDConnectProviderThumbprint(ctx, mgr, values)
|
||||
return resp, err, true
|
||||
case actionTagOpenIDConnectProvider:
|
||||
resp, err := e.tagOpenIDConnectProvider(ctx, mgr, values)
|
||||
return resp, err, true
|
||||
case actionUntagOpenIDConnectProvider:
|
||||
resp, err := e.untagOpenIDConnectProvider(ctx, mgr, values)
|
||||
return resp, err, true
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
// extractMemberList collects all values from `Foo.member.<n>=...` form
|
||||
// parameters in order of N. AWS query-string conventions encode list inputs
|
||||
// this way, so List/Add/Update/Tag actions all share this helper.
|
||||
func extractMemberList(values url.Values, prefix string) []string {
|
||||
out := []string{}
|
||||
// Members are indexed from 1; iterate until a missing index breaks the run.
|
||||
for i := 1; ; i++ {
|
||||
key := fmt.Sprintf("%s.member.%d", prefix, i)
|
||||
v := values.Get(key)
|
||||
if v == "" {
|
||||
break
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *EmbeddedIamApi) createOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.CreateOpenIDConnectProviderResponse, *iamError) {
|
||||
urlStr := strings.TrimSpace(values.Get("Url"))
|
||||
if urlStr == "" {
|
||||
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("Url is required")}
|
||||
}
|
||||
clientIDs := extractMemberList(values, "ClientIDList")
|
||||
if len(clientIDs) == 0 {
|
||||
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("ClientIDList must contain at least one entry")}
|
||||
}
|
||||
thumbprints := extractMemberList(values, "ThumbprintList")
|
||||
tags := extractTags(values)
|
||||
|
||||
accountID := mgr.GetSTSService().Config.AccountId
|
||||
arn, err := integration.DeriveOIDCProviderARN(accountID, urlStr)
|
||||
if err != nil {
|
||||
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err}
|
||||
}
|
||||
|
||||
rec := &integration.OIDCProviderRecord{
|
||||
AccountID: accountID,
|
||||
ARN: arn,
|
||||
URL: urlStr,
|
||||
ClientIDs: clientIDs,
|
||||
Thumbprints: thumbprints,
|
||||
Tags: tags,
|
||||
}
|
||||
if err := mgr.CreateOIDCProvider(ctx, rec); err != nil {
|
||||
if errors.Is(err, integration.ErrOIDCProviderAlreadyExists) {
|
||||
return nil, &iamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: err}
|
||||
}
|
||||
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err}
|
||||
}
|
||||
|
||||
resp := &iamlib.CreateOpenIDConnectProviderResponse{}
|
||||
resp.CreateOpenIDConnectProviderResult.OpenIDConnectProviderArn = rec.ARN
|
||||
if len(rec.Tags) > 0 {
|
||||
out := make([]*iamlib.IAMTag, 0, len(rec.Tags))
|
||||
for k, v := range rec.Tags {
|
||||
out = append(out, &iamlib.IAMTag{Key: k, Value: v})
|
||||
}
|
||||
resp.CreateOpenIDConnectProviderResult.Tags = out
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (e *EmbeddedIamApi) deleteOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.DeleteOpenIDConnectProviderResponse, *iamError) {
|
||||
arn, iamErr := requireProviderArn(values)
|
||||
if iamErr != nil {
|
||||
return nil, iamErr
|
||||
}
|
||||
if err := mgr.DeleteOIDCProvider(ctx, arn); err != nil {
|
||||
return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
|
||||
}
|
||||
return &iamlib.DeleteOpenIDConnectProviderResponse{}, nil
|
||||
}
|
||||
|
||||
func (e *EmbeddedIamApi) addClientIDToOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.AddClientIDToOpenIDConnectProviderResponse, *iamError) {
|
||||
arn, iamErr := requireProviderArn(values)
|
||||
if iamErr != nil {
|
||||
return nil, iamErr
|
||||
}
|
||||
clientID := strings.TrimSpace(values.Get("ClientID"))
|
||||
if clientID == "" {
|
||||
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("ClientID is required")}
|
||||
}
|
||||
if err := mgr.AddClientIDToOIDCProvider(ctx, arn, clientID); err != nil {
|
||||
if errors.Is(err, integration.ErrOIDCProviderNotFound) {
|
||||
return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err}
|
||||
}
|
||||
return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
|
||||
}
|
||||
return &iamlib.AddClientIDToOpenIDConnectProviderResponse{}, nil
|
||||
}
|
||||
|
||||
func (e *EmbeddedIamApi) removeClientIDFromOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.RemoveClientIDFromOpenIDConnectProviderResponse, *iamError) {
|
||||
arn, iamErr := requireProviderArn(values)
|
||||
if iamErr != nil {
|
||||
return nil, iamErr
|
||||
}
|
||||
clientID := strings.TrimSpace(values.Get("ClientID"))
|
||||
if clientID == "" {
|
||||
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("ClientID is required")}
|
||||
}
|
||||
if err := mgr.RemoveClientIDFromOIDCProvider(ctx, arn, clientID); err != nil {
|
||||
if errors.Is(err, integration.ErrOIDCProviderNotFound) {
|
||||
return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err}
|
||||
}
|
||||
return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
|
||||
}
|
||||
return &iamlib.RemoveClientIDFromOpenIDConnectProviderResponse{}, nil
|
||||
}
|
||||
|
||||
func (e *EmbeddedIamApi) updateOpenIDConnectProviderThumbprint(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.UpdateOpenIDConnectProviderThumbprintResponse, *iamError) {
|
||||
arn, iamErr := requireProviderArn(values)
|
||||
if iamErr != nil {
|
||||
return nil, iamErr
|
||||
}
|
||||
thumbprints := extractMemberList(values, "ThumbprintList")
|
||||
if len(thumbprints) == 0 {
|
||||
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("ThumbprintList must contain at least one entry")}
|
||||
}
|
||||
if err := mgr.UpdateOIDCProviderThumbprints(ctx, arn, thumbprints); err != nil {
|
||||
if errors.Is(err, integration.ErrOIDCProviderNotFound) {
|
||||
return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err}
|
||||
}
|
||||
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err}
|
||||
}
|
||||
return &iamlib.UpdateOpenIDConnectProviderThumbprintResponse{}, nil
|
||||
}
|
||||
|
||||
func (e *EmbeddedIamApi) tagOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.TagOpenIDConnectProviderResponse, *iamError) {
|
||||
arn, iamErr := requireProviderArn(values)
|
||||
if iamErr != nil {
|
||||
return nil, iamErr
|
||||
}
|
||||
tags := extractTags(values)
|
||||
if len(tags) == 0 {
|
||||
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("Tags must contain at least one Key/Value pair")}
|
||||
}
|
||||
if err := mgr.TagOIDCProvider(ctx, arn, tags); err != nil {
|
||||
if errors.Is(err, integration.ErrOIDCProviderNotFound) {
|
||||
return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err}
|
||||
}
|
||||
return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
|
||||
}
|
||||
return &iamlib.TagOpenIDConnectProviderResponse{}, nil
|
||||
}
|
||||
|
||||
func (e *EmbeddedIamApi) untagOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.UntagOpenIDConnectProviderResponse, *iamError) {
|
||||
arn, iamErr := requireProviderArn(values)
|
||||
if iamErr != nil {
|
||||
return nil, iamErr
|
||||
}
|
||||
keys := extractMemberList(values, "TagKeys")
|
||||
if len(keys) == 0 {
|
||||
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("TagKeys must contain at least one entry")}
|
||||
}
|
||||
if err := mgr.UntagOIDCProvider(ctx, arn, keys); err != nil {
|
||||
if errors.Is(err, integration.ErrOIDCProviderNotFound) {
|
||||
return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err}
|
||||
}
|
||||
return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
|
||||
}
|
||||
return &iamlib.UntagOpenIDConnectProviderResponse{}, nil
|
||||
}
|
||||
|
||||
// extractTags walks the AWS IAM "Tags.member.N.Key / Tags.member.N.Value"
|
||||
// query-string convention and returns the parsed map. Returns nil (not an
|
||||
// empty map) when no tags are present so the caller can distinguish
|
||||
// "untouched" from "empty".
|
||||
func extractTags(values url.Values) map[string]string {
|
||||
var out map[string]string
|
||||
for i := 1; ; i++ {
|
||||
k := values.Get(fmt.Sprintf("Tags.member.%d.Key", i))
|
||||
if k == "" {
|
||||
break
|
||||
}
|
||||
if out == nil {
|
||||
out = make(map[string]string)
|
||||
}
|
||||
out[k] = values.Get(fmt.Sprintf("Tags.member.%d.Value", i))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func requireProviderArn(values url.Values) (string, *iamError) {
|
||||
arn := strings.TrimSpace(values.Get("OpenIDConnectProviderArn"))
|
||||
if arn == "" {
|
||||
return "", &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("OpenIDConnectProviderArn is required")}
|
||||
}
|
||||
return arn, nil
|
||||
}
|
||||
|
||||
func (e *EmbeddedIamApi) oidcIAMManager() *integration.IAMManager {
|
||||
if e.iam == nil || e.iam.iamIntegration == nil {
|
||||
return nil
|
||||
|
||||
@ -149,3 +149,248 @@ func TestReadOnlyAllowsOIDCList(t *testing.T) {
|
||||
t.Fatalf("read-only mode should allow ListOpenIDConnectProviders: %v", iamErr.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyDeniesOIDCMutations(t *testing.T) {
|
||||
api, _ := newOIDCTestAPI(t)
|
||||
api.readOnly = true
|
||||
mutations := []string{
|
||||
actionCreateOpenIDConnectProvider,
|
||||
actionDeleteOpenIDConnectProvider,
|
||||
actionAddClientIDToOpenIDConnectProvider,
|
||||
actionRemoveClientIDFromOpenIDConnectProvider,
|
||||
actionUpdateOpenIDConnectProviderThumbprint,
|
||||
actionTagOpenIDConnectProvider,
|
||||
actionUntagOpenIDConnectProvider,
|
||||
}
|
||||
for _, action := range mutations {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
values := url.Values{}
|
||||
values.Set("Action", action)
|
||||
_, iamErr := api.ExecuteAction(context.Background(), values, true, "ro-deny")
|
||||
if iamErr == nil {
|
||||
t.Fatalf("expected denial for %s in read-only mode", action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOpenIDConnectProvider(t *testing.T) {
|
||||
api, _ := newOIDCTestAPI(t)
|
||||
values := url.Values{}
|
||||
values.Set("Action", actionCreateOpenIDConnectProvider)
|
||||
values.Set("Url", "https://auth.example.com")
|
||||
values.Set("ClientIDList.member.1", "alpha")
|
||||
values.Set("ClientIDList.member.2", "beta")
|
||||
values.Set("ThumbprintList.member.1", "9e99a48a9960b14926bb7f3b02e22da2b0ab7280")
|
||||
values.Set("Tags.member.1.Key", "team")
|
||||
values.Set("Tags.member.1.Value", "infra")
|
||||
|
||||
resp, iamErr := api.ExecuteAction(context.Background(), values, true, "create-1")
|
||||
if iamErr != nil {
|
||||
t.Fatalf("ExecuteAction: %v", iamErr.Error)
|
||||
}
|
||||
createResp, ok := resp.(*iamlib.CreateOpenIDConnectProviderResponse)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected response type %T", resp)
|
||||
}
|
||||
wantArn := "arn:aws:iam::111122223333:oidc-provider/auth.example.com"
|
||||
if createResp.CreateOpenIDConnectProviderResult.OpenIDConnectProviderArn != wantArn {
|
||||
t.Fatalf("ARN mismatch: got=%s want=%s",
|
||||
createResp.CreateOpenIDConnectProviderResult.OpenIDConnectProviderArn, wantArn)
|
||||
}
|
||||
|
||||
// Get the new provider back to confirm persistence and clientId list.
|
||||
getValues := url.Values{}
|
||||
getValues.Set("Action", actionGetOpenIDConnectProvider)
|
||||
getValues.Set("OpenIDConnectProviderArn", wantArn)
|
||||
getResp, iamErr := api.ExecuteAction(context.Background(), getValues, true, "get-after-create")
|
||||
if iamErr != nil {
|
||||
t.Fatalf("Get after create: %v", iamErr.Error)
|
||||
}
|
||||
gr := getResp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult
|
||||
if len(gr.ClientIDList) != 2 || gr.ClientIDList[0] != "alpha" || gr.ClientIDList[1] != "beta" {
|
||||
t.Fatalf("ClientIDList persisted incorrectly: %v", gr.ClientIDList)
|
||||
}
|
||||
if len(gr.ThumbprintList) != 1 || gr.ThumbprintList[0] != "9e99a48a9960b14926bb7f3b02e22da2b0ab7280" {
|
||||
t.Fatalf("ThumbprintList persisted incorrectly: %v", gr.ThumbprintList)
|
||||
}
|
||||
if len(gr.Tags) != 1 || gr.Tags[0].Key != "team" || gr.Tags[0].Value != "infra" {
|
||||
t.Fatalf("Tags persisted incorrectly: %v", gr.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOpenIDConnectProviderRejectsBadInput(t *testing.T) {
|
||||
api, _ := newOIDCTestAPI(t)
|
||||
|
||||
t.Run("missing Url", func(t *testing.T) {
|
||||
values := url.Values{}
|
||||
values.Set("Action", actionCreateOpenIDConnectProvider)
|
||||
values.Set("ClientIDList.member.1", "x")
|
||||
_, iamErr := api.ExecuteAction(context.Background(), values, true, "")
|
||||
if iamErr == nil || iamErr.Code != "InvalidInput" {
|
||||
t.Fatalf("expected InvalidInput, got %v", iamErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing ClientIDList", func(t *testing.T) {
|
||||
values := url.Values{}
|
||||
values.Set("Action", actionCreateOpenIDConnectProvider)
|
||||
values.Set("Url", "https://auth.example.com")
|
||||
_, iamErr := api.ExecuteAction(context.Background(), values, true, "")
|
||||
if iamErr == nil || iamErr.Code != "InvalidInput" {
|
||||
t.Fatalf("expected InvalidInput, got %v", iamErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid thumbprint", func(t *testing.T) {
|
||||
values := url.Values{}
|
||||
values.Set("Action", actionCreateOpenIDConnectProvider)
|
||||
values.Set("Url", "https://auth.example.com")
|
||||
values.Set("ClientIDList.member.1", "x")
|
||||
values.Set("ThumbprintList.member.1", "not-a-sha1")
|
||||
_, iamErr := api.ExecuteAction(context.Background(), values, true, "")
|
||||
if iamErr == nil || iamErr.Code != "InvalidInput" {
|
||||
t.Fatalf("expected InvalidInput, got %v", iamErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateOpenIDConnectProviderConflict(t *testing.T) {
|
||||
api, _ := newOIDCTestAPI(t)
|
||||
values := url.Values{}
|
||||
values.Set("Action", actionCreateOpenIDConnectProvider)
|
||||
values.Set("Url", "https://accounts.google.com") // already mirrored from static config
|
||||
values.Set("ClientIDList.member.1", "x")
|
||||
|
||||
_, iamErr := api.ExecuteAction(context.Background(), values, true, "")
|
||||
if iamErr == nil {
|
||||
t.Fatal("expected conflict for duplicate provider")
|
||||
}
|
||||
if iamErr.Code != "EntityAlreadyExists" {
|
||||
t.Fatalf("expected EntityAlreadyExists, got %s", iamErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteOpenIDConnectProvider(t *testing.T) {
|
||||
api, _ := newOIDCTestAPI(t)
|
||||
arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
|
||||
|
||||
values := url.Values{}
|
||||
values.Set("Action", actionDeleteOpenIDConnectProvider)
|
||||
values.Set("OpenIDConnectProviderArn", arn)
|
||||
|
||||
if _, iamErr := api.ExecuteAction(context.Background(), values, true, ""); iamErr != nil {
|
||||
t.Fatalf("delete: %v", iamErr.Error)
|
||||
}
|
||||
// Verify gone.
|
||||
getValues := url.Values{}
|
||||
getValues.Set("Action", actionGetOpenIDConnectProvider)
|
||||
getValues.Set("OpenIDConnectProviderArn", arn)
|
||||
if _, iamErr := api.ExecuteAction(context.Background(), getValues, true, ""); iamErr == nil {
|
||||
t.Fatal("expected not-found after delete")
|
||||
}
|
||||
// Idempotent.
|
||||
if _, iamErr := api.ExecuteAction(context.Background(), values, true, ""); iamErr != nil {
|
||||
t.Fatalf("idempotent delete failed: %v", iamErr.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRemoveClientID(t *testing.T) {
|
||||
api, _ := newOIDCTestAPI(t)
|
||||
arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
|
||||
|
||||
add := url.Values{}
|
||||
add.Set("Action", actionAddClientIDToOpenIDConnectProvider)
|
||||
add.Set("OpenIDConnectProviderArn", arn)
|
||||
add.Set("ClientID", "extra-client")
|
||||
if _, iamErr := api.ExecuteAction(context.Background(), add, true, ""); iamErr != nil {
|
||||
t.Fatalf("add: %v", iamErr.Error)
|
||||
}
|
||||
|
||||
getValues := url.Values{}
|
||||
getValues.Set("Action", actionGetOpenIDConnectProvider)
|
||||
getValues.Set("OpenIDConnectProviderArn", arn)
|
||||
resp, iamErr := api.ExecuteAction(context.Background(), getValues, true, "")
|
||||
if iamErr != nil {
|
||||
t.Fatalf("get: %v", iamErr.Error)
|
||||
}
|
||||
got := resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult.ClientIDList
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 clients after add, got %v", got)
|
||||
}
|
||||
|
||||
// Idempotent add.
|
||||
if _, iamErr := api.ExecuteAction(context.Background(), add, true, ""); iamErr != nil {
|
||||
t.Fatalf("idempotent add: %v", iamErr.Error)
|
||||
}
|
||||
|
||||
// Remove.
|
||||
rm := url.Values{}
|
||||
rm.Set("Action", actionRemoveClientIDFromOpenIDConnectProvider)
|
||||
rm.Set("OpenIDConnectProviderArn", arn)
|
||||
rm.Set("ClientID", "extra-client")
|
||||
if _, iamErr := api.ExecuteAction(context.Background(), rm, true, ""); iamErr != nil {
|
||||
t.Fatalf("remove: %v", iamErr.Error)
|
||||
}
|
||||
resp, _ = api.ExecuteAction(context.Background(), getValues, true, "")
|
||||
got = resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult.ClientIDList
|
||||
if len(got) != 1 || got[0] != "client-google" {
|
||||
t.Fatalf("unexpected clients after remove: %v", got)
|
||||
}
|
||||
|
||||
// Removing a missing client is a no-op.
|
||||
if _, iamErr := api.ExecuteAction(context.Background(), rm, true, ""); iamErr != nil {
|
||||
t.Fatalf("idempotent remove: %v", iamErr.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateThumbprintAndTags(t *testing.T) {
|
||||
api, _ := newOIDCTestAPI(t)
|
||||
arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
|
||||
|
||||
upd := url.Values{}
|
||||
upd.Set("Action", actionUpdateOpenIDConnectProviderThumbprint)
|
||||
upd.Set("OpenIDConnectProviderArn", arn)
|
||||
upd.Set("ThumbprintList.member.1", "0000000000000000000000000000000000000000")
|
||||
upd.Set("ThumbprintList.member.2", "1111111111111111111111111111111111111111")
|
||||
if _, iamErr := api.ExecuteAction(context.Background(), upd, true, ""); iamErr != nil {
|
||||
t.Fatalf("update thumbprints: %v", iamErr.Error)
|
||||
}
|
||||
|
||||
tag := url.Values{}
|
||||
tag.Set("Action", actionTagOpenIDConnectProvider)
|
||||
tag.Set("OpenIDConnectProviderArn", arn)
|
||||
tag.Set("Tags.member.1.Key", "owner")
|
||||
tag.Set("Tags.member.1.Value", "platform")
|
||||
if _, iamErr := api.ExecuteAction(context.Background(), tag, true, ""); iamErr != nil {
|
||||
t.Fatalf("tag: %v", iamErr.Error)
|
||||
}
|
||||
|
||||
get := url.Values{}
|
||||
get.Set("Action", actionGetOpenIDConnectProvider)
|
||||
get.Set("OpenIDConnectProviderArn", arn)
|
||||
resp, iamErr := api.ExecuteAction(context.Background(), get, true, "")
|
||||
if iamErr != nil {
|
||||
t.Fatalf("get: %v", iamErr.Error)
|
||||
}
|
||||
gr := resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult
|
||||
if len(gr.ThumbprintList) != 2 {
|
||||
t.Fatalf("ThumbprintList persisted incorrectly: %v", gr.ThumbprintList)
|
||||
}
|
||||
if len(gr.Tags) != 1 || gr.Tags[0].Key != "owner" {
|
||||
t.Fatalf("Tags persisted incorrectly: %v", gr.Tags)
|
||||
}
|
||||
|
||||
untag := url.Values{}
|
||||
untag.Set("Action", actionUntagOpenIDConnectProvider)
|
||||
untag.Set("OpenIDConnectProviderArn", arn)
|
||||
untag.Set("TagKeys.member.1", "owner")
|
||||
if _, iamErr := api.ExecuteAction(context.Background(), untag, true, ""); iamErr != nil {
|
||||
t.Fatalf("untag: %v", iamErr.Error)
|
||||
}
|
||||
resp, _ = api.ExecuteAction(context.Background(), get, true, "")
|
||||
gr = resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult
|
||||
if len(gr.Tags) != 0 {
|
||||
t.Fatalf("Tags should be empty after untag, got: %v", gr.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
@ -373,6 +373,7 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
|
||||
filer.IamConfigDirectory + "/policies",
|
||||
filer.IamConfigDirectory + "/service_accounts",
|
||||
filer.IamConfigDirectory + "/groups",
|
||||
filer.IamConfigDirectory + "/oidc-providers",
|
||||
})
|
||||
|
||||
// Start bucket size metrics collection in background
|
||||
|
||||
Loading…
Reference in New Issue
Block a user