package s3api import ( "context" "crypto/sha256" "encoding/hex" "fmt" "net/http" "net/url" "strconv" "strings" "time" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" ) // S3PresignedURLManager handles IAM integration for presigned URLs type S3PresignedURLManager struct { s3iam *S3IAMIntegration } // NewS3PresignedURLManager creates a new presigned URL manager with IAM integration func NewS3PresignedURLManager(s3iam *S3IAMIntegration) *S3PresignedURLManager { return &S3PresignedURLManager{ s3iam: s3iam, } } // PresignedURLRequest represents a request to generate a presigned URL type PresignedURLRequest struct { Method string `json:"method"` // HTTP method (GET, PUT, POST, DELETE) Bucket string `json:"bucket"` // S3 bucket name ObjectKey string `json:"object_key"` // S3 object key Expiration time.Duration `json:"expiration"` // URL expiration duration SessionToken string `json:"session_token"` // JWT session token for IAM Headers map[string]string `json:"headers"` // Additional headers to sign QueryParams map[string]string `json:"query_params"` // Additional query parameters } // PresignedURLResponse represents the generated presigned URL type PresignedURLResponse struct { URL string `json:"url"` // The presigned URL Method string `json:"method"` // HTTP method Headers map[string]string `json:"headers"` // Required headers ExpiresAt time.Time `json:"expires_at"` // URL expiration time SignedHeaders []string `json:"signed_headers"` // List of signed headers CanonicalQuery string `json:"canonical_query"` // Canonical query string } // ValidatePresignedURLWithIAM validates a presigned URL request using IAM policies func (iam *IdentityAccessManagement) ValidatePresignedURLWithIAM(r *http.Request, identity *Identity) s3err.ErrorCode { if iam.iamIntegration == nil { // Fall back to standard validation return s3err.ErrNone } // Extract bucket and object from request bucket, object := s3_constants.GetBucketAndObject(r) // Determine the S3 action from HTTP method and path action := determineS3ActionFromRequest(r, bucket, object) // Check if the user has permission for this action ctx := r.Context() sessionToken := extractSessionTokenFromPresignedURL(r) if sessionToken == "" { // No session token in presigned URL - use standard auth return s3err.ErrNone } // Parse JWT token to extract role and session information tokenClaims, err := parseJWTToken(sessionToken) if err != nil { glog.V(3).Infof("Failed to parse JWT token in presigned URL: %v", err) return s3err.ErrAccessDenied } // Extract role information from token claims roleName, ok := tokenClaims["role"].(string) if !ok || roleName == "" { glog.V(3).Info("No role found in JWT token for presigned URL") return s3err.ErrAccessDenied } sessionName, ok := tokenClaims["snam"].(string) if !ok || sessionName == "" { sessionName = "presigned-session" // 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) } // Create IAM identity for authorization using extracted information iamIdentity := &IAMIdentity{ Name: identity.Name, Principal: principalArn, SessionToken: sessionToken, Account: identity.Account, } // Authorize using IAM errCode := iam.iamIntegration.AuthorizeAction(ctx, iamIdentity, action, bucket, object, r) if errCode != s3err.ErrNone { glog.V(3).Infof("IAM authorization failed for presigned URL: principal=%s action=%s bucket=%s object=%s", iamIdentity.Principal, action, bucket, object) return errCode } glog.V(3).Infof("IAM authorization succeeded for presigned URL: principal=%s action=%s bucket=%s object=%s", iamIdentity.Principal, action, bucket, object) return s3err.ErrNone } // GeneratePresignedURLWithIAM generates a presigned URL with IAM policy validation func (pm *S3PresignedURLManager) GeneratePresignedURLWithIAM(ctx context.Context, req *PresignedURLRequest, baseURL string) (*PresignedURLResponse, error) { if pm.s3iam == nil || !pm.s3iam.enabled { return nil, fmt.Errorf("IAM integration not enabled") } // Validate session token and get identity // Use a proper ARN format for the principal principalArn := fmt.Sprintf("arn:seaweed:sts::assumed-role/PresignedUser/presigned-session") iamIdentity := &IAMIdentity{ SessionToken: req.SessionToken, Principal: principalArn, Name: "presigned-user", Account: &AccountAdmin, } // Determine S3 action from method action := determineS3ActionFromMethodAndPath(req.Method, req.Bucket, req.ObjectKey) // Check IAM permissions before generating URL authRequest := &http.Request{ Method: req.Method, URL: &url.URL{Path: "/" + req.Bucket + "/" + req.ObjectKey}, Header: make(http.Header), } authRequest.Header.Set("Authorization", "Bearer "+req.SessionToken) authRequest = authRequest.WithContext(ctx) errCode := pm.s3iam.AuthorizeAction(ctx, iamIdentity, action, req.Bucket, req.ObjectKey, authRequest) if errCode != s3err.ErrNone { return nil, fmt.Errorf("IAM authorization failed: user does not have permission for action %s on resource %s/%s", action, req.Bucket, req.ObjectKey) } // Generate presigned URL with validated permissions return pm.generatePresignedURL(req, baseURL, iamIdentity) } // generatePresignedURL creates the actual presigned URL func (pm *S3PresignedURLManager) generatePresignedURL(req *PresignedURLRequest, baseURL string, identity *IAMIdentity) (*PresignedURLResponse, error) { // Calculate expiration time expiresAt := time.Now().Add(req.Expiration) // Build the base URL urlPath := "/" + req.Bucket if req.ObjectKey != "" { urlPath += "/" + req.ObjectKey } // Create query parameters for AWS signature v4 queryParams := make(map[string]string) for k, v := range req.QueryParams { queryParams[k] = v } // Add AWS signature v4 parameters queryParams["X-Amz-Algorithm"] = "AWS4-HMAC-SHA256" queryParams["X-Amz-Credential"] = fmt.Sprintf("seaweedfs/%s/us-east-1/s3/aws4_request", expiresAt.Format("20060102")) queryParams["X-Amz-Date"] = expiresAt.Format("20060102T150405Z") queryParams["X-Amz-Expires"] = strconv.Itoa(int(req.Expiration.Seconds())) queryParams["X-Amz-SignedHeaders"] = "host" // Add session token if available if identity.SessionToken != "" { queryParams["X-Amz-Security-Token"] = identity.SessionToken } // Build canonical query string canonicalQuery := buildCanonicalQuery(queryParams) // For now, we'll create a mock signature // In production, this would use proper AWS signature v4 signing mockSignature := generateMockSignature(req.Method, urlPath, canonicalQuery, identity.SessionToken) queryParams["X-Amz-Signature"] = mockSignature // Build final URL finalQuery := buildCanonicalQuery(queryParams) fullURL := baseURL + urlPath + "?" + finalQuery // Prepare response headers := make(map[string]string) for k, v := range req.Headers { headers[k] = v } return &PresignedURLResponse{ URL: fullURL, Method: req.Method, Headers: headers, ExpiresAt: expiresAt, SignedHeaders: []string{"host"}, CanonicalQuery: canonicalQuery, }, nil } // Helper functions // determineS3ActionFromRequest determines the S3 action based on HTTP request func determineS3ActionFromRequest(r *http.Request, bucket, object string) Action { return determineS3ActionFromMethodAndPath(r.Method, bucket, object) } // determineS3ActionFromMethodAndPath determines the S3 action based on method and path func determineS3ActionFromMethodAndPath(method, bucket, object string) Action { switch method { case "GET": if object == "" { return s3_constants.ACTION_LIST // ListBucket } else { return s3_constants.ACTION_READ // GetObject } case "PUT", "POST": return s3_constants.ACTION_WRITE // PutObject case "DELETE": if object == "" { return s3_constants.ACTION_DELETE_BUCKET // DeleteBucket } else { return s3_constants.ACTION_WRITE // DeleteObject (uses WRITE action) } case "HEAD": if object == "" { return s3_constants.ACTION_LIST // HeadBucket } else { return s3_constants.ACTION_READ // HeadObject } default: return s3_constants.ACTION_READ // Default to read } } // extractSessionTokenFromPresignedURL extracts session token from presigned URL query parameters func extractSessionTokenFromPresignedURL(r *http.Request) string { // Check for X-Amz-Security-Token in query parameters if token := r.URL.Query().Get("X-Amz-Security-Token"); token != "" { return token } // Check for session token in other possible locations if token := r.URL.Query().Get("SessionToken"); token != "" { return token } return "" } // buildCanonicalQuery builds a canonical query string for AWS signature func buildCanonicalQuery(params map[string]string) string { var keys []string for k := range params { keys = append(keys, k) } // Sort keys for canonical order for i := 0; i < len(keys); i++ { for j := i + 1; j < len(keys); j++ { if keys[i] > keys[j] { keys[i], keys[j] = keys[j], keys[i] } } } var parts []string for _, k := range keys { parts = append(parts, fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(params[k]))) } return strings.Join(parts, "&") } // generateMockSignature generates a mock signature for testing purposes func generateMockSignature(method, path, query, sessionToken string) string { // This is a simplified signature for demonstration // In production, use proper AWS signature v4 calculation data := fmt.Sprintf("%s\n%s\n%s\n%s", method, path, query, sessionToken) hash := sha256.Sum256([]byte(data)) return hex.EncodeToString(hash[:])[:16] // Truncate for readability } // ValidatePresignedURLExpiration validates that a presigned URL hasn't expired func ValidatePresignedURLExpiration(r *http.Request) error { query := r.URL.Query() // Get X-Amz-Date and X-Amz-Expires dateStr := query.Get("X-Amz-Date") expiresStr := query.Get("X-Amz-Expires") if dateStr == "" || expiresStr == "" { return fmt.Errorf("missing required presigned URL parameters") } // Parse date (always in UTC) signedDate, err := time.Parse("20060102T150405Z", dateStr) if err != nil { return fmt.Errorf("invalid X-Amz-Date format: %v", err) } // Parse expires expires, err := strconv.Atoi(expiresStr) if err != nil { return fmt.Errorf("invalid X-Amz-Expires format: %v", err) } // Check expiration - compare in UTC expirationTime := signedDate.Add(time.Duration(expires) * time.Second) now := time.Now().UTC() if now.After(expirationTime) { return fmt.Errorf("presigned URL has expired") } return nil } // PresignedURLSecurityPolicy represents security constraints for presigned URL generation type PresignedURLSecurityPolicy struct { MaxExpirationDuration time.Duration `json:"max_expiration_duration"` // Maximum allowed expiration AllowedMethods []string `json:"allowed_methods"` // Allowed HTTP methods RequiredHeaders []string `json:"required_headers"` // Headers that must be present IPWhitelist []string `json:"ip_whitelist"` // Allowed IP addresses/ranges MaxFileSize int64 `json:"max_file_size"` // Maximum file size for uploads } // DefaultPresignedURLSecurityPolicy returns a default security policy func DefaultPresignedURLSecurityPolicy() *PresignedURLSecurityPolicy { return &PresignedURLSecurityPolicy{ MaxExpirationDuration: 7 * 24 * time.Hour, // 7 days max AllowedMethods: []string{"GET", "PUT", "POST", "HEAD"}, RequiredHeaders: []string{}, IPWhitelist: []string{}, // Empty means no IP restrictions MaxFileSize: 5 * 1024 * 1024 * 1024, // 5GB default } } // ValidatePresignedURLRequest validates a presigned URL request against security policy func (policy *PresignedURLSecurityPolicy) ValidatePresignedURLRequest(req *PresignedURLRequest) error { // Check expiration duration if req.Expiration > policy.MaxExpirationDuration { return fmt.Errorf("expiration duration %v exceeds maximum allowed %v", req.Expiration, policy.MaxExpirationDuration) } // Check HTTP method methodAllowed := false for _, allowedMethod := range policy.AllowedMethods { if req.Method == allowedMethod { methodAllowed = true break } } if !methodAllowed { return fmt.Errorf("HTTP method %s is not allowed", req.Method) } // Check required headers for _, requiredHeader := range policy.RequiredHeaders { if _, exists := req.Headers[requiredHeader]; !exists { return fmt.Errorf("required header %s is missing", requiredHeader) } } return nil }