mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-28 16:00:37 +01:00
* s3/iam: retry a failed OIDC provider refresh until the store answers RefreshOIDCProvidersFromStore reports a failure and nothing retries it. Its callers can't: a metadata-subscription event reports each change once, so a refresh that found the filer unreachable on it (the filer restarting, say) left a peer's new provider untrusted, or a deleted one trusted, until some unrelated later change. The refresh after a local IAM API mutation has the same shape. Only the startup load retried. A failed refresh now retries in the background with the startup load's backoff until the store answers. At most one retry runs, however many refreshes fail meanwhile, and installing another store cancels it. The startup load uses the same path instead of its own. Seen on a SeaweedFS operator cluster whose filer restarted while an S3OIDCProvider was created: the gateway logged "OIDC provider refresh after /etc/iam/oidc-providers change failed: ... fail to dial". The operator's periodic re-apply happened to recover it; an IAM API client would not. * s3/iam: never retry or apply a superseded OIDC provider store, and never drop a failure during a retry Review of the retry (#11521) found two ways to lose the state it protects. A refresh of store A that failed as store B was installed could start a retry for A after B's install had cancelled retries. Nothing cancelled it, and when A answered it replaced B's providers in STS. The installed store now changes under the retry lock, a store that is no longer current gets no retry, and a snapshot of a replaced store is never handed to STS, even when the refresh listed it just before the swap. A refresh that failed while a retry ran was dropped by the at-most-one guard, though the retry might already have listed an older snapshot, so the change the failed refresh would have loaded stayed unloaded. The retry now runs once more after its success when a failure arrived meanwhile. Each has a test that fails without its guard.
1897 lines
68 KiB
Go
1897 lines
68 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
|
|
// staticOIDCProviders are the OIDC providers of this server's IAM config
|
|
// file, by ARN. With an in-memory store they are also written to the
|
|
// store; a persistent store never holds them (see installOIDCProviderStore).
|
|
staticOIDCProviders map[string]*OIDCProviderRecord
|
|
// oidcRetryMu guards the background refresh retry and which store is
|
|
// current: cancelOIDCLoad stops the retry, oidcRetryGen names the one
|
|
// running (0 when none) so at most one runs, and oidcRetryAgain records a
|
|
// refresh that failed while it ran, so the retry runs once more.
|
|
oidcRetryMu sync.Mutex
|
|
cancelOIDCLoad context.CancelFunc
|
|
oidcRetryGen uint64
|
|
oidcRetrySeq uint64
|
|
oidcRetryAgain bool
|
|
// oidcRefreshMu serializes refreshes from reading the store to handing
|
|
// STS the result, so an older snapshot cannot replace a newer one.
|
|
oidcRefreshMu sync.Mutex
|
|
oidcAuditSink OIDCProviderAuditSink
|
|
revocationStore SessionRevocationStore
|
|
filerAddressProvider func() string // Function to get current filer address
|
|
initialized bool
|
|
runtimePolicyMu sync.Mutex
|
|
runtimePolicyNames map[string]struct{}
|
|
}
|
|
|
|
// SetOIDCProviderAuditSink configures the lifecycle event sink. When nil
|
|
// (default), GlogAuditSink is used so events still surface in logs.
|
|
func (m *IAMManager) SetOIDCProviderAuditSink(sink OIDCProviderAuditSink) {
|
|
m.oidcAuditSink = sink
|
|
}
|
|
|
|
// emitOIDCAudit logs a lifecycle event. Errors are swallowed: an audit
|
|
// failure must never block an IAM mutation that has already succeeded.
|
|
func (m *IAMManager) emitOIDCAudit(ctx context.Context, eventType OIDCProviderAuditEventType, arn, url string, detail map[string]string) {
|
|
sink := m.oidcAuditSink
|
|
if sink == nil {
|
|
sink = GlogAuditSink{}
|
|
}
|
|
event := &OIDCProviderAuditEvent{
|
|
Type: eventType,
|
|
ARN: arn,
|
|
URL: url,
|
|
Detail: detail,
|
|
OccurredAt: time.Now().UTC(),
|
|
}
|
|
if err := sink.Emit(ctx, event); err != nil {
|
|
glog.Warningf("OIDC audit emit %s for %s: %v", eventType, arn, err)
|
|
}
|
|
}
|
|
|
|
// SetSessionRevocationStore configures the per-session revocation list. When
|
|
// nil, RevokeSession returns an error and IsSessionRevoked is a no-op (every
|
|
// session is considered live until natural expiry). Operators who want
|
|
// revocation must wire a store explicitly.
|
|
func (m *IAMManager) SetSessionRevocationStore(store SessionRevocationStore) {
|
|
m.revocationStore = store
|
|
}
|
|
|
|
// RevokeSession marks a session as revoked using its JTI (which equals the
|
|
// session ID for STS-issued tokens).
|
|
func (m *IAMManager) RevokeSession(ctx context.Context, jti string, expiresAt time.Time, reason string) error {
|
|
if m.revocationStore == nil {
|
|
return fmt.Errorf("session revocation store not configured")
|
|
}
|
|
if jti == "" {
|
|
return fmt.Errorf("jti cannot be empty")
|
|
}
|
|
return m.revocationStore.Revoke(ctx, m.getFilerAddress(), &RevocationEntry{
|
|
JTI: jti,
|
|
ExpiresAt: expiresAt,
|
|
Reason: reason,
|
|
})
|
|
}
|
|
|
|
// IsSessionRevoked returns true if the given JTI has been revoked. Returns
|
|
// false (with nil error) when no revocation store is configured.
|
|
func (m *IAMManager) IsSessionRevoked(ctx context.Context, jti string) (bool, error) {
|
|
if m.revocationStore == nil || jti == "" {
|
|
return false, nil
|
|
}
|
|
return m.revocationStore.IsRevoked(ctx, m.getFilerAddress(), jti)
|
|
}
|
|
|
|
// PurgeRevokedSessions removes revocation entries whose underlying session
|
|
// has already expired. Safe to call on a cron schedule.
|
|
func (m *IAMManager) PurgeRevokedSessions(ctx context.Context) (int, error) {
|
|
if m.revocationStore == nil {
|
|
return 0, nil
|
|
}
|
|
return m.revocationStore.Purge(ctx, m.getFilerAddress(), time.Now().UTC())
|
|
}
|
|
|
|
// SetOIDCProviderStore configures the IAM-managed OIDC provider store. When
|
|
// nil, OIDC provider IAM actions return ServiceNotReady.
|
|
func (m *IAMManager) SetOIDCProviderStore(store OIDCProviderStore) {
|
|
var stsConfig *sts.STSConfig
|
|
if m.stsService != nil {
|
|
stsConfig = m.stsService.Config
|
|
}
|
|
m.installOIDCProviderStore(store, stsConfig)
|
|
}
|
|
|
|
// GetOIDCProviderStore returns the configured store (may be nil).
|
|
func (m *IAMManager) GetOIDCProviderStore() OIDCProviderStore {
|
|
return m.oidcProviderStore
|
|
}
|
|
|
|
// GetOIDCProvider returns the record for the given ARN, or an error if the
|
|
// store is not configured or the record is missing.
|
|
func (m *IAMManager) GetOIDCProvider(ctx context.Context, arn string) (*OIDCProviderRecord, error) {
|
|
if m.oidcProviderStore == nil {
|
|
return nil, fmt.Errorf("OIDC provider store not configured")
|
|
}
|
|
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
|
|
if errors.Is(err, ErrOIDCProviderNotFound) {
|
|
if static, ok := m.staticOIDCProviders[arn]; ok {
|
|
return copyOIDCProviderRecord(static), nil
|
|
}
|
|
}
|
|
return rec, err
|
|
}
|
|
|
|
// ListOIDCProviders enumerates all configured OIDC providers.
|
|
func (m *IAMManager) ListOIDCProviders(ctx context.Context) ([]*OIDCProviderRecord, error) {
|
|
if m.oidcProviderStore == nil {
|
|
return nil, fmt.Errorf("OIDC provider store not configured")
|
|
}
|
|
records, err := m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// A persistent store does not hold the config file's providers.
|
|
seen := make(map[string]bool, len(records))
|
|
for _, rec := range records {
|
|
seen[rec.ARN] = true
|
|
}
|
|
for arn, static := range m.staticOIDCProviders {
|
|
if !seen[arn] {
|
|
records = append(records, copyOIDCProviderRecord(static))
|
|
}
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
// mutableOIDCProvider loads a stored provider for a change. A provider only
|
|
// the IAM config file defines is refused with ErrOIDCProviderStatic.
|
|
func (m *IAMManager) mutableOIDCProvider(ctx context.Context, arn string) (*OIDCProviderRecord, error) {
|
|
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
|
|
if errors.Is(err, ErrOIDCProviderNotFound) {
|
|
if _, ok := m.staticOIDCProviders[arn]; ok {
|
|
return nil, fmt.Errorf("%w: %s", ErrOIDCProviderStatic, arn)
|
|
}
|
|
}
|
|
return rec, err
|
|
}
|
|
|
|
// CreateOIDCProvider persists a new IAM-managed OIDC provider record. Refuses
|
|
// to overwrite an existing record so callers see EntityAlreadyExists semantics.
|
|
func (m *IAMManager) CreateOIDCProvider(ctx context.Context, rec *OIDCProviderRecord) error {
|
|
if m.oidcProviderStore == nil {
|
|
return fmt.Errorf("OIDC provider store not configured")
|
|
}
|
|
if rec == nil {
|
|
return fmt.Errorf("record cannot be nil")
|
|
}
|
|
if err := validateOIDCProviderRecord(rec); err != nil {
|
|
return err
|
|
}
|
|
if _, static := m.staticOIDCProviders[rec.ARN]; static {
|
|
return fmt.Errorf("%w: %s", ErrOIDCProviderAlreadyExists, rec.ARN)
|
|
}
|
|
existing, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), rec.ARN)
|
|
if err == nil && existing != nil {
|
|
return fmt.Errorf("%w: %s", ErrOIDCProviderAlreadyExists, rec.ARN)
|
|
}
|
|
if err != nil && !errors.Is(err, ErrOIDCProviderNotFound) {
|
|
return fmt.Errorf("lookup existing OIDC provider %q: %w", rec.ARN, err)
|
|
}
|
|
now := time.Now().UTC()
|
|
rec.CreatedAt = now
|
|
rec.UpdatedAt = now
|
|
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
|
return err
|
|
}
|
|
m.refreshOIDCProvidersBestEffort(ctx, "CreateOIDCProvider", rec.ARN)
|
|
m.emitOIDCAudit(ctx, OIDCAuditEventCreated, rec.ARN, rec.URL, nil)
|
|
return nil
|
|
}
|
|
|
|
// DeleteOIDCProvider removes the IAM-managed record. Idempotent.
|
|
func (m *IAMManager) DeleteOIDCProvider(ctx context.Context, arn string) error {
|
|
if m.oidcProviderStore == nil {
|
|
return fmt.Errorf("OIDC provider store not configured")
|
|
}
|
|
if _, err := m.mutableOIDCProvider(ctx, arn); errors.Is(err, ErrOIDCProviderStatic) {
|
|
return err
|
|
}
|
|
if err := m.oidcProviderStore.DeleteProvider(ctx, m.getFilerAddress(), arn); err != nil {
|
|
return err
|
|
}
|
|
m.refreshOIDCProvidersBestEffort(ctx, "DeleteOIDCProvider", arn)
|
|
m.emitOIDCAudit(ctx, OIDCAuditEventDeleted, arn, "", nil)
|
|
return nil
|
|
}
|
|
|
|
// AddClientIDToOIDCProvider appends `clientID` to the provider's allowed
|
|
// audience list. Adding an existing client ID is a no-op (AWS-compat).
|
|
func (m *IAMManager) AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error {
|
|
if m.oidcProviderStore == nil {
|
|
return fmt.Errorf("OIDC provider store not configured")
|
|
}
|
|
if clientID == "" {
|
|
return fmt.Errorf("ClientID cannot be empty")
|
|
}
|
|
rec, err := m.mutableOIDCProvider(ctx, arn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, existing := range rec.ClientIDs {
|
|
if existing == clientID {
|
|
return nil // idempotent
|
|
}
|
|
}
|
|
if len(rec.ClientIDs) >= 100 {
|
|
return fmt.Errorf("ClientIDList must contain at most 100 entries")
|
|
}
|
|
rec.ClientIDs = append(rec.ClientIDs, clientID)
|
|
rec.UpdatedAt = time.Now().UTC()
|
|
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
|
return err
|
|
}
|
|
m.refreshOIDCProvidersBestEffort(ctx, "AddClientIDToOIDCProvider", arn)
|
|
m.emitOIDCAudit(ctx, OIDCAuditEventClientIDAdded, rec.ARN, rec.URL, map[string]string{"clientId": clientID})
|
|
return nil
|
|
}
|
|
|
|
// RemoveClientIDFromOIDCProvider drops `clientID` from the allowed audience
|
|
// list. Removing a missing client ID is a no-op.
|
|
func (m *IAMManager) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error {
|
|
if m.oidcProviderStore == nil {
|
|
return fmt.Errorf("OIDC provider store not configured")
|
|
}
|
|
rec, err := m.mutableOIDCProvider(ctx, arn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pruned := make([]string, 0, len(rec.ClientIDs))
|
|
for _, existing := range rec.ClientIDs {
|
|
if existing != clientID {
|
|
pruned = append(pruned, existing)
|
|
}
|
|
}
|
|
if len(pruned) == len(rec.ClientIDs) {
|
|
return nil // not present; no-op
|
|
}
|
|
rec.ClientIDs = pruned
|
|
rec.UpdatedAt = time.Now().UTC()
|
|
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
|
return err
|
|
}
|
|
m.refreshOIDCProvidersBestEffort(ctx, "RemoveClientIDFromOIDCProvider", arn)
|
|
m.emitOIDCAudit(ctx, OIDCAuditEventClientIDRemoved, rec.ARN, rec.URL, map[string]string{"clientId": clientID})
|
|
return nil
|
|
}
|
|
|
|
// UpdateOIDCProviderThumbprints replaces the entire thumbprint list. AWS
|
|
// constrains the list to 1..5 entries when non-empty; we mirror that bound.
|
|
func (m *IAMManager) UpdateOIDCProviderThumbprints(ctx context.Context, arn string, thumbprints []string) error {
|
|
if m.oidcProviderStore == nil {
|
|
return fmt.Errorf("OIDC provider store not configured")
|
|
}
|
|
if len(thumbprints) > 5 {
|
|
return fmt.Errorf("ThumbprintList must contain at most 5 entries, got %d", len(thumbprints))
|
|
}
|
|
for _, tp := range thumbprints {
|
|
if !isValidSHA1Thumbprint(tp) {
|
|
return fmt.Errorf("invalid thumbprint %q: must be 40-character SHA-1 hex", tp)
|
|
}
|
|
}
|
|
rec, err := m.mutableOIDCProvider(ctx, arn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rec.Thumbprints = append([]string(nil), thumbprints...)
|
|
rec.UpdatedAt = time.Now().UTC()
|
|
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
|
return err
|
|
}
|
|
m.refreshOIDCProvidersBestEffort(ctx, "UpdateOIDCProviderThumbprints", arn)
|
|
m.emitOIDCAudit(ctx, OIDCAuditEventThumbprintsSet, rec.ARN, rec.URL, map[string]string{"count": fmt.Sprintf("%d", len(thumbprints))})
|
|
return nil
|
|
}
|
|
|
|
// TagOIDCProvider merges the supplied tags into the provider's tag set.
|
|
func (m *IAMManager) TagOIDCProvider(ctx context.Context, arn string, tags map[string]string) error {
|
|
if m.oidcProviderStore == nil {
|
|
return fmt.Errorf("OIDC provider store not configured")
|
|
}
|
|
rec, err := m.mutableOIDCProvider(ctx, arn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rec.Tags == nil {
|
|
rec.Tags = make(map[string]string, len(tags))
|
|
}
|
|
for k, v := range tags {
|
|
rec.Tags[k] = v
|
|
}
|
|
rec.UpdatedAt = time.Now().UTC()
|
|
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
|
return err
|
|
}
|
|
m.emitOIDCAudit(ctx, OIDCAuditEventTagsAdded, rec.ARN, rec.URL, map[string]string{"count": fmt.Sprintf("%d", len(tags))})
|
|
return nil
|
|
}
|
|
|
|
// UntagOIDCProvider removes the named tags from the provider's tag set.
|
|
func (m *IAMManager) UntagOIDCProvider(ctx context.Context, arn string, keys []string) error {
|
|
if m.oidcProviderStore == nil {
|
|
return fmt.Errorf("OIDC provider store not configured")
|
|
}
|
|
rec, err := m.mutableOIDCProvider(ctx, arn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, k := range keys {
|
|
delete(rec.Tags, k)
|
|
}
|
|
rec.UpdatedAt = time.Now().UTC()
|
|
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
|
return err
|
|
}
|
|
m.emitOIDCAudit(ctx, OIDCAuditEventTagsRemoved, rec.ARN, rec.URL, map[string]string{"count": fmt.Sprintf("%d", len(keys))})
|
|
return nil
|
|
}
|
|
|
|
// validateOIDCProviderRecord enforces the invariants AWS imposes on the
|
|
// underlying CreateOpenIDConnectProvider call.
|
|
func validateOIDCProviderRecord(rec *OIDCProviderRecord) error {
|
|
if rec.URL == "" {
|
|
return fmt.Errorf("Url is required")
|
|
}
|
|
if rec.ARN == "" {
|
|
return fmt.Errorf("ARN is required")
|
|
}
|
|
if len(rec.ClientIDs) == 0 {
|
|
return fmt.Errorf("ClientIDList must contain at least one entry")
|
|
}
|
|
if len(rec.ClientIDs) > 100 {
|
|
return fmt.Errorf("ClientIDList must contain at most 100 entries")
|
|
}
|
|
if len(rec.Thumbprints) > 5 {
|
|
return fmt.Errorf("ThumbprintList must contain at most 5 entries")
|
|
}
|
|
for _, tp := range rec.Thumbprints {
|
|
if !isValidSHA1Thumbprint(tp) {
|
|
return fmt.Errorf("invalid thumbprint %q: must be 40-character SHA-1 hex", tp)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isValidSHA1Thumbprint returns true iff `s` is exactly 40 hex characters,
|
|
// matching the SHA-1 digest format AWS expects.
|
|
func isValidSHA1Thumbprint(s string) bool {
|
|
if len(s) != 40 {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= '0' && r <= '9':
|
|
case r >= 'a' && r <= 'f':
|
|
case r >= 'A' && r <= 'F':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// IAMConfig holds configuration for all IAM components
|
|
type IAMConfig struct {
|
|
// STS service configuration
|
|
STS *sts.STSConfig `json:"sts"`
|
|
|
|
// Policy engine configuration
|
|
Policy *policy.PolicyEngineConfig `json:"policy"`
|
|
|
|
// Role store configuration
|
|
Roles *RoleStoreConfig `json:"roleStore"`
|
|
|
|
// OIDCProviders configures the IAM-managed OIDC provider store. Optional;
|
|
// if absent the manager defaults to an in-memory store hydrated from
|
|
// STS.Providers at boot.
|
|
OIDCProviders *OIDCProviderStoreConfig `json:"oidcProviderStore,omitempty"`
|
|
}
|
|
|
|
// OIDCProviderStoreConfig holds OIDC provider store configuration.
|
|
type OIDCProviderStoreConfig struct {
|
|
StoreType string `json:"storeType"` // memory, filer
|
|
StoreConfig map[string]interface{} `json:"storeConfig,omitempty"`
|
|
}
|
|
|
|
// RoleStoreConfig holds role store configuration
|
|
type RoleStoreConfig struct {
|
|
// StoreType specifies the role store backend (memory, filer, etc.)
|
|
StoreType string `json:"storeType"`
|
|
|
|
// StoreConfig contains store-specific configuration
|
|
StoreConfig map[string]interface{} `json:"storeConfig,omitempty"`
|
|
}
|
|
|
|
// UserStore defines the interface for retrieving IAM user policy attachments.
|
|
type UserStore interface {
|
|
GetUser(ctx context.Context, username string) (*iam_pb.Identity, error)
|
|
}
|
|
|
|
// RoleDefinition defines a role with its trust policy and attached policies
|
|
type RoleDefinition struct {
|
|
// RoleName is the name of the role
|
|
RoleName string `json:"roleName"`
|
|
|
|
// RoleArn is the full ARN of the role
|
|
RoleArn string `json:"roleArn"`
|
|
|
|
// TrustPolicy defines who can assume this role
|
|
TrustPolicy *policy.PolicyDocument `json:"trustPolicy"`
|
|
|
|
// AttachedPolicies lists the policy names attached to this role
|
|
AttachedPolicies []string `json:"attachedPolicies"`
|
|
|
|
// Description is an optional description of the role
|
|
Description string `json:"description,omitempty"`
|
|
|
|
// MaxSessionDuration is the upper bound (in seconds) on session length when
|
|
// callers assume this role. Zero means "use the global STS default". When
|
|
// set it must satisfy AWS bounds: 3600 ≤ MaxSessionDuration ≤ 43200.
|
|
// Honoured by AssumeRole, AssumeRoleWithWebIdentity, AssumeRoleWithCredentials.
|
|
MaxSessionDuration int64 `json:"maxSessionDuration,omitempty"`
|
|
}
|
|
|
|
// ActionRequest represents a request to perform an action
|
|
type ActionRequest struct {
|
|
// Principal is the entity performing the action
|
|
Principal string `json:"principal"`
|
|
|
|
// Action is the action being requested
|
|
Action string `json:"action"`
|
|
|
|
// Resource is the resource being accessed
|
|
Resource string `json:"resource"`
|
|
|
|
// SessionToken for temporary credential validation
|
|
SessionToken string `json:"sessionToken"`
|
|
|
|
// RequestContext contains additional request information
|
|
RequestContext map[string]interface{} `json:"requestContext,omitempty"`
|
|
|
|
// PolicyNames to evaluate (overrides role-based policies if present)
|
|
PolicyNames []string `json:"policyNames,omitempty"`
|
|
}
|
|
|
|
// NewIAMManager creates a new IAM manager
|
|
func NewIAMManager() *IAMManager {
|
|
return &IAMManager{}
|
|
}
|
|
|
|
// SetUserStore assigns the user store used to resolve IAM user policy attachments.
|
|
func (m *IAMManager) SetUserStore(store UserStore) {
|
|
m.userStore = store
|
|
}
|
|
|
|
// SyncRuntimePolicies keeps zero-config runtime policies available to the
|
|
// in-memory policy engine used by the advanced IAM authorizer.
|
|
func (m *IAMManager) SyncRuntimePolicies(ctx context.Context, policies []*iam_pb.Policy) error {
|
|
if !m.initialized || m.policyEngine == nil {
|
|
return nil
|
|
}
|
|
if m.policyEngine.StoreType() != sts.StoreTypeMemory {
|
|
return nil
|
|
}
|
|
|
|
desiredPolicies := make(map[string]*policy.PolicyDocument, len(policies))
|
|
for _, runtimePolicy := range policies {
|
|
if runtimePolicy == nil || runtimePolicy.Name == "" {
|
|
continue
|
|
}
|
|
|
|
var document policy.PolicyDocument
|
|
if err := json.Unmarshal([]byte(runtimePolicy.Content), &document); err != nil {
|
|
// Drop just this one: aborting here would leave every other policy
|
|
// unsynced. Leaving it out of desiredPolicies also deletes it from
|
|
// the engine below, which is the point — a policy whose stored
|
|
// definition no longer parses must stop granting access rather than
|
|
// keep enforcing a document the operator can no longer see.
|
|
glog.Warningf("skipping unparsable runtime policy %q: %v", runtimePolicy.Name, err)
|
|
continue
|
|
}
|
|
|
|
desiredPolicies[runtimePolicy.Name] = &document
|
|
}
|
|
|
|
m.runtimePolicyMu.Lock()
|
|
defer m.runtimePolicyMu.Unlock()
|
|
|
|
filerAddress := m.getFilerAddress()
|
|
for policyName := range m.runtimePolicyNames {
|
|
if _, keep := desiredPolicies[policyName]; keep {
|
|
continue
|
|
}
|
|
if err := m.policyEngine.DeletePolicy(ctx, filerAddress, policyName); err != nil {
|
|
return fmt.Errorf("failed to delete runtime policy %q: %w", policyName, err)
|
|
}
|
|
}
|
|
|
|
for policyName, document := range desiredPolicies {
|
|
if err := m.policyEngine.AddPolicy(filerAddress, policyName, document); err != nil {
|
|
return fmt.Errorf("failed to sync runtime policy %q: %w", policyName, err)
|
|
}
|
|
}
|
|
|
|
m.runtimePolicyNames = make(map[string]struct{}, len(desiredPolicies))
|
|
for policyName := range desiredPolicies {
|
|
m.runtimePolicyNames[policyName] = struct{}{}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Initialize initializes the IAM manager with all components
|
|
func (m *IAMManager) Initialize(config *IAMConfig, filerAddressProvider func() string) error {
|
|
if config == nil {
|
|
return fmt.Errorf("config cannot be nil")
|
|
}
|
|
|
|
// Store the filer address provider function
|
|
m.filerAddressProvider = filerAddressProvider
|
|
|
|
// Initialize STS service
|
|
m.stsService = sts.NewSTSService()
|
|
if err := m.stsService.Initialize(config.STS); err != nil {
|
|
return fmt.Errorf("failed to initialize STS service: %w", err)
|
|
}
|
|
|
|
// CRITICAL SECURITY: Set trust policy validator to ensure proper role assumption validation
|
|
m.stsService.SetTrustPolicyValidator(m)
|
|
|
|
// Initialize policy engine
|
|
m.policyEngine = policy.NewPolicyEngine()
|
|
if err := m.policyEngine.InitializeWithProvider(config.Policy, m.filerAddressProvider); err != nil {
|
|
return fmt.Errorf("failed to initialize policy engine: %w", err)
|
|
}
|
|
|
|
// Initialize role store
|
|
roleStore, err := m.createRoleStoreWithProvider(config.Roles, m.filerAddressProvider)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize role store: %w", err)
|
|
}
|
|
m.roleStore = roleStore
|
|
|
|
// Initialize OIDC provider store and hydrate from static configuration so
|
|
// the read-only IAM API can return the same providers the STS service
|
|
// already accepts. Mutations will land in Phase 2b.
|
|
if err := m.initOIDCProviderStore(config); err != nil {
|
|
return fmt.Errorf("failed to initialize OIDC provider store: %w", err)
|
|
}
|
|
|
|
m.initialized = true
|
|
return nil
|
|
}
|
|
|
|
// initOIDCProviderStore creates the OIDC provider store and seeds it from the
|
|
// static STS provider configuration. The static path remains the bootstrap
|
|
// source: each enabled OIDC entry under STS.Providers is mirrored as an
|
|
// OIDCProviderRecord so the IAM API surfaces the same set the STS service
|
|
// validates against.
|
|
func (m *IAMManager) initOIDCProviderStore(config *IAMConfig) error {
|
|
store, err := m.createOIDCProviderStore(config.OIDCProviders)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.installOIDCProviderStore(store, config.STS)
|
|
return nil
|
|
}
|
|
|
|
// installOIDCProviderStore makes store the manager's OIDC provider store.
|
|
//
|
|
// The IAM config file's providers are reported by the IAM API alongside the
|
|
// stored ones. An in-memory store holds them as records, as it always has. A
|
|
// persistent store never does: it outlives this process and may be shared by
|
|
// S3 servers with different config files, so a record written from one file
|
|
// would outlive its removal from that file and be trusted by every server.
|
|
// Those providers stay in memory (staticOIDCProviders) and STS keeps serving
|
|
// them from its static configuration; the store holds only providers created
|
|
// through the IAM API, and those are loaded into STS here.
|
|
//
|
|
// A provider stored under the same ARN as a config-file provider takes
|
|
// precedence, in the IAM API as in STS, which already prefers IAM-managed
|
|
// providers so that an API call can shadow a bootstrap entry. Deleting the
|
|
// stored provider brings the config-file one back.
|
|
func (m *IAMManager) installOIDCProviderStore(store OIDCProviderStore, stsConfig *sts.STSConfig) {
|
|
// Cancel the old store's retry and switch stores in one step, so a failed
|
|
// refresh of the old store cannot start a retry after the cancel.
|
|
m.oidcRetryMu.Lock()
|
|
m.stopOIDCRetryLocked()
|
|
m.oidcProviderStore = store
|
|
m.oidcRetryMu.Unlock()
|
|
m.staticOIDCProviders = staticOIDCProviderRecords(stsConfig)
|
|
if _, inMemory := store.(*MemoryOIDCProviderStore); inMemory {
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
for _, rec := range m.staticOIDCProviders {
|
|
mirrored := copyOIDCProviderRecord(rec)
|
|
mirrored.CreatedAt, mirrored.UpdatedAt = now, now
|
|
if err := store.StoreProvider(ctx, m.getFilerAddress(), mirrored); err != nil {
|
|
glog.Warningf("mirror static OIDC provider %s into store: %v", rec.ARN, err)
|
|
}
|
|
}
|
|
// The store now holds them and the API may change them, as before;
|
|
// the overlay is only for stores that must not hold them.
|
|
m.staticOIDCProviders = nil
|
|
return
|
|
}
|
|
// The metadata subscription only reports changes made from now on, so
|
|
// providers already in the store would stay unknown until one changes; a
|
|
// failed load is retried (RefreshOIDCProvidersFromStore).
|
|
if err := m.RefreshOIDCProvidersFromStore(context.Background()); err != nil {
|
|
glog.Warningf("load OIDC providers from the store at startup: %v; retrying in the background", err)
|
|
}
|
|
}
|
|
|
|
// startOIDCRetry retries loading store in the background until it succeeds.
|
|
// A store that is no longer current gets no retry: nothing would cancel it,
|
|
// and its eventual success would replace the current store's providers. When
|
|
// a retry is already running, it is asked to run once more instead, because
|
|
// it may already have listed a snapshot older than this failure.
|
|
func (m *IAMManager) startOIDCRetry(store OIDCProviderStore) {
|
|
m.oidcRetryMu.Lock()
|
|
defer m.oidcRetryMu.Unlock()
|
|
if store != m.oidcProviderStore {
|
|
return
|
|
}
|
|
if m.oidcRetryGen != 0 {
|
|
m.oidcRetryAgain = true
|
|
return
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
m.oidcRetrySeq++
|
|
gen := m.oidcRetrySeq
|
|
m.cancelOIDCLoad, m.oidcRetryGen = cancel, gen
|
|
bounds := oidcHydrateRetry // read here, not in the goroutine: it outlives its caller
|
|
go func() {
|
|
defer cancel()
|
|
for {
|
|
m.retryOIDCProviderLoad(ctx, store, bounds)
|
|
m.oidcRetryMu.Lock()
|
|
if m.oidcRetryGen != gen {
|
|
m.oidcRetryMu.Unlock()
|
|
return // cancelled: another store was installed
|
|
}
|
|
if m.oidcRetryAgain && ctx.Err() == nil {
|
|
m.oidcRetryAgain = false
|
|
m.oidcRetryMu.Unlock()
|
|
continue
|
|
}
|
|
m.cancelOIDCLoad, m.oidcRetryGen, m.oidcRetryAgain = nil, 0, false
|
|
m.oidcRetryMu.Unlock()
|
|
return
|
|
}
|
|
}()
|
|
}
|
|
|
|
// stopOIDCRetryLocked cancels a running retry, as installing another store
|
|
// must. The caller holds oidcRetryMu.
|
|
func (m *IAMManager) stopOIDCRetryLocked() {
|
|
if m.cancelOIDCLoad != nil {
|
|
m.cancelOIDCLoad()
|
|
}
|
|
m.cancelOIDCLoad, m.oidcRetryGen, m.oidcRetryAgain = nil, 0, false
|
|
}
|
|
|
|
// currentOIDCProviderStore is the installed store, read under oidcRetryMu.
|
|
func (m *IAMManager) currentOIDCProviderStore() OIDCProviderStore {
|
|
m.oidcRetryMu.Lock()
|
|
defer m.oidcRetryMu.Unlock()
|
|
return m.oidcProviderStore
|
|
}
|
|
|
|
// staticOIDCProviderRecords describes the enabled OIDC providers of the IAM
|
|
// config file as provider records.
|
|
func staticOIDCProviderRecords(stsConfig *sts.STSConfig) map[string]*OIDCProviderRecord {
|
|
out := map[string]*OIDCProviderRecord{}
|
|
if stsConfig == nil {
|
|
return out
|
|
}
|
|
for _, pc := range stsConfig.Providers {
|
|
if pc == nil || !pc.Enabled || pc.Type != sts.ProviderTypeOIDC {
|
|
continue
|
|
}
|
|
issuer, _ := pc.Config["issuer"].(string)
|
|
if issuer == "" {
|
|
glog.Warningf("OIDC provider %s in static config has empty issuer; skipping", pc.Name)
|
|
continue
|
|
}
|
|
arn, err := DeriveOIDCProviderARN(stsConfig.AccountId, issuer)
|
|
if err != nil {
|
|
glog.Warningf("derive ARN for static OIDC provider %s: %v", pc.Name, err)
|
|
continue
|
|
}
|
|
out[arn] = &OIDCProviderRecord{
|
|
AccountID: stsConfig.AccountId,
|
|
ARN: arn,
|
|
URL: issuer,
|
|
ClientIDs: extractClientIDs(pc.Config),
|
|
Thumbprints: extractStringList(pc.Config, "thumbprints"),
|
|
AllowedPrincipalTagKeys: extractStringList(pc.Config, "allowedPrincipalTagKeys"),
|
|
PolicyClaim: extractString(pc.Config, "policyClaim"),
|
|
// No CreatedAt: a config-file provider has no creation time the
|
|
// server could report consistently across restarts.
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// oidcHydrateRetry bounds the backoff between startup load attempts.
|
|
var oidcHydrateRetry = struct{ initial, max time.Duration }{initial: time.Second, max: 30 * time.Second}
|
|
|
|
// retryOIDCProviderLoad retries loading store, the store it was started for,
|
|
// until it succeeds or ctx is cancelled because another store was installed.
|
|
// It calls refreshOIDCProvidersFrom, not RefreshOIDCProvidersFromStore, so it
|
|
// never schedules a retry of its own.
|
|
func (m *IAMManager) retryOIDCProviderLoad(ctx context.Context, store OIDCProviderStore, bounds struct{ initial, max time.Duration }) {
|
|
delay := bounds.initial
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(delay):
|
|
}
|
|
err := m.refreshOIDCProvidersFrom(ctx, store)
|
|
if err == nil {
|
|
glog.V(0).Infof("loaded OIDC providers from the store after retrying")
|
|
return
|
|
}
|
|
glog.V(1).Infof("load OIDC providers from the store: %v; retrying in %v", err, delay)
|
|
if delay *= 2; delay > bounds.max {
|
|
delay = bounds.max
|
|
}
|
|
}
|
|
}
|
|
|
|
// refreshOIDCProvidersBestEffort calls RefreshOIDCProvidersFromStore and
|
|
// logs a warning on failure. The IAM API call has already succeeded by the
|
|
// time we get here, so a refresh failure must not surface to the caller —
|
|
// the worst case is that the local instance keeps the stale runtime view
|
|
// until a peer's metadata-subscribe event triggers another refresh.
|
|
func (m *IAMManager) refreshOIDCProvidersBestEffort(ctx context.Context, op, arn string) {
|
|
if err := m.RefreshOIDCProvidersFromStore(ctx); err != nil {
|
|
glog.Warningf("refresh OIDC providers after %s on %s: %v", op, arn, err)
|
|
}
|
|
}
|
|
|
|
// RefreshOIDCProvidersFromStore reloads every OIDCProviderRecord from the
|
|
// configured store and pushes the resulting runtime providers into the STS
|
|
// service so AssumeRoleWithWebIdentity sees the latest set without a
|
|
// restart. Safe to call when no store is configured (returns nil) and when
|
|
// the store is empty (clears the IAM-managed map). Records with empty URLs
|
|
// or invalid configuration are logged and skipped so a single bad entry
|
|
// does not stop the rest from refreshing.
|
|
//
|
|
// A refresh that fails keeps retrying in the background until the store
|
|
// answers. Every caller needs that: a metadata-subscription event reports each
|
|
// change ONCE, so a refresh that found the filer unreachable on it would leave
|
|
// a peer's new provider untrusted, or a deleted one trusted, until an unrelated
|
|
// later change; the refresh after a local IAM API mutation and the startup load
|
|
// have the same shape. At most one retry runs.
|
|
func (m *IAMManager) RefreshOIDCProvidersFromStore(ctx context.Context) error {
|
|
store := m.currentOIDCProviderStore()
|
|
err := m.refreshOIDCProvidersFrom(ctx, store)
|
|
if err != nil && store != nil {
|
|
m.startOIDCRetry(store)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// refreshOIDCProvidersFrom is RefreshOIDCProvidersFromStore for a given store.
|
|
func (m *IAMManager) refreshOIDCProvidersFrom(ctx context.Context, store OIDCProviderStore) error {
|
|
if store == nil || m.stsService == nil {
|
|
return nil
|
|
}
|
|
// Refreshes run concurrently: after an IAM API change, on a peer's change
|
|
// and in the startup retry. Unserialized, a refresh that read the store
|
|
// before a DeleteOIDCProvider could finish after that call's own refresh
|
|
// and keep the deleted provider trusted.
|
|
m.oidcRefreshMu.Lock()
|
|
defer m.oidcRefreshMu.Unlock()
|
|
records, err := store.ListProviders(ctx, m.getFilerAddress())
|
|
if err != nil {
|
|
return fmt.Errorf("list OIDC providers: %w", err)
|
|
}
|
|
// A snapshot of a store that has since been replaced must not replace the
|
|
// current store's providers: a retry is cancelled when another store is
|
|
// installed, and a refresh may have listed the old store just before.
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if store != m.currentOIDCProviderStore() {
|
|
return fmt.Errorf("list OIDC providers: the store was replaced during the refresh")
|
|
}
|
|
byIssuer := make(map[string][]sts.ScopedOIDCProvider, len(records))
|
|
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...),
|
|
AllowedPrincipalTagKeys: append([]string(nil), rec.AllowedPrincipalTagKeys...),
|
|
PolicyClaim: rec.PolicyClaim,
|
|
}
|
|
provider := oidc.NewOIDCProvider(rec.ARN)
|
|
if err := provider.Initialize(cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
return provider, nil
|
|
}
|
|
|
|
// createOIDCProviderStore selects an OIDCProviderStore implementation. Defaults
|
|
// to memory; "filer" requires a filerAddressProvider to be configured.
|
|
func (m *IAMManager) createOIDCProviderStore(cfg *OIDCProviderStoreConfig) (OIDCProviderStore, error) {
|
|
if cfg == nil || cfg.StoreType == "" || cfg.StoreType == "memory" {
|
|
return NewMemoryOIDCProviderStore(), nil
|
|
}
|
|
if cfg.StoreType == "filer" {
|
|
return NewFilerOIDCProviderStore(cfg.StoreConfig, m.filerAddressProvider), nil
|
|
}
|
|
return nil, fmt.Errorf("unsupported OIDC provider store type: %s", cfg.StoreType)
|
|
}
|
|
|
|
// extractClientIDs reads a single clientId or a clientIds list from the
|
|
// provider's static config map. Mirrors the OIDCConfig schema.
|
|
func extractClientIDs(cfg map[string]interface{}) []string {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
if list, ok := cfg["clientIds"].([]interface{}); ok {
|
|
out := make([]string, 0, len(list))
|
|
for _, v := range list {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
if len(out) > 0 {
|
|
return out
|
|
}
|
|
}
|
|
if id, ok := cfg["clientId"].(string); ok && id != "" {
|
|
return []string{id}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extractStringList reads a JSON string array out of the provider's static
|
|
// config map and returns the non-empty entries. Returns nil when the key is
|
|
// missing, the value is the wrong shape, or every entry is empty.
|
|
func extractStringList(cfg map[string]interface{}, key string) []string {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
list, ok := cfg[key].([]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(list))
|
|
for _, v := range list {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// extractString reads a single string field from the provider's static
|
|
// config map; missing or non-string values produce "".
|
|
func extractString(cfg map[string]interface{}, key string) string {
|
|
if cfg == nil {
|
|
return ""
|
|
}
|
|
s, _ := cfg[key].(string)
|
|
return s
|
|
}
|
|
|
|
// getFilerAddress returns the current filer address using the provider function
|
|
func (m *IAMManager) getFilerAddress() string {
|
|
if m.filerAddressProvider != nil {
|
|
return m.filerAddressProvider()
|
|
}
|
|
return "" // Fallback to empty string if no provider is set
|
|
}
|
|
|
|
// createRoleStoreWithProvider creates a role store with a filer address provider function
|
|
func (m *IAMManager) createRoleStoreWithProvider(config *RoleStoreConfig, filerAddressProvider func() string) (RoleStore, error) {
|
|
if config == nil {
|
|
// Default to generic cached filer role store when no config provided
|
|
return NewGenericCachedRoleStore(nil, filerAddressProvider)
|
|
}
|
|
|
|
switch config.StoreType {
|
|
case "", "filer":
|
|
// Check if caching is explicitly disabled
|
|
if config.StoreConfig != nil {
|
|
if noCache, ok := config.StoreConfig["noCache"].(bool); ok && noCache {
|
|
return NewFilerRoleStore(config.StoreConfig, filerAddressProvider)
|
|
}
|
|
}
|
|
// Default to generic cached filer store for better performance
|
|
return NewGenericCachedRoleStore(config.StoreConfig, filerAddressProvider)
|
|
case "cached-filer", "generic-cached":
|
|
return NewGenericCachedRoleStore(config.StoreConfig, filerAddressProvider)
|
|
case "memory":
|
|
return NewMemoryRoleStore(), nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported role store type: %s", config.StoreType)
|
|
}
|
|
}
|
|
|
|
// RegisterIdentityProvider registers an identity provider
|
|
func (m *IAMManager) RegisterIdentityProvider(provider providers.IdentityProvider) error {
|
|
if !m.initialized {
|
|
return fmt.Errorf("IAM manager not initialized")
|
|
}
|
|
|
|
return m.stsService.RegisterProvider(provider)
|
|
}
|
|
|
|
// CreatePolicy creates a new policy
|
|
func (m *IAMManager) CreatePolicy(ctx context.Context, filerAddress string, name string, policyDoc *policy.PolicyDocument) error {
|
|
if !m.initialized {
|
|
return fmt.Errorf("IAM manager not initialized")
|
|
}
|
|
|
|
return m.policyEngine.AddPolicy(filerAddress, name, policyDoc)
|
|
}
|
|
|
|
// CreateRole creates a new role with trust policy and attached policies
|
|
func (m *IAMManager) CreateRole(ctx context.Context, filerAddress string, roleName string, roleDef *RoleDefinition) error {
|
|
if !m.initialized {
|
|
return fmt.Errorf("IAM manager not initialized")
|
|
}
|
|
|
|
if 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)
|
|
}
|
|
|
|
// EnsureBucketPolicy stores the policy for a bucket only when no mirror is
|
|
// stored yet, backfilling policies that predate the IAM integration (the
|
|
// metadata subscription only sees changes). A present mirror is left alone,
|
|
// so repeat calls cost one cached read. Returns whether a write happened,
|
|
// so the caller can reconcile a write that raced a concurrent change.
|
|
func (m *IAMManager) EnsureBucketPolicy(ctx context.Context, bucketName string, policyJSON []byte) (bool, error) {
|
|
if !m.initialized {
|
|
return false, fmt.Errorf("IAM manager not initialized")
|
|
}
|
|
|
|
if bucketName == "" {
|
|
return false, fmt.Errorf("bucket name cannot be empty")
|
|
}
|
|
|
|
if existing, err := m.policyEngine.GetPolicy(ctx, m.getFilerAddress(), "bucket-policy:"+bucketName); err == nil && existing != nil {
|
|
return false, nil
|
|
}
|
|
|
|
if err := m.UpdateBucketPolicy(ctx, bucketName, policyJSON); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// RemoveBucketPolicy deletes the stored policy for a bucket. Removing a
|
|
// policy that was never stored is a success.
|
|
func (m *IAMManager) RemoveBucketPolicy(ctx context.Context, bucketName string) error {
|
|
if !m.initialized {
|
|
return fmt.Errorf("IAM manager not initialized")
|
|
}
|
|
|
|
if bucketName == "" {
|
|
return fmt.Errorf("bucket name cannot be empty")
|
|
}
|
|
|
|
return m.policyEngine.DeletePolicy(ctx, m.getFilerAddress(), "bucket-policy:"+bucketName)
|
|
}
|
|
|
|
// AssumeRoleWithWebIdentity assumes a role using web identity (OIDC)
|
|
func (m *IAMManager) AssumeRoleWithWebIdentity(ctx context.Context, request *sts.AssumeRoleWithWebIdentityRequest) (*sts.AssumeRoleResponse, error) {
|
|
if !m.initialized {
|
|
return nil, fmt.Errorf("IAM manager not initialized")
|
|
}
|
|
|
|
// Claim-based mode bypasses role lookup and trust policy entirely; the
|
|
// STS service handles the policy resolution from the JWT itself. Account
|
|
// scoping still applies but at the provider-resolution layer in Phase 3c
|
|
// (we'll plug that in once a multi-account assume path lands).
|
|
if sts.IsClaimBasedPolicyRoleArn(request.RoleArn) {
|
|
return m.stsService.AssumeRoleWithWebIdentity(ctx, request)
|
|
}
|
|
|
|
// Extract role name from ARN
|
|
roleName := utils.ExtractRoleNameFromArn(request.RoleArn)
|
|
|
|
// Get role definition
|
|
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("role not found: %s", roleName)
|
|
}
|
|
|
|
// Account scoping: when the role lives in account A, the OIDC provider
|
|
// validating the token must be either global (AccountID="") or also live
|
|
// in account A. Skip when we can't resolve account context; fall through
|
|
// to the existing trust-policy enforcement.
|
|
if err := m.enforceProviderAccountScope(ctx, request); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Validate trust policy before allowing STS to assume the role
|
|
if err := m.validateTrustPolicyForWebIdentity(ctx, roleDef, request.WebIdentityToken, request.DurationSeconds); err != nil {
|
|
return nil, fmt.Errorf("trust policy validation failed: %w", err)
|
|
}
|
|
|
|
// Apply role-level MaxSessionDuration cap. The STS service still applies
|
|
// the global MaxSessionLength and the source-token-expiry cap on top of
|
|
// this; per-role takes precedence whenever it is the tightest bound.
|
|
request.DurationSeconds = capDurationByRole(request.DurationSeconds, roleDef.MaxSessionDuration, m.defaultTokenDurationSeconds(), m.maxSessionLengthSeconds())
|
|
|
|
// Use STS service to assume the role
|
|
return m.stsService.AssumeRoleWithWebIdentity(ctx, request)
|
|
}
|
|
|
|
// enforceProviderAccountScope checks that the OIDC provider matching the
|
|
// token's issuer is registered in either the role's account or as a global
|
|
// (account-less) provider. Returns nil when no provider store is configured
|
|
// (preserves the static-config-only path) or when the issuer is not known to
|
|
// the store (the existing STS-layer issuer→provider map handles that case
|
|
// during validation).
|
|
func (m *IAMManager) enforceProviderAccountScope(ctx context.Context, request *sts.AssumeRoleWithWebIdentityRequest) error {
|
|
if m.oidcProviderStore == nil {
|
|
return nil
|
|
}
|
|
roleAccount := utils.ParseRoleARN(request.RoleArn).AccountID
|
|
if roleAccount == "" {
|
|
// Legacy ARN form (no account): nothing to enforce.
|
|
return nil
|
|
}
|
|
issuer, err := extractIssuerFromJWT(request.WebIdentityToken)
|
|
if err != nil {
|
|
return nil // signature validation will reject, no need to fail twice
|
|
}
|
|
// Look for a provider that is either global or scoped to the role's
|
|
// account. Multiple providers may share an issuer (e.g. one global plus
|
|
// one per tenant), and GetProviderByIssuer returns the first match
|
|
// arbitrarily — using it here would falsely reject a valid request
|
|
// whenever the wrong record happens to come back first.
|
|
if _, err := m.oidcProviderStore.GetProviderByIssuerAndAccount(ctx, m.getFilerAddress(), issuer, roleAccount); err == nil {
|
|
return nil
|
|
}
|
|
// No allowed match. Distinguish "issuer entirely unknown" (let the STS
|
|
// layer reject with the existing not-registered error) from "issuer is
|
|
// registered but only in a different account" (surface a precise error
|
|
// so the operator knows the call was cross-account).
|
|
if other, err := m.oidcProviderStore.GetProviderByIssuer(ctx, m.getFilerAddress(), issuer); err == nil {
|
|
return fmt.Errorf("OIDC provider for issuer %s is registered in account %s; cannot be used to assume a role in account %s", issuer, other.AccountID, roleAccount)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extractIssuerFromJWT returns the iss claim of a JWT without verifying its
|
|
// signature. Safe here because the caller still goes through full signature
|
|
// + issuer validation in the STS service; this is purely for routing.
|
|
func extractIssuerFromJWT(token string) (string, error) {
|
|
parser := new(jwt.Parser)
|
|
parsed, _, err := parser.ParseUnverified(token, jwt.MapClaims{})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
claims, ok := parsed.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
return "", fmt.Errorf("invalid claims")
|
|
}
|
|
iss, _ := claims["iss"].(string)
|
|
if iss == "" {
|
|
return "", fmt.Errorf("token has no iss claim")
|
|
}
|
|
return iss, nil
|
|
}
|
|
|
|
// capDurationByRole returns the session duration clamped to the role's
|
|
// MaxSessionDuration. An omitted DurationSeconds resolves to the configured
|
|
// default first, so the role bound caps defaults and explicit values alike.
|
|
// A nil request is only materialized when something tightened the default
|
|
// and the explicit value still passes the service's own input validation —
|
|
// everything else is left nil so the service resolves the default and its
|
|
// MaxSessionLength cap itself.
|
|
func capDurationByRole(requested *int64, roleMax, defaultSec, serviceMaxSec int64) *int64 {
|
|
if requested != nil {
|
|
d := *requested
|
|
if roleMax > 0 && d > roleMax {
|
|
d = roleMax
|
|
}
|
|
return &d
|
|
}
|
|
d := defaultSec
|
|
if roleMax > 0 && d > roleMax {
|
|
d = roleMax
|
|
}
|
|
if d > serviceMaxSec {
|
|
d = serviceMaxSec
|
|
}
|
|
if d < 900 && roleMax > 0 {
|
|
d = 900
|
|
}
|
|
if d >= defaultSec || d < 900 {
|
|
return nil
|
|
}
|
|
return &d
|
|
}
|
|
|
|
func (m *IAMManager) defaultTokenDurationSeconds() int64 {
|
|
if m.stsService == nil || m.stsService.Config == nil {
|
|
return sts.DefaultTokenDuration
|
|
}
|
|
return int64(m.stsService.Config.TokenDuration.Duration / time.Second)
|
|
}
|
|
|
|
// maxSessionLengthSeconds mirrors validateSessionDurationSeconds so a
|
|
// materialized default stays inside the bound the service will enforce.
|
|
func (m *IAMManager) maxSessionLengthSeconds() int64 {
|
|
maxSec := int64(sts.DefaultMaxSessionLength)
|
|
if m.stsService != nil && m.stsService.Config != nil && m.stsService.Config.MaxSessionLength.Duration > 0 {
|
|
if configured := int64(m.stsService.Config.MaxSessionLength.Duration / time.Second); configured >= 900 {
|
|
maxSec = configured
|
|
}
|
|
}
|
|
return maxSec
|
|
}
|
|
|
|
// AssumeRoleWithCredentials assumes a role using credentials (LDAP)
|
|
func (m *IAMManager) AssumeRoleWithCredentials(ctx context.Context, request *sts.AssumeRoleWithCredentialsRequest) (*sts.AssumeRoleResponse, error) {
|
|
if !m.initialized {
|
|
return nil, fmt.Errorf("IAM manager not initialized")
|
|
}
|
|
|
|
// Extract role name from ARN
|
|
roleName := utils.ExtractRoleNameFromArn(request.RoleArn)
|
|
|
|
// Get role definition
|
|
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("role not found: %s", roleName)
|
|
}
|
|
|
|
// Validate trust policy
|
|
if err := m.validateTrustPolicyForCredentials(ctx, roleDef, request); err != nil {
|
|
return nil, fmt.Errorf("trust policy validation failed: %w", err)
|
|
}
|
|
|
|
// Apply role-level MaxSessionDuration cap.
|
|
request.DurationSeconds = capDurationByRole(request.DurationSeconds, roleDef.MaxSessionDuration, m.defaultTokenDurationSeconds(), m.maxSessionLengthSeconds())
|
|
|
|
// Use STS service to assume the role
|
|
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)
|
|
}
|