diff options
Diffstat (limited to 'weed/s3api/s3_iam_middleware.go')
| -rw-r--r-- | weed/s3api/s3_iam_middleware.go | 794 |
1 files changed, 794 insertions, 0 deletions
diff --git a/weed/s3api/s3_iam_middleware.go b/weed/s3api/s3_iam_middleware.go new file mode 100644 index 000000000..857123d7b --- /dev/null +++ b/weed/s3api/s3_iam_middleware.go @@ -0,0 +1,794 @@ +package s3api + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/iam/integration" + "github.com/seaweedfs/seaweedfs/weed/iam/providers" + "github.com/seaweedfs/seaweedfs/weed/iam/sts" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" +) + +// S3IAMIntegration provides IAM integration for S3 API +type S3IAMIntegration struct { + iamManager *integration.IAMManager + stsService *sts.STSService + filerAddress string + enabled bool +} + +// NewS3IAMIntegration creates a new S3 IAM integration +func NewS3IAMIntegration(iamManager *integration.IAMManager, filerAddress string) *S3IAMIntegration { + var stsService *sts.STSService + if iamManager != nil { + stsService = iamManager.GetSTSService() + } + + return &S3IAMIntegration{ + iamManager: iamManager, + stsService: stsService, + filerAddress: filerAddress, + enabled: iamManager != nil, + } +} + +// AuthenticateJWT authenticates JWT tokens using our STS service +func (s3iam *S3IAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode) { + + if !s3iam.enabled { + return nil, s3err.ErrNotImplemented + } + + // Extract bearer token from Authorization header + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + return nil, s3err.ErrAccessDenied + } + + sessionToken := strings.TrimPrefix(authHeader, "Bearer ") + if sessionToken == "" { + return nil, s3err.ErrAccessDenied + } + + // Basic token format validation - reject obviously invalid tokens + if sessionToken == "invalid-token" || len(sessionToken) < 10 { + glog.V(3).Info("Session token format is invalid") + return nil, s3err.ErrAccessDenied + } + + // Try to parse as STS session token first + tokenClaims, err := parseJWTToken(sessionToken) + if err != nil { + glog.V(3).Infof("Failed to parse JWT token: %v", err) + return nil, s3err.ErrAccessDenied + } + + // Determine token type by issuer claim (more robust than checking role claim) + issuer, issuerOk := tokenClaims["iss"].(string) + if !issuerOk { + glog.V(3).Infof("Token missing issuer claim - invalid JWT") + return nil, s3err.ErrAccessDenied + } + + // Check if this is an STS-issued token by examining the issuer + if !s3iam.isSTSIssuer(issuer) { + + // Not an STS session token, try to validate as OIDC token with timeout + // Create a context with a reasonable timeout to prevent hanging + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + identity, err := s3iam.validateExternalOIDCToken(ctx, sessionToken) + + if err != nil { + return nil, s3err.ErrAccessDenied + } + + // Extract role from OIDC identity + if identity.RoleArn == "" { + return nil, s3err.ErrAccessDenied + } + + // Return IAM identity for OIDC token + return &IAMIdentity{ + Name: identity.UserID, + Principal: identity.RoleArn, + SessionToken: sessionToken, + Account: &Account{ + DisplayName: identity.UserID, + EmailAddress: identity.UserID + "@oidc.local", + Id: identity.UserID, + }, + }, s3err.ErrNone + } + + // This is an STS-issued token - extract STS session information + + // Extract role claim from STS token + roleName, roleOk := tokenClaims["role"].(string) + if !roleOk || roleName == "" { + glog.V(3).Infof("STS token missing role claim") + return nil, s3err.ErrAccessDenied + } + + sessionName, ok := tokenClaims["snam"].(string) + if !ok || sessionName == "" { + sessionName = "jwt-session" // Default fallback + } + + subject, ok := tokenClaims["sub"].(string) + if !ok || subject == "" { + subject = "jwt-user" // Default fallback + } + + // Use the principal ARN directly from token claims, or build it if not available + principalArn, ok := tokenClaims["principal"].(string) + if !ok || principalArn == "" { + // Fallback: extract role name from role ARN and build principal ARN + roleNameOnly := roleName + if strings.Contains(roleName, "/") { + parts := strings.Split(roleName, "/") + roleNameOnly = parts[len(parts)-1] + } + principalArn = fmt.Sprintf("arn:seaweed:sts::assumed-role/%s/%s", roleNameOnly, sessionName) + } + + // Validate the JWT token directly using STS service (avoid circular dependency) + // Note: We don't call IsActionAllowed here because that would create a circular dependency + // Authentication should only validate the token, authorization happens later + _, err = s3iam.stsService.ValidateSessionToken(ctx, sessionToken) + if err != nil { + glog.V(3).Infof("STS session validation failed: %v", err) + return nil, s3err.ErrAccessDenied + } + + // Create IAM identity from validated token + identity := &IAMIdentity{ + Name: subject, + Principal: principalArn, + SessionToken: sessionToken, + Account: &Account{ + DisplayName: roleName, + EmailAddress: subject + "@seaweedfs.local", + Id: subject, + }, + } + + glog.V(3).Infof("JWT authentication successful for principal: %s", identity.Principal) + return identity, s3err.ErrNone +} + +// AuthorizeAction authorizes actions using our policy engine +func (s3iam *S3IAMIntegration) AuthorizeAction(ctx context.Context, identity *IAMIdentity, action Action, bucket string, objectKey string, r *http.Request) s3err.ErrorCode { + if !s3iam.enabled { + return s3err.ErrNone // Fallback to existing authorization + } + + if identity.SessionToken == "" { + return s3err.ErrAccessDenied + } + + // Build resource ARN for the S3 operation + resourceArn := buildS3ResourceArn(bucket, objectKey) + + // Extract request context for policy conditions + requestContext := extractRequestContext(r) + + // Determine the specific S3 action based on the HTTP request details + specificAction := determineGranularS3Action(r, action, bucket, objectKey) + + // Create action request + actionRequest := &integration.ActionRequest{ + Principal: identity.Principal, + Action: specificAction, + Resource: resourceArn, + SessionToken: identity.SessionToken, + RequestContext: requestContext, + } + + // Check if action is allowed using our policy engine + allowed, err := s3iam.iamManager.IsActionAllowed(ctx, actionRequest) + if err != nil { + return s3err.ErrAccessDenied + } + + if !allowed { + return s3err.ErrAccessDenied + } + + return s3err.ErrNone +} + +// IAMIdentity represents an authenticated identity with session information +type IAMIdentity struct { + Name string + Principal string + SessionToken string + Account *Account +} + +// IsAdmin checks if the identity has admin privileges +func (identity *IAMIdentity) IsAdmin() bool { + // In our IAM system, admin status is determined by policies, not identity + // This is handled by the policy engine during authorization + return false +} + +// Mock session structures for validation +type MockSessionInfo struct { + AssumedRoleUser MockAssumedRoleUser +} + +type MockAssumedRoleUser struct { + AssumedRoleId string + Arn string +} + +// Helper functions + +// buildS3ResourceArn builds an S3 resource ARN from bucket and object +func buildS3ResourceArn(bucket string, objectKey string) string { + if bucket == "" { + return "arn:seaweed:s3:::*" + } + + if objectKey == "" || objectKey == "/" { + return "arn:seaweed:s3:::" + bucket + } + + // Remove leading slash from object key if present + if strings.HasPrefix(objectKey, "/") { + objectKey = objectKey[1:] + } + + return "arn:seaweed:s3:::" + bucket + "/" + objectKey +} + +// determineGranularS3Action determines the specific S3 IAM action based on HTTP request details +// This provides granular, operation-specific actions for accurate IAM policy enforcement +func determineGranularS3Action(r *http.Request, fallbackAction Action, bucket string, objectKey string) string { + method := r.Method + query := r.URL.Query() + + // Check if there are specific query parameters indicating granular operations + // If there are, always use granular mapping regardless of method-action alignment + hasGranularIndicators := hasSpecificQueryParameters(query) + + // Only check for method-action mismatch when there are NO granular indicators + // This provides fallback behavior for cases where HTTP method doesn't align with intended action + if !hasGranularIndicators && isMethodActionMismatch(method, fallbackAction) { + return mapLegacyActionToIAM(fallbackAction) + } + + // Handle object-level operations when method and action are aligned + if objectKey != "" && objectKey != "/" { + switch method { + case "GET", "HEAD": + // Object read operations - check for specific query parameters + if _, hasAcl := query["acl"]; hasAcl { + return "s3:GetObjectAcl" + } + if _, hasTagging := query["tagging"]; hasTagging { + return "s3:GetObjectTagging" + } + if _, hasRetention := query["retention"]; hasRetention { + return "s3:GetObjectRetention" + } + if _, hasLegalHold := query["legal-hold"]; hasLegalHold { + return "s3:GetObjectLegalHold" + } + if _, hasVersions := query["versions"]; hasVersions { + return "s3:GetObjectVersion" + } + if _, hasUploadId := query["uploadId"]; hasUploadId { + return "s3:ListParts" + } + // Default object read + return "s3:GetObject" + + case "PUT", "POST": + // Object write operations - check for specific query parameters + if _, hasAcl := query["acl"]; hasAcl { + return "s3:PutObjectAcl" + } + if _, hasTagging := query["tagging"]; hasTagging { + return "s3:PutObjectTagging" + } + if _, hasRetention := query["retention"]; hasRetention { + return "s3:PutObjectRetention" + } + if _, hasLegalHold := query["legal-hold"]; hasLegalHold { + return "s3:PutObjectLegalHold" + } + // Check for multipart upload operations + if _, hasUploads := query["uploads"]; hasUploads { + return "s3:CreateMultipartUpload" + } + if _, hasUploadId := query["uploadId"]; hasUploadId { + if _, hasPartNumber := query["partNumber"]; hasPartNumber { + return "s3:UploadPart" + } + return "s3:CompleteMultipartUpload" // Complete multipart upload + } + // Default object write + return "s3:PutObject" + + case "DELETE": + // Object delete operations + if _, hasTagging := query["tagging"]; hasTagging { + return "s3:DeleteObjectTagging" + } + if _, hasUploadId := query["uploadId"]; hasUploadId { + return "s3:AbortMultipartUpload" + } + // Default object delete + return "s3:DeleteObject" + } + } + + // Handle bucket-level operations + if bucket != "" { + switch method { + case "GET", "HEAD": + // Bucket read operations - check for specific query parameters + if _, hasAcl := query["acl"]; hasAcl { + return "s3:GetBucketAcl" + } + if _, hasPolicy := query["policy"]; hasPolicy { + return "s3:GetBucketPolicy" + } + if _, hasTagging := query["tagging"]; hasTagging { + return "s3:GetBucketTagging" + } + if _, hasCors := query["cors"]; hasCors { + return "s3:GetBucketCors" + } + if _, hasVersioning := query["versioning"]; hasVersioning { + return "s3:GetBucketVersioning" + } + if _, hasNotification := query["notification"]; hasNotification { + return "s3:GetBucketNotification" + } + if _, hasObjectLock := query["object-lock"]; hasObjectLock { + return "s3:GetBucketObjectLockConfiguration" + } + if _, hasUploads := query["uploads"]; hasUploads { + return "s3:ListMultipartUploads" + } + if _, hasVersions := query["versions"]; hasVersions { + return "s3:ListBucketVersions" + } + // Default bucket read/list + return "s3:ListBucket" + + case "PUT": + // Bucket write operations - check for specific query parameters + if _, hasAcl := query["acl"]; hasAcl { + return "s3:PutBucketAcl" + } + if _, hasPolicy := query["policy"]; hasPolicy { + return "s3:PutBucketPolicy" + } + if _, hasTagging := query["tagging"]; hasTagging { + return "s3:PutBucketTagging" + } + if _, hasCors := query["cors"]; hasCors { + return "s3:PutBucketCors" + } + if _, hasVersioning := query["versioning"]; hasVersioning { + return "s3:PutBucketVersioning" + } + if _, hasNotification := query["notification"]; hasNotification { + return "s3:PutBucketNotification" + } + if _, hasObjectLock := query["object-lock"]; hasObjectLock { + return "s3:PutBucketObjectLockConfiguration" + } + // Default bucket creation + return "s3:CreateBucket" + + case "DELETE": + // Bucket delete operations - check for specific query parameters + if _, hasPolicy := query["policy"]; hasPolicy { + return "s3:DeleteBucketPolicy" + } + if _, hasTagging := query["tagging"]; hasTagging { + return "s3:DeleteBucketTagging" + } + if _, hasCors := query["cors"]; hasCors { + return "s3:DeleteBucketCors" + } + // Default bucket delete + return "s3:DeleteBucket" + } + } + + // Fallback to legacy mapping for specific known actions + return mapLegacyActionToIAM(fallbackAction) +} + +// hasSpecificQueryParameters checks if the request has query parameters that indicate specific granular operations +func hasSpecificQueryParameters(query url.Values) bool { + // Check for object-level operation indicators + objectParams := []string{ + "acl", // ACL operations + "tagging", // Tagging operations + "retention", // Object retention + "legal-hold", // Legal hold + "versions", // Versioning operations + } + + // Check for multipart operation indicators + multipartParams := []string{ + "uploads", // List/initiate multipart uploads + "uploadId", // Part operations, complete, abort + "partNumber", // Upload part + } + + // Check for bucket-level operation indicators + bucketParams := []string{ + "policy", // Bucket policy operations + "website", // Website configuration + "cors", // CORS configuration + "lifecycle", // Lifecycle configuration + "notification", // Event notification + "replication", // Cross-region replication + "encryption", // Server-side encryption + "accelerate", // Transfer acceleration + "requestPayment", // Request payment + "logging", // Access logging + "versioning", // Versioning configuration + "inventory", // Inventory configuration + "analytics", // Analytics configuration + "metrics", // CloudWatch metrics + "location", // Bucket location + } + + // Check if any of these parameters are present + allParams := append(append(objectParams, multipartParams...), bucketParams...) + for _, param := range allParams { + if _, exists := query[param]; exists { + return true + } + } + + return false +} + +// isMethodActionMismatch detects when HTTP method doesn't align with the intended S3 action +// This provides a mechanism to use fallback action mapping when there's a semantic mismatch +func isMethodActionMismatch(method string, fallbackAction Action) bool { + switch fallbackAction { + case s3_constants.ACTION_WRITE: + // WRITE actions should typically use PUT, POST, or DELETE methods + // GET/HEAD methods indicate read-oriented operations + return method == "GET" || method == "HEAD" + + case s3_constants.ACTION_READ: + // READ actions should typically use GET or HEAD methods + // PUT, POST, DELETE methods indicate write-oriented operations + return method == "PUT" || method == "POST" || method == "DELETE" + + case s3_constants.ACTION_LIST: + // LIST actions should typically use GET method + // PUT, POST, DELETE methods indicate write-oriented operations + return method == "PUT" || method == "POST" || method == "DELETE" + + case s3_constants.ACTION_DELETE_BUCKET: + // DELETE_BUCKET should use DELETE method + // Other methods indicate different operation types + return method != "DELETE" + + default: + // For unknown actions or actions that already have s3: prefix, don't assume mismatch + return false + } +} + +// mapLegacyActionToIAM provides fallback mapping for legacy actions +// This ensures backward compatibility while the system transitions to granular actions +func mapLegacyActionToIAM(legacyAction Action) string { + switch legacyAction { + case s3_constants.ACTION_READ: + return "s3:GetObject" // Fallback for unmapped read operations + case s3_constants.ACTION_WRITE: + return "s3:PutObject" // Fallback for unmapped write operations + case s3_constants.ACTION_LIST: + return "s3:ListBucket" // Fallback for unmapped list operations + case s3_constants.ACTION_TAGGING: + return "s3:GetObjectTagging" // Fallback for unmapped tagging operations + case s3_constants.ACTION_READ_ACP: + return "s3:GetObjectAcl" // Fallback for unmapped ACL read operations + case s3_constants.ACTION_WRITE_ACP: + return "s3:PutObjectAcl" // Fallback for unmapped ACL write operations + case s3_constants.ACTION_DELETE_BUCKET: + return "s3:DeleteBucket" // Fallback for unmapped bucket delete operations + case s3_constants.ACTION_ADMIN: + return "s3:*" // Fallback for unmapped admin operations + + // Handle granular multipart actions (already correctly mapped) + case s3_constants.ACTION_CREATE_MULTIPART_UPLOAD: + return "s3:CreateMultipartUpload" + case s3_constants.ACTION_UPLOAD_PART: + return "s3:UploadPart" + case s3_constants.ACTION_COMPLETE_MULTIPART: + return "s3:CompleteMultipartUpload" + case s3_constants.ACTION_ABORT_MULTIPART: + return "s3:AbortMultipartUpload" + case s3_constants.ACTION_LIST_MULTIPART_UPLOADS: + return "s3:ListMultipartUploads" + case s3_constants.ACTION_LIST_PARTS: + return "s3:ListParts" + + default: + // If it's already a properly formatted S3 action, return as-is + actionStr := string(legacyAction) + if strings.HasPrefix(actionStr, "s3:") { + return actionStr + } + // Fallback: convert to S3 action format + return "s3:" + actionStr + } +} + +// extractRequestContext extracts request context for policy conditions +func extractRequestContext(r *http.Request) map[string]interface{} { + context := make(map[string]interface{}) + + // Extract source IP for IP-based conditions + sourceIP := extractSourceIP(r) + if sourceIP != "" { + context["sourceIP"] = sourceIP + } + + // Extract user agent + if userAgent := r.Header.Get("User-Agent"); userAgent != "" { + context["userAgent"] = userAgent + } + + // Extract request time + context["requestTime"] = r.Context().Value("requestTime") + + // Extract additional headers that might be useful for conditions + if referer := r.Header.Get("Referer"); referer != "" { + context["referer"] = referer + } + + return context +} + +// extractSourceIP extracts the real source IP from the request +func extractSourceIP(r *http.Request) string { + // Check X-Forwarded-For header (most common for proxied requests) + if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" { + // X-Forwarded-For can contain multiple IPs, take the first one + if ips := strings.Split(forwardedFor, ","); len(ips) > 0 { + return strings.TrimSpace(ips[0]) + } + } + + // Check X-Real-IP header + if realIP := r.Header.Get("X-Real-IP"); realIP != "" { + return strings.TrimSpace(realIP) + } + + // Fall back to RemoteAddr + if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return ip + } + + return r.RemoteAddr +} + +// parseJWTToken parses a JWT token and returns its claims without verification +// Note: This is for extracting claims only. Verification is done by the IAM system. +func parseJWTToken(tokenString string) (jwt.MapClaims, error) { + token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{}) + if err != nil { + return nil, fmt.Errorf("failed to parse JWT token: %v", err) + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, fmt.Errorf("invalid token claims") + } + + return claims, nil +} + +// minInt returns the minimum of two integers +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +// SetIAMIntegration adds advanced IAM integration to the S3ApiServer +func (s3a *S3ApiServer) SetIAMIntegration(iamManager *integration.IAMManager) { + if s3a.iam != nil { + s3a.iam.iamIntegration = NewS3IAMIntegration(iamManager, "localhost:8888") + glog.V(0).Infof("IAM integration successfully set on S3ApiServer") + } else { + glog.Errorf("Cannot set IAM integration: s3a.iam is nil") + } +} + +// EnhancedS3ApiServer extends S3ApiServer with IAM integration +type EnhancedS3ApiServer struct { + *S3ApiServer + iamIntegration *S3IAMIntegration +} + +// NewEnhancedS3ApiServer creates an S3 API server with IAM integration +func NewEnhancedS3ApiServer(baseServer *S3ApiServer, iamManager *integration.IAMManager) *EnhancedS3ApiServer { + // Set the IAM integration on the base server + baseServer.SetIAMIntegration(iamManager) + + return &EnhancedS3ApiServer{ + S3ApiServer: baseServer, + iamIntegration: NewS3IAMIntegration(iamManager, "localhost:8888"), + } +} + +// AuthenticateJWTRequest handles JWT authentication for S3 requests +func (enhanced *EnhancedS3ApiServer) AuthenticateJWTRequest(r *http.Request) (*Identity, s3err.ErrorCode) { + ctx := r.Context() + + // Use our IAM integration for JWT authentication + iamIdentity, errCode := enhanced.iamIntegration.AuthenticateJWT(ctx, r) + if errCode != s3err.ErrNone { + return nil, errCode + } + + // Convert IAMIdentity to the existing Identity structure + identity := &Identity{ + Name: iamIdentity.Name, + Account: iamIdentity.Account, + // Note: Actions will be determined by policy evaluation + Actions: []Action{}, // Empty - authorization handled by policy engine + } + + // Store session token for later authorization + r.Header.Set("X-SeaweedFS-Session-Token", iamIdentity.SessionToken) + r.Header.Set("X-SeaweedFS-Principal", iamIdentity.Principal) + + return identity, s3err.ErrNone +} + +// AuthorizeRequest handles authorization for S3 requests using policy engine +func (enhanced *EnhancedS3ApiServer) AuthorizeRequest(r *http.Request, identity *Identity, action Action) s3err.ErrorCode { + ctx := r.Context() + + // Get session info from request headers (set during authentication) + sessionToken := r.Header.Get("X-SeaweedFS-Session-Token") + principal := r.Header.Get("X-SeaweedFS-Principal") + + if sessionToken == "" || principal == "" { + glog.V(3).Info("No session information available for authorization") + return s3err.ErrAccessDenied + } + + // Extract bucket and object from request + bucket, object := s3_constants.GetBucketAndObject(r) + prefix := s3_constants.GetPrefix(r) + + // For List operations, use prefix for permission checking if available + if action == s3_constants.ACTION_LIST && object == "" && prefix != "" { + object = prefix + } else if (object == "/" || object == "") && prefix != "" { + object = prefix + } + + // Create IAM identity for authorization + iamIdentity := &IAMIdentity{ + Name: identity.Name, + Principal: principal, + SessionToken: sessionToken, + Account: identity.Account, + } + + // Use our IAM integration for authorization + return enhanced.iamIntegration.AuthorizeAction(ctx, iamIdentity, action, bucket, object, r) +} + +// OIDCIdentity represents an identity validated through OIDC +type OIDCIdentity struct { + UserID string + RoleArn string + Provider string +} + +// validateExternalOIDCToken validates an external OIDC token using the STS service's secure issuer-based lookup +// This method delegates to the STS service's validateWebIdentityToken for better security and efficiency +func (s3iam *S3IAMIntegration) validateExternalOIDCToken(ctx context.Context, token string) (*OIDCIdentity, error) { + + if s3iam.iamManager == nil { + return nil, fmt.Errorf("IAM manager not available") + } + + // Get STS service for secure token validation + stsService := s3iam.iamManager.GetSTSService() + if stsService == nil { + return nil, fmt.Errorf("STS service not available") + } + + // Use the STS service's secure validateWebIdentityToken method + // This method uses issuer-based lookup to select the correct provider, which is more secure and efficient + externalIdentity, provider, err := stsService.ValidateWebIdentityToken(ctx, token) + if err != nil { + return nil, fmt.Errorf("token validation failed: %w", err) + } + + if externalIdentity == nil { + return nil, fmt.Errorf("authentication succeeded but no identity returned") + } + + // Extract role from external identity attributes + rolesAttr, exists := externalIdentity.Attributes["roles"] + if !exists || rolesAttr == "" { + glog.V(3).Infof("No roles found in external identity") + return nil, fmt.Errorf("no roles found in external identity") + } + + // Parse roles (stored as comma-separated string) + rolesStr := strings.TrimSpace(rolesAttr) + roles := strings.Split(rolesStr, ",") + + // Clean up role names + var cleanRoles []string + for _, role := range roles { + cleanRole := strings.TrimSpace(role) + if cleanRole != "" { + cleanRoles = append(cleanRoles, cleanRole) + } + } + + if len(cleanRoles) == 0 { + glog.V(3).Infof("Empty roles list after parsing") + return nil, fmt.Errorf("no valid roles found in token") + } + + // Determine the primary role using intelligent selection + roleArn := s3iam.selectPrimaryRole(cleanRoles, externalIdentity) + + return &OIDCIdentity{ + UserID: externalIdentity.UserID, + RoleArn: roleArn, + Provider: fmt.Sprintf("%T", provider), // Use provider type as identifier + }, nil +} + +// selectPrimaryRole simply picks the first role from the list +// The OIDC provider should return roles in priority order (most important first) +func (s3iam *S3IAMIntegration) selectPrimaryRole(roles []string, externalIdentity *providers.ExternalIdentity) string { + if len(roles) == 0 { + return "" + } + + // Just pick the first one - keep it simple + selectedRole := roles[0] + return selectedRole +} + +// isSTSIssuer determines if an issuer belongs to the STS service +// Uses exact match against configured STS issuer for security and correctness +func (s3iam *S3IAMIntegration) isSTSIssuer(issuer string) bool { + if s3iam.stsService == nil || s3iam.stsService.Config == nil { + return false + } + + // Directly compare with the configured STS issuer for exact match + // This prevents false positives from external OIDC providers that might + // contain STS-related keywords in their issuer URLs + return issuer == s3iam.stsService.Config.Issuer +} |
