Files
seaweedfs/weed/iam/integration/iam_manager.go
T
Chris Lu 3b9e196e5f sts: enforce session-policy explicit deny during role chaining (#10103)
* sts: enforce session-policy explicit deny during role chaining

A chained AssumeRole caller authenticates with an STS session token whose
inline session policy can explicitly deny sts:AssumeRole. The deny check only
evaluated the caller's named policies, so such a session could still chain into
any role its trust policy admits. Validate the session token in the deny check
and honor an explicit Deny in the inline session policy too.

* test(sts): integration coverage for AssumeRole authorization

Add an end-to-end AssumeRole authorization test (real weed mini + boto3):
a non-admin caller assumes a role its trust policy admits, an explicit
identity-side deny is blocked, and a session policy's explicit deny blocks
role chaining.

* sts: skip OIDC tokens and reject revoked sessions in the chaining deny check

Review follow-ups on the session-policy deny check:
- Guard session validation with !isOIDCToken so a bearer token our STS service
  cannot validate does not error into a false deny.
- Reject a revoked session before evaluating its policy, restoring the
  revocation enforcement the AssumeRole path lost when it stopped routing
  through IsActionAllowed.
2026-06-24 21:38:21 -07:00

1613 lines
58 KiB
Go

package integration
import (
"context"
"encoding/base64"
"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
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. 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())
}
// CreateOIDCProvider persists a new IAM-managed OIDC provider record. Refuses
// to overwrite an existing record so callers see EntityAlreadyExists semantics.
func (m *IAMManager) CreateOIDCProvider(ctx context.Context, rec *OIDCProviderRecord) error {
if m.oidcProviderStore == nil {
return fmt.Errorf("OIDC provider store not configured")
}
if rec == nil {
return fmt.Errorf("record cannot be nil")
}
if err := validateOIDCProviderRecord(rec); err != nil {
return err
}
existing, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), rec.ARN)
if err == nil && existing != nil {
return fmt.Errorf("%w: %s", ErrOIDCProviderAlreadyExists, rec.ARN)
}
if err != nil && !errors.Is(err, ErrOIDCProviderNotFound) {
return fmt.Errorf("lookup existing OIDC provider %q: %w", rec.ARN, err)
}
now := time.Now().UTC()
rec.CreatedAt = now
rec.UpdatedAt = now
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
return err
}
m.refreshOIDCProvidersBestEffort(ctx, "CreateOIDCProvider", rec.ARN)
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.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.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
if err != nil {
return err
}
for _, existing := range rec.ClientIDs {
if existing == clientID {
return nil // idempotent
}
}
if len(rec.ClientIDs) >= 100 {
return fmt.Errorf("ClientIDList must contain at most 100 entries")
}
rec.ClientIDs = append(rec.ClientIDs, clientID)
rec.UpdatedAt = time.Now().UTC()
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
return err
}
m.refreshOIDCProvidersBestEffort(ctx, "AddClientIDToOIDCProvider", arn)
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.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
if err != nil {
return err
}
pruned := make([]string, 0, len(rec.ClientIDs))
for _, existing := range rec.ClientIDs {
if existing != clientID {
pruned = append(pruned, existing)
}
}
if len(pruned) == len(rec.ClientIDs) {
return nil // not present; no-op
}
rec.ClientIDs = pruned
rec.UpdatedAt = time.Now().UTC()
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
return err
}
m.refreshOIDCProvidersBestEffort(ctx, "RemoveClientIDFromOIDCProvider", arn)
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.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
if err != nil {
return err
}
rec.Thumbprints = append([]string(nil), thumbprints...)
rec.UpdatedAt = time.Now().UTC()
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
return err
}
m.refreshOIDCProvidersBestEffort(ctx, "UpdateOIDCProviderThumbprints", arn)
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.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
if err != nil {
return err
}
if rec.Tags == nil {
rec.Tags = make(map[string]string, len(tags))
}
for k, v := range tags {
rec.Tags[k] = v
}
rec.UpdatedAt = time.Now().UTC()
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.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), 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"`
}
// 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 {
return fmt.Errorf("failed to parse runtime policy %q: %w", runtimePolicy.Name, err)
}
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.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,
Thumbprints: extractStringList(pc.Config, "thumbprints"),
AllowedPrincipalTagKeys: extractStringList(pc.Config, "allowedPrincipalTagKeys"),
PolicyClaim: extractString(pc.Config, "policyClaim"),
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
}
// refreshOIDCProvidersBestEffort calls RefreshOIDCProvidersFromStore and
// logs a warning on failure. The IAM API call has already succeeded by the
// time we get here, so a refresh failure must not surface to the caller —
// the worst case is that the local instance keeps the stale runtime view
// until a peer's metadata-subscribe event triggers another refresh.
func (m *IAMManager) refreshOIDCProvidersBestEffort(ctx context.Context, op, arn string) {
if err := m.RefreshOIDCProvidersFromStore(ctx); err != nil {
glog.Warningf("refresh OIDC providers after %s on %s: %v", op, arn, err)
}
}
// RefreshOIDCProvidersFromStore reloads every OIDCProviderRecord from the
// configured store and pushes the resulting runtime providers into the STS
// service so AssumeRoleWithWebIdentity sees the latest set without a
// restart. Safe to call when no store is configured (returns nil) and when
// the store is empty (clears the IAM-managed map). Records with empty URLs
// or invalid configuration are logged and skipped so a single bad entry
// does not stop the rest from refreshing.
func (m *IAMManager) RefreshOIDCProvidersFromStore(ctx context.Context) error {
if m.oidcProviderStore == nil || m.stsService == nil {
return nil
}
records, err := m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress())
if err != nil {
return fmt.Errorf("list OIDC providers: %w", err)
}
byIssuer := make(map[string][]sts.ScopedOIDCProvider, len(records))
for _, rec := range records {
if rec == nil || rec.URL == "" {
continue
}
provider, err := buildOIDCProviderFromRecord(rec)
if err != nil {
glog.Warningf("skip refreshing OIDC provider %s: %v", rec.ARN, err)
continue
}
// 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,
})
}
m.stsService.SetIAMManagedOIDCProviders(byIssuer)
return nil
}
// buildOIDCProviderFromRecord turns a stored record into a runtime
// OIDCProvider. The provider name is the ARN so re-registration is
// idempotent and never collides with static-config entries.
func buildOIDCProviderFromRecord(rec *OIDCProviderRecord) (*oidc.OIDCProvider, error) {
if rec == nil {
return nil, fmt.Errorf("record cannot be nil")
}
cfg := &oidc.OIDCConfig{
Issuer: rec.URL,
ClientIDs: append([]string(nil), rec.ClientIDs...),
Thumbprints: append([]string(nil), rec.Thumbprints...),
}
provider := oidc.NewOIDCProvider(rec.ARN)
if err := provider.Initialize(cfg); err != nil {
return nil, err
}
return provider, nil
}
// createOIDCProviderStore selects an OIDCProviderStore implementation. Defaults
// to memory; "filer" requires a filerAddressProvider to be configured.
func (m *IAMManager) createOIDCProviderStore(cfg *OIDCProviderStoreConfig) (OIDCProviderStore, error) {
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 roleName == "" {
return fmt.Errorf("role name cannot be empty")
}
if roleDef == nil {
return fmt.Errorf("role definition cannot be nil")
}
// 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)
}
}
// Store role definition
return m.roleStore.StoreRole(ctx, "", roleName, roleDef)
}
// GetRole retrieves a role definition by name.
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)
}
// 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)
// 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 requested duration clamped to the role's
// MaxSessionDuration. A nil requested duration is left nil so the STS
// service's calculateSessionDuration applies the global default (typically
// 1 hour) — substituting the role's max here would silently mint a 12h
// session for any caller who omitted DurationSeconds, which AWS does not
// do. The role-max upper bound still applies in the downstream cap chain
// once the request has a concrete duration.
func capDurationByRole(requested *int64, roleMax int64) *int64 {
if roleMax <= 0 || requested == nil {
return requested
}
if *requested > roleMax {
v := roleMax
return &v
}
return requested
}
// 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)
// Use STS service to assume the role
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
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")
}
}
}
}
// 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, 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)
}