Files
seaweedfs/weed/iam/integration/iam_manager.go
T
Chris Lu 0002e5cc7f s3api: load document-style policies from the advanced IAM config (#10481)
* s3api: load document-style policies from the advanced IAM config

The advanced IAM file doubles as the S3 identity config when only
-s3.iam.config is given. protojson drops its "document" field, so every
policy landed with empty content and warned "skipping invalid policy" on
each reload. Worse, if the same file also declares identities the empty
content sticks in the policy map and fails the whole runtime policy sync
into the IAM manager, so policies created later never reach it.

* iam: skip an unparsable policy instead of failing the whole runtime sync

One policy the engine cannot parse aborted SyncRuntimePolicies before it
touched anything, so every other policy stayed unsynced and the engine
kept serving whatever it last held.

* s3api: reject a non-role RoleArn in AssumeRole as a bad request

arn:aws:iam:::user/name can never resolve to a role, but the handler ran
it through the trust-policy check and answered "not authorized to assume
role", pointing the caller at a permission problem they do not have.

* s3api: build the policy content before touching the entry

Deleting "document" up front meant a marshal failure left the policy with
neither field, so a later rewrite would emit it with no definition at all.

* iam: pin the fail-closed handling of an unparsable policy

Say in the comment that dropping it from the desired set deletes it from
the engine on purpose, and cover it with a test.

* s3api: widen the non-role RoleArn test to canonical ARN shapes

The reported ARN omits the account id; a user ARN that carries one, and a
non-principal ARN, must be rejected the same way.
2026-07-29 10:30:30 -07:00

1619 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 {
// 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.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)
}