mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-01 12:16:07 +00:00
* sts: export CalculateSessionDuration Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: honor configured session bounds on AssumeRole and LDAP identity prepareSTSCredentials hardcoded a one-hour session when the caller omitted DurationSeconds, so sts.tokenDuration was ignored and sts.maxSessionLength only clamped explicit requests: asking for 3600s against a 20m ceiling was rejected while omitting the parameter was granted a full hour (#11473). The two affected handlers now use the same default-then-cap calculation as AssumeRoleWithWebIdentity. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * iam: keep MaxSessionDuration through role store copies copyRoleDefinition rebuilt RoleDefinition field by field and dropped MaxSessionDuration, so memory-backed role stores silently discarded the per-role session bound on every write and read (devin on #11478). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * sts: apply per-role MaxSessionDuration to resolved session durations Review follow-up on #11478 (devin): the role bound only ever applied to explicit DurationSeconds values — an omitted duration resolved to the configured default and sailed past a shorter role max on every assume path. - capDurationByRole now resolves min(requested||tokenDuration, roleMax), so AssumeRoleWithWebIdentity and AssumeRoleWithCredentials cap defaults the same way they cap explicit values. - prepareSTSCredentials caps the calculated duration at the named role's MaxSessionDuration, covering the AssumeRole and LDAP handlers; self-assumption has no role definition to consult. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * iam: keep MaxSessionDuration through the cached role store genericCopyRoleDefinition drops MaxSessionDuration the same way copyRoleDefinition did, so the cached filer role store reads back a zero maximum and every downstream duration cap is skipped (greptile on #11478). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * sts: only materialize defaults that pass session duration validation Review follow-up on #11478 (greptile): materializing an omitted DurationSeconds into an explicit value could exceed the service's own input bound (a configured tokenDuration above maxSessionLength) and turn a previously working request into a validation error. capDurationByRole now leaves nil anything the service can resolve better itself, clamps a tightened default at maxSessionLengthSeconds, and floors a role bound below 900s to the tightest issuable value. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1689 lines
60 KiB
Go
1689 lines
60 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|