aboutsummaryrefslogtreecommitdiff
path: root/weed/s3api/s3_presigned_url_iam.go
blob: 86b07668bd7bbfbc806b6b3315a819d43aca4523 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
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
}