Files
seaweedfs/weed/s3api/s3_action_resolver.go
T
Chris Lu a7f4b88a61 s3: require a bucket-policy action to write a bucket policy (#10444)
* s3: require a bucket-policy action to write a bucket policy

PutBucketPolicy and DeleteBucketPolicy were gated on ACTION_WRITE, the same
action that grants object writes. An explicit Allow in a bucket policy
short-circuits IAM entirely -- authRequestWithAuthType sets policyAllows and
skips VerifyActionPermission -- so anyone who could write an object could
author a policy granting itself, or anonymous, anything on the bucket.

That is what separates a bucket policy from the sibling bucket controls also
gated on ACTION_WRITE: rewriting cors or lifecycle can destroy data, but only
a policy hands out access.

Give the two verbs their own actions, mapped to the AWS names that were
already defined but unrouted. ACTION_ADMIN would also have closed it, but it
resolves to s3:* for IAM identities, forcing a blanket grant on a user holding
a precise s3:PutBucketPolicy. Admins are unaffected, since isAdmin
short-circuits CanDo, and an operator can delegate with PutBucketPolicy:bucket.

The route binding is asserted from the router source: checking the action
constants alone still passes when the route says ACTION_WRITE.

* s3: also read the action from a direct iam.Auth call in the route test

Routes read iam.Auth(cb.Limit(handler, ACTION)), a multi-value pass-through:
Limit returns (http.HandlerFunc, Action) and those become Auth's parameters, so
the action Auth authorizes on is Limit's second argument and the two cannot
disagree -- Auth(Limit(h, X), Y) does not compile.

A route that skipped Limit and called Auth with its own action would compile,
though, and the test reported that as a missing route rather than as the wrong
action. Recognise the two-argument Auth form so it names the action instead.

* s3: make the bucket-policy actions grantable through an IAM policy

The new actions close the escalation only if an operator can grant them, and
they were not reachable: MapToStatementAction had no entry for PutBucketPolicy,
so an IAM policy naming s3:PutBucketPolicy was rejected outright with "not a
valid action". GetBucketPolicy was unmapped the same way.

DeleteBucketPolicy was mapped, but to ACTION_ADMIN -- granting an identity
permission to delete a bucket policy handed it full administrative access.

Map all three to the actions the router now uses, and add the reverse
direction so an identity holding them renders back as a policy statement
instead of a bare "s3:".

* admin: offer the bucket-policy permissions in the user editor

The two new actions are otherwise only grantable by hand-editing identity JSON
or by calling the IAM API, so an operator using the UI cannot delegate bucket
policy management without granting Admin.

Regenerating this file also picks up codegen the repo has not taken yet: the
checked-in _templ.go files were produced by templ v0.3.1001 while go.mod pins
v0.3.1020, so the generator rewrites the attribute-value calls. That churn is
confined to this one file; running `make generate` in weed/admin reproduces it
across all 36.
2026-07-26 00:52:22 -07:00

346 lines
12 KiB
Go

package s3api
import (
"net/http"
"net/url"
"strings"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
// ResolveS3Action determines the specific S3 action from HTTP request context.
// This is the unified implementation used by both the bucket policy engine
// and the IAM integration for consistent action resolution.
//
// It examines the HTTP method, path, query parameters, and headers to determine
// the most specific S3 action string (e.g., "s3:DeleteObject", "s3:PutObjectTagging").
//
// Parameters:
// - r: HTTP request containing method, URL, query params, and headers
// - baseAction: Coarse-grained action constant (e.g., ACTION_WRITE, ACTION_READ)
// - bucket: Bucket name from the request path
// - object: Object key from the request path (may be empty for bucket operations)
//
// Returns:
// - Specific S3 action string (e.g., "s3:DeleteObject")
// - Falls back to base action mapping if no specific resolution is possible
// - Always returns a valid S3 action string (never empty)
func ResolveS3Action(r *http.Request, baseAction string, bucket string, object string) string {
if r == nil || r.URL == nil {
// No HTTP context available: fall back to coarse-grained mapping
// This ensures consistent behavior and avoids returning empty strings
return mapBaseActionToS3Format(baseAction)
}
method := r.Method
query := r.URL.Query()
// Determine if this is an object or bucket operation
// Note: "/" is treated as bucket-level, not object-level
hasObject := object != "" && object != "/"
// Priority 1: Check for specific query parameters that indicate specific actions
// These override everything else because they explicitly indicate the operation type
if action := resolveFromQueryParameters(query, method, hasObject); action != "" {
return action
}
// Priority 2: Handle basic operations based on method and resource type
// Only use the result if a specific action was resolved; otherwise fall through to Priority 3
if hasObject {
if action := resolveObjectLevelAction(method, baseAction); action != "" {
return action
}
} else if bucket != "" {
if action := resolveBucketLevelAction(method, baseAction); action != "" {
return action
}
}
// Priority 3: Fallback to legacy action mapping
return mapBaseActionToS3Format(baseAction)
}
// bucketQueryActions maps bucket-level query parameters to their corresponding S3 actions by HTTP method
var bucketQueryActions = map[string]map[string]string{
"policy": {
http.MethodGet: s3_constants.S3_ACTION_GET_BUCKET_POLICY,
http.MethodPut: s3_constants.S3_ACTION_PUT_BUCKET_POLICY,
http.MethodDelete: s3_constants.S3_ACTION_DELETE_BUCKET_POLICY,
},
"cors": {
http.MethodGet: s3_constants.S3_ACTION_GET_BUCKET_CORS,
http.MethodPut: s3_constants.S3_ACTION_PUT_BUCKET_CORS,
http.MethodDelete: s3_constants.S3_ACTION_DELETE_BUCKET_CORS,
},
"lifecycle": {
http.MethodGet: s3_constants.S3_ACTION_GET_BUCKET_LIFECYCLE,
http.MethodPut: s3_constants.S3_ACTION_PUT_BUCKET_LIFECYCLE,
http.MethodDelete: s3_constants.S3_ACTION_PUT_BUCKET_LIFECYCLE, // DELETE uses same permission as PUT
},
"versioning": {
http.MethodGet: s3_constants.S3_ACTION_GET_BUCKET_VERSIONING,
http.MethodPut: s3_constants.S3_ACTION_PUT_BUCKET_VERSIONING,
},
"notification": {
http.MethodGet: s3_constants.S3_ACTION_GET_BUCKET_NOTIFICATION,
http.MethodPut: s3_constants.S3_ACTION_PUT_BUCKET_NOTIFICATION,
},
"object-lock": {
http.MethodGet: s3_constants.S3_ACTION_GET_BUCKET_OBJECT_LOCK,
http.MethodPut: s3_constants.S3_ACTION_PUT_BUCKET_OBJECT_LOCK,
},
}
// resolveFromQueryParameters checks query parameters to determine specific S3 actions
func resolveFromQueryParameters(query url.Values, method string, hasObject bool) string {
// Multipart upload operations with uploadId parameter (object-level only)
// All multipart operations require an object in the path
if hasObject && query.Has("uploadId") {
switch method {
case http.MethodPut:
if query.Has("partNumber") {
return s3_constants.S3_ACTION_UPLOAD_PART
}
case http.MethodPost:
return s3_constants.S3_ACTION_COMPLETE_MULTIPART
case http.MethodDelete:
return s3_constants.S3_ACTION_ABORT_MULTIPART
case http.MethodGet:
return s3_constants.S3_ACTION_LIST_PARTS
}
}
// Multipart upload operations
// CreateMultipartUpload: POST /bucket/object?uploads (object-level)
// ListMultipartUploads: GET /bucket?uploads (bucket-level)
if query.Has("uploads") {
if method == http.MethodPost && hasObject {
return s3_constants.S3_ACTION_CREATE_MULTIPART
} else if method == http.MethodGet && !hasObject {
return s3_constants.S3_ACTION_LIST_MULTIPART_UPLOADS
}
}
// ACL operations
if query.Has("acl") {
switch method {
case http.MethodGet, http.MethodHead:
if hasObject {
return s3_constants.S3_ACTION_GET_OBJECT_ACL
}
return s3_constants.S3_ACTION_GET_BUCKET_ACL
case http.MethodPut:
if hasObject {
return s3_constants.S3_ACTION_PUT_OBJECT_ACL
}
return s3_constants.S3_ACTION_PUT_BUCKET_ACL
}
}
// Tagging operations
if query.Has("tagging") {
switch method {
case http.MethodGet:
if hasObject {
return s3_constants.S3_ACTION_GET_OBJECT_TAGGING
}
return s3_constants.S3_ACTION_GET_BUCKET_TAGGING
case http.MethodPut:
if hasObject {
return s3_constants.S3_ACTION_PUT_OBJECT_TAGGING
}
return s3_constants.S3_ACTION_PUT_BUCKET_TAGGING
case http.MethodDelete:
if hasObject {
return s3_constants.S3_ACTION_DELETE_OBJECT_TAGGING
}
return s3_constants.S3_ACTION_DELETE_BUCKET_TAGGING
}
}
// GetObjectAttributes (object-level only)
// Must be checked before versionId, because GET /bucket/key?attributes&versionId=xyz
// is a GetObjectAttributes request, not a GetObjectVersion request
if hasObject && query.Has("attributes") && method == http.MethodGet {
return s3_constants.S3_ACTION_GET_OBJECT_ATTRIBUTES
}
// Versioning operations - distinguish between versionId (specific version) and versions (list versions)
// versionId: Used to access/delete a specific version of an object (e.g., GET /bucket/key?versionId=xyz)
if query.Has("versionId") {
if hasObject {
switch method {
case http.MethodGet, http.MethodHead:
return s3_constants.S3_ACTION_GET_OBJECT_VERSION
case http.MethodDelete:
return s3_constants.S3_ACTION_DELETE_OBJECT_VERSION
}
}
}
// versions: Used to list all versions of objects in a bucket (e.g., GET /bucket?versions)
if query.Has("versions") {
if method == http.MethodGet && !hasObject {
return s3_constants.S3_ACTION_LIST_BUCKET_VERSIONS
}
}
// Check bucket-level query parameters using data-driven approach
// These are strictly bucket-level operations, so only apply when !hasObject
if !hasObject {
for param, actions := range bucketQueryActions {
if query.Has(param) {
if action, ok := actions[method]; ok {
return action
}
}
}
}
// Location (GET only, bucket-level)
if query.Has("location") && method == http.MethodGet && !hasObject {
return s3_constants.S3_ACTION_GET_BUCKET_LOCATION
}
// Object retention and legal hold operations (object-level only)
if hasObject {
if query.Has("retention") {
switch method {
case http.MethodGet:
return s3_constants.S3_ACTION_GET_OBJECT_RETENTION
case http.MethodPut:
return s3_constants.S3_ACTION_PUT_OBJECT_RETENTION
}
}
if query.Has("legal-hold") {
switch method {
case http.MethodGet:
return s3_constants.S3_ACTION_GET_OBJECT_LEGAL_HOLD
case http.MethodPut:
return s3_constants.S3_ACTION_PUT_OBJECT_LEGAL_HOLD
}
}
}
// Batch delete - POST request with delete query parameter (bucket-level operation)
// Example: POST /bucket?delete (not POST /bucket/object?delete)
if query.Has("delete") && method == http.MethodPost && !hasObject {
return s3_constants.S3_ACTION_DELETE_OBJECT
}
return ""
}
// resolveObjectLevelAction determines the S3 action for object-level operations
func resolveObjectLevelAction(method string, baseAction string) string {
switch method {
case http.MethodGet, http.MethodHead:
if baseAction == s3_constants.ACTION_READ {
return s3_constants.S3_ACTION_GET_OBJECT
}
case http.MethodPut:
if baseAction == s3_constants.ACTION_WRITE {
// Note: CopyObject operations also use s3:PutObject permission (same as MinIO/AWS)
// Copy requires s3:PutObject on destination and s3:GetObject on source
return s3_constants.S3_ACTION_PUT_OBJECT
}
case http.MethodDelete:
// CRITICAL: Map DELETE method to s3:DeleteObject
// This fixes the architectural limitation where ACTION_WRITE was mapped to s3:PutObject
if baseAction == s3_constants.ACTION_WRITE {
return s3_constants.S3_ACTION_DELETE_OBJECT
}
case http.MethodPost:
// POST without query params is typically multipart or form upload
if baseAction == s3_constants.ACTION_WRITE {
return s3_constants.S3_ACTION_PUT_OBJECT
}
}
return ""
}
// resolveBucketLevelAction determines the S3 action for bucket-level operations
func resolveBucketLevelAction(method string, baseAction string) string {
switch method {
case http.MethodGet, http.MethodHead:
if baseAction == s3_constants.ACTION_LIST || baseAction == s3_constants.ACTION_READ {
return s3_constants.S3_ACTION_LIST_BUCKET
}
case http.MethodPut:
if baseAction == s3_constants.ACTION_WRITE {
return s3_constants.S3_ACTION_CREATE_BUCKET
}
case http.MethodDelete:
if baseAction == s3_constants.ACTION_DELETE_BUCKET {
return s3_constants.S3_ACTION_DELETE_BUCKET
}
case http.MethodPost:
// POST to bucket is typically form upload
if baseAction == s3_constants.ACTION_WRITE {
return s3_constants.S3_ACTION_PUT_OBJECT
}
}
return ""
}
// mapBaseActionToS3Format converts coarse-grained base actions to S3 format
// This is the fallback when no specific resolution is found
func mapBaseActionToS3Format(baseAction string) string {
// Handle actions that already have a known service prefix
if strings.HasPrefix(baseAction, "s3:") || strings.HasPrefix(baseAction, "iam:") || strings.HasPrefix(baseAction, "sts:") {
return baseAction
}
// Map coarse-grained actions to their most common S3 equivalent
// Note: The s3_constants values ARE the string values (e.g., ACTION_READ = "Read")
switch baseAction {
case s3_constants.ACTION_READ: // "Read"
return s3_constants.S3_ACTION_GET_OBJECT
case s3_constants.ACTION_WRITE: // "Write"
return s3_constants.S3_ACTION_PUT_OBJECT
case s3_constants.ACTION_LIST: // "List"
return s3_constants.S3_ACTION_LIST_BUCKET
case s3_constants.ACTION_TAGGING: // "Tagging"
return s3_constants.S3_ACTION_PUT_OBJECT_TAGGING
case s3_constants.ACTION_ADMIN: // "Admin"
return s3_constants.S3_ACTION_ALL
case s3_constants.ACTION_READ_ACP: // "ReadAcp"
return s3_constants.S3_ACTION_GET_OBJECT_ACL
case s3_constants.ACTION_WRITE_ACP: // "WriteAcp"
return s3_constants.S3_ACTION_PUT_OBJECT_ACL
case s3_constants.ACTION_DELETE_BUCKET: // "DeleteBucket"
return s3_constants.S3_ACTION_DELETE_BUCKET
case s3_constants.ACTION_BYPASS_GOVERNANCE_RETENTION:
return s3_constants.S3_ACTION_BYPASS_GOVERNANCE
case s3_constants.ACTION_GET_OBJECT_RETENTION:
return s3_constants.S3_ACTION_GET_OBJECT_RETENTION
case s3_constants.ACTION_PUT_OBJECT_RETENTION:
return s3_constants.S3_ACTION_PUT_OBJECT_RETENTION
case s3_constants.ACTION_GET_OBJECT_LEGAL_HOLD:
return s3_constants.S3_ACTION_GET_OBJECT_LEGAL_HOLD
case s3_constants.ACTION_PUT_OBJECT_LEGAL_HOLD:
return s3_constants.S3_ACTION_PUT_OBJECT_LEGAL_HOLD
case s3_constants.ACTION_GET_BUCKET_OBJECT_LOCK_CONFIG:
return s3_constants.S3_ACTION_GET_BUCKET_OBJECT_LOCK
case s3_constants.ACTION_PUT_BUCKET_OBJECT_LOCK_CONFIG:
return s3_constants.S3_ACTION_PUT_BUCKET_OBJECT_LOCK
case s3_constants.ACTION_PUT_BUCKET_POLICY:
return s3_constants.S3_ACTION_PUT_BUCKET_POLICY
case s3_constants.ACTION_DELETE_BUCKET_POLICY:
return s3_constants.S3_ACTION_DELETE_BUCKET_POLICY
default:
// For unknown actions, prefix with s3: to maintain format consistency
return "s3:" + baseAction
}
}