Files
seaweedfs/weed/s3api/s3_presigned_url_iam.go
T
chrislu 7eb23464f3 🚀 S3 MULTIPART UPLOAD IAM INTEGRATION COMPLETE: Advanced Policy-Controlled Multipart Operations!
STEP 4 MILESTONE: Full IAM Integration for S3 Multipart Upload Operations

🏆 PRODUCTION-READY MULTIPART IAM SYSTEM:
- S3MultipartIAMManager: Complete multipart operation validation
- ValidateMultipartOperationWithIAM: Policy-based multipart authorization
- MultipartUploadPolicy: Comprehensive security policy validation
- Session token extraction from multiple sources (Bearer, X-Amz-Security-Token)

✅ COMPREHENSIVE IAM INTEGRATION:
- Multipart operation mapping (initiate, upload_part, complete, abort, list)
- Principal ARN validation with assumed role format (MultipartUser/session)
- S3 action determination for multipart operations
- Policy evaluation before operation execution
- Enhanced IAM handlers for all multipart operations

🚀 ROBUST SECURITY & POLICY ENFORCEMENT:
- Part size validation (5MB-5GB AWS limits)
- Part number validation (1-10,000 parts)
- Content type restrictions and validation
- Required headers enforcement
- IP whitelisting support for multipart operations
- Upload duration limits (7 days default)

✅ COMPREHENSIVE TEST COVERAGE (100% PASSING - 25/25):
- TestMultipartIAMValidation: Operation authorization (7/7) ✅
  • Initiate multipart upload with session tokens ✅
  • Upload part with IAM policy validation ✅
  • Complete/Abort multipart with proper permissions ✅
  • List operations with appropriate roles ✅
  • Invalid session token handling (ErrAccessDenied) ✅
- TestMultipartUploadPolicy: Policy validation (7/7) ✅
  • Part size limits and validation ✅
  • Part number range validation ✅
  • Content type restrictions ✅
  • Required headers validation (fixed order) ✅
- TestMultipartS3ActionMapping: Action mapping (7/7) ✅
- TestSessionTokenExtraction: Token source handling (5/5) ✅
- TestUploadPartValidation: Request validation (4/4) ✅

🎯 AWS S3-COMPATIBLE FEATURES:
- All standard multipart operations (initiate, upload, complete, abort, list)
- AWS-compatible error handling (ErrAccessDenied for auth failures)
- Multipart session management with IAM integration
- Part-level validation and policy enforcement
- Upload cleanup and expiration management

🔧 KEY BUG FIXES RESOLVED:
- Fixed name collision: CompleteMultipartUpload enum → MultipartOpComplete
- Fixed error handling: ErrInternalError → ErrAccessDenied for auth failures
- Fixed validation order: Required headers checked before content type
- Enhanced token extraction from Authorization header, X-Amz-Security-Token
- Proper principal ARN construction for multipart operations

�� ENTERPRISE SECURITY FEATURES:
- Maximum part size enforcement (5GB AWS limit)
- Minimum part size validation (5MB, except last part)
- Maximum parts limit (10,000 AWS limit)
- Content type whitelisting for uploads
- Required headers enforcement (e.g., Content-Type)
- IP address restrictions via policy conditions
- Session-based access control with JWT tokens

This completes advanced IAM integration for all S3 multipart upload operations
with comprehensive policy enforcement and AWS-compatible behavior!

Next: S3-Specific IAM Policy Templates & Examples
2025-08-23 23:24:21 -07:00

355 lines
12 KiB
Go

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
}
// Create IAM identity for authorization
// Use a proper ARN format for the principal
principalArn := fmt.Sprintf("arn:seaweed:sts::assumed-role/PresignedUser/%s", identity.Name)
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
}