aboutsummaryrefslogtreecommitdiff
path: root/weed/s3api/s3_presigned_url_iam.go
diff options
context:
space:
mode:
Diffstat (limited to 'weed/s3api/s3_presigned_url_iam.go')
-rw-r--r--weed/s3api/s3_presigned_url_iam.go383
1 files changed, 383 insertions, 0 deletions
diff --git a/weed/s3api/s3_presigned_url_iam.go b/weed/s3api/s3_presigned_url_iam.go
new file mode 100644
index 000000000..86b07668b
--- /dev/null
+++ b/weed/s3api/s3_presigned_url_iam.go
@@ -0,0 +1,383 @@
+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
+}