mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
* Add ResolveIdentityClaim helper for OIDC audit identity ComputeParentUser derives a stable per-identity hash from (sub, iss) for internal keying, but it is opaque and not human-readable. Audit logs for STS-assumed OIDC sessions currently surface that opaque value (or the random session id) as the requester, leaving no authoritative trace of the federated user. Add ResolveIdentityClaim next to ComputeParentUser to recover a human-readable, server-asserted identity attribute from the STS request context populated at federation time. It walks a priority list (preferred_username, email, name, sub) so a federated session always audits against a stable OIDC claim rather than a client-supplied role session name. For #11264 * Surface authoritative OIDC identity claim in S3 audit log For STS-assumed sessions minted from an OIDC web identity, the audit log requester field is the opaque session subject, which cannot be traced back to the federated user who performed the operation. The OIDC identity claims (preferred_username, email, sub) are already carried in the session request context and reach the auth layer as identity.Claims, but they were never surfaced to the audit log. Add a requester_identity field to the S3 access audit log, populated from the authoritative OIDC identity claim resolved via ResolveIdentityClaim. The claim is propagated through the shared identity holder (the same mechanism the requester name and principal ARN already use) so it survives the request-context copy that hides auth-set values from the outer audit middleware. The existing requester field is left unchanged for backward compatibility; requester_identity is empty for non-federated sessions, where requester already carries the real username. For #11264 * Gate OIDC audit identity on federation marker and harden resolver Address review feedback (Devin Review, Greptile) on the initial implementation: - Non-federated STS sessions no longer gain a false requester_identity. ValidateJWTWithClaims merges the JWT registered sub claim (the opaque session id) into RequestContext for sessions without an explicit request context, so the previous ResolveIdentityClaim fallback to sub surfaced that session id as an authoritative identity. Resolution is now gated on SessionInfo.ParentUser, which is set only for OIDC-federated sessions in AssumeRoleWithWebIdentity. The claim is resolved from the original sessionInfo.RequestContext (not the local claims map, whose sub the bearer path overwrites with the session subject) so SigV4 and bearer sessions surface the same identity. - ResolveIdentityClaim now trims whitespace and treats whitespace-only claims as absent, so a blank preferred_username no longer masks a usable email or sub. The resolved claim is carried on Identity.IdentityClaim (and IAMIdentity for the bearer path) rather than re-derived in recordIdentityInContext, making the federation gate explicit at the auth boundary. For #11264 * Resolve OIDC identity claim for external bearer tokens The external OIDC bearer path (a raw OIDC JWT presented directly, not via STS) populates Claims with preferred_username/email/name/sub from the validated token but did not set IdentityClaim, so requester_identity stayed blank for that authentication path. Resolve the claim there too — sub is the real OIDC subject on this path (not an STS session id), so no federation gate is needed. Also drop an ineffectual ctx assignment flagged by ineffassign in the audit test. For #11264
562 lines
19 KiB
Go
562 lines
19 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"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/s3err"
|
|
"github.com/seaweedfs/seaweedfs/weed/security"
|
|
)
|
|
|
|
// IAMIntegration defines the interface for IAM integration
|
|
type IAMIntegration interface {
|
|
AuthenticateJWT(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode)
|
|
AuthorizeAction(ctx context.Context, identity *IAMIdentity, action Action, bucket string, objectKey string, r *http.Request) s3err.ErrorCode
|
|
ValidateSessionToken(ctx context.Context, token string) (*sts.SessionInfo, error)
|
|
ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error
|
|
DefaultAllow() bool
|
|
}
|
|
|
|
// IAMManagerProvider exposes the IAMManager backing an IAM integration.
|
|
type IAMManagerProvider interface {
|
|
GetIAMManager() *integration.IAMManager
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
// GetIAMManager returns the IAMManager backing this integration.
|
|
func (s3iam *S3IAMIntegration) GetIAMManager() *integration.IAMManager {
|
|
return s3iam.iamManager
|
|
}
|
|
|
|
// 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, security.BearerPrefix) {
|
|
return nil, s3err.ErrAccessDenied
|
|
}
|
|
|
|
sessionToken := strings.TrimPrefix(authHeader, security.BearerPrefix)
|
|
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
|
|
}
|
|
|
|
// SECURITY NOTE: ParseJWTToken parses without cryptographic verification
|
|
// This is SAFE because we only use the unverified claims to route to the correct
|
|
// verification method. All code paths below perform full cryptographic verification:
|
|
// - OIDC tokens: validated via validateExternalOIDCToken (line 98)
|
|
// - STS tokens: validated via ValidateSessionToken (line 156)
|
|
// The unverified issuer claim is only used for routing, never for authorization.
|
|
tokenClaims, err := ParseUnverifiedJWTToken(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)
|
|
// We use the unverified claims ONLY for routing to the correct verification method.
|
|
// We DO NOT use these claims for building the identity.
|
|
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
|
|
}
|
|
|
|
// Same trust-policy gate as AssumeRoleWithWebIdentity: the role mapping
|
|
// alone must not grant a role that STS would refuse for this token.
|
|
if err := s3iam.iamManager.ValidateTrustPolicyForWebIdentity(ctx, identity.RoleArn, sessionToken, nil); err != nil {
|
|
glog.V(3).Infof("OIDC bearer token rejected by trust policy of role %s: %v", identity.RoleArn, err)
|
|
return nil, s3err.ErrAccessDenied
|
|
}
|
|
|
|
// Create claims map and populate with standard claims and attributes
|
|
claims := make(map[string]interface{}, len(identity.Attributes)+5)
|
|
|
|
// Add all attributes from the identity to the claims
|
|
// This makes attributes like "preferred_username" available for policy substitution
|
|
for k, v := range identity.Attributes {
|
|
claims[k] = v
|
|
}
|
|
|
|
// Add standard OIDC fields to claims so they are available as variables
|
|
// This ensures ${jwt:email}, ${jwt:name}, etc. work as documented in the wiki
|
|
if identity.Email != "" {
|
|
claims["email"] = identity.Email
|
|
}
|
|
if identity.DisplayName != "" {
|
|
claims["name"] = identity.DisplayName
|
|
}
|
|
if len(identity.Groups) > 0 {
|
|
claims["groups"] = identity.Groups
|
|
}
|
|
|
|
// Set critical claims explicitly, overwriting any from attributes to ensure correctness.
|
|
claims["sub"] = identity.UserID
|
|
claims["role"] = identity.RoleArn
|
|
|
|
// Use real email address if available
|
|
emailAddress := identity.UserID + "@oidc.local"
|
|
if identity.Email != "" {
|
|
emailAddress = identity.Email
|
|
}
|
|
|
|
displayName := identity.UserID
|
|
if identity.DisplayName != "" {
|
|
displayName = identity.DisplayName
|
|
}
|
|
|
|
// Return IAM identity for OIDC token
|
|
return &IAMIdentity{
|
|
Name: identity.UserID,
|
|
Principal: identity.RoleArn,
|
|
SessionToken: sessionToken,
|
|
Account: &Account{
|
|
DisplayName: displayName,
|
|
EmailAddress: emailAddress,
|
|
Id: identity.UserID,
|
|
},
|
|
Claims: claims,
|
|
IdentityClaim: sts.ResolveIdentityClaim(claims),
|
|
}, s3err.ErrNone
|
|
}
|
|
|
|
// This is an STS-issued token - validate with STS service
|
|
// ValidateSessionToken performs cryptographic verification and extraction of trusted claims
|
|
sessionInfo, err := s3iam.stsService.ValidateSessionToken(ctx, sessionToken)
|
|
if err != nil {
|
|
glog.V(3).Infof("STS session validation failed: %v", err)
|
|
return nil, s3err.ErrAccessDenied
|
|
}
|
|
|
|
// Create claims map starting with request context (which holds custom claims)
|
|
claims := make(map[string]interface{})
|
|
if sessionInfo.RequestContext != nil {
|
|
for k, v := range sessionInfo.RequestContext {
|
|
claims[k] = v
|
|
}
|
|
}
|
|
|
|
// Add standard claims
|
|
claims["sub"] = sessionInfo.Subject
|
|
claims["role"] = sessionInfo.RoleArn
|
|
claims["principal"] = sessionInfo.Principal
|
|
claims["snam"] = sessionInfo.SessionName
|
|
|
|
// Create IAM identity from VALIDATED session info
|
|
// We use the trusted data returned by the STS service, not the unverified token claims
|
|
identity := &IAMIdentity{
|
|
Name: sessionInfo.Subject,
|
|
Principal: sessionInfo.Principal,
|
|
SessionToken: sessionToken,
|
|
Account: &Account{
|
|
DisplayName: sessionInfo.SessionName,
|
|
EmailAddress: sessionInfo.Subject + "@seaweedfs.local",
|
|
Id: sessionInfo.Subject,
|
|
},
|
|
Claims: claims,
|
|
}
|
|
// ParentUser is set only for OIDC-federated sessions. Resolve the audit
|
|
// identity claim from the original request context (not the local claims
|
|
// map, whose sub was overwritten with the opaque session subject above) so
|
|
// the bearer path surfaces the same authoritative OIDC identity as SigV4.
|
|
if sessionInfo.ParentUser != "" {
|
|
identity.IdentityClaim = sts.ResolveIdentityClaim(sessionInfo.RequestContext)
|
|
}
|
|
|
|
glog.V(3).Infof("JWT authentication successful for principal: %s", identity.Principal)
|
|
return identity, s3err.ErrNone
|
|
}
|
|
|
|
// ValidateSessionToken checks the validity of an STS session token
|
|
func (s3iam *S3IAMIntegration) ValidateSessionToken(ctx context.Context, token string) (*sts.SessionInfo, error) {
|
|
if s3iam.stsService == nil {
|
|
return nil, fmt.Errorf("STS service not available")
|
|
}
|
|
return s3iam.stsService.ValidateSessionToken(ctx, token)
|
|
}
|
|
|
|
// 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 == nil || identity.Principal == "" {
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
|
|
// Extract request context for policy conditions
|
|
requestContext := extractRequestContext(r)
|
|
|
|
// For list operations, populate the s3:prefix condition key and ensure the
|
|
// resource ARN stays at bucket level (matching AWS ListBucket semantics).
|
|
// See https://github.com/seaweedfs/seaweedfs/issues/8969
|
|
resourceObjectKey := objectKey
|
|
if action == "List" {
|
|
listPrefix := r.URL.Query().Get("prefix")
|
|
if listPrefix != "" {
|
|
requestContext["s3:prefix"] = listPrefix
|
|
} else if objectKey != "" && objectKey != "/" {
|
|
requestContext["s3:prefix"] = objectKey
|
|
} else {
|
|
requestContext["s3:prefix"] = ""
|
|
}
|
|
resourceObjectKey = ""
|
|
}
|
|
|
|
// Build resource ARN for the S3 operation
|
|
resourceArn := buildS3ResourceArn(bucket, resourceObjectKey)
|
|
|
|
// Add identity claims to request context for policy variables
|
|
// Only add claim keys if they don't already exist (to avoid overwriting request-derived context)
|
|
if identity.Claims != nil {
|
|
for k, v := range identity.Claims {
|
|
// Only add the claim if this key doesn't already exist in request context
|
|
if _, exists := requestContext[k]; !exists {
|
|
requestContext[k] = v
|
|
}
|
|
|
|
// If the claim doesn't have a namespace prefix (e.g. "email"), add "jwt:" prefix
|
|
// This allows ${jwt:email} or ${jwt:preferred_username} to work
|
|
// Only add namespaced version if it doesn't already exist
|
|
if !strings.Contains(k, ":") {
|
|
jwtKey := "jwt:" + k
|
|
if _, exists := requestContext[jwtKey]; !exists {
|
|
requestContext[jwtKey] = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Determine the specific S3 action based on the HTTP request details
|
|
specificAction := ResolveS3Action(r, string(action), bucket, objectKey)
|
|
|
|
// Create action request
|
|
actionRequest := &integration.ActionRequest{
|
|
Principal: identity.Principal,
|
|
Action: specificAction,
|
|
Resource: resourceArn,
|
|
// Static SigV4 IAM users do not carry a session token. IAMManager
|
|
// evaluates their attached policies directly and only validates STS/OIDC
|
|
// session state when a token is actually present.
|
|
SessionToken: identity.SessionToken,
|
|
RequestContext: requestContext,
|
|
PolicyNames: identity.PolicyNames,
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ValidateTrustPolicyForPrincipal delegates to IAMManager to validate trust policy
|
|
func (s3iam *S3IAMIntegration) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
|
|
if s3iam.iamManager == nil {
|
|
return fmt.Errorf("IAM manager not available")
|
|
}
|
|
return s3iam.iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn, principalArn)
|
|
}
|
|
|
|
// DefaultAllow returns whether access is allowed by default when no policy is found
|
|
func (s3iam *S3IAMIntegration) DefaultAllow() bool {
|
|
if s3iam.iamManager == nil {
|
|
return true // Default to true if IAM is not enabled
|
|
}
|
|
return s3iam.iamManager.DefaultAllow()
|
|
}
|
|
|
|
// IAMIdentity represents an authenticated identity with session information
|
|
type IAMIdentity struct {
|
|
Name string
|
|
Principal string
|
|
SessionToken string
|
|
Account *Account
|
|
PolicyNames []string
|
|
Claims map[string]interface{}
|
|
IdentityClaim string // Authoritative OIDC identity claim for audit logging; empty for non-federated sessions
|
|
}
|
|
|
|
// 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 is already an ARN, return it as-is
|
|
if strings.HasPrefix(bucket, "arn:") {
|
|
return bucket
|
|
}
|
|
|
|
if bucket == "" {
|
|
return "arn:aws:s3:::*"
|
|
}
|
|
|
|
if objectKey == "" || objectKey == "/" {
|
|
return "arn:aws:s3:::" + bucket
|
|
}
|
|
|
|
// Remove leading slash from object key if present
|
|
objectKey = strings.TrimPrefix(objectKey, "/")
|
|
|
|
return "arn:aws:s3:::" + bucket + "/" + objectKey
|
|
}
|
|
|
|
// 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
|
|
// Use AWS-compatible key name for policy variable substitution
|
|
sourceIP := extractSourceIP(r)
|
|
if sourceIP != "" {
|
|
context["aws: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 returns the direct TCP peer address for aws:SourceIp
|
|
// condition evaluation. Forwarding headers (X-Forwarded-For, X-Real-IP) are
|
|
// intentionally ignored: without a configurable trusted-proxy allowlist they
|
|
// are client-controlled and spoofable, which would let a caller behind a
|
|
// private-looking peer bypass any aws:SourceIp restriction.
|
|
func extractSourceIP(r *http.Request) string {
|
|
remoteIP := r.RemoteAddr
|
|
if ip, _, err := net.SplitHostPort(remoteIP); err == nil {
|
|
remoteIP = ip
|
|
}
|
|
return remoteIP
|
|
}
|
|
|
|
// ParseUnverifiedJWTToken parses a JWT token and returns its claims WITHOUT cryptographic verification
|
|
//
|
|
// SECURITY WARNING: This function does NOT validate the token signature!
|
|
// It should ONLY be used for:
|
|
// 1. Routing tokens to the appropriate verification method (e.g., checking issuer to determine STS vs OIDC)
|
|
// 2. Extracting claims for logging/debugging AFTER the token has been cryptographically verified
|
|
//
|
|
// NEVER use the returned claims for authorization decisions without first calling a proper
|
|
// verification function like ValidateSessionToken() or validateExternalOIDCToken().
|
|
func ParseUnverifiedJWTToken(tokenString string) (jwt.MapClaims, error) {
|
|
// Parse token without verification to get claims
|
|
// This token IS NOT VERIFIED at this stage.
|
|
// It is only used to peek at claims (like issuer) to determine which verification key/strategy to use.
|
|
token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if claims, ok := token.Claims.(jwt.MapClaims); ok {
|
|
return claims, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("invalid token claims")
|
|
}
|
|
|
|
// 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(1).Infof("IAM integration successfully set on S3ApiServer")
|
|
} else {
|
|
glog.Errorf("Cannot set IAM integration: s3a.iam is nil")
|
|
}
|
|
}
|
|
|
|
// OIDCIdentity represents an identity validated through OIDC
|
|
type OIDCIdentity struct {
|
|
UserID string
|
|
RoleArn string
|
|
Provider string
|
|
Email string
|
|
DisplayName string
|
|
Groups []string
|
|
Attributes map[string]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
|
|
Email: externalIdentity.Email,
|
|
DisplayName: externalIdentity.DisplayName,
|
|
Groups: externalIdentity.Groups,
|
|
Attributes: externalIdentity.Attributes,
|
|
}, 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
|
|
}
|