package integration import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "strings" "sync" "time" "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" "github.com/seaweedfs/seaweedfs/weed/iam/utils" "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" ) // maxPoliciesForEvaluation defines an upper bound on the number of policies that // will be evaluated for a single request. This protects against pathological or // malicious inputs that attempt to create extremely large policy lists. const maxPoliciesForEvaluation = 1024 // IAMManager orchestrates all IAM components type IAMManager struct { stsService *sts.STSService policyEngine *policy.PolicyEngine roleStore RoleStore userStore UserStore oidcProviderStore OIDCProviderStore // staticOIDCProviders are the OIDC providers of this server's IAM config // file, by ARN. With an in-memory store they are also written to the // store; a persistent store never holds them (see installOIDCProviderStore). staticOIDCProviders map[string]*OIDCProviderRecord // staticRoles are the roles of this server's IAM config file, by name, once // LoadStaticRoles has run (see installRoleStore). staticRoles map[string]*RoleDefinition // oidcRetryMu guards the background refresh retry and which store is // current: cancelOIDCLoad stops the retry, oidcRetryGen names the one // running (0 when none) so at most one runs, and oidcRetryAgain records a // refresh that failed while it ran, so the retry runs once more. oidcRetryMu sync.Mutex cancelOIDCLoad context.CancelFunc oidcRetryGen uint64 oidcRetrySeq uint64 oidcRetryAgain bool // oidcRefreshMu serializes refreshes from reading the store to handing // STS the result, so an older snapshot cannot replace a newer one. oidcRefreshMu sync.Mutex // oidcManagedProviders caches providers refreshOIDCProvidersFrom built, // keyed by record identity, so an unchanged record reuses its provider: // rebuilding would drop the provider's consumed-token (jti) replay cache // and let an already-used token be presented again. Guarded by // oidcRefreshMu. oidcManagedProviders map[string]*oidcManagedProvider oidcAuditSink OIDCProviderAuditSink revocationStore SessionRevocationStore filerAddressProvider func() string // Function to get current filer address initialized bool runtimePolicyMu sync.Mutex runtimePolicyNames map[string]struct{} } // SetOIDCProviderAuditSink configures the lifecycle event sink. When nil // (default), GlogAuditSink is used so events still surface in logs. func (m *IAMManager) SetOIDCProviderAuditSink(sink OIDCProviderAuditSink) { m.oidcAuditSink = sink } // emitOIDCAudit logs a lifecycle event. Errors are swallowed: an audit // failure must never block an IAM mutation that has already succeeded. func (m *IAMManager) emitOIDCAudit(ctx context.Context, eventType OIDCProviderAuditEventType, arn, url string, detail map[string]string) { sink := m.oidcAuditSink if sink == nil { sink = GlogAuditSink{} } event := &OIDCProviderAuditEvent{ Type: eventType, ARN: arn, URL: url, Detail: detail, OccurredAt: time.Now().UTC(), } if err := sink.Emit(ctx, event); err != nil { glog.Warningf("OIDC audit emit %s for %s: %v", eventType, arn, err) } } // SetSessionRevocationStore configures the per-session revocation list. When // nil, RevokeSession returns an error and IsSessionRevoked is a no-op (every // session is considered live until natural expiry). Operators who want // revocation must wire a store explicitly. func (m *IAMManager) SetSessionRevocationStore(store SessionRevocationStore) { m.revocationStore = store } // RevokeSession marks a session as revoked using its JTI (which equals the // session ID for STS-issued tokens). func (m *IAMManager) RevokeSession(ctx context.Context, jti string, expiresAt time.Time, reason string) error { if m.revocationStore == nil { return fmt.Errorf("session revocation store not configured") } if jti == "" { return fmt.Errorf("jti cannot be empty") } return m.revocationStore.Revoke(ctx, m.getFilerAddress(), &RevocationEntry{ JTI: jti, ExpiresAt: expiresAt, Reason: reason, }) } // IsSessionRevoked returns true if the given JTI has been revoked. Returns // false (with nil error) when no revocation store is configured. func (m *IAMManager) IsSessionRevoked(ctx context.Context, jti string) (bool, error) { if m.revocationStore == nil || jti == "" { return false, nil } return m.revocationStore.IsRevoked(ctx, m.getFilerAddress(), jti) } // PurgeRevokedSessions removes revocation entries whose underlying session // has already expired. Safe to call on a cron schedule. func (m *IAMManager) PurgeRevokedSessions(ctx context.Context) (int, error) { if m.revocationStore == nil { return 0, nil } return m.revocationStore.Purge(ctx, m.getFilerAddress(), time.Now().UTC()) } // SetOIDCProviderStore configures the IAM-managed OIDC provider store. When // nil, OIDC provider IAM actions return ServiceNotReady. func (m *IAMManager) SetOIDCProviderStore(store OIDCProviderStore) { var stsConfig *sts.STSConfig if m.stsService != nil { stsConfig = m.stsService.Config } m.installOIDCProviderStore(store, stsConfig) } // GetOIDCProviderStore returns the configured store (may be nil). func (m *IAMManager) GetOIDCProviderStore() OIDCProviderStore { return m.oidcProviderStore } // GetRoleStore returns the configured role store. func (m *IAMManager) GetRoleStore() RoleStore { return m.roleStore } // SetRoleStore replaces the role store. An S3 server builds the list of // directories it watches for peers' changes once, at startup, from the store // installed then (RoleStoreDirectory): a filer-backed store installed later // with a different basePath is not watched, so peers' changes to it reach this // server's cached roles only when the cache expires. func (m *IAMManager) SetRoleStore(store RoleStore) { m.installRoleStore(context.Background(), store) } // 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") } rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn) if errors.Is(err, ErrOIDCProviderNotFound) { if static, ok := m.staticOIDCProviders[arn]; ok { return copyOIDCProviderRecord(static), nil } } return rec, err } // 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") } records, err := m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress()) if err != nil { return nil, err } // A persistent store does not hold the config file's providers. seen := make(map[string]bool, len(records)) for _, rec := range records { seen[rec.ARN] = true } for arn, static := range m.staticOIDCProviders { if !seen[arn] { records = append(records, copyOIDCProviderRecord(static)) } } return records, nil } // mutableOIDCProvider loads a stored provider for a change. A provider only // the IAM config file defines is refused with ErrOIDCProviderStatic. func (m *IAMManager) mutableOIDCProvider(ctx context.Context, arn string) (*OIDCProviderRecord, error) { rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn) if errors.Is(err, ErrOIDCProviderNotFound) { if _, ok := m.staticOIDCProviders[arn]; ok { return nil, fmt.Errorf("%w: %s", ErrOIDCProviderStatic, arn) } } return rec, err } // 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 } if _, static := m.staticOIDCProviders[rec.ARN]; static { return fmt.Errorf("%w: %s", ErrOIDCProviderAlreadyExists, rec.ARN) } 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) m.emitOIDCAudit(ctx, OIDCAuditEventCreated, rec.ARN, rec.URL, nil) 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.mutableOIDCProvider(ctx, arn); errors.Is(err, ErrOIDCProviderStatic) { return err } if err := m.oidcProviderStore.DeleteProvider(ctx, m.getFilerAddress(), arn); err != nil { return err } m.refreshOIDCProvidersBestEffort(ctx, "DeleteOIDCProvider", arn) m.emitOIDCAudit(ctx, OIDCAuditEventDeleted, arn, "", nil) 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.mutableOIDCProvider(ctx, 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) m.emitOIDCAudit(ctx, OIDCAuditEventClientIDAdded, rec.ARN, rec.URL, map[string]string{"clientId": clientID}) 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.mutableOIDCProvider(ctx, 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) m.emitOIDCAudit(ctx, OIDCAuditEventClientIDRemoved, rec.ARN, rec.URL, map[string]string{"clientId": clientID}) 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.mutableOIDCProvider(ctx, 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) m.emitOIDCAudit(ctx, OIDCAuditEventThumbprintsSet, rec.ARN, rec.URL, map[string]string{"count": fmt.Sprintf("%d", len(thumbprints))}) 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.mutableOIDCProvider(ctx, 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() if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil { return err } m.emitOIDCAudit(ctx, OIDCAuditEventTagsAdded, rec.ARN, rec.URL, map[string]string{"count": fmt.Sprintf("%d", len(tags))}) return nil } // 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.mutableOIDCProvider(ctx, arn) if err != nil { return err } for _, k := range keys { delete(rec.Tags, k) } rec.UpdatedAt = time.Now().UTC() if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil { return err } m.emitOIDCAudit(ctx, OIDCAuditEventTagsRemoved, rec.ARN, rec.URL, map[string]string{"count": fmt.Sprintf("%d", len(keys))}) return nil } // 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 STS *sts.STSConfig `json:"sts"` // Policy engine configuration Policy *policy.PolicyEngineConfig `json:"policy"` // 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 type RoleStoreConfig struct { // StoreType specifies the role store backend (memory, filer, etc.) StoreType string `json:"storeType"` // StoreConfig contains store-specific configuration StoreConfig map[string]interface{} `json:"storeConfig,omitempty"` } // UserStore defines the interface for retrieving IAM user policy attachments. type UserStore interface { GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) } // RoleDefinition defines a role with its trust policy and attached policies type RoleDefinition struct { // RoleName is the name of the role RoleName string `json:"roleName"` // RoleArn is the full ARN of the role RoleArn string `json:"roleArn"` // TrustPolicy defines who can assume this role TrustPolicy *policy.PolicyDocument `json:"trustPolicy"` // AttachedPolicies lists the policy names attached to this role AttachedPolicies []string `json:"attachedPolicies"` // Description is an optional description of the role Description string `json:"description,omitempty"` // MaxSessionDuration is the upper bound (in seconds) on session length when // callers assume this role. Zero means "use the global STS default". When // set it must satisfy AWS bounds: 3600 ≤ MaxSessionDuration ≤ 43200. // Honoured by AssumeRole, AssumeRoleWithWebIdentity, AssumeRoleWithCredentials. MaxSessionDuration int64 `json:"maxSessionDuration,omitempty"` // Source records where the role came from. RoleSourceStaticConfig marks a // role loaded from the IAM config file; empty means it was created at // runtime. Only static-config roles are pruned when they leave the file. Source string `json:"source,omitempty"` // CreatedAt is when the role was created through the IAM API. Zero for // roles loaded from the config file. CreatedAt time.Time `json:"createdAt,omitempty"` // RoleId uniquely identifies this role, as AWS's RoleId does. A role // deleted and created again under the same name gets a new ID, and a // session is honoured only while the role it was issued for still has // the ID the session carries — so a session outlives neither the role's // deletion nor a later role that reuses its name. RoleId string `json:"roleId,omitempty"` } // NewRoleID returns a fresh, random role ID in AWS's AROA form. func NewRoleID() string { const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" b := make([]byte, 17) if _, err := rand.Read(b); err != nil { panic(fmt.Sprintf("read random role id: %v", err)) } for i := range b { b[i] = alphabet[int(b[i])%len(alphabet)] } return "AROA" + string(b) } // StaticRoleID is the ID of a role defined in the IAM config file. Such a // role is created again at every start, so its ID is derived rather than // random, to keep sessions valid across restarts. It is derived from the name // and the trust policy together: a role removed from the file and replaced by // a different one under the same name trusts different principals, and must // not inherit the old role's sessions. Restoring the same role restores its ID. func StaticRoleID(role *RoleDefinition) string { h := sha256.New() h.Write([]byte("static-role:" + role.RoleName + "\x00")) if role.TrustPolicy != nil { trust, err := json.Marshal(role.TrustPolicy) if err != nil { // A trust policy that cannot be encoded cannot be matched on // either; give the role an ID no session can carry. return NewRoleID() } h.Write(trust) } return "AROA" + strings.ToUpper(hex.EncodeToString(h.Sum(nil)))[:17] } // RoleSourceStaticConfig is the Source of a role loaded from the IAM config // file. const RoleSourceStaticConfig = "static-config" // checkSessionRoleBinding refuses a session issued for a role that has since // been deleted, or replaced by a role reusing its name: the role's current ID // must be the one the session carries. It runs for every session carrying a // role ID, whatever policies the session embeds — the policies a session // embeds are the ones its role had, and outlive the role otherwise. A session // issued before role IDs were recorded carries none and is not bound. It // returns the role it checked, nil for an unbound session. func (m *IAMManager) checkSessionRoleBinding(ctx context.Context, sessionInfo *sts.SessionInfo) (*RoleDefinition, error) { if sessionInfo == nil || sessionInfo.RoleId == "" { return nil, nil } roleName := utils.ExtractRoleNameFromArn(sessionInfo.RoleArn) if roleName == "" { return nil, nil } role, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName) if errors.Is(err, ErrRoleNotFound) { return nil, fmt.Errorf("session was issued for role %s, which no longer exists", roleName) } if err != nil { return nil, fmt.Errorf("resolve role %s for session: %w", roleName, err) } if role.RoleId != sessionInfo.RoleId { return nil, fmt.Errorf("session was issued for an earlier role named %s", roleName) } return role, nil } // ActionRequest represents a request to perform an action type ActionRequest struct { // Principal is the entity performing the action Principal string `json:"principal"` // Action is the action being requested Action string `json:"action"` // Resource is the resource being accessed Resource string `json:"resource"` // SessionToken for temporary credential validation SessionToken string `json:"sessionToken"` // RequestContext contains additional request information RequestContext map[string]interface{} `json:"requestContext,omitempty"` // PolicyNames to evaluate (overrides role-based policies if present) PolicyNames []string `json:"policyNames,omitempty"` } // NewIAMManager creates a new IAM manager func NewIAMManager() *IAMManager { return &IAMManager{} } // SetUserStore assigns the user store used to resolve IAM user policy attachments. func (m *IAMManager) SetUserStore(store UserStore) { m.userStore = store } // SyncRuntimePolicies keeps zero-config runtime policies available to the // in-memory policy engine used by the advanced IAM authorizer. func (m *IAMManager) SyncRuntimePolicies(ctx context.Context, policies []*iam_pb.Policy) error { if !m.initialized || m.policyEngine == nil { return nil } if m.policyEngine.StoreType() != sts.StoreTypeMemory { return nil } desiredPolicies := make(map[string]*policy.PolicyDocument, len(policies)) for _, runtimePolicy := range policies { if runtimePolicy == nil || runtimePolicy.Name == "" { continue } var document policy.PolicyDocument if err := json.Unmarshal([]byte(runtimePolicy.Content), &document); err != nil { // Drop just this one: aborting here would leave every other policy // unsynced. Leaving it out of desiredPolicies also deletes it from // the engine below, which is the point — a policy whose stored // definition no longer parses must stop granting access rather than // keep enforcing a document the operator can no longer see. glog.Warningf("skipping unparsable runtime policy %q: %v", runtimePolicy.Name, err) continue } desiredPolicies[runtimePolicy.Name] = &document } m.runtimePolicyMu.Lock() defer m.runtimePolicyMu.Unlock() filerAddress := m.getFilerAddress() for policyName := range m.runtimePolicyNames { if _, keep := desiredPolicies[policyName]; keep { continue } if err := m.policyEngine.DeletePolicy(ctx, filerAddress, policyName); err != nil { return fmt.Errorf("failed to delete runtime policy %q: %w", policyName, err) } } for policyName, document := range desiredPolicies { if err := m.policyEngine.AddPolicy(filerAddress, policyName, document); err != nil { return fmt.Errorf("failed to sync runtime policy %q: %w", policyName, err) } } m.runtimePolicyNames = make(map[string]struct{}, len(desiredPolicies)) for policyName := range desiredPolicies { m.runtimePolicyNames[policyName] = struct{}{} } return nil } // Initialize initializes the IAM manager with all components func (m *IAMManager) Initialize(config *IAMConfig, filerAddressProvider func() string) error { if config == nil { return fmt.Errorf("config cannot be nil") } // Store the filer address provider function m.filerAddressProvider = filerAddressProvider // Initialize STS service m.stsService = sts.NewSTSService() if err := m.stsService.Initialize(config.STS); err != nil { return fmt.Errorf("failed to initialize STS service: %w", err) } // CRITICAL SECURITY: Set trust policy validator to ensure proper role assumption validation m.stsService.SetTrustPolicyValidator(m) // Initialize policy engine m.policyEngine = policy.NewPolicyEngine() if err := m.policyEngine.InitializeWithProvider(config.Policy, m.filerAddressProvider); err != nil { return fmt.Errorf("failed to initialize policy engine: %w", err) } // Initialize role store roleStore, err := m.createRoleStoreWithProvider(config.Roles, m.filerAddressProvider) if err != nil { return fmt.Errorf("failed to initialize role store: %w", err) } 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.installOIDCProviderStore(store, config.STS) return nil } // installOIDCProviderStore makes store the manager's OIDC provider store. // // The IAM config file's providers are reported by the IAM API alongside the // stored ones. An in-memory store holds them as records, as it always has. A // persistent store never does: it outlives this process and may be shared by // S3 servers with different config files, so a record written from one file // would outlive its removal from that file and be trusted by every server. // Those providers stay in memory (staticOIDCProviders) and STS keeps serving // them from its static configuration; the store holds only providers created // through the IAM API, and those are loaded into STS here. // // A provider stored under the same ARN as a config-file provider takes // precedence, in the IAM API as in STS, which already prefers IAM-managed // providers so that an API call can shadow a bootstrap entry. Deleting the // stored provider brings the config-file one back. func (m *IAMManager) installOIDCProviderStore(store OIDCProviderStore, stsConfig *sts.STSConfig) { // Cancel the old store's retry and switch stores in one step, so a failed // refresh of the old store cannot start a retry after the cancel. m.oidcRetryMu.Lock() m.stopOIDCRetryLocked() m.oidcProviderStore = store m.oidcRetryMu.Unlock() m.staticOIDCProviders = staticOIDCProviderRecords(stsConfig) if _, inMemory := store.(*MemoryOIDCProviderStore); inMemory { ctx := context.Background() now := time.Now().UTC() for _, rec := range m.staticOIDCProviders { mirrored := copyOIDCProviderRecord(rec) mirrored.CreatedAt, mirrored.UpdatedAt = now, now if err := store.StoreProvider(ctx, m.getFilerAddress(), mirrored); err != nil { glog.Warningf("mirror static OIDC provider %s into store: %v", rec.ARN, err) } } // The store now holds them and the API may change them, as before; // the overlay is only for stores that must not hold them. m.staticOIDCProviders = nil return } // The metadata subscription only reports changes made from now on, so // providers already in the store would stay unknown until one changes; a // failed load is retried (RefreshOIDCProvidersFromStore). if err := m.RefreshOIDCProvidersFromStore(context.Background()); err != nil { glog.Warningf("load OIDC providers from the store at startup: %v; retrying in the background", err) } } // startOIDCRetry retries loading store in the background until it succeeds. // A store that is no longer current gets no retry: nothing would cancel it, // and its eventual success would replace the current store's providers. When // a retry is already running, it is asked to run once more instead, because // it may already have listed a snapshot older than this failure. func (m *IAMManager) startOIDCRetry(store OIDCProviderStore) { m.oidcRetryMu.Lock() defer m.oidcRetryMu.Unlock() if store != m.oidcProviderStore { return } if m.oidcRetryGen != 0 { m.oidcRetryAgain = true return } ctx, cancel := context.WithCancel(context.Background()) m.oidcRetrySeq++ gen := m.oidcRetrySeq m.cancelOIDCLoad, m.oidcRetryGen = cancel, gen bounds := oidcHydrateRetry // read here, not in the goroutine: it outlives its caller go func() { defer cancel() for { m.retryOIDCProviderLoad(ctx, store, bounds) m.oidcRetryMu.Lock() if m.oidcRetryGen != gen { m.oidcRetryMu.Unlock() return // cancelled: another store was installed } if m.oidcRetryAgain && ctx.Err() == nil { m.oidcRetryAgain = false m.oidcRetryMu.Unlock() continue } m.cancelOIDCLoad, m.oidcRetryGen, m.oidcRetryAgain = nil, 0, false m.oidcRetryMu.Unlock() return } }() } // stopOIDCRetryLocked cancels a running retry, as installing another store // must. The caller holds oidcRetryMu. func (m *IAMManager) stopOIDCRetryLocked() { if m.cancelOIDCLoad != nil { m.cancelOIDCLoad() } m.cancelOIDCLoad, m.oidcRetryGen, m.oidcRetryAgain = nil, 0, false } // currentOIDCProviderStore is the installed store, read under oidcRetryMu. func (m *IAMManager) currentOIDCProviderStore() OIDCProviderStore { m.oidcRetryMu.Lock() defer m.oidcRetryMu.Unlock() return m.oidcProviderStore } // staticOIDCProviderRecords describes the enabled OIDC providers of the IAM // config file as provider records. func staticOIDCProviderRecords(stsConfig *sts.STSConfig) map[string]*OIDCProviderRecord { out := map[string]*OIDCProviderRecord{} if stsConfig == nil { return out } for _, pc := range stsConfig.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", pc.Name) continue } arn, err := DeriveOIDCProviderARN(stsConfig.AccountId, issuer) if err != nil { glog.Warningf("derive ARN for static OIDC provider %s: %v", pc.Name, err) continue } out[arn] = &OIDCProviderRecord{ AccountID: stsConfig.AccountId, ARN: arn, URL: issuer, ClientIDs: extractClientIDs(pc.Config), Thumbprints: extractStringList(pc.Config, "thumbprints"), AllowedPrincipalTagKeys: extractStringList(pc.Config, "allowedPrincipalTagKeys"), PolicyClaim: extractString(pc.Config, "policyClaim"), // No CreatedAt: a config-file provider has no creation time the // server could report consistently across restarts. } } return out } // oidcHydrateRetry bounds the backoff between startup load attempts. var oidcHydrateRetry = struct{ initial, max time.Duration }{initial: time.Second, max: 30 * time.Second} // retryOIDCProviderLoad retries loading store, the store it was started for, // until it succeeds or ctx is cancelled because another store was installed. // It calls refreshOIDCProvidersFrom, not RefreshOIDCProvidersFromStore, so it // never schedules a retry of its own. func (m *IAMManager) retryOIDCProviderLoad(ctx context.Context, store OIDCProviderStore, bounds struct{ initial, max time.Duration }) { delay := bounds.initial for { select { case <-ctx.Done(): return case <-time.After(delay): } err := m.refreshOIDCProvidersFrom(ctx, store) if err == nil { glog.V(0).Infof("loaded OIDC providers from the store after retrying") return } glog.V(1).Infof("load OIDC providers from the store: %v; retrying in %v", err, delay) if delay *= 2; delay > bounds.max { delay = bounds.max } } } // 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. // // A refresh that fails keeps retrying in the background until the store // answers. Every caller needs that: a metadata-subscription event reports each // change ONCE, so a refresh that found the filer unreachable on it would leave // a peer's new provider untrusted, or a deleted one trusted, until an unrelated // later change; the refresh after a local IAM API mutation and the startup load // have the same shape. At most one retry runs. func (m *IAMManager) RefreshOIDCProvidersFromStore(ctx context.Context) error { store := m.currentOIDCProviderStore() err := m.refreshOIDCProvidersFrom(ctx, store) if err != nil && store != nil { m.startOIDCRetry(store) } return err } // refreshOIDCProvidersFrom is RefreshOIDCProvidersFromStore for a given store. func (m *IAMManager) refreshOIDCProvidersFrom(ctx context.Context, store OIDCProviderStore) error { if store == nil || m.stsService == nil { return nil } // Refreshes run concurrently: after an IAM API change, on a peer's change // and in the startup retry. Unserialized, a refresh that read the store // before a DeleteOIDCProvider could finish after that call's own refresh // and keep the deleted provider trusted. m.oidcRefreshMu.Lock() defer m.oidcRefreshMu.Unlock() records, err := store.ListProviders(ctx, m.getFilerAddress()) if err != nil { return fmt.Errorf("list OIDC providers: %w", err) } // A snapshot of a store that has since been replaced must not replace the // current store's providers: a retry is cancelled when another store is // installed, and a refresh may have listed the old store just before. if err := ctx.Err(); err != nil { return err } if store != m.currentOIDCProviderStore() { return fmt.Errorf("list OIDC providers: the store was replaced during the refresh") } byIssuer := make(map[string][]sts.ScopedOIDCProvider, len(records)) if m.oidcManagedProviders == nil { m.oidcManagedProviders = make(map[string]*oidcManagedProvider) } seen := make(map[string]struct{}, len(records)) // Records for one issuer with identical validation settings share a single // provider — and with it the consumed-token (jti) replay store — so a token // spent through one account's record stays spent through the other's too. sharedProviders := make(map[string]*oidc.OIDCProvider) for _, rec := range records { if rec == nil || rec.URL == "" { continue } key := rec.ARN if key == "" { key = rec.URL + "\x00" + rec.AccountID } seen[key] = struct{}{} fingerprint := oidcProviderFingerprint(rec) configFingerprint := oidcProviderConfigFingerprint(rec) var provider *oidc.OIDCProvider cached := m.oidcManagedProviders[key] if shared, ok := sharedProviders[configFingerprint]; ok { provider = shared if cached != nil && cached.provider != nil && cached.provider != shared { // The record's own provider is superseded; its consumed // tokens move to the shared store so none reopen for replay. shared.MergeReplayStateFrom(cached.provider) } m.oidcManagedProviders[key] = &oidcManagedProvider{fingerprint: fingerprint, provider: provider} if cached != nil && cached.provider != nil && cached.provider != shared && !m.oidcProviderStillReferenced(cached.provider) { if err := cached.provider.Shutdown(ctx); err != nil { glog.Warningf("shutdown superseded OIDC provider %s: %v", rec.ARN, err) } } } else if cached != nil && cached.fingerprint == fingerprint { provider = cached.provider sharedProviders[configFingerprint] = provider } else { var err error provider, err = buildOIDCProviderFromRecord(rec) if err != nil { delete(m.oidcManagedProviders, key) glog.Warningf("skip refreshing OIDC provider %s: %v", rec.ARN, err) continue } if cached != nil && cached.provider != nil { // The rebuild must keep rejecting tokens the replaced // provider consumed; carry its replay store over, then stop // its cleanup worker so only the new one sweeps the store — // unless another record still references that provider. provider.AdoptReplayStateFrom(cached.provider) } sharedProviders[configFingerprint] = provider m.oidcManagedProviders[key] = &oidcManagedProvider{fingerprint: fingerprint, provider: provider} if cached != nil && cached.provider != nil && !m.oidcProviderStillReferenced(cached.provider) { if err := cached.provider.Shutdown(ctx); err != nil { glog.Warningf("shutdown replaced OIDC provider %s: %v", rec.ARN, err) } } } // Multiple records may share an issuer when each is scoped to a // different account; STS picks the right one at validation time // based on the role being assumed. See lookupOIDCProviderForAccount. byIssuer[rec.URL] = append(byIssuer[rec.URL], sts.ScopedOIDCProvider{ AccountID: rec.AccountID, Provider: provider, }) } for key, managed := range m.oidcManagedProviders { if _, ok := seen[key]; !ok { delete(m.oidcManagedProviders, key) // A removed record's provider may be shared by records that are // still present; shut it down only when nothing references it. if managed.provider != nil && !m.oidcProviderStillReferenced(managed.provider) { if err := managed.provider.Shutdown(ctx); err != nil { glog.Warningf("shutdown removed OIDC provider %s: %v", key, err) } } } } m.stsService.SetIAMManagedOIDCProviders(byIssuer) return nil } // oidcManagedProvider is a runtime provider together with the fingerprint of // the record it was built from. type oidcManagedProvider struct { fingerprint string provider *oidc.OIDCProvider } // oidcProviderStillReferenced reports whether any managed record still points // at the provider, so a shared provider is not shut down with a removed record. func (m *IAMManager) oidcProviderStillReferenced(provider *oidc.OIDCProvider) bool { for _, managed := range m.oidcManagedProviders { if managed.provider == provider { return true } } return false } // oidcProviderFingerprint identifies the record content a provider was built // from, so a refresh rebuilds the provider only when that content changed. func oidcProviderFingerprint(rec *OIDCProviderRecord) string { key, _ := json.Marshal(struct { ARN string AccountID string URL string ClientIDs []string Thumbprints []string AllowedPrincipalTagKeys []string PolicyClaim string }{rec.ARN, rec.AccountID, rec.URL, rec.ClientIDs, rec.Thumbprints, rec.AllowedPrincipalTagKeys, rec.PolicyClaim}) sum := sha256.Sum256(key) return hex.EncodeToString(sum[:]) } // oidcProviderConfigFingerprint identifies the validation settings a provider // is built from — everything buildOIDCProviderFromRecord consumes except the // record's name — so records that differ only in identity reuse one provider. func oidcProviderConfigFingerprint(rec *OIDCProviderRecord) string { key, _ := json.Marshal(&oidc.OIDCConfig{ Issuer: rec.URL, ClientIDs: rec.ClientIDs, Thumbprints: rec.Thumbprints, AllowedPrincipalTagKeys: rec.AllowedPrincipalTagKeys, PolicyClaim: rec.PolicyClaim, }) sum := sha256.Sum256(key) return hex.EncodeToString(sum[:]) } // 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...), AllowedPrincipalTagKeys: append([]string(nil), rec.AllowedPrincipalTagKeys...), PolicyClaim: rec.PolicyClaim, } 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) { 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 } // extractStringList reads a JSON string array out of the provider's static // config map and returns the non-empty entries. Returns nil when the key is // missing, the value is the wrong shape, or every entry is empty. func extractStringList(cfg map[string]interface{}, key string) []string { if cfg == nil { return nil } list, ok := cfg[key].([]interface{}) if !ok { return nil } 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 nil } return out } // extractString reads a single string field from the provider's static // config map; missing or non-string values produce "". func extractString(cfg map[string]interface{}, key string) string { if cfg == nil { return "" } s, _ := cfg[key].(string) return s } // getFilerAddress returns the current filer address using the provider function func (m *IAMManager) getFilerAddress() string { if m.filerAddressProvider != nil { return m.filerAddressProvider() } return "" // Fallback to empty string if no provider is set } // createRoleStoreWithProvider creates a role store with a filer address provider function func (m *IAMManager) createRoleStoreWithProvider(config *RoleStoreConfig, filerAddressProvider func() string) (RoleStore, error) { if config == nil { // Default to generic cached filer role store when no config provided return NewGenericCachedRoleStore(nil, filerAddressProvider) } switch config.StoreType { case "", "filer": // Check if caching is explicitly disabled if config.StoreConfig != nil { if noCache, ok := config.StoreConfig["noCache"].(bool); ok && noCache { return NewFilerRoleStore(config.StoreConfig, filerAddressProvider) } } // Default to generic cached filer store for better performance return NewGenericCachedRoleStore(config.StoreConfig, filerAddressProvider) case "cached-filer", "generic-cached": return NewGenericCachedRoleStore(config.StoreConfig, filerAddressProvider) case "memory": return NewMemoryRoleStore(), nil default: return nil, fmt.Errorf("unsupported role store type: %s", config.StoreType) } } // RegisterIdentityProvider registers an identity provider func (m *IAMManager) RegisterIdentityProvider(provider providers.IdentityProvider) error { if !m.initialized { return fmt.Errorf("IAM manager not initialized") } return m.stsService.RegisterProvider(provider) } // CreatePolicy creates a new policy func (m *IAMManager) CreatePolicy(ctx context.Context, filerAddress string, name string, policyDoc *policy.PolicyDocument) error { if !m.initialized { return fmt.Errorf("IAM manager not initialized") } return m.policyEngine.AddPolicy(filerAddress, name, policyDoc) } // CreateRole creates a new role with trust policy and attached policies func (m *IAMManager) CreateRole(ctx context.Context, filerAddress string, roleName string, roleDef *RoleDefinition) error { if !m.initialized { return fmt.Errorf("IAM manager not initialized") } if err := PrepareRoleDefinition(roleName, roleDef); err != nil { return err } if roleDef.RoleId == "" { roleDef.RoleId = NewRoleID() } // Store role definition return m.roleStore.StoreRole(ctx, "", roleName, roleDef) } // UpdateRole changes a role atomically in the role store (see // RoleStore.UpdateRole). update receives the role's current definition, nil // when it does not exist, and its result is validated like CreateRole's; a // nil result deletes the role. // The IAM API's role actions use it, so a change made on one S3 server is // neither lost to a concurrent change on another nor written over a delete. func (m *IAMManager) UpdateRole(ctx context.Context, roleName string, update RoleUpdate) error { if !m.initialized { return fmt.Errorf("IAM manager not initialized") } return m.roleStore.UpdateRole(ctx, "", roleName, func(current *RoleDefinition) (*RoleDefinition, error) { next, err := update(current) if err != nil || next == nil { return next, err } if err := PrepareRoleDefinition(roleName, next); err != nil { return nil, err } return next, nil }) } // PrepareRoleDefinition applies CreateRole's defaults and validation to a role // about to be stored or loaded. func PrepareRoleDefinition(roleName string, roleDef *RoleDefinition) error { if roleName == "" { return fmt.Errorf("role name cannot be empty") } if roleDef == nil { return fmt.Errorf("role definition cannot be nil") } if roleDef.RoleName == "" { roleDef.RoleName = roleName } // Set role ARN if not provided if roleDef.RoleArn == "" { roleDef.RoleArn = fmt.Sprintf("arn:aws:iam::role/%s", roleName) } // Validate trust policy if roleDef.TrustPolicy != nil { if err := policy.ValidateTrustPolicyDocument(roleDef.TrustPolicy); err != nil { return fmt.Errorf("invalid trust policy: %w", err) } } // Validate per-role MaxSessionDuration if specified. AWS bounds: 1h..12h. if roleDef.MaxSessionDuration != 0 { if roleDef.MaxSessionDuration < 3600 || roleDef.MaxSessionDuration > 43200 { return fmt.Errorf("MaxSessionDuration must be between 3600 and 43200 seconds, got %d", roleDef.MaxSessionDuration) } } return nil } // LoadStaticRoles installs the roles of the IAM config file. // // An in-memory store holds them as records, as it always has. A persistent // store never does: it outlives this process and may be shared by S3 servers // with different config files, so a record written from one file would // outlive its removal from that file and be honoured by every server. Those // roles are served from memory instead, ahead of the store, and cannot be // changed or deleted through the store (ErrRoleStatic). A role stored under // the same name takes precedence. They report no creation time. func (m *IAMManager) LoadStaticRoles(ctx context.Context, roles []*RoleDefinition) { defs := make(map[string]*RoleDefinition, len(roles)) for _, role := range roles { if role == nil { continue } role.Source = RoleSourceStaticConfig if role.RoleId == "" { role.RoleId = StaticRoleID(role) } if err := PrepareRoleDefinition(role.RoleName, role); err != nil { glog.Warningf("Failed to load role %s: %v", role.RoleName, err) continue } defs[role.RoleName] = role } m.staticRoles = defs m.installRoleStore(ctx, m.roleStore) } // installRoleStore makes store the role store, with the config-file roles // installed in it as LoadStaticRoles describes, so a store set after startup // behaves like the one set at startup. func (m *IAMManager) installRoleStore(ctx context.Context, store RoleStore) { if overlay, ok := store.(*staticRoleOverlay); ok { store = overlay.inner } if store == nil || m.staticRoles == nil { m.roleStore = store return } if _, inMemory := store.(*MemoryRoleStore); inMemory { for name, role := range m.staticRoles { if err := store.StoreRole(ctx, "", name, role); err != nil { glog.Warningf("Failed to create role %s: %v", name, err) } } m.roleStore = store return } m.roleStore = &staticRoleOverlay{static: m.staticRoles, inner: store} } // GetRole retrieves a role definition by name. // ListRoles returns every stored role definition. func (m *IAMManager) ListRoles(ctx context.Context) ([]*RoleDefinition, error) { if !m.initialized { return nil, fmt.Errorf("IAM manager not initialized") } names, err := m.roleStore.ListRoles(ctx, m.getFilerAddress()) if err != nil { return nil, fmt.Errorf("list roles: %w", err) } roles := make([]*RoleDefinition, 0, len(names)) for _, name := range names { role, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), name) if errors.Is(err, ErrRoleNotFound) { continue // deleted between list and read } if err != nil { return nil, fmt.Errorf("get role %s: %w", name, err) } roles = append(roles, role) } return roles, nil } // DeleteRole removes a role definition. func (m *IAMManager) DeleteRole(ctx context.Context, roleName string) error { if !m.initialized { return fmt.Errorf("IAM manager not initialized") } if roleName == "" { return fmt.Errorf("role name cannot be empty") } return m.roleStore.DeleteRole(ctx, m.getFilerAddress(), roleName) } // InvalidateRoleCache drops any cached role definitions, so a change written // to the store by a peer is seen on the next lookup rather than after the // cache TTL. func (m *IAMManager) InvalidateRoleCache() { if cached, ok := m.roleStore.(interface{ ClearCache() }); ok { cached.ClearCache() } } // RoleStoreDirectory is the filer directory the role store keeps roles in, // its configured basePath; empty when the store is not filer-backed. S3 // servers watch it to drop cached roles when a peer changes one. func (m *IAMManager) RoleStoreDirectory() string { store := m.roleStore if overlay, ok := store.(*staticRoleOverlay); ok { store = overlay.inner } if cached, ok := store.(*GenericCachedRoleStore); ok { store = cached.adapter.store } if filerStore, ok := store.(*FilerRoleStore); ok { return filerStore.basePath } return "" } func (m *IAMManager) GetRole(ctx context.Context, roleName string) (*RoleDefinition, error) { if !m.initialized { return nil, fmt.Errorf("IAM manager not initialized") } if roleName == "" { return nil, fmt.Errorf("role name cannot be empty") } return m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName) } // UpdateBucketPolicy updates the policy for a bucket func (m *IAMManager) UpdateBucketPolicy(ctx context.Context, bucketName string, policyJSON []byte) error { if !m.initialized { return fmt.Errorf("IAM manager not initialized") } if bucketName == "" { return fmt.Errorf("bucket name cannot be empty") } // Parse the policy document handled by the IAM policy engine var policyDoc policy.PolicyDocument if err := json.Unmarshal(policyJSON, &policyDoc); err != nil { return fmt.Errorf("invalid policy JSON: %w", err) } // Store the policy with a special prefix to distinguish from IAM policies policyName := "bucket-policy:" + bucketName return m.policyEngine.AddPolicy(m.getFilerAddress(), policyName, &policyDoc) } // EnsureBucketPolicy stores the policy for a bucket only when no mirror is // stored yet, backfilling policies that predate the IAM integration (the // metadata subscription only sees changes). A present mirror is left alone, // so repeat calls cost one cached read. Returns whether a write happened, // so the caller can reconcile a write that raced a concurrent change. func (m *IAMManager) EnsureBucketPolicy(ctx context.Context, bucketName string, policyJSON []byte) (bool, error) { if !m.initialized { return false, fmt.Errorf("IAM manager not initialized") } if bucketName == "" { return false, fmt.Errorf("bucket name cannot be empty") } if existing, err := m.policyEngine.GetPolicy(ctx, m.getFilerAddress(), "bucket-policy:"+bucketName); err == nil && existing != nil { return false, nil } if err := m.UpdateBucketPolicy(ctx, bucketName, policyJSON); err != nil { return false, err } return true, nil } // RemoveBucketPolicy deletes the stored policy for a bucket. Removing a // policy that was never stored is a success. func (m *IAMManager) RemoveBucketPolicy(ctx context.Context, bucketName string) error { if !m.initialized { return fmt.Errorf("IAM manager not initialized") } if bucketName == "" { return fmt.Errorf("bucket name cannot be empty") } return m.policyEngine.DeletePolicy(ctx, m.getFilerAddress(), "bucket-policy:"+bucketName) } // AssumeRoleWithWebIdentity assumes a role using web identity (OIDC) func (m *IAMManager) AssumeRoleWithWebIdentity(ctx context.Context, request *sts.AssumeRoleWithWebIdentityRequest) (*sts.AssumeRoleResponse, error) { if !m.initialized { return nil, fmt.Errorf("IAM manager not initialized") } // Claim-based mode bypasses role lookup and trust policy entirely; the // STS service handles the policy resolution from the JWT itself. Account // scoping still applies but at the provider-resolution layer in Phase 3c // (we'll plug that in once a multi-account assume path lands). if sts.IsClaimBasedPolicyRoleArn(request.RoleArn) { return m.stsService.AssumeRoleWithWebIdentity(ctx, request) } // Extract role name from ARN roleName := utils.ExtractRoleNameFromArn(request.RoleArn) // Get role definition roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName) if err != nil { return nil, fmt.Errorf("role not found: %s", roleName) } // Account scoping: when the role lives in account A, the OIDC provider // validating the token must be either global (AccountID="") or also live // in account A. Skip when we can't resolve account context; fall through // to the existing trust-policy enforcement. if err := m.enforceProviderAccountScope(ctx, request); err != nil { return nil, err } // Validate trust policy before allowing STS to assume the role if err := m.validateTrustPolicyForWebIdentity(ctx, roleDef, request.WebIdentityToken, request.DurationSeconds); err != nil { return nil, fmt.Errorf("trust policy validation failed: %w", err) } // Apply role-level MaxSessionDuration cap. The STS service still applies // the global MaxSessionLength and the source-token-expiry cap on top of // this; per-role takes precedence whenever it is the tightest bound. request.DurationSeconds = capDurationByRole(request.DurationSeconds, roleDef.MaxSessionDuration, m.defaultTokenDurationSeconds(), m.maxSessionLengthSeconds()) request.RoleId = roleDef.RoleId // Use STS service to assume the role return m.stsService.AssumeRoleWithWebIdentity(ctx, request) } // enforceProviderAccountScope checks that the OIDC provider matching the // token's issuer is registered in either the role's account or as a global // (account-less) provider. Returns nil when no provider store is configured // (preserves the static-config-only path) or when the issuer is not known to // the store (the existing STS-layer issuer→provider map handles that case // during validation). func (m *IAMManager) enforceProviderAccountScope(ctx context.Context, request *sts.AssumeRoleWithWebIdentityRequest) error { if m.oidcProviderStore == nil { return nil } roleAccount := utils.ParseRoleARN(request.RoleArn).AccountID if roleAccount == "" { // Legacy ARN form (no account): nothing to enforce. return nil } issuer, err := extractIssuerFromJWT(request.WebIdentityToken) if err != nil { return nil // signature validation will reject, no need to fail twice } // Look for a provider that is either global or scoped to the role's // account. Multiple providers may share an issuer (e.g. one global plus // one per tenant), and GetProviderByIssuer returns the first match // arbitrarily — using it here would falsely reject a valid request // whenever the wrong record happens to come back first. if _, err := m.oidcProviderStore.GetProviderByIssuerAndAccount(ctx, m.getFilerAddress(), issuer, roleAccount); err == nil { return nil } // No allowed match. Distinguish "issuer entirely unknown" (let the STS // layer reject with the existing not-registered error) from "issuer is // registered but only in a different account" (surface a precise error // so the operator knows the call was cross-account). if other, err := m.oidcProviderStore.GetProviderByIssuer(ctx, m.getFilerAddress(), issuer); err == nil { return fmt.Errorf("OIDC provider for issuer %s is registered in account %s; cannot be used to assume a role in account %s", issuer, other.AccountID, roleAccount) } return nil } // extractIssuerFromJWT returns the iss claim of a JWT without verifying its // signature. Safe here because the caller still goes through full signature // + issuer validation in the STS service; this is purely for routing. func extractIssuerFromJWT(token string) (string, error) { parser := new(jwt.Parser) parsed, _, err := parser.ParseUnverified(token, jwt.MapClaims{}) if err != nil { return "", err } claims, ok := parsed.Claims.(jwt.MapClaims) if !ok { return "", fmt.Errorf("invalid claims") } iss, _ := claims["iss"].(string) if iss == "" { return "", fmt.Errorf("token has no iss claim") } return iss, nil } // capDurationByRole returns the session duration clamped to the role's // MaxSessionDuration. An omitted DurationSeconds resolves to the configured // default first, so the role bound caps defaults and explicit values alike. // A nil request is only materialized when something tightened the default // and the explicit value still passes the service's own input validation — // everything else is left nil so the service resolves the default and its // MaxSessionLength cap itself. func capDurationByRole(requested *int64, roleMax, defaultSec, serviceMaxSec int64) *int64 { if requested != nil { d := *requested if roleMax > 0 && d > roleMax { d = roleMax } return &d } d := defaultSec if roleMax > 0 && d > roleMax { d = roleMax } if d > serviceMaxSec { d = serviceMaxSec } if d < 900 && roleMax > 0 { d = 900 } if d >= defaultSec || d < 900 { return nil } return &d } func (m *IAMManager) defaultTokenDurationSeconds() int64 { if m.stsService == nil || m.stsService.Config == nil { return sts.DefaultTokenDuration } return int64(m.stsService.Config.TokenDuration.Duration / time.Second) } // maxSessionLengthSeconds mirrors validateSessionDurationSeconds so a // materialized default stays inside the bound the service will enforce. func (m *IAMManager) maxSessionLengthSeconds() int64 { maxSec := int64(sts.DefaultMaxSessionLength) if m.stsService != nil && m.stsService.Config != nil && m.stsService.Config.MaxSessionLength.Duration > 0 { if configured := int64(m.stsService.Config.MaxSessionLength.Duration / time.Second); configured >= 900 { maxSec = configured } } return maxSec } // AssumeRoleWithCredentials assumes a role using credentials (LDAP) func (m *IAMManager) AssumeRoleWithCredentials(ctx context.Context, request *sts.AssumeRoleWithCredentialsRequest) (*sts.AssumeRoleResponse, error) { if !m.initialized { return nil, fmt.Errorf("IAM manager not initialized") } // Extract role name from ARN roleName := utils.ExtractRoleNameFromArn(request.RoleArn) // Get role definition roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName) if err != nil { return nil, fmt.Errorf("role not found: %s", roleName) } // Validate trust policy if err := m.validateTrustPolicyForCredentials(ctx, roleDef, request); err != nil { return nil, fmt.Errorf("trust policy validation failed: %w", err) } // Apply role-level MaxSessionDuration cap. request.DurationSeconds = capDurationByRole(request.DurationSeconds, roleDef.MaxSessionDuration, m.defaultTokenDurationSeconds(), m.maxSessionLengthSeconds()) // Use STS service to assume the role request.RoleId = roleDef.RoleId return m.stsService.AssumeRoleWithCredentials(ctx, request) } // IsActionAllowed checks if a principal is allowed to perform an action on a resource func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest) (bool, error) { if !m.initialized { return false, fmt.Errorf("IAM manager not initialized") } // Validate session token if present // We always try to validate with the internal STS service first if it's a SeaweedFS token. // This ensures that session policies embedded in the token are correctly extracted and enforced. var sessionInfo *sts.SessionInfo // boundRole is the role a session carrying a role ID was checked against; // its policies are the ones evaluated, so the check and the evaluation // see one definition even if the role is replaced in between. var boundRole *RoleDefinition if request.SessionToken != "" { // Parse unverified to check issuer parsed, _, err := new(jwt.Parser).ParseUnverified(request.SessionToken, jwt.MapClaims{}) isInternal := false if err == nil { if claims, ok := parsed.Claims.(jwt.MapClaims); ok { if issuer, ok := claims["iss"].(string); ok && m.stsService != nil && m.stsService.Config != nil { if issuer == m.stsService.Config.Issuer { isInternal = true } } } } if isInternal || !isOIDCToken(request.SessionToken) { var err error sessionInfo, err = m.stsService.ValidateSessionToken(ctx, request.SessionToken) if err != nil { return false, fmt.Errorf("invalid session: %w", err) } // Reject sessions whose JTI has been added to the revocation // blocklist. SessionId == JTI for STS-issued tokens; this lookup // is hot, so the store implementation must be O(1). if sessionInfo != nil && sessionInfo.SessionId != "" { revoked, rerr := m.IsSessionRevoked(ctx, sessionInfo.SessionId) if rerr != nil { return false, fmt.Errorf("revocation check failed: %w", rerr) } if revoked { return false, fmt.Errorf("session has been revoked") } } if boundRole, err = m.checkSessionRoleBinding(ctx, sessionInfo); err != nil { return false, err } } } // Create evaluation context evalCtx := &policy.EvaluationContext{ Principal: request.Principal, Action: request.Action, Resource: request.Resource, RequestContext: request.RequestContext, } // Ensure RequestContext exists and populate with principal info if evalCtx.RequestContext == nil { evalCtx.RequestContext = make(map[string]interface{}) } // Add principal to context for policy matching // The PolicyEngine checks RequestContext["principal"] or RequestContext["aws:PrincipalArn"] evalCtx.RequestContext["principal"] = request.Principal evalCtx.RequestContext["aws:PrincipalArn"] = request.Principal // AWS standard key // Check if this is an admin request - bypass policy evaluation if so // This mirrors the logic in auth_signature_v4.go but applies it at authorization time isAdmin := false if request.RequestContext != nil { if val, ok := request.RequestContext["is_admin"].(bool); ok && val { isAdmin = true } // Print full request context for debugging } // Parse principal ARN to extract details for context variables (e.g. ${aws:username}) arnInfo := utils.ParsePrincipalARN(request.Principal) if arnInfo.RoleName != "" { // For assumed roles, AWS docs say aws:username IS the role name. // However, for user isolation in these tests, we typically map the session name (the user who assumed the role) to aws:username. // arn:aws:sts::account:assumed-role/RoleName/SessionName awsUsername := arnInfo.RoleName if idx := strings.LastIndex(request.Principal, "/"); idx != -1 && idx < len(request.Principal)-1 { awsUsername = request.Principal[idx+1:] } evalCtx.RequestContext["aws:username"] = awsUsername evalCtx.RequestContext["aws:userid"] = arnInfo.RoleName } else if userName := utils.ExtractUserNameFromPrincipal(request.Principal); userName != "" { evalCtx.RequestContext["aws:username"] = userName evalCtx.RequestContext["aws:userid"] = userName } if arnInfo.AccountID != "" { evalCtx.RequestContext["aws:PrincipalAccount"] = arnInfo.AccountID } // Determine if there is a bucket policy to evaluate var bucketPolicyName string if strings.HasPrefix(request.Resource, "arn:aws:s3:::") { resourcePath := request.Resource[13:] // remove "arn:aws:s3:::" parts := strings.SplitN(resourcePath, "/", 2) if len(parts) > 0 && parts[0] != "" { bucketPolicyName = "bucket-policy:" + parts[0] } } var baseResult *policy.EvaluationResult var err error // hasManagedSubject is true once we've resolved the principal to a registered // IAM user or role (or the caller has supplied PolicyNames directly). For a // managed subject, "no matching statement" must deny — the DefaultEffect=Allow // fallback is only meant for the unmanaged zero-config startup case. hasManagedSubject := false if isAdmin { // Admin always has base access allowed baseResult = &policy.EvaluationResult{Effect: policy.EffectAllow} } else { policies := request.PolicyNames if len(policies) > 0 { hasManagedSubject = true } if len(policies) == 0 { // Extract role name from principal ARN roleName := utils.ExtractRoleNameFromPrincipal(request.Principal) if roleName == "" { userName := utils.ExtractUserNameFromPrincipal(request.Principal) if userName == "" { return false, fmt.Errorf("could not extract role from principal: %s", request.Principal) } if m.userStore == nil { return false, fmt.Errorf("user store unavailable for principal: %s", request.Principal) } user, err := m.userStore.GetUser(ctx, userName) if err != nil || user == nil { return false, fmt.Errorf("user not found for principal: %s (user=%s)", request.Principal, userName) } hasManagedSubject = true policies = user.GetPolicyNames() } else { // Get role definition roleDef := boundRole if roleDef == nil || roleDef.RoleName != roleName { roleDef, err = m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName) if err != nil { return false, fmt.Errorf("role not found: %s", roleName) } } hasManagedSubject = true policies = roleDef.AttachedPolicies } } if bucketPolicyName != "" { // Enforce an upper bound on the number of policies to avoid excessive allocations if len(policies) >= maxPoliciesForEvaluation { return false, fmt.Errorf("too many policies for evaluation: %d >= %d", len(policies), maxPoliciesForEvaluation) } // Create a new slice to avoid modifying the original and append the bucket policy copied := make([]string, len(policies)) copy(copied, policies) policies = append(copied, bucketPolicyName) } baseResult, err = m.policyEngine.Evaluate(ctx, "", evalCtx, policies) if err != nil { return false, fmt.Errorf("policy evaluation failed: %w", err) } } // Base policy must allow; if it doesn't, deny immediately (session policy can only further restrict) if baseResult.Effect != policy.EffectAllow { return false, nil } // Zero-config IAM uses DefaultEffect=Allow to preserve open-by-default behavior // for requests without any subject policies. Once we resolve the principal to // a registered IAM user or role (or the caller hands us policy names), // "no matching statement" must fall back to deny — otherwise a freshly // created user with zero policies would inherit full access. if hasManagedSubject && len(baseResult.MatchingStatements) == 0 { return false, nil } // If there's a session policy, it must also allow the action if sessionInfo != nil && sessionInfo.SessionPolicy != "" { var sessionPolicy policy.PolicyDocument if err := json.Unmarshal([]byte(sessionInfo.SessionPolicy), &sessionPolicy); err != nil { return false, fmt.Errorf("invalid session policy JSON: %w", err) } if err := policy.ValidatePolicyDocument(&sessionPolicy); err != nil { return false, fmt.Errorf("invalid session policy document: %w", err) } sessionResult, err := m.policyEngine.EvaluatePolicyDocument(ctx, evalCtx, "session-policy", &sessionPolicy, policy.EffectDeny) if err != nil { return false, fmt.Errorf("session policy evaluation failed: %w", err) } if sessionResult.Effect != policy.EffectAllow { // Session policy does not allow this action return false, nil } } return true, nil } // IsPrincipalActionExplicitlyDenied reports whether the action on the resource is // explicitly denied for the principal by either the named policies or, for a // chained STS caller, the inline session policy carried by sessionToken. Unlike // IsActionAllowed it does not require an allow — the absence of a matching // statement is not a denial. Used to enforce AWS deny-always-wins when the allow // is granted elsewhere (e.g. a role trust policy for sts:AssumeRole). // // A chained session that fails validation or has been revoked yields an error so // callers fail closed. Raw OIDC tokens are skipped here — they are validated on // the JWT path, not by the STS service. func (m *IAMManager) IsPrincipalActionExplicitlyDenied(ctx context.Context, principal, action, resource string, policyNames []string, sessionToken string, requestContext map[string]interface{}) (bool, error) { if !m.initialized { return false, fmt.Errorf("IAM manager not initialized") } if requestContext == nil { requestContext = make(map[string]interface{}) } requestContext["principal"] = principal requestContext["aws:PrincipalArn"] = principal evalCtx := &policy.EvaluationContext{ Principal: principal, Action: action, Resource: resource, RequestContext: requestContext, } // Base policies: the caller's attached identity policies, or for a chained // caller the assumed role's attached policies. if len(policyNames) > 0 { result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policyNames) if err != nil { return false, fmt.Errorf("policy evaluation failed: %w", err) } if hasExplicitDeny(result.MatchingStatements) { return true, nil } } // A chained STS caller's session restricts what it may do. Skip raw OIDC // tokens (validated on the JWT path); for our own session tokens, reject a // revoked session and honor an explicit Deny in the inline session policy. if sessionToken != "" && m.stsService != nil && !isOIDCToken(sessionToken) { sessionInfo, err := m.stsService.ValidateSessionToken(ctx, sessionToken) if err != nil { return false, fmt.Errorf("session validation failed: %w", err) } if sessionInfo != nil && sessionInfo.SessionId != "" { revoked, rerr := m.IsSessionRevoked(ctx, sessionInfo.SessionId) if rerr != nil { return false, fmt.Errorf("revocation check failed: %w", rerr) } if revoked { return false, fmt.Errorf("session has been revoked") } } if sessionInfo != nil && sessionInfo.SessionPolicy != "" { var sessionPolicy policy.PolicyDocument if err := json.Unmarshal([]byte(sessionInfo.SessionPolicy), &sessionPolicy); err != nil { return false, fmt.Errorf("invalid session policy JSON: %w", err) } result, err := m.policyEngine.EvaluatePolicyDocument(ctx, evalCtx, "session-policy", &sessionPolicy, policy.EffectDeny) if err != nil { return false, fmt.Errorf("session policy evaluation failed: %w", err) } if hasExplicitDeny(result.MatchingStatements) { return true, nil } } } return false, nil } // hasExplicitDeny reports whether any matched statement is a Deny. func hasExplicitDeny(matches []policy.StatementMatch) bool { for _, stmt := range matches { if stmt.Effect == policy.EffectDeny { return true } } return false } // ValidateTrustPolicy validates if a principal can assume a role (for testing) func (m *IAMManager) ValidateTrustPolicy(ctx context.Context, roleArn, provider, userID string) bool { roleName := utils.ExtractRoleNameFromArn(roleArn) roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName) if err != nil { return false } // Simple validation based on provider in trust policy if roleDef.TrustPolicy != nil { for _, statement := range roleDef.TrustPolicy.Statement { if statement.Effect == "Allow" { if principal, ok := statement.Principal.(map[string]interface{}); ok { if federated, ok := principal["Federated"].(string); ok { // For OIDC, check against issuer URL if provider == "oidc" && federated == "test-oidc" { return true } // For LDAP, check against test-ldap if provider == "ldap" && federated == "test-ldap" { return true } // Also check for wildcard if federated == "*" { return true } } } } } } return false } // validateTrustPolicyForWebIdentity validates trust policy for OIDC assumption func (m *IAMManager) validateTrustPolicyForWebIdentity(ctx context.Context, roleDef *RoleDefinition, webIdentityToken string, durationSeconds *int64) error { if roleDef.TrustPolicy == nil { return fmt.Errorf("role has no trust policy") } // Create evaluation context for trust policy validation requestContext := make(map[string]interface{}) // Try to parse as JWT first, fallback to mock token handling tokenClaims, err := parseJWTTokenForTrustPolicy(webIdentityToken) if err != nil { // If JWT parsing fails, this might be a mock token (like "valid-oidc-token") // For mock tokens, we'll use default values that match the trust policy expectations requestContext["aws:FederatedProvider"] = "test-oidc" requestContext["oidc:iss"] = "test-oidc" // This ensures aws:userid key is populated even for mock tokens if needed requestContext["aws:userid"] = "mock-user" requestContext["oidc:sub"] = "mock-user" } else { // Add standard context values from JWT claims that trust policies might check // See: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html#condition-keys-web-identity-federation // The issuer is the federated provider for OIDC if iss, ok := tokenClaims["iss"].(string); ok { // Default to issuer URL requestContext["aws:FederatedProvider"] = iss requestContext["oidc:iss"] = iss // Try to resolve provider name from issuer for better policy matching // This allows policies to reference the provider name (e.g. "keycloak") instead of the full issuer URL if m.stsService != nil { for name, provider := range m.stsService.GetProviders() { if oidcProvider, ok := provider.(interface{ GetIssuer() string }); ok { confIssuer := oidcProvider.GetIssuer() if confIssuer == iss { requestContext["aws:FederatedProvider"] = name break } } } } } if sub, ok := tokenClaims["sub"].(string); ok { requestContext["oidc:sub"] = sub // Map subject to aws:userid as well for compatibility requestContext["aws:userid"] = sub } if aud, ok := tokenClaims["aud"].(string); ok { requestContext["oidc:aud"] = aud } // Custom claims can be prefixed if needed, but for "be 100% compatible with AWS", // we should rely on standard OIDC claims. // Add all other claims with oidc: prefix to support custom claims in trust policies // This enables checking claims like "oidc:roles", "oidc:groups", "oidc:email", etc. for k, v := range tokenClaims { // Skip claims we've already handled explicitly or shouldn't expose if k == "iss" || k == "sub" || k == "aud" { continue } // Add with oidc: prefix requestContext["oidc:"+k] = v } } // Add DurationSeconds to context if provided if durationSeconds != nil { requestContext["sts:DurationSeconds"] = *durationSeconds } // Create evaluation context for trust policy evalCtx := &policy.EvaluationContext{ Principal: "web-identity-user", // Placeholder principal for trust policy evaluation Action: "sts:AssumeRoleWithWebIdentity", Resource: roleDef.RoleArn, RequestContext: requestContext, } // Evaluate the trust policy directly if !m.evaluateTrustPolicy(roleDef.TrustPolicy, evalCtx) { return fmt.Errorf("trust policy denies web identity assumption") } return nil } // validateTrustPolicyForCredentials validates trust policy for credential assumption func (m *IAMManager) validateTrustPolicyForCredentials(ctx context.Context, roleDef *RoleDefinition, request *sts.AssumeRoleWithCredentialsRequest) error { if roleDef.TrustPolicy == nil { return fmt.Errorf("role has no trust policy") } // Check if trust policy allows credential assumption for the specific provider for _, statement := range roleDef.TrustPolicy.Statement { if statement.Effect == "Allow" { for _, action := range statement.Action { if action == "sts:AssumeRoleWithCredentials" { if principal, ok := statement.Principal.(map[string]interface{}); ok { if federated, ok := principal["Federated"].(string); ok { if federated == request.ProviderName { return nil // Allow } } } } } } } return fmt.Errorf("trust policy does not allow credential assumption for provider: %s", request.ProviderName) } // Helper functions // ExpireSessionForTesting manually expires a session for testing purposes func (m *IAMManager) ExpireSessionForTesting(ctx context.Context, sessionToken string) error { if !m.initialized { return fmt.Errorf("IAM manager not initialized") } return m.stsService.ExpireSessionForTesting(ctx, sessionToken) } // GetPoliciesForUser returns the policy names attached to an IAM user. // Returns an error if the user store is not configured or the lookup fails, // so callers can fail closed on policy-resolution failures. func (m *IAMManager) GetPoliciesForUser(ctx context.Context, username string) ([]string, error) { if m.userStore == nil { return nil, fmt.Errorf("user store not configured") } user, err := m.userStore.GetUser(ctx, username) if err != nil { return nil, fmt.Errorf("failed to look up user %q: %w", username, err) } if user == nil { return nil, nil } return user.PolicyNames, nil } // GetSTSService returns the STS service instance func (m *IAMManager) GetSTSService() *sts.STSService { return m.stsService } // DefaultAllow returns whether the default effect is Allow func (m *IAMManager) DefaultAllow() bool { if !m.initialized || m.policyEngine == nil { return true // Default to true if not initialized } return m.policyEngine.DefaultAllow() } // parseJWTTokenForTrustPolicy parses a JWT token to extract claims for trust policy evaluation func parseJWTTokenForTrustPolicy(tokenString string) (map[string]interface{}, error) { // Simple JWT parsing without verification (for trust policy context only) // In production, this should use proper JWT parsing with signature verification parts := strings.Split(tokenString, ".") if len(parts) != 3 { return nil, fmt.Errorf("invalid JWT format") } // Decode the payload (second part) payload := parts[1] // Add padding if needed for len(payload)%4 != 0 { payload += "=" } decoded, err := base64.URLEncoding.DecodeString(payload) if err != nil { return nil, fmt.Errorf("failed to decode JWT payload: %w", err) } var claims map[string]interface{} if err := json.Unmarshal(decoded, &claims); err != nil { return nil, fmt.Errorf("failed to unmarshal JWT claims: %w", err) } return claims, nil } // evaluateTrustPolicy evaluates a trust policy against the evaluation context // Now delegates to PolicyEngine for unified policy evaluation func (m *IAMManager) evaluateTrustPolicy(trustPolicy *policy.PolicyDocument, evalCtx *policy.EvaluationContext) bool { if trustPolicy == nil { return false } // Use the PolicyEngine to evaluate the trust policy // The PolicyEngine now handles Principal, Action, Resource, and Condition matching result, err := m.policyEngine.EvaluateTrustPolicy(context.Background(), trustPolicy, evalCtx) if err != nil { return false } return result.Effect == policy.EffectAllow } // evaluateTrustPolicyConditions and evaluatePrincipalValue have been removed // Trust policy evaluation is now handled entirely by PolicyEngine.EvaluateTrustPolicy() // isOIDCToken checks if a token is an OIDC JWT token (vs STS session token) func isOIDCToken(token string) bool { // JWT tokens have three parts separated by dots and start with base64-encoded JSON parts := strings.Split(token, ".") if len(parts) != 3 { return false } // JWT tokens typically start with "eyJ" (base64 encoded JSON starting with "{") if !strings.HasPrefix(token, "eyJ") { return false } parsed, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{}) if err != nil { return false } claims, ok := parsed.Claims.(jwt.MapClaims) if !ok { return false } if typ, ok := claims["typ"].(string); ok && typ == sts.TokenTypeSession { return false } if typ, ok := claims[sts.JWTClaimTokenType].(string); ok && typ == sts.TokenTypeSession { return false } return true } // TrustPolicyValidator interface implementation // These methods allow the IAMManager to serve as the trust policy validator for the STS service // ValidateTrustPolicyForWebIdentity implements the TrustPolicyValidator interface func (m *IAMManager) ValidateTrustPolicyForWebIdentity(ctx context.Context, roleArn string, webIdentityToken string, durationSeconds *int64) error { if !m.initialized { return fmt.Errorf("IAM manager not initialized") } // Extract role name from ARN roleName := utils.ExtractRoleNameFromArn(roleArn) // Get role definition roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName) if err != nil { return fmt.Errorf("role not found: %s", roleName) } // Use existing trust policy validation logic return m.validateTrustPolicyForWebIdentity(ctx, roleDef, webIdentityToken, durationSeconds) } // ValidateTrustPolicyForCredentials implements the TrustPolicyValidator interface func (m *IAMManager) ValidateTrustPolicyForCredentials(ctx context.Context, roleArn string, identity *providers.ExternalIdentity) error { if !m.initialized { return fmt.Errorf("IAM manager not initialized") } // Extract role name from ARN roleName := utils.ExtractRoleNameFromArn(roleArn) // Get role definition roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName) if err != nil { return fmt.Errorf("role not found: %s", roleName) } // For credentials, we need to create a mock request to reuse existing validation // This is a bit of a hack, but it allows us to reuse the existing logic mockRequest := &sts.AssumeRoleWithCredentialsRequest{ ProviderName: identity.Provider, // Use the provider name from the identity } // Use existing trust policy validation logic return m.validateTrustPolicyForCredentials(ctx, roleDef, mockRequest) } // PrepareOIDCProviderRecord builds and validates the record that // CreateOpenIDConnectProvider stores for an issuer, deriving its ARN from the // account ID and issuer URL. func PrepareOIDCProviderRecord(accountID, issuerURL string, clientIDs, thumbprints []string) (*OIDCProviderRecord, error) { arn, err := DeriveOIDCProviderARN(accountID, issuerURL) if err != nil { return nil, err } rec := &OIDCProviderRecord{ AccountID: accountID, ARN: arn, URL: issuerURL, ClientIDs: append([]string(nil), clientIDs...), Thumbprints: append([]string(nil), thumbprints...), } if err := validateOIDCProviderRecord(rec); err != nil { return nil, err } return rec, nil }