mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-23 06:20:48 +02:00
* s3api: support s3:RequestObjectTag/<key> in bucket policies The tags come from X-Amz-Tagging only where the handler stores them from there: not on PutObjectTagging, which reads the body, and not on a copy that keeps the source's tags. For a repeated key the last value counts, since that is what CopyObject stores. * s3api: extract RequestObjectTag only where the handler stores it The header was honored on any request carrying it, so a tag attached to a GET, DELETE, part upload, multipart completion, directory marker or a PUT subresource satisfied a tag-conditioned policy the object never receives. Extraction now follows the handlers: PutObject, a copy under X-Amz-Tagging-Directive: REPLACE, and CreateMultipartUpload store the header; a plain object PUT is identified by carrying only X-Amz-* signing parameters. * s3api: exclude multipart part PUTs before the copy-source check A PUT carrying X-Amz-Copy-Source plus a numeric partNumber and uploadId routes to CopyObjectPartHandler, which stores a part and never reads X-Amz-Tagging. Checking the copy source first let such a request satisfy an s3:RequestObjectTag condition under REPLACE with tags that are never stored. Mirror the router: the part handlers match only when both partNumber (digits only) and uploadId are present, so a lone or non-numeric partNumber still falls through to the storing handlers. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3api: gate tag extraction on the routed subresource, not query key shape Rejecting every PUT whose query held a key outside X-Amz- dropped X-Amz-Tagging from ordinary object PUTs that carry arbitrary parameters — Signature V2 presigns (AWSAccessKeyId/Signature/Expires) among them — so a tag-conditioned Deny could be bypassed by appending an unrelated query parameter while putToFiler still stored the tags. Exclude the object PUT subresources the router actually dispatches (tagging, renameObject, acl, retention, legal-hold) ahead of the copy-source check instead. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3api: cover the routed-subresource and part-copy tag extraction cases Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Chris Lu <chris.lu@gmail.com> Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
759 lines
25 KiB
Go
759 lines
25 KiB
Go
package policy_engine
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
)
|
|
|
|
// PolicyEvaluationResult represents the result of policy evaluation
|
|
type PolicyEvaluationResult int
|
|
|
|
const (
|
|
PolicyResultDeny PolicyEvaluationResult = iota
|
|
PolicyResultAllow
|
|
PolicyResultIndeterminate
|
|
)
|
|
|
|
// PolicyEvaluationContext manages policy evaluation for a bucket
|
|
type PolicyEvaluationContext struct {
|
|
bucketName string
|
|
policy *CompiledPolicy
|
|
cache *PolicyCache
|
|
}
|
|
|
|
// PolicyEngine is the main policy evaluation engine
|
|
type PolicyEngine struct {
|
|
contexts map[string]*PolicyEvaluationContext
|
|
mutex sync.RWMutex
|
|
trustedProxies atomic.Pointer[TrustedProxies]
|
|
}
|
|
|
|
// NewPolicyEngine creates a new policy evaluation engine
|
|
func NewPolicyEngine() *PolicyEngine {
|
|
return &PolicyEngine{
|
|
contexts: make(map[string]*PolicyEvaluationContext),
|
|
}
|
|
}
|
|
|
|
// SetTrustedProxies configures the allowlist used to decide whether
|
|
// forwarded headers are honored when extracting aws:SourceIp.
|
|
func (engine *PolicyEngine) SetTrustedProxies(tp *TrustedProxies) {
|
|
engine.trustedProxies.Store(tp)
|
|
}
|
|
|
|
// SetBucketPolicy sets the policy for a bucket
|
|
func (engine *PolicyEngine) SetBucketPolicy(bucketName string, policyJSON string) error {
|
|
policy, err := ParsePolicy(policyJSON)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid policy: %w", err)
|
|
}
|
|
|
|
compiled, err := CompilePolicy(policy)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to compile policy: %w", err)
|
|
}
|
|
|
|
engine.mutex.Lock()
|
|
defer engine.mutex.Unlock()
|
|
|
|
context := &PolicyEvaluationContext{
|
|
bucketName: bucketName,
|
|
policy: compiled,
|
|
cache: NewPolicyCache(),
|
|
}
|
|
|
|
engine.contexts[bucketName] = context
|
|
glog.V(4).Infof("SetBucketPolicy: Successfully cached policy for bucket=%s, statements=%d", bucketName, len(compiled.Statements))
|
|
return nil
|
|
}
|
|
|
|
// GetBucketPolicy gets the policy for a bucket
|
|
func (engine *PolicyEngine) GetBucketPolicy(bucketName string) (*PolicyDocument, error) {
|
|
engine.mutex.RLock()
|
|
defer engine.mutex.RUnlock()
|
|
|
|
context, exists := engine.contexts[bucketName]
|
|
if !exists {
|
|
return nil, fmt.Errorf("no policy found for bucket %s", bucketName)
|
|
}
|
|
|
|
return context.policy.Document, nil
|
|
}
|
|
|
|
// DeleteBucketPolicy deletes the policy for a bucket
|
|
func (engine *PolicyEngine) DeleteBucketPolicy(bucketName string) error {
|
|
engine.mutex.Lock()
|
|
defer engine.mutex.Unlock()
|
|
|
|
delete(engine.contexts, bucketName)
|
|
glog.V(2).Infof("Deleted bucket policy for %s", bucketName)
|
|
return nil
|
|
}
|
|
|
|
// HasPolicyForBucket checks if a bucket has a policy configured
|
|
func (engine *PolicyEngine) HasPolicyForBucket(bucketName string) bool {
|
|
engine.mutex.RLock()
|
|
defer engine.mutex.RUnlock()
|
|
_, exists := engine.contexts[bucketName]
|
|
return exists
|
|
}
|
|
|
|
// EvaluatePolicy evaluates a policy for the given arguments
|
|
func (engine *PolicyEngine) EvaluatePolicy(bucketName string, args *PolicyEvaluationArgs) PolicyEvaluationResult {
|
|
engine.mutex.RLock()
|
|
context, exists := engine.contexts[bucketName]
|
|
engine.mutex.RUnlock()
|
|
|
|
if !exists {
|
|
glog.V(4).Infof("EvaluatePolicy: No policy found for bucket=%s (PolicyResultIndeterminate)", bucketName)
|
|
return PolicyResultIndeterminate
|
|
}
|
|
|
|
glog.V(4).Infof("EvaluatePolicy: Found policy for bucket=%s, evaluating with action=%s resource=%s principal=%s",
|
|
bucketName, args.Action, args.Resource, args.Principal)
|
|
return engine.evaluateCompiledPolicy(context.policy, args)
|
|
}
|
|
|
|
// evaluateCompiledPolicy evaluates a compiled policy
|
|
func (engine *PolicyEngine) evaluateCompiledPolicy(policy *CompiledPolicy, args *PolicyEvaluationArgs) PolicyEvaluationResult {
|
|
// AWS Policy evaluation logic:
|
|
// 1. Check for explicit Deny - if found, return Deny
|
|
// 2. Check for explicit Allow - if found, return Allow
|
|
// 3. If no matching statements, return Indeterminate (fall through to IAM)
|
|
|
|
hasExplicitAllow := false
|
|
|
|
for _, stmt := range policy.Statements {
|
|
if engine.evaluateStatement(stmt, args) {
|
|
if stmt.Statement.Effect == PolicyEffectDeny {
|
|
return PolicyResultDeny // Explicit deny trumps everything
|
|
}
|
|
if stmt.Statement.Effect == PolicyEffectAllow {
|
|
hasExplicitAllow = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if hasExplicitAllow {
|
|
return PolicyResultAllow
|
|
}
|
|
|
|
// No matching statements - return Indeterminate to fall through to IAM
|
|
// This allows IAM policies to grant access even when bucket policy doesn't mention the action
|
|
return PolicyResultIndeterminate
|
|
}
|
|
|
|
// matchesDynamicPatterns checks if a value matches any of the dynamic patterns after variable substitution
|
|
func (engine *PolicyEngine) matchesDynamicPatterns(patterns []string, value string, args *PolicyEvaluationArgs) bool {
|
|
for _, pattern := range patterns {
|
|
substituted := SubstituteVariables(pattern, args.Conditions, args.Claims)
|
|
if FastMatchesWildcard(substituted, value) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// evaluateStatement evaluates a single policy statement
|
|
func (engine *PolicyEngine) evaluateStatement(stmt *CompiledStatement, args *PolicyEvaluationArgs) bool {
|
|
// Check if action matches
|
|
matchedAction := engine.matchesPatterns(stmt.ActionPatterns, args.Action)
|
|
if !matchedAction {
|
|
matchedAction = engine.matchesDynamicPatterns(stmt.DynamicActionPatterns, args.Action, args)
|
|
}
|
|
// Multipart upload actions (CreateMultipartUpload, UploadPart, CompleteMultipartUpload, etc.)
|
|
// are implicitly allowed by s3:PutObject, since multipart upload is an implementation
|
|
// detail of putting objects. Check if this is a multipart action and the statement
|
|
// grants s3:PutObject.
|
|
if !matchedAction && multipartActionSet[args.Action] {
|
|
matchedAction = engine.matchesPatterns(stmt.ActionPatterns, "s3:PutObject")
|
|
if !matchedAction {
|
|
matchedAction = engine.matchesDynamicPatterns(stmt.DynamicActionPatterns, "s3:PutObject", args)
|
|
}
|
|
}
|
|
if !matchedAction {
|
|
return false
|
|
}
|
|
|
|
// Check if resource matches
|
|
hasResource := len(stmt.ResourcePatterns) > 0 || len(stmt.DynamicResourcePatterns) > 0
|
|
hasNotResource := len(stmt.NotResourcePatterns) > 0 || len(stmt.DynamicNotResourcePatterns) > 0
|
|
if hasResource {
|
|
matchedResource := engine.matchesPatterns(stmt.ResourcePatterns, args.Resource)
|
|
if !matchedResource {
|
|
matchedResource = engine.matchesDynamicPatterns(stmt.DynamicResourcePatterns, args.Resource, args)
|
|
}
|
|
if !matchedResource {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if hasNotResource {
|
|
matchedNotResource := false
|
|
for _, matcher := range stmt.NotResourceMatchers {
|
|
if matcher.Match(args.Resource) {
|
|
matchedNotResource = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !matchedNotResource {
|
|
matchedNotResource = engine.matchesDynamicPatterns(stmt.DynamicNotResourcePatterns, args.Resource, args)
|
|
}
|
|
|
|
if matchedNotResource {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Check if principal matches
|
|
if len(stmt.PrincipalPatterns) > 0 || len(stmt.DynamicPrincipalPatterns) > 0 {
|
|
matchedPrincipal := engine.matchesPatterns(stmt.PrincipalPatterns, args.Principal)
|
|
if !matchedPrincipal {
|
|
matchedPrincipal = engine.matchesDynamicPatterns(stmt.DynamicPrincipalPatterns, args.Principal, args)
|
|
}
|
|
if !matchedPrincipal {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Check NotPrincipal (statement applies to everyone EXCEPT these principals)
|
|
if len(stmt.NotPrincipalPatterns) > 0 || len(stmt.DynamicNotPrincipalPatterns) > 0 {
|
|
matchedNotPrincipal := engine.matchesPatterns(stmt.NotPrincipalPatterns, args.Principal)
|
|
if !matchedNotPrincipal {
|
|
matchedNotPrincipal = engine.matchesDynamicPatterns(stmt.DynamicNotPrincipalPatterns, args.Principal, args)
|
|
}
|
|
if matchedNotPrincipal {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Check conditions
|
|
if len(stmt.Statement.Condition) > 0 {
|
|
condCtx := args.Conditions
|
|
// Multipart continuation actions (UploadPart, UploadPartCopy) inherit SSE
|
|
// from CreateMultipartUpload and do not carry their own SSE header.
|
|
// Inject the real inherited algorithm so Null/StringEquals conditions
|
|
// evaluate against the value that was set at upload initiation.
|
|
if IsMultipartContinuationAction(args.Action) {
|
|
condCtx = injectSSEForMultipart(args.Conditions, args.InheritedSSEAlgorithm)
|
|
}
|
|
match := EvaluateConditions(stmt.Statement.Condition, condCtx, args.ObjectEntry, args.Claims)
|
|
// Preserve positive string denies on the original complete list; allows, negative conditions, and variables remain canonical.
|
|
if !match && stmt.Statement.Effect == PolicyEffectDeny && len(args.OriginalGrantConditions) != 0 {
|
|
match = evaluateConditions(stmt.Statement.Condition, condCtx, args.ObjectEntry, args.Claims, args.OriginalGrantConditions)
|
|
}
|
|
if !match {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// matchesPatterns checks if a value matches any of the compiled patterns
|
|
func (engine *PolicyEngine) matchesPatterns(patterns []*regexp.Regexp, value string) bool {
|
|
for _, pattern := range patterns {
|
|
if pattern.MatchString(value) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SubstituteVariables replaces ${variable} in a pattern with values from context and claims
|
|
// Supports:
|
|
// - Standard context variables (aws:SourceIp, s3:prefix, etc.)
|
|
// - JWT claims (jwt:preferred_username, jwt:sub, jwt:*)
|
|
// - LDAP claims (ldap:username, ldap:dn, ldap:*)
|
|
func SubstituteVariables(pattern string, context map[string][]string, claims map[string]interface{}) string {
|
|
result := PolicyVariableRegex.ReplaceAllStringFunc(pattern, func(match string) string {
|
|
// match is like "${aws:username}"
|
|
// extract variable name "aws:username"
|
|
variable := match[2 : len(match)-1]
|
|
|
|
// Check standard context first
|
|
if values, ok := context[variable]; ok && len(values) > 0 {
|
|
return values[0]
|
|
}
|
|
|
|
// Check JWT claims for jwt:* variables
|
|
if strings.HasPrefix(variable, "jwt:") {
|
|
claimName := variable[4:] // Remove "jwt:" prefix
|
|
if claimValue, ok := claims[claimName]; ok {
|
|
switch v := claimValue.(type) {
|
|
case string:
|
|
return v
|
|
case float64:
|
|
// JWT numbers are often float64
|
|
if v == float64(int64(v)) {
|
|
return fmt.Sprintf("%d", int64(v))
|
|
}
|
|
return fmt.Sprintf("%g", v)
|
|
case bool:
|
|
return fmt.Sprintf("%t", v)
|
|
case int:
|
|
return fmt.Sprintf("%d", v)
|
|
case int32:
|
|
return fmt.Sprintf("%d", v)
|
|
case int64:
|
|
return fmt.Sprintf("%d", v)
|
|
default:
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check LDAP claims for ldap:* variables
|
|
// FALLBACK MECHANISM: Try both prefixed and unprefixed keys
|
|
// Some LDAP providers store claims with the "ldap:" prefix (e.g., "ldap:username")
|
|
// while others store them without the prefix (e.g., "username").
|
|
// We check the prefixed key first for consistency, then fall back to unprefixed.
|
|
if strings.HasPrefix(variable, "ldap:") {
|
|
claimName := variable[5:] // Remove "ldap:" prefix
|
|
// Try prefixed key first (e.g., "ldap:username"), then unprefixed
|
|
var claimValue interface{}
|
|
var ok bool
|
|
if claimValue, ok = claims[variable]; !ok {
|
|
claimValue, ok = claims[claimName]
|
|
}
|
|
if ok {
|
|
switch v := claimValue.(type) {
|
|
case string:
|
|
return v
|
|
case float64:
|
|
if v == float64(int64(v)) {
|
|
return fmt.Sprintf("%d", int64(v))
|
|
}
|
|
return fmt.Sprintf("%g", v)
|
|
case bool:
|
|
return fmt.Sprintf("%t", v)
|
|
case int:
|
|
return fmt.Sprintf("%d", v)
|
|
case int32:
|
|
return fmt.Sprintf("%d", v)
|
|
case int64:
|
|
return fmt.Sprintf("%d", v)
|
|
default:
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Variable not found, leave as-is to avoid unexpected matching
|
|
return match
|
|
})
|
|
return result
|
|
}
|
|
|
|
// ExtractPrincipalVariables extracts policy variables from a principal ARN
|
|
func ExtractPrincipalVariables(principal string) map[string][]string {
|
|
vars := make(map[string][]string)
|
|
|
|
// Handle non-ARN principals (e.g., "*" or simple usernames)
|
|
if !strings.HasPrefix(principal, "arn:aws:") {
|
|
return vars
|
|
}
|
|
|
|
// Parse ARN: arn:aws:service::account:resource
|
|
parts := strings.Split(principal, ":")
|
|
if len(parts) < 6 {
|
|
return vars
|
|
}
|
|
|
|
account := parts[4] // account ID
|
|
resourcePart := parts[5] // user/username or assumed-role/role/session
|
|
|
|
// Set aws:PrincipalAccount if account is present
|
|
if account != "" {
|
|
vars["aws:PrincipalAccount"] = []string{account}
|
|
}
|
|
|
|
resourceParts := strings.Split(resourcePart, "/")
|
|
if len(resourceParts) < 2 {
|
|
return vars
|
|
}
|
|
|
|
resourceType := resourceParts[0] // "user", "role", "assumed-role"
|
|
|
|
// Set aws:principaltype and extract username/userid based on resource type
|
|
switch resourceType {
|
|
case "user":
|
|
vars["aws:principaltype"] = []string{"IAMUser"}
|
|
// For users with paths like "user/path/to/username", use the last segment
|
|
username := resourceParts[len(resourceParts)-1]
|
|
vars["aws:username"] = []string{username}
|
|
vars["aws:userid"] = []string{username} // In SeaweedFS, userid is same as username
|
|
case "role":
|
|
vars["aws:principaltype"] = []string{"IAMRole"}
|
|
// For roles with paths like "role/path/to/rolename", use the last segment
|
|
// Note: IAM Roles do NOT have aws:userid, but aws:PrincipalAccount is kept for condition evaluations
|
|
if len(resourceParts) >= 2 {
|
|
roleName := resourceParts[len(resourceParts)-1]
|
|
vars["aws:username"] = []string{roleName}
|
|
}
|
|
case "assumed-role":
|
|
vars["aws:principaltype"] = []string{"AssumedRole"}
|
|
// For assumed roles: assumed-role/RoleName/SessionName or assumed-role/path/to/RoleName/SessionName
|
|
// The session name is always the last segment
|
|
if len(resourceParts) >= 3 {
|
|
sessionName := resourceParts[len(resourceParts)-1]
|
|
vars["aws:username"] = []string{sessionName}
|
|
vars["aws:userid"] = []string{sessionName}
|
|
}
|
|
}
|
|
|
|
// Note: principaltype is already set correctly in the switch above based on resource type
|
|
|
|
return vars
|
|
}
|
|
|
|
// ExtractConditionValuesFromRequest extracts condition values from HTTP request
|
|
func (engine *PolicyEngine) ExtractConditionValuesFromRequest(r *http.Request) map[string][]string {
|
|
values := make(map[string][]string)
|
|
|
|
// AWS condition keys
|
|
values["aws:SourceIp"] = []string{engine.extractSourceIP(r)}
|
|
values["aws:SecureTransport"] = []string{fmt.Sprintf("%t", r.TLS != nil)}
|
|
// Use AWS standard condition key for current time
|
|
values["aws:CurrentTime"] = []string{time.Now().Format(time.RFC3339)}
|
|
// Keep RequestTime for backward compatibility
|
|
values["aws:RequestTime"] = []string{time.Now().Format(time.RFC3339)}
|
|
|
|
// S3 specific condition keys
|
|
if userAgent := r.Header.Get("User-Agent"); userAgent != "" {
|
|
values["aws:UserAgent"] = []string{userAgent}
|
|
}
|
|
|
|
if referer := r.Header.Get("Referer"); referer != "" {
|
|
values["aws:Referer"] = []string{referer}
|
|
}
|
|
|
|
// Note: s3:ExistingObjectTag/<key> conditions are evaluated using objectEntry
|
|
// passed to EvaluatePolicy, not extracted from the request.
|
|
|
|
for key, value := range requestObjectTags(r) {
|
|
values[RequestObjectTagPrefix+key] = []string{value}
|
|
}
|
|
|
|
// S3 bucket-level conditions
|
|
if delimiter := r.URL.Query().Get("delimiter"); delimiter != "" {
|
|
values["s3:delimiter"] = []string{delimiter}
|
|
}
|
|
|
|
if prefix := r.URL.Query().Get("prefix"); prefix != "" {
|
|
values["s3:prefix"] = []string{prefix}
|
|
}
|
|
|
|
if maxKeys := r.URL.Query().Get("max-keys"); maxKeys != "" {
|
|
values["s3:max-keys"] = []string{maxKeys}
|
|
}
|
|
|
|
// Authentication method
|
|
if authHeader := r.Header.Get("Authorization"); authHeader != "" {
|
|
if strings.HasPrefix(authHeader, "AWS4-HMAC-SHA256") {
|
|
values["s3:authType"] = []string{"REST-HEADER"}
|
|
} else if strings.HasPrefix(authHeader, "AWS ") {
|
|
values["s3:authType"] = []string{"REST-HEADER"}
|
|
}
|
|
} else if r.URL.Query().Get("AWSAccessKeyId") != "" {
|
|
values["s3:authType"] = []string{"REST-QUERY-STRING"}
|
|
}
|
|
|
|
// HTTP method
|
|
values["s3:RequestMethod"] = []string{r.Method}
|
|
|
|
// Extract custom headers with s3: prefix for AWS-compatible condition keys
|
|
for key, headerValues := range r.Header {
|
|
lowerKey := strings.ToLower(key)
|
|
if strings.HasPrefix(lowerKey, "x-amz-") {
|
|
values["s3:"+lowerKey] = headerValues
|
|
}
|
|
}
|
|
|
|
// Normalize s3:x-amz-server-side-encryption value to canonical form.
|
|
// AWS accepts "AES256" case-insensitively; normalise so that policy
|
|
// StringEquals conditions work regardless of client capitalisation.
|
|
const sseKey = "s3:x-amz-server-side-encryption"
|
|
if sseVals, ok := values[sseKey]; ok {
|
|
normalized := make([]string, len(sseVals))
|
|
for i, v := range sseVals {
|
|
switch strings.ToUpper(v) {
|
|
case "AES256":
|
|
normalized[i] = "AES256"
|
|
case "AWS:KMS":
|
|
normalized[i] = "aws:kms"
|
|
default:
|
|
normalized[i] = v
|
|
}
|
|
}
|
|
values[sseKey] = normalized
|
|
}
|
|
|
|
return values
|
|
}
|
|
|
|
// requestObjectTags returns the tags that X-Amz-Tagging will store on the
|
|
// object, or nil when the handler ignores the header — otherwise a tag
|
|
// attached to a read or to a write that never stores it would satisfy a
|
|
// tag-conditioned policy the object cannot meet.
|
|
func requestObjectTags(r *http.Request) map[string]string {
|
|
tagging := r.Header.Get(s3_constants.AmzObjectTagging)
|
|
if tagging == "" {
|
|
return nil
|
|
}
|
|
if !storesRequestObjectTags(r) {
|
|
return nil
|
|
}
|
|
// A malformed header is rejected by the handler with InvalidTag.
|
|
parsed, err := url.ParseQuery(tagging)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
// putToFiler rejects a repeated key, but CopyObject stores its last
|
|
// value, so that is the one the policy has to see.
|
|
tags := make(map[string]string, len(parsed))
|
|
for key, vals := range parsed {
|
|
tags[key] = vals[len(vals)-1]
|
|
}
|
|
return tags
|
|
}
|
|
|
|
// storesRequestObjectTags reports whether the request's handler saves the
|
|
// X-Amz-Tagging header on the object: PutObject, a copy under
|
|
// X-Amz-Tagging-Directive: REPLACE, and CreateMultipartUpload do; every
|
|
// other operation — reads, deletes, part uploads, completion, tagging
|
|
// subresources — does not.
|
|
func storesRequestObjectTags(r *http.Request) bool {
|
|
query := r.URL.Query()
|
|
if r.Method == http.MethodPost {
|
|
return query.Has("uploads") && !query.Has("uploadId")
|
|
}
|
|
if r.Method != http.MethodPut {
|
|
return false
|
|
}
|
|
// A numeric partNumber together with uploadId routes to the part
|
|
// handlers — with or without a copy source — which never read
|
|
// X-Amz-Tagging.
|
|
if query.Has("partNumber") && query.Has("uploadId") && isDigits(query.Get("partNumber")) {
|
|
return false
|
|
}
|
|
// The PUT subresources route before CopyObject and never read
|
|
// X-Amz-Tagging; any other query key — presign parameters included —
|
|
// still reaches the object PUT handlers.
|
|
for _, subresource := range []string{"tagging", "renameObject", "acl", "retention", "legal-hold"} {
|
|
if query.Has(subresource) {
|
|
return false
|
|
}
|
|
}
|
|
if r.Header.Get("X-Amz-Copy-Source") != "" {
|
|
return r.Header.Get(s3_constants.AmzObjectTaggingDirective) == "REPLACE"
|
|
}
|
|
if query.Has("partNumber") || query.Has("uploadId") {
|
|
return false
|
|
}
|
|
// An explicit directory-marker PUT stores no tags.
|
|
if strings.HasSuffix(r.URL.Path, "/") && r.ContentLength <= 1024 {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// isDigits reports whether s is non-empty and all ASCII digits, matching the
|
|
// partNumber route constraint.
|
|
func isDigits(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if c < '0' || c > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// IsMultipartContinuationAction returns true for actions that do not carry
|
|
// their own SSE header because SSE is inherited from CreateMultipartUpload.
|
|
func IsMultipartContinuationAction(action string) bool {
|
|
return action == "s3:UploadPart" || action == "s3:UploadPartCopy"
|
|
}
|
|
|
|
// injectSSEForMultipart returns a condition context augmented with the
|
|
// inherited SSE algorithm for multipart continuation actions.
|
|
//
|
|
// UploadPart and UploadPartCopy do not re-send the SSE header because
|
|
// encryption is set once at CreateMultipartUpload. The caller supplies
|
|
// inheritedSSE (the canonical algorithm, e.g. "AES256" or "aws:kms") so
|
|
// that Null/StringEquals conditions on s3:x-amz-server-side-encryption
|
|
// evaluate against the real value.
|
|
//
|
|
// If inheritedSSE is empty (no SSE was requested at initiation), the
|
|
// conditions map is returned unchanged so Null("true") will correctly
|
|
// match and deny the request.
|
|
func injectSSEForMultipart(conditions map[string][]string, inheritedSSE string) map[string][]string {
|
|
const sseKey = "s3:x-amz-server-side-encryption"
|
|
if inheritedSSE == "" {
|
|
return conditions // no SSE at upload initiation; let Null("true") fire
|
|
}
|
|
if _, exists := conditions[sseKey]; exists {
|
|
return conditions // SSE header was actually sent on this request
|
|
}
|
|
modified := make(map[string][]string, len(conditions)+1)
|
|
for k, v := range conditions {
|
|
modified[k] = v
|
|
}
|
|
modified[sseKey] = []string{inheritedSSE}
|
|
return modified
|
|
}
|
|
|
|
// extractSourceIP returns the client IP for aws:SourceIp condition
|
|
// evaluation, honoring forwarded headers only when the direct TCP peer is in
|
|
// the configured trusted-proxy allowlist (see SetTrustedProxies).
|
|
func (engine *PolicyEngine) extractSourceIP(r *http.Request) string {
|
|
return engine.trustedProxies.Load().ExtractSourceIP(r)
|
|
}
|
|
|
|
// BuildResourceArn builds an ARN for the given bucket and object
|
|
func BuildResourceArn(bucketName, objectName string) string {
|
|
if objectName == "" {
|
|
return fmt.Sprintf("arn:aws:s3:::%s", bucketName)
|
|
}
|
|
return fmt.Sprintf("arn:aws:s3:::%s/%s", bucketName, objectName)
|
|
}
|
|
|
|
// BuildActionName builds a standardized action name
|
|
func BuildActionName(action string) string {
|
|
if strings.HasPrefix(action, "s3:") {
|
|
return action
|
|
}
|
|
return fmt.Sprintf("s3:%s", action)
|
|
}
|
|
|
|
// GetPolicyStatements returns all policy statements for a bucket
|
|
func (engine *PolicyEngine) GetPolicyStatements(bucketName string) []PolicyStatement {
|
|
engine.mutex.RLock()
|
|
defer engine.mutex.RUnlock()
|
|
|
|
context, exists := engine.contexts[bucketName]
|
|
if !exists {
|
|
return nil
|
|
}
|
|
|
|
return context.policy.Document.Statement
|
|
}
|
|
|
|
// BucketsAllowedForAction returns the buckets a policy names in the Allow
|
|
// statements that can match the action, and whether those names cover every
|
|
// bucket the policy can allow it on. A statement reaching buckets it does not
|
|
// name -- a wildcard resource, a policy variable, a NotResource -- leaves the
|
|
// set incomplete, as does an unknown policy name.
|
|
func (engine *PolicyEngine) BucketsAllowedForAction(policyName string, action string) (buckets []string, complete bool) {
|
|
engine.mutex.RLock()
|
|
context, exists := engine.contexts[policyName]
|
|
engine.mutex.RUnlock()
|
|
|
|
if !exists {
|
|
return nil, false
|
|
}
|
|
|
|
for _, statement := range context.policy.Document.Statement {
|
|
// A Deny only narrows what an Allow named.
|
|
if statement.Effect != PolicyEffectAllow || !statementMayAllowAction(statement.Action.Strings(), action) {
|
|
continue
|
|
}
|
|
if statement.Resource == nil || statement.NotResource != nil {
|
|
return nil, false
|
|
}
|
|
resources := statement.Resource.Strings()
|
|
if len(resources) == 0 {
|
|
return nil, false
|
|
}
|
|
for _, resource := range resources {
|
|
if PolicyVariableRegex.MatchString(resource) {
|
|
return nil, false
|
|
}
|
|
bucket := GetBucketFromResource(resource)
|
|
if bucket == "" || strings.ContainsAny(bucket, "*?") {
|
|
return nil, false
|
|
}
|
|
buckets = append(buckets, bucket)
|
|
}
|
|
}
|
|
return buckets, true
|
|
}
|
|
|
|
// ValidatePolicyForBucket validates if a policy is valid for a bucket
|
|
func (engine *PolicyEngine) ValidatePolicyForBucket(bucketName string, policyJSON string) error {
|
|
policy, err := ParsePolicy(policyJSON)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Additional validation specific to the bucket
|
|
for _, stmt := range policy.Statement {
|
|
resources := normalizeToStringSlice(stmt.Resource)
|
|
for _, resource := range resources {
|
|
if resourceBucket := GetBucketFromResource(resource); resourceBucket != "" {
|
|
if resourceBucket != bucketName {
|
|
return fmt.Errorf("policy resource %s does not match bucket %s", resource, bucketName)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ClearAllPolicies clears all bucket policies
|
|
func (engine *PolicyEngine) ClearAllPolicies() {
|
|
engine.mutex.Lock()
|
|
defer engine.mutex.Unlock()
|
|
|
|
engine.contexts = make(map[string]*PolicyEvaluationContext)
|
|
glog.V(2).Info("Cleared all bucket policies")
|
|
}
|
|
|
|
// GetAllBucketsWithPolicies returns all buckets that have policies
|
|
func (engine *PolicyEngine) GetAllBucketsWithPolicies() []string {
|
|
engine.mutex.RLock()
|
|
defer engine.mutex.RUnlock()
|
|
|
|
buckets := make([]string, 0, len(engine.contexts))
|
|
for bucketName := range engine.contexts {
|
|
buckets = append(buckets, bucketName)
|
|
}
|
|
return buckets
|
|
}
|
|
|
|
// EvaluatePolicyForRequest evaluates policy for an HTTP request
|
|
func (engine *PolicyEngine) EvaluatePolicyForRequest(bucketName, objectName, action, principal string, r *http.Request) PolicyEvaluationResult {
|
|
resource := BuildResourceArn(bucketName, objectName)
|
|
actionName := BuildActionName(action)
|
|
conditions := engine.ExtractConditionValuesFromRequest(r)
|
|
|
|
// Extract principal information for variables
|
|
principalVars := ExtractPrincipalVariables(principal)
|
|
for k, v := range principalVars {
|
|
conditions[k] = v
|
|
}
|
|
|
|
args := &PolicyEvaluationArgs{
|
|
Action: actionName,
|
|
Resource: resource,
|
|
Principal: principal,
|
|
Conditions: conditions,
|
|
}
|
|
|
|
return engine.EvaluatePolicy(bucketName, args)
|
|
}
|