mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-06-13 23:36:45 +03:00
feat(iam): OIDC provider store + read-only IAM API (Phase 2a) (#9319)
* feat(iam): STS web-identity AWS-fidelity polish - OIDC discovery via .well-known/openid-configuration; falls back to /.well-known/jwks.json when discovery is absent. Reject discovery docs whose issuer claim does not match the configured issuer to defend against issuer-substitution. - ComputeParentUser derives a stable per-identity hash from (sub, iss). Surface as aws:userid in the request context and as a parent_user claim in the session JWT so per-user state survives token rotation. - Per-role MaxSessionDuration (3600..43200) clamps requested DurationSeconds before the STS service applies its own caps. - Tighten RoleSessionName to the AWS contract: 2..64 chars from [\w+=,.@-]. - Populate PackedPolicySize in AssumeRole / AssumeRoleWithWebIdentity / AssumeRoleWithLDAPIdentity responses as a percentage of the 2048-byte inline session policy budget. * fix(iam): leave omitted DurationSeconds nil so STS default applies capDurationByRole was substituting the role's MaxSessionDuration when the caller omitted DurationSeconds entirely. AWS returns the configured default (typically 1 hour) in that case, not the role's upper bound — a 12h MaxSessionDuration shouldn't silently make every no-duration assume-role mint a 12h session. Return nil when requested is nil; let the downstream calculateSessionDuration in the STS service apply its TokenDuration default. The role-max upper bound still clamps when the request arrives with a concrete value above the cap. Addresses gemini high-priority review on PR #9318. * fix(iam): synchronize OIDCProvider JWKS cache fields jwksCache, jwksFetchedAt, resolvedJWKSUri, and discoveryFailed are mutated lazily on the first token-validate call and refreshed afterwards on TTL expiry. Multiple S3 requests can land here in parallel, so the writes were racing against subsequent reads on every other goroutine. resolvedJWKSUri/discoveryFailed inherited the same un-protected pattern when discovery shipped. Add sync.RWMutex; getPublicKey takes the read lock for the common cache-hit path and promotes to the write lock for misses + refreshes. fetchJWKSLocked / resolveJWKSUriLocked assume the write lock is held by the caller; fetchJWKS keeps the test-friendly entry point that acquires the lock itself. Addresses gemini high-priority review on PR #9318. * fix(iam): trim trailing slash + retry discovery after transient failure Two OIDC discovery edge cases reviewers flagged: 1. Issuer comparison was sensitive to trailing slashes. resolveJWKSUri trims them when building the discovery URL, but the doc.Issuer ↔ p.config.Issuer check did not, so an IDP whose issuer claim drops or adds the slash relative to the configured value would be falsely rejected. Trim a single trailing slash on each side before comparing. 2. discoveryFailed flipped to true on any error and stayed there for the process lifetime. A transient 5xx at startup permanently locked the provider into the /.well-known/jwks.json fallback. Reset the flag at the top of fetchJWKSLocked when no URI has been cached yet, so each JWKS refresh (typically once per TTL = 1h) reattempts discovery. Successful discovery remains cached via resolvedJWKSUri so we don't pay the discovery RTT on every refresh. Addresses gemini security-medium + medium reviews on PR #9318. * fix(iam): require non-empty issuer in OIDC discovery doc The previous "doc.Issuer != "" && ..." guard let a discovery document that omitted the issuer field bypass the issuer-mismatch check entirely, letting the doc steer fetchJWKS at any URL it provided. OIDC Discovery 1.0 §3 mandates the issuer field; treat missing as a hard failure same as mismatched. Trailing-slash equivalence still applies. Adds TestDiscoveryRejectsMissingIssuer alongside the existing TestDiscoveryRejectsIssuerMismatch via a new omitDiscoveryIssuer toggle on fakeIDP. * feat(iam): OIDC provider store + read-only IAM API Add OIDCProviderRecord — the persisted, IAM-managed view of an OIDC identity provider — and an OIDCProviderStore interface with memory and filer implementations mirroring the existing role-store pattern. The store is hydrated at boot from the static STS.Providers list so the new IAM API surfaces the same set the STS service already validates against. Two read-only actions land now: - ListOpenIDConnectProviders -> ARN-only list, AWS-shape XML. - GetOpenIDConnectProvider -> URL, ClientIDList, ThumbprintList, Tags, CreateDate. Mutations (Create/Delete/Add-Remove ClientID/Update Thumbprint), multiple client_ids per provider, and TLS thumbprint pinning come in Phase 2b. * fix(iam): preserve CreatedAt across boots + paginate ListProviders Two medium-priority issues gemini flagged on the read-only IAM API: 1. The static-config bootstrap was setting CreatedAt = time.Now() on every server start, so the IAM GetOpenIDConnectProvider response's CreateDate shifted on each restart even when backed by a persistent store. Look up the existing record via GetProviderByARN first and preserve its CreatedAt; only the UpdatedAt advances. 2. FilerOIDCProviderStore.ListProviders had a hardcoded Limit: 1000 that silently truncated above that. Stream-paginate via StartFromFileName, returning io.EOF naturally and surfacing all other errors instead of swallowing them. Addresses two gemini medium reviews on PR #9319.
This commit is contained in:
@@ -7,8 +7,10 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
@@ -27,12 +29,44 @@ type IAMManager struct {
|
||||
policyEngine *policy.PolicyEngine
|
||||
roleStore RoleStore
|
||||
userStore UserStore
|
||||
oidcProviderStore OIDCProviderStore
|
||||
filerAddressProvider func() string // Function to get current filer address
|
||||
initialized bool
|
||||
runtimePolicyMu sync.Mutex
|
||||
runtimePolicyNames map[string]struct{}
|
||||
}
|
||||
|
||||
// SetOIDCProviderStore configures the IAM-managed OIDC provider store. When
|
||||
// nil, OIDC provider IAM actions return ServiceNotReady. The store is the
|
||||
// source of truth for AssumeRoleWithWebIdentity provider resolution once
|
||||
// Phase 2b lands; in Phase 2a it is read-only and populated from static
|
||||
// configuration at boot.
|
||||
func (m *IAMManager) SetOIDCProviderStore(store OIDCProviderStore) {
|
||||
m.oidcProviderStore = store
|
||||
}
|
||||
|
||||
// GetOIDCProviderStore returns the configured store (may be nil).
|
||||
func (m *IAMManager) GetOIDCProviderStore() OIDCProviderStore {
|
||||
return m.oidcProviderStore
|
||||
}
|
||||
|
||||
// GetOIDCProvider returns the record for the given ARN, or an error if the
|
||||
// store is not configured or the record is missing.
|
||||
func (m *IAMManager) GetOIDCProvider(ctx context.Context, arn string) (*OIDCProviderRecord, error) {
|
||||
if m.oidcProviderStore == nil {
|
||||
return nil, fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
return m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
|
||||
}
|
||||
|
||||
// ListOIDCProviders enumerates all configured OIDC providers.
|
||||
func (m *IAMManager) ListOIDCProviders(ctx context.Context) ([]*OIDCProviderRecord, error) {
|
||||
if m.oidcProviderStore == nil {
|
||||
return nil, fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
return m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress())
|
||||
}
|
||||
|
||||
// IAMConfig holds configuration for all IAM components
|
||||
type IAMConfig struct {
|
||||
// STS service configuration
|
||||
@@ -43,6 +77,17 @@ type IAMConfig struct {
|
||||
|
||||
// Role store configuration
|
||||
Roles *RoleStoreConfig `json:"roleStore"`
|
||||
|
||||
// OIDCProviders configures the IAM-managed OIDC provider store. Optional;
|
||||
// if absent the manager defaults to an in-memory store hydrated from
|
||||
// STS.Providers at boot.
|
||||
OIDCProviders *OIDCProviderStoreConfig `json:"oidcProviderStore,omitempty"`
|
||||
}
|
||||
|
||||
// OIDCProviderStoreConfig holds OIDC provider store configuration.
|
||||
type OIDCProviderStoreConfig struct {
|
||||
StoreType string `json:"storeType"` // memory, filer
|
||||
StoreConfig map[string]interface{} `json:"storeConfig,omitempty"`
|
||||
}
|
||||
|
||||
// RoleStoreConfig holds role store configuration
|
||||
@@ -196,10 +241,110 @@ func (m *IAMManager) Initialize(config *IAMConfig, filerAddressProvider func() s
|
||||
}
|
||||
m.roleStore = roleStore
|
||||
|
||||
// Initialize OIDC provider store and hydrate from static configuration so
|
||||
// the read-only IAM API can return the same providers the STS service
|
||||
// already accepts. Mutations will land in Phase 2b.
|
||||
if err := m.initOIDCProviderStore(config); err != nil {
|
||||
return fmt.Errorf("failed to initialize OIDC provider store: %w", err)
|
||||
}
|
||||
|
||||
m.initialized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// initOIDCProviderStore creates the OIDC provider store and seeds it from the
|
||||
// static STS provider configuration. The static path remains the bootstrap
|
||||
// source: each enabled OIDC entry under STS.Providers is mirrored as an
|
||||
// OIDCProviderRecord so the IAM API surfaces the same set the STS service
|
||||
// validates against.
|
||||
func (m *IAMManager) initOIDCProviderStore(config *IAMConfig) error {
|
||||
store, err := m.createOIDCProviderStore(config.OIDCProviders)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.oidcProviderStore = store
|
||||
|
||||
if config.STS == nil {
|
||||
return nil
|
||||
}
|
||||
for _, pc := range config.STS.Providers {
|
||||
if pc == nil || !pc.Enabled || pc.Type != sts.ProviderTypeOIDC {
|
||||
continue
|
||||
}
|
||||
issuer, _ := pc.Config["issuer"].(string)
|
||||
if issuer == "" {
|
||||
glog.Warningf("OIDC provider %s in static config has empty issuer; skipping mirror to store", pc.Name)
|
||||
continue
|
||||
}
|
||||
accountID := ""
|
||||
if config.STS != nil {
|
||||
accountID = config.STS.AccountId
|
||||
}
|
||||
arn, err := DeriveOIDCProviderARN(accountID, issuer)
|
||||
if err != nil {
|
||||
glog.Warningf("derive ARN for static OIDC provider %s: %v", pc.Name, err)
|
||||
continue
|
||||
}
|
||||
clientIDs := extractClientIDs(pc.Config)
|
||||
ctx := context.Background()
|
||||
// Preserve CreatedAt across reboots when a persistent store already
|
||||
// has this provider — IAM's GetOpenIDConnectProvider response
|
||||
// shouldn't shift its CreateDate every time the server restarts.
|
||||
now := time.Now().UTC()
|
||||
createdAt := now
|
||||
if existing, err := store.GetProviderByARN(ctx, m.getFilerAddress(), arn); err == nil && existing != nil && !existing.CreatedAt.IsZero() {
|
||||
createdAt = existing.CreatedAt
|
||||
}
|
||||
rec := &OIDCProviderRecord{
|
||||
AccountID: accountID,
|
||||
ARN: arn,
|
||||
URL: issuer,
|
||||
ClientIDs: clientIDs,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := store.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
||||
glog.Warningf("mirror static OIDC provider %s into store: %v", pc.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createOIDCProviderStore selects an OIDCProviderStore implementation. Defaults
|
||||
// to memory; "filer" requires a filerAddressProvider to be configured.
|
||||
func (m *IAMManager) createOIDCProviderStore(cfg *OIDCProviderStoreConfig) (OIDCProviderStore, error) {
|
||||
if cfg == nil || cfg.StoreType == "" || cfg.StoreType == "memory" {
|
||||
return NewMemoryOIDCProviderStore(), nil
|
||||
}
|
||||
if cfg.StoreType == "filer" {
|
||||
return NewFilerOIDCProviderStore(cfg.StoreConfig, m.filerAddressProvider), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported OIDC provider store type: %s", cfg.StoreType)
|
||||
}
|
||||
|
||||
// extractClientIDs reads a single clientId or a clientIds list from the
|
||||
// provider's static config map. Mirrors the OIDCConfig schema.
|
||||
func extractClientIDs(cfg map[string]interface{}) []string {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
if list, ok := cfg["clientIds"].([]interface{}); ok {
|
||||
out := make([]string, 0, len(list))
|
||||
for _, v := range list {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
if id, ok := cfg["clientId"].(string); ok && id != "" {
|
||||
return []string{id}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getFilerAddress returns the current filer address using the provider function
|
||||
func (m *IAMManager) getFilerAddress() string {
|
||||
if m.filerAddressProvider != nil {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
)
|
||||
|
||||
func TestStaticConfigSeedsProviderStore(t *testing.T) {
|
||||
mgr := NewIAMManager()
|
||||
cfg := &IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
AccountId: "111122223333",
|
||||
Providers: []*sts.ProviderConfig{
|
||||
{
|
||||
Name: "google",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: true,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://accounts.google.com",
|
||||
"clientId": "1234.apps.googleusercontent.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "github-actions",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: true,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://token.actions.githubusercontent.com",
|
||||
"clientId": "sts.amazonaws.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "disabled-google",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: false,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://accounts.disabled.example",
|
||||
"clientId": "x",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
|
||||
Roles: &RoleStoreConfig{StoreType: "memory"},
|
||||
}
|
||||
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
|
||||
store := mgr.GetOIDCProviderStore()
|
||||
if store == nil {
|
||||
t.Fatal("expected store to be initialized")
|
||||
}
|
||||
|
||||
got, err := mgr.ListOIDCProviders(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListOIDCProviders: %v", err)
|
||||
}
|
||||
// Disabled providers must not be mirrored.
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 providers, got %d (%v)", len(got), got)
|
||||
}
|
||||
|
||||
byArn := map[string]*OIDCProviderRecord{}
|
||||
for _, r := range got {
|
||||
byArn[r.ARN] = r
|
||||
}
|
||||
|
||||
googleARN := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
|
||||
g, ok := byArn[googleARN]
|
||||
if !ok {
|
||||
t.Fatalf("missing google ARN; have %v", byArn)
|
||||
}
|
||||
if len(g.ClientIDs) != 1 || g.ClientIDs[0] != "1234.apps.googleusercontent.com" {
|
||||
t.Fatalf("google clientIds wrong: %v", g.ClientIDs)
|
||||
}
|
||||
|
||||
ghARN := "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
|
||||
gh, ok := byArn[ghARN]
|
||||
if !ok {
|
||||
t.Fatalf("missing github-actions ARN; have %v", byArn)
|
||||
}
|
||||
if len(gh.ClientIDs) != 1 || gh.ClientIDs[0] != "sts.amazonaws.com" {
|
||||
t.Fatalf("github clientIds wrong: %v", gh.ClientIDs)
|
||||
}
|
||||
|
||||
// Spot-check the IAM read path returns the same record.
|
||||
rec, err := mgr.GetOIDCProvider(context.Background(), googleARN)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOIDCProvider: %v", err)
|
||||
}
|
||||
if rec.URL != "https://accounts.google.com" {
|
||||
t.Fatalf("URL mismatch: %s", rec.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreNotConfiguredReturnsClearError(t *testing.T) {
|
||||
mgr := NewIAMManager()
|
||||
if _, err := mgr.GetOIDCProvider(context.Background(), "arn:..."); err == nil {
|
||||
t.Fatal("expected error when store not configured")
|
||||
}
|
||||
if _, err := mgr.ListOIDCProviders(context.Background()); err == nil {
|
||||
t.Fatal("expected error when store not configured")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// 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
|
||||
// startup so the resolution path is uniform.
|
||||
type OIDCProviderRecord struct {
|
||||
// AccountID scopes the record. Empty means "global" — usable from any
|
||||
// account. Non-empty records are only resolvable when the assuming
|
||||
// caller's RoleArn lives in the same account. Phase 2a leaves this
|
||||
// field unenforced; Phase 3c lights up the cross-account check.
|
||||
AccountID string `json:"accountId,omitempty"`
|
||||
|
||||
// ARN is the canonical IAM identifier for the provider:
|
||||
// arn:aws:iam::<account>:oidc-provider/<host>/<path>.
|
||||
ARN string `json:"arn"`
|
||||
|
||||
// URL is the issuer URL (no trailing slash), e.g.
|
||||
// https://token.actions.githubusercontent.com.
|
||||
URL string `json:"url"`
|
||||
|
||||
// ClientIDs are the audiences AWS calls "client IDs". A token's `aud` or
|
||||
// `azp` must match one of these for the token to be accepted.
|
||||
ClientIDs []string `json:"clientIds,omitempty"`
|
||||
|
||||
// Thumbprints are SHA-1 hex digests of the IDP's TLS certificate, matching
|
||||
// the AWS-compatible thumbprint algorithm. Used at JWKS-fetch time when
|
||||
// non-empty; an empty list means "trust the system root store".
|
||||
Thumbprints []string `json:"thumbprints,omitempty"`
|
||||
|
||||
// AllowedPrincipalTagKeys is the per-provider ABAC allowlist for the
|
||||
// `https://aws.amazon.com/tags/principal_tags` claim namespace. Empty
|
||||
// means no tags are surfaced from this provider.
|
||||
AllowedPrincipalTagKeys []string `json:"allowedPrincipalTagKeys,omitempty"`
|
||||
|
||||
// PolicyClaim, when non-empty, enables claim-based policy mode for this
|
||||
// provider: tokens whose RoleArn matches the configured sentinel will
|
||||
// derive their effective policies from this JWT claim.
|
||||
PolicyClaim string `json:"policyClaim,omitempty"`
|
||||
|
||||
// Tags is the AWS-style tag set attached to the IAM resource itself
|
||||
// (audit/inventory metadata, not propagated into sessions).
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// OIDCProviderStore stores OIDCProviderRecord entries. Implementations are
|
||||
// expected to be safe for concurrent use.
|
||||
type OIDCProviderStore interface {
|
||||
StoreProvider(ctx context.Context, filerAddress string, record *OIDCProviderRecord) error
|
||||
GetProviderByARN(ctx context.Context, filerAddress string, arn string) (*OIDCProviderRecord, error)
|
||||
GetProviderByIssuer(ctx context.Context, filerAddress string, issuer string) (*OIDCProviderRecord, error)
|
||||
ListProviders(ctx context.Context, filerAddress string) ([]*OIDCProviderRecord, error)
|
||||
DeleteProvider(ctx context.Context, filerAddress string, arn string) error
|
||||
}
|
||||
|
||||
// MemoryOIDCProviderStore is a process-local store, suitable for tests and
|
||||
// single-node deployments. It also acts as the in-memory cache hydrated from
|
||||
// static config at boot.
|
||||
type MemoryOIDCProviderStore struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]*OIDCProviderRecord // keyed by ARN
|
||||
}
|
||||
|
||||
// NewMemoryOIDCProviderStore creates an empty in-memory store.
|
||||
func NewMemoryOIDCProviderStore() *MemoryOIDCProviderStore {
|
||||
return &MemoryOIDCProviderStore{providers: make(map[string]*OIDCProviderRecord)}
|
||||
}
|
||||
|
||||
// StoreProvider replaces any existing record with the same ARN.
|
||||
func (m *MemoryOIDCProviderStore) StoreProvider(ctx context.Context, _ string, record *OIDCProviderRecord) error {
|
||||
if record == nil {
|
||||
return fmt.Errorf("record cannot be nil")
|
||||
}
|
||||
if record.ARN == "" {
|
||||
return fmt.Errorf("record.ARN is required")
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.providers[record.ARN] = copyOIDCProviderRecord(record)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProviderByARN returns the record with the given ARN, or an error if absent.
|
||||
func (m *MemoryOIDCProviderStore) GetProviderByARN(ctx context.Context, _ string, arn string) (*OIDCProviderRecord, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
rec, ok := m.providers[arn]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("OIDC provider not found: %s", arn)
|
||||
}
|
||||
return copyOIDCProviderRecord(rec), nil
|
||||
}
|
||||
|
||||
// GetProviderByIssuer scans for the first record whose URL matches `issuer`.
|
||||
// Comparison strips trailing slashes and is case-insensitive on host.
|
||||
func (m *MemoryOIDCProviderStore) GetProviderByIssuer(ctx context.Context, _ string, issuer string) (*OIDCProviderRecord, error) {
|
||||
want := normalizeIssuer(issuer)
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, rec := range m.providers {
|
||||
if normalizeIssuer(rec.URL) == want {
|
||||
return copyOIDCProviderRecord(rec), nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no OIDC provider registered for issuer: %s", issuer)
|
||||
}
|
||||
|
||||
// ListProviders returns every record sorted by ARN for stable output.
|
||||
func (m *MemoryOIDCProviderStore) ListProviders(ctx context.Context, _ string) ([]*OIDCProviderRecord, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]*OIDCProviderRecord, 0, len(m.providers))
|
||||
for _, rec := range m.providers {
|
||||
out = append(out, copyOIDCProviderRecord(rec))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ARN < out[j].ARN })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteProvider removes the record. Idempotent — deleting a missing ARN is success.
|
||||
func (m *MemoryOIDCProviderStore) DeleteProvider(ctx context.Context, _ string, arn string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.providers, arn)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilerOIDCProviderStore persists records as JSON files in a filer directory,
|
||||
// mirroring FilerRoleStore.
|
||||
type FilerOIDCProviderStore struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
basePath string
|
||||
filerAddressProvider func() string
|
||||
}
|
||||
|
||||
// NewFilerOIDCProviderStore returns a filer-backed store. Default path
|
||||
// `/etc/iam/oidc-providers` aligns with the existing roles directory.
|
||||
func NewFilerOIDCProviderStore(config map[string]interface{}, filerAddressProvider func() string) *FilerOIDCProviderStore {
|
||||
store := &FilerOIDCProviderStore{
|
||||
basePath: "/etc/iam/oidc-providers",
|
||||
filerAddressProvider: filerAddressProvider,
|
||||
}
|
||||
if config != nil {
|
||||
if bp, ok := config["basePath"].(string); ok && bp != "" {
|
||||
store.basePath = strings.TrimSuffix(bp, "/")
|
||||
}
|
||||
}
|
||||
glog.V(2).Infof("Initialized FilerOIDCProviderStore with basePath %s", store.basePath)
|
||||
return store
|
||||
}
|
||||
|
||||
func (f *FilerOIDCProviderStore) resolveFilerAddress(filerAddress string) string {
|
||||
if filerAddress != "" {
|
||||
return filerAddress
|
||||
}
|
||||
if f.filerAddressProvider != nil {
|
||||
return f.filerAddressProvider()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *FilerOIDCProviderStore) fileName(arn string) string {
|
||||
// Hash the ARN to a fixed-width filename so we can store any character set
|
||||
// (including the URL fragments AWS lets through) without filer-path issues.
|
||||
h := sha1.Sum([]byte(arn))
|
||||
return hex.EncodeToString(h[:]) + ".json"
|
||||
}
|
||||
|
||||
// StoreProvider persists `record` as JSON.
|
||||
func (f *FilerOIDCProviderStore) StoreProvider(ctx context.Context, filerAddress string, record *OIDCProviderRecord) error {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required")
|
||||
}
|
||||
if record == nil || record.ARN == "" {
|
||||
return fmt.Errorf("record with ARN is required")
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal OIDC provider: %v", err)
|
||||
}
|
||||
|
||||
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: f.fileName(record.ARN),
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0o600),
|
||||
},
|
||||
Content: data,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("store OIDC provider %s: %v", record.ARN, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetProviderByARN looks up the record file by its ARN-derived filename.
|
||||
func (f *FilerOIDCProviderStore) GetProviderByARN(ctx context.Context, filerAddress string, arn string) (*OIDCProviderRecord, error) {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required")
|
||||
}
|
||||
|
||||
var data []byte
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: f.fileName(arn),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("OIDC provider not found: %v", err)
|
||||
}
|
||||
if resp.Entry == nil {
|
||||
return fmt.Errorf("OIDC provider not found: %s", arn)
|
||||
}
|
||||
data = resp.Entry.Content
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rec OIDCProviderRecord
|
||||
if err := json.Unmarshal(data, &rec); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal OIDC provider: %v", err)
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// GetProviderByIssuer linearly scans the directory. Phase 2b adds an issuer
|
||||
// index when write throughput grows; for read-only this is good enough.
|
||||
func (f *FilerOIDCProviderStore) GetProviderByIssuer(ctx context.Context, filerAddress string, issuer string) (*OIDCProviderRecord, error) {
|
||||
records, err := f.ListProviders(ctx, filerAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want := normalizeIssuer(issuer)
|
||||
for _, rec := range records {
|
||||
if normalizeIssuer(rec.URL) == want {
|
||||
return rec, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no OIDC provider registered for issuer: %s", issuer)
|
||||
}
|
||||
|
||||
// ListProviders enumerates every record in the directory.
|
||||
func (f *FilerOIDCProviderStore) ListProviders(ctx context.Context, filerAddress string) ([]*OIDCProviderRecord, error) {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required")
|
||||
}
|
||||
|
||||
var out []*OIDCProviderRecord
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Stream-paginate via StartFromFileName so deployments with more
|
||||
// than 1000 providers don't get a silently truncated list.
|
||||
const pageSize = 1000
|
||||
startFrom := ""
|
||||
for {
|
||||
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
|
||||
Directory: f.basePath,
|
||||
Limit: pageSize,
|
||||
StartFromFileName: startFrom,
|
||||
InclusiveStartFrom: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list OIDC providers: %v", err)
|
||||
}
|
||||
lastName := ""
|
||||
pageCount := 0
|
||||
for {
|
||||
resp, recvErr := stream.Recv()
|
||||
if recvErr != nil {
|
||||
if errors.Is(recvErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("recv OIDC provider entry: %w", recvErr)
|
||||
}
|
||||
if resp.Entry == nil || resp.Entry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
lastName = resp.Entry.Name
|
||||
pageCount++
|
||||
if !strings.HasSuffix(resp.Entry.Name, ".json") {
|
||||
continue
|
||||
}
|
||||
var rec OIDCProviderRecord
|
||||
if err := json.Unmarshal(resp.Entry.Content, &rec); err != nil {
|
||||
glog.Warningf("skipping malformed OIDC provider record %s: %v", resp.Entry.Name, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, &rec)
|
||||
}
|
||||
if pageCount < pageSize {
|
||||
break
|
||||
}
|
||||
startFrom = lastName
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ARN < out[j].ARN })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteProvider removes the record. Missing ARN is treated as success.
|
||||
func (f *FilerOIDCProviderStore) DeleteProvider(ctx context.Context, filerAddress string, arn string) error {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required")
|
||||
}
|
||||
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: f.fileName(arn),
|
||||
IsDeleteData: true,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("delete OIDC provider %s: %v", arn, err)
|
||||
}
|
||||
if resp.Error != "" && !strings.Contains(resp.Error, "not found") {
|
||||
return fmt.Errorf("delete OIDC provider %s: %s", arn, resp.Error)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (f *FilerOIDCProviderStore) withFilerClient(filerAddress string, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), f.grpcDialOption, fn)
|
||||
}
|
||||
|
||||
// DeriveOIDCProviderARN turns an issuer URL into the canonical IAM ARN AWS uses.
|
||||
// Empty `accountID` produces a global-style ARN.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// https://accounts.google.com -> arn:aws:iam::<acct>:oidc-provider/accounts.google.com
|
||||
// https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED ->
|
||||
// arn:aws:iam::<acct>:oidc-provider/oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED
|
||||
func DeriveOIDCProviderARN(accountID, issuerURL string) (string, error) {
|
||||
if issuerURL == "" {
|
||||
return "", fmt.Errorf("issuer URL is required")
|
||||
}
|
||||
u, err := url.Parse(issuerURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", fmt.Errorf("invalid issuer URL: %s", issuerURL)
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
resource := host + strings.TrimSuffix(u.Path, "/")
|
||||
return fmt.Sprintf("arn:aws:iam::%s:oidc-provider/%s", accountID, resource), nil
|
||||
}
|
||||
|
||||
// normalizeIssuer compares-friendly form: lowercased host, no trailing slash.
|
||||
func normalizeIssuer(issuer string) string {
|
||||
u, err := url.Parse(issuer)
|
||||
if err != nil || u.Host == "" {
|
||||
return strings.TrimSuffix(strings.ToLower(issuer), "/")
|
||||
}
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
u.Path = strings.TrimSuffix(u.Path, "/")
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func copyOIDCProviderRecord(rec *OIDCProviderRecord) *OIDCProviderRecord {
|
||||
if rec == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *rec
|
||||
if rec.ClientIDs != nil {
|
||||
cp.ClientIDs = append([]string(nil), rec.ClientIDs...)
|
||||
}
|
||||
if rec.Thumbprints != nil {
|
||||
cp.Thumbprints = append([]string(nil), rec.Thumbprints...)
|
||||
}
|
||||
if rec.AllowedPrincipalTagKeys != nil {
|
||||
cp.AllowedPrincipalTagKeys = append([]string(nil), rec.AllowedPrincipalTagKeys...)
|
||||
}
|
||||
if rec.Tags != nil {
|
||||
cp.Tags = make(map[string]string, len(rec.Tags))
|
||||
for k, v := range rec.Tags {
|
||||
cp.Tags[k] = v
|
||||
}
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newRecord(arn, url string) *OIDCProviderRecord {
|
||||
now := time.Now()
|
||||
return &OIDCProviderRecord{
|
||||
ARN: arn,
|
||||
URL: url,
|
||||
ClientIDs: []string{"sts.amazonaws.com"},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreCRUD(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
|
||||
rec := newRecord(
|
||||
"arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com",
|
||||
"https://token.actions.githubusercontent.com",
|
||||
)
|
||||
|
||||
// Store + Get round-trip preserves the record.
|
||||
if err := store.StoreProvider(ctx, "", rec); err != nil {
|
||||
t.Fatalf("StoreProvider: %v", err)
|
||||
}
|
||||
got, err := store.GetProviderByARN(ctx, "", rec.ARN)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProviderByARN: %v", err)
|
||||
}
|
||||
if got.URL != rec.URL {
|
||||
t.Fatalf("URL mismatch: got=%s want=%s", got.URL, rec.URL)
|
||||
}
|
||||
|
||||
// Mutate the returned copy and verify the store wasn't affected.
|
||||
got.ClientIDs[0] = "tampered"
|
||||
again, _ := store.GetProviderByARN(ctx, "", rec.ARN)
|
||||
if again.ClientIDs[0] == "tampered" {
|
||||
t.Fatal("store handed out a shared slice; mutations leaked back")
|
||||
}
|
||||
|
||||
// List returns the entry.
|
||||
all, err := store.ListProviders(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListProviders: %v", err)
|
||||
}
|
||||
if len(all) != 1 || all[0].ARN != rec.ARN {
|
||||
t.Fatalf("ListProviders unexpected result: %+v", all)
|
||||
}
|
||||
|
||||
// Delete -> Get returns not found.
|
||||
if err := store.DeleteProvider(ctx, "", rec.ARN); err != nil {
|
||||
t.Fatalf("DeleteProvider: %v", err)
|
||||
}
|
||||
if _, err := store.GetProviderByARN(ctx, "", rec.ARN); err == nil {
|
||||
t.Fatal("expected not-found after delete")
|
||||
}
|
||||
// Delete is idempotent.
|
||||
if err := store.DeleteProvider(ctx, "", rec.ARN); err != nil {
|
||||
t.Fatalf("idempotent delete should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreGetByIssuerNormalizesHost(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
|
||||
rec := newRecord(
|
||||
"arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com",
|
||||
"https://Token.Actions.GithubUserContent.com/", // mixed case + trailing slash
|
||||
)
|
||||
if err := store.StoreProvider(ctx, "", rec); err != nil {
|
||||
t.Fatalf("StoreProvider: %v", err)
|
||||
}
|
||||
|
||||
cases := []string{
|
||||
"https://token.actions.githubusercontent.com",
|
||||
"https://token.actions.githubusercontent.com/",
|
||||
"https://TOKEN.actions.GITHUBUSERCONTENT.com",
|
||||
}
|
||||
for _, want := range cases {
|
||||
got, err := store.GetProviderByIssuer(ctx, "", want)
|
||||
if err != nil {
|
||||
t.Errorf("issuer %q: GetProviderByIssuer: %v", want, err)
|
||||
continue
|
||||
}
|
||||
if got.ARN != rec.ARN {
|
||||
t.Errorf("issuer %q: ARN mismatch: got=%s", want, got.ARN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreGetByIssuerMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
_, err := store.GetProviderByIssuer(ctx, "", "https://other.example/")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unregistered issuer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveOIDCProviderARN(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
accountID string
|
||||
issuer string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "google",
|
||||
accountID: "111122223333",
|
||||
issuer: "https://accounts.google.com",
|
||||
want: "arn:aws:iam::111122223333:oidc-provider/accounts.google.com",
|
||||
},
|
||||
{
|
||||
name: "EKS with path",
|
||||
accountID: "999999999999",
|
||||
issuer: "https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED",
|
||||
want: "arn:aws:iam::999999999999:oidc-provider/oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED",
|
||||
},
|
||||
{
|
||||
name: "uppercase host normalized",
|
||||
accountID: "111122223333",
|
||||
issuer: "https://Accounts.Google.com",
|
||||
want: "arn:aws:iam::111122223333:oidc-provider/accounts.google.com",
|
||||
},
|
||||
{
|
||||
name: "trailing slash trimmed",
|
||||
accountID: "111122223333",
|
||||
issuer: "https://accounts.google.com/",
|
||||
want: "arn:aws:iam::111122223333:oidc-provider/accounts.google.com",
|
||||
},
|
||||
{
|
||||
name: "empty account allowed",
|
||||
accountID: "",
|
||||
issuer: "https://accounts.google.com",
|
||||
want: "arn:aws:iam:::oidc-provider/accounts.google.com",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := DeriveOIDCProviderARN(tc.accountID, tc.issuer)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got=%s want=%s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveOIDCProviderARNRejectsBadInput(t *testing.T) {
|
||||
cases := []string{"", "not a url", "http://"}
|
||||
for _, in := range cases {
|
||||
if _, err := DeriveOIDCProviderARN("123", in); err == nil {
|
||||
t.Errorf("expected error for input %q", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsEmptyARN(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
rec := newRecord("", "https://issuer/")
|
||||
if err := store.StoreProvider(ctx, "", rec); err == nil {
|
||||
t.Fatal("expected error storing record with empty ARN")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsNil(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
if err := store.StoreProvider(ctx, "", nil); err == nil {
|
||||
t.Fatal("expected error storing nil record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIssuerRejectsEmpty(t *testing.T) {
|
||||
if got := normalizeIssuer(""); got != "" {
|
||||
t.Fatalf("empty issuer should normalize to empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIssuerHandlesNonURL(t *testing.T) {
|
||||
// Defense in depth: even when issuer is junk, normalize doesn't panic and
|
||||
// at least lowercases the input.
|
||||
got := normalizeIssuer("Some Random String/")
|
||||
if !strings.Contains(got, "some random") {
|
||||
t.Fatalf("normalize should lowercase non-URL input, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,39 @@ type CommonResponse struct {
|
||||
} `xml:"ResponseMetadata"`
|
||||
}
|
||||
|
||||
// IAMTag mirrors AWS IAM's Tag list element with the Key/Value pair shape.
|
||||
type IAMTag struct {
|
||||
Key string `xml:"Key"`
|
||||
Value string `xml:"Value"`
|
||||
}
|
||||
|
||||
// OpenIDConnectProviderListEntry is one element of ListOpenIDConnectProviders.
|
||||
type OpenIDConnectProviderListEntry struct {
|
||||
Arn string `xml:"Arn"`
|
||||
}
|
||||
|
||||
// ListOpenIDConnectProvidersResponse is the response for ListOpenIDConnectProviders.
|
||||
type ListOpenIDConnectProvidersResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListOpenIDConnectProvidersResponse"`
|
||||
ListOpenIDConnectProvidersResult struct {
|
||||
OpenIDConnectProviderList []*OpenIDConnectProviderListEntry `xml:"OpenIDConnectProviderList>member"`
|
||||
} `xml:"ListOpenIDConnectProvidersResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// GetOpenIDConnectProviderResponse is the response for GetOpenIDConnectProvider.
|
||||
type GetOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetOpenIDConnectProviderResponse"`
|
||||
GetOpenIDConnectProviderResult struct {
|
||||
Url string `xml:"Url"`
|
||||
ClientIDList []string `xml:"ClientIDList>member,omitempty"`
|
||||
ThumbprintList []string `xml:"ThumbprintList>member,omitempty"`
|
||||
Tags []*IAMTag `xml:"Tags>member,omitempty"`
|
||||
CreateDate string `xml:"CreateDate,omitempty"`
|
||||
} `xml:"GetOpenIDConnectProviderResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// SetRequestId stores the request ID generated for the current HTTP request.
|
||||
func (r *CommonResponse) SetRequestId(requestID string) {
|
||||
r.ResponseMetadata.RequestId = requestID
|
||||
|
||||
@@ -2165,13 +2165,24 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s
|
||||
if e.readOnly {
|
||||
switch action {
|
||||
case "ListUsers", "ListAccessKeys", "GetUser", "GetUserPolicy", "ListUserPolicies", "ListAttachedUserPolicies", "ListPolicies", "GetPolicy", "ListPolicyVersions", "GetPolicyVersion", "ListServiceAccounts", "GetServiceAccount",
|
||||
"GetGroup", "ListGroups", "ListAttachedGroupPolicies", "GetGroupPolicy", "ListGroupPolicies", "ListGroupsForUser":
|
||||
"GetGroup", "ListGroups", "ListAttachedGroupPolicies", "GetGroupPolicy", "ListGroupPolicies", "ListGroupsForUser",
|
||||
actionListOpenIDConnectProviders, actionGetOpenIDConnectProvider:
|
||||
// Allowed read-only actions
|
||||
default:
|
||||
return nil, &iamError{Code: s3err.GetAPIError(s3err.ErrAccessDenied).Code, Error: fmt.Errorf("IAM write operations are disabled on this server")}
|
||||
}
|
||||
}
|
||||
|
||||
// OIDC provider actions don't operate on S3ApiConfiguration; dispatch
|
||||
// before the unrelated config load + reload churn.
|
||||
if response, iamErr, ok := e.dispatchOIDCProviderAction(ctx, values); ok {
|
||||
if iamErr != nil {
|
||||
return nil, iamErr
|
||||
}
|
||||
response.SetRequestId(reqID)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
s3cfg := &iam_pb.S3ApiConfiguration{}
|
||||
if err := e.GetS3ApiConfiguration(s3cfg); err != nil && !errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return nil, &iamError{Code: s3err.GetAPIError(s3err.ErrInternalError).Code, Error: fmt.Errorf("failed to get s3 api configuration: %v", err)}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
iamlib "github.com/seaweedfs/seaweedfs/weed/iam"
|
||||
"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.
|
||||
const (
|
||||
actionGetOpenIDConnectProvider = "GetOpenIDConnectProvider"
|
||||
actionListOpenIDConnectProviders = "ListOpenIDConnectProviders"
|
||||
)
|
||||
|
||||
// isOIDCProviderAction reports whether an action belongs to the OIDC provider
|
||||
// family. Used by ExecuteAction to short-circuit the S3ApiConfiguration code
|
||||
// path for actions that don't operate on it.
|
||||
func isOIDCProviderAction(action string) bool {
|
||||
switch action {
|
||||
case actionGetOpenIDConnectProvider,
|
||||
actionListOpenIDConnectProviders:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchOIDCProviderAction handles the OIDC provider IAM actions. Returns a
|
||||
// response, the IAM error if any, and a boolean indicating whether the action
|
||||
// was recognised (so the caller can fall through when false).
|
||||
func (e *EmbeddedIamApi) dispatchOIDCProviderAction(ctx context.Context, values url.Values) (iamlib.RequestIDSetter, *iamError, bool) {
|
||||
if !isOIDCProviderAction(values.Get("Action")) {
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
mgr := e.oidcIAMManager()
|
||||
if mgr == nil {
|
||||
return nil, &iamError{
|
||||
Code: iam.ErrCodeServiceFailureException,
|
||||
Error: errors.New("OIDC provider store not configured"),
|
||||
}, true
|
||||
}
|
||||
|
||||
switch values.Get("Action") {
|
||||
case actionListOpenIDConnectProviders:
|
||||
resp, err := e.listOpenIDConnectProviders(ctx, mgr)
|
||||
return resp, err, true
|
||||
case actionGetOpenIDConnectProvider:
|
||||
resp, err := e.getOpenIDConnectProvider(ctx, mgr, values)
|
||||
return resp, err, true
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
func (e *EmbeddedIamApi) oidcIAMManager() *integration.IAMManager {
|
||||
if e.iam == nil || e.iam.iamIntegration == nil {
|
||||
return nil
|
||||
}
|
||||
provider, ok := e.iam.iamIntegration.(IAMManagerProvider)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return provider.GetIAMManager()
|
||||
}
|
||||
|
||||
func (e *EmbeddedIamApi) listOpenIDConnectProviders(ctx context.Context, mgr *integration.IAMManager) (*iamlib.ListOpenIDConnectProvidersResponse, *iamError) {
|
||||
records, err := mgr.ListOIDCProviders(ctx)
|
||||
if err != nil {
|
||||
return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
|
||||
}
|
||||
resp := &iamlib.ListOpenIDConnectProvidersResponse{}
|
||||
resp.ListOpenIDConnectProvidersResult.OpenIDConnectProviderList = make([]*iamlib.OpenIDConnectProviderListEntry, 0, len(records))
|
||||
for _, rec := range records {
|
||||
resp.ListOpenIDConnectProvidersResult.OpenIDConnectProviderList = append(
|
||||
resp.ListOpenIDConnectProvidersResult.OpenIDConnectProviderList,
|
||||
&iamlib.OpenIDConnectProviderListEntry{Arn: rec.ARN},
|
||||
)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (e *EmbeddedIamApi) getOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.GetOpenIDConnectProviderResponse, *iamError) {
|
||||
arn := strings.TrimSpace(values.Get("OpenIDConnectProviderArn"))
|
||||
if arn == "" {
|
||||
return nil, &iamError{
|
||||
Code: iam.ErrCodeInvalidInputException,
|
||||
Error: fmt.Errorf("OpenIDConnectProviderArn is required"),
|
||||
}
|
||||
}
|
||||
rec, err := mgr.GetOIDCProvider(ctx, arn)
|
||||
if err != nil {
|
||||
return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err}
|
||||
}
|
||||
resp := &iamlib.GetOpenIDConnectProviderResponse{}
|
||||
resp.GetOpenIDConnectProviderResult.Url = rec.URL
|
||||
resp.GetOpenIDConnectProviderResult.ClientIDList = append([]string(nil), rec.ClientIDs...)
|
||||
resp.GetOpenIDConnectProviderResult.ThumbprintList = append([]string(nil), rec.Thumbprints...)
|
||||
if !rec.CreatedAt.IsZero() {
|
||||
// AWS uses ISO-8601; the IAM XML format accepts time.Time-string output.
|
||||
resp.GetOpenIDConnectProviderResult.CreateDate = rec.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
if len(rec.Tags) > 0 {
|
||||
tags := make([]*iamlib.IAMTag, 0, len(rec.Tags))
|
||||
for k, v := range rec.Tags {
|
||||
tags = append(tags, &iamlib.IAMTag{Key: k, Value: v})
|
||||
}
|
||||
resp.GetOpenIDConnectProviderResult.Tags = tags
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
iamlib "github.com/seaweedfs/seaweedfs/weed/iam"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
)
|
||||
|
||||
// stubIntegration is the smallest IAMManagerProvider that lets the OIDC
|
||||
// dispatcher reach an IAMManager. The other IAMIntegration methods are
|
||||
// unused by these tests and panic if invoked, which is what we want — any
|
||||
// unexpected call signals a routing bug.
|
||||
type stubIntegration struct {
|
||||
IAMIntegration
|
||||
mgr *integration.IAMManager
|
||||
}
|
||||
|
||||
func (s *stubIntegration) GetIAMManager() *integration.IAMManager { return s.mgr }
|
||||
|
||||
func newOIDCTestAPI(t *testing.T) (*EmbeddedIamApiForTest, *integration.IAMManager) {
|
||||
t.Helper()
|
||||
mgr := integration.NewIAMManager()
|
||||
cfg := &integration.IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
AccountId: "111122223333",
|
||||
Providers: []*sts.ProviderConfig{
|
||||
{
|
||||
Name: "google",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: true,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://accounts.google.com",
|
||||
"clientId": "client-google",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "github",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: true,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://token.actions.githubusercontent.com",
|
||||
"clientId": "sts.amazonaws.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
|
||||
Roles: &integration.RoleStoreConfig{StoreType: "memory"},
|
||||
}
|
||||
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
|
||||
t.Fatalf("Initialize IAM manager: %v", err)
|
||||
}
|
||||
|
||||
api := NewEmbeddedIamApiForTest()
|
||||
api.iam.iamIntegration = &stubIntegration{mgr: mgr}
|
||||
return api, mgr
|
||||
}
|
||||
|
||||
func TestListOpenIDConnectProviders(t *testing.T) {
|
||||
api, _ := newOIDCTestAPI(t)
|
||||
values := url.Values{}
|
||||
values.Set("Action", actionListOpenIDConnectProviders)
|
||||
|
||||
resp, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-1")
|
||||
if iamErr != nil {
|
||||
t.Fatalf("ExecuteAction: code=%s err=%v", iamErr.Code, iamErr.Error)
|
||||
}
|
||||
listResp, ok := resp.(*iamlib.ListOpenIDConnectProvidersResponse)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected response type %T", resp)
|
||||
}
|
||||
got := listResp.ListOpenIDConnectProvidersResult.OpenIDConnectProviderList
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 providers, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOpenIDConnectProvider(t *testing.T) {
|
||||
api, _ := newOIDCTestAPI(t)
|
||||
arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
|
||||
|
||||
values := url.Values{}
|
||||
values.Set("Action", actionGetOpenIDConnectProvider)
|
||||
values.Set("OpenIDConnectProviderArn", arn)
|
||||
|
||||
resp, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-2")
|
||||
if iamErr != nil {
|
||||
t.Fatalf("ExecuteAction: code=%s err=%v", iamErr.Code, iamErr.Error)
|
||||
}
|
||||
getResp, ok := resp.(*iamlib.GetOpenIDConnectProviderResponse)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected response type %T", resp)
|
||||
}
|
||||
if getResp.GetOpenIDConnectProviderResult.Url != "https://accounts.google.com" {
|
||||
t.Fatalf("URL mismatch: %s", getResp.GetOpenIDConnectProviderResult.Url)
|
||||
}
|
||||
if len(getResp.GetOpenIDConnectProviderResult.ClientIDList) != 1 ||
|
||||
getResp.GetOpenIDConnectProviderResult.ClientIDList[0] != "client-google" {
|
||||
t.Fatalf("ClientIDList wrong: %v", getResp.GetOpenIDConnectProviderResult.ClientIDList)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOpenIDConnectProviderMissing(t *testing.T) {
|
||||
api, _ := newOIDCTestAPI(t)
|
||||
values := url.Values{}
|
||||
values.Set("Action", actionGetOpenIDConnectProvider)
|
||||
values.Set("OpenIDConnectProviderArn", "arn:aws:iam::111122223333:oidc-provider/nope.example")
|
||||
|
||||
_, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-3")
|
||||
if iamErr == nil {
|
||||
t.Fatal("expected NoSuchEntity error")
|
||||
}
|
||||
if iamErr.Code != "NoSuchEntity" {
|
||||
t.Fatalf("expected NoSuchEntity code, got %s", iamErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOpenIDConnectProviderRequiresArn(t *testing.T) {
|
||||
api, _ := newOIDCTestAPI(t)
|
||||
values := url.Values{}
|
||||
values.Set("Action", actionGetOpenIDConnectProvider)
|
||||
|
||||
_, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-4")
|
||||
if iamErr == nil {
|
||||
t.Fatal("expected error for missing ARN")
|
||||
}
|
||||
if iamErr.Code != "InvalidInput" {
|
||||
t.Fatalf("expected InvalidInput code, got %s", iamErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyAllowsOIDCList(t *testing.T) {
|
||||
api, _ := newOIDCTestAPI(t)
|
||||
api.readOnly = true
|
||||
values := url.Values{}
|
||||
values.Set("Action", actionListOpenIDConnectProviders)
|
||||
|
||||
if _, iamErr := api.ExecuteAction(context.Background(), values, true, "ro-1"); iamErr != nil {
|
||||
t.Fatalf("read-only mode should allow ListOpenIDConnectProviders: %v", iamErr.Error)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user