Files
seaweedFS/weed/s3api/policy_engine/engine.go
Chris Lu d6d893c8c3 s3: add s3:ExistingObjectTag condition support for bucket policies (#7677)
* s3: add s3:ExistingObjectTag condition support in policy engine

Add support for s3:ExistingObjectTag/<tag-key> condition keys in bucket
policies, allowing access control based on object tags.

Changes:
- Add ObjectEntry field to PolicyEvaluationArgs (entry.Extended metadata)
- Update EvaluateConditions to handle s3:ExistingObjectTag/<key> format
- Extract tag value from entry metadata using X-Amz-Tagging-<key> prefix

This enables policies like:
{
  "Condition": {
    "StringEquals": {
      "s3:ExistingObjectTag/status": ["public"]
    }
  }
}

Fixes: https://github.com/seaweedfs/seaweedfs/issues/7447

* s3: update EvaluatePolicy to accept object entry for tag conditions

Update BucketPolicyEngine.EvaluatePolicy to accept objectEntry parameter
(entry.Extended metadata) for evaluating tag-based policy conditions.

Changes:
- Add objectEntry parameter to EvaluatePolicy method
- Update callers in auth_credentials.go and s3api_bucket_handlers.go
- Pass nil for objectEntry in auth layer (entry fetched later in handlers)

For tag-based conditions to work, handlers should call EvaluatePolicy
with the object's entry.Extended after fetching the entry from filer.

* s3: add tests for s3:ExistingObjectTag policy conditions

Add comprehensive tests for object tag-based policy conditions:

- TestExistingObjectTagCondition: Basic tag matching scenarios
  - Matching/non-matching tag values
  - Missing tags, no tags, empty tags
  - Multiple tags with one matching

- TestExistingObjectTagConditionMultipleTags: Multiple tag conditions
  - Both tags match
  - Only one tag matches

- TestExistingObjectTagDenyPolicy: Deny policies with tag conditions
  - Default allow without tag
  - Deny when specific tag present

* s3: document s3:ExistingObjectTag support and feature status

Update policy engine documentation:

- Add s3:ExistingObjectTag/<tag-key> to supported condition keys
- Add 'Object Tag-Based Access Control' section with examples
- Add 'Feature Status' section with implemented and planned features

Planned features for future implementation:
- s3:RequestObjectTag/<key>
- s3:RequestObjectTagKeys
- s3:x-amz-server-side-encryption
- Cross-account access

* Implement tag-based policy re-check in handlers

- Add checkPolicyWithEntry helper to S3ApiServer for handlers to re-check
  policy after fetching object entry (for s3:ExistingObjectTag conditions)
- Add HasPolicyForBucket method to policy engine for efficient check
- Integrate policy re-check in GetObjectHandler after entry is fetched
- Integrate policy re-check in HeadObjectHandler after entry is fetched
- Update auth_credentials.go comments to explain two-phase evaluation
- Update documentation with supported operations for tag-based conditions

This implements 'Approach 1' where handlers re-check the policy with
the object entry after fetching it, allowing tag-based conditions to
be properly evaluated.

* Add integration tests for s3:ExistingObjectTag conditions

- Add TestCheckPolicyWithEntry: tests checkPolicyWithEntry helper with various
  tag scenarios (matching tags, non-matching tags, empty entry, nil entry)
- Add TestCheckPolicyWithEntryNoPolicyForBucket: tests early return when no policy
- Add TestCheckPolicyWithEntryNilPolicyEngine: tests nil engine handling
- Add TestCheckPolicyWithEntryDenyPolicy: tests deny policies with tag conditions
- Add TestHasPolicyForBucket: tests HasPolicyForBucket method

These tests cover the Phase 2 policy evaluation with object entry metadata,
ensuring tag-based conditions are properly evaluated.

* Address code review nitpicks

- Remove unused extractObjectTags placeholder function (engine.go)
- Add clarifying comment about s3:ExistingObjectTag/<key> evaluation
- Consolidate duplicate tag-based examples in README
- Factor out tagsToEntry helper to package level in tests

* Address code review feedback

- Fix unsafe type assertions in GetObjectHandler and HeadObjectHandler
  when getting identity from context (properly handle type assertion failure)
- Extract getConditionContextValue helper to eliminate duplicated logic
  between EvaluateConditions and EvaluateConditionsLegacy
- Ensure consistent handling of missing condition keys (always return
  empty slice)

* Fix GetObjectHandler to match HeadObjectHandler pattern

Add safety check for nil objectEntryForSSE before tag-based policy
evaluation, ensuring tag-based conditions are always evaluated rather
than silently skipped if entry is unexpectedly nil.

Addresses review comment from Copilot.

* Fix HeadObject action name in docs for consistency

Change 'HeadObject' to 's3:HeadObject' to match other action names.

* Extract recheckPolicyWithObjectEntry helper to reduce duplication

Move the repeated identity extraction and policy re-check logic from
GetObjectHandler and HeadObjectHandler into a shared helper method.

* Add validation for empty tag key in s3:ExistingObjectTag condition

Prevent potential issues with malformed policies containing
s3:ExistingObjectTag/ (empty tag key after slash).
2025-12-09 09:48:13 -08:00

425 lines
12 KiB
Go

package policy_engine
import (
"fmt"
"net"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
// 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
mutex sync.RWMutex
}
// PolicyEngine is the main policy evaluation engine
type PolicyEngine struct {
contexts map[string]*PolicyEvaluationContext
mutex sync.RWMutex
}
// NewPolicyEngine creates a new policy evaluation engine
func NewPolicyEngine() *PolicyEngine {
return &PolicyEngine{
contexts: make(map[string]*PolicyEvaluationContext),
}
}
// 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(2).Infof("Set bucket policy for %s", bucketName)
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 {
return PolicyResultIndeterminate
}
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
}
// evaluateStatement evaluates a single policy statement
func (engine *PolicyEngine) evaluateStatement(stmt *CompiledStatement, args *PolicyEvaluationArgs) bool {
// Check if action matches
if !engine.matchesPatterns(stmt.ActionPatterns, args.Action) {
return false
}
// Check if resource matches
if !engine.matchesPatterns(stmt.ResourcePatterns, args.Resource) {
return false
}
// Check if principal matches (if specified)
if len(stmt.PrincipalPatterns) > 0 {
if !engine.matchesPatterns(stmt.PrincipalPatterns, args.Principal) {
return false
}
}
// Check conditions
if len(stmt.Statement.Condition) > 0 {
if !EvaluateConditions(stmt.Statement.Condition, args.Conditions, args.ObjectEntry) {
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
}
// ExtractConditionValuesFromRequest extracts condition values from HTTP request
func ExtractConditionValuesFromRequest(r *http.Request) map[string][]string {
values := make(map[string][]string)
// AWS condition keys
// Extract IP address without port for proper IP matching
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
// Log a warning if splitting fails
glog.Warningf("Failed to parse IP address from RemoteAddr %q: %v", r.RemoteAddr, err)
// If splitting fails, use the original RemoteAddr (might be just IP without port)
host = r.RemoteAddr
}
values["aws:SourceIp"] = []string{host}
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.
// 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
for key, headerValues := range r.Header {
if strings.HasPrefix(strings.ToLower(key), "x-amz-") {
values[strings.ToLower(key)] = headerValues
}
}
return values
}
// 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)
}
// IsReadAction checks if an action is a read action
func IsReadAction(action string) bool {
readActions := []string{
"s3:GetObject",
"s3:GetObjectVersion",
"s3:GetObjectAcl",
"s3:GetObjectVersionAcl",
"s3:GetObjectTagging",
"s3:GetObjectVersionTagging",
"s3:ListBucket",
"s3:ListBucketVersions",
"s3:GetBucketLocation",
"s3:GetBucketVersioning",
"s3:GetBucketAcl",
"s3:GetBucketCors",
"s3:GetBucketPolicy",
"s3:GetBucketTagging",
"s3:GetBucketNotification",
"s3:GetBucketObjectLockConfiguration",
"s3:GetObjectRetention",
"s3:GetObjectLegalHold",
}
for _, readAction := range readActions {
if action == readAction {
return true
}
}
return false
}
// IsWriteAction checks if an action is a write action
func IsWriteAction(action string) bool {
writeActions := []string{
"s3:PutObject",
"s3:PutObjectAcl",
"s3:PutObjectTagging",
"s3:DeleteObject",
"s3:DeleteObjectVersion",
"s3:DeleteObjectTagging",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploads",
"s3:ListParts",
"s3:PutBucketAcl",
"s3:PutBucketCors",
"s3:PutBucketPolicy",
"s3:PutBucketTagging",
"s3:PutBucketNotification",
"s3:PutBucketVersioning",
"s3:DeleteBucketPolicy",
"s3:DeleteBucketTagging",
"s3:DeleteBucketCors",
"s3:PutBucketObjectLockConfiguration",
"s3:PutObjectRetention",
"s3:PutObjectLegalHold",
"s3:BypassGovernanceRetention",
}
for _, writeAction := range writeActions {
if action == writeAction {
return true
}
}
return false
}
// GetBucketNameFromArn extracts bucket name from ARN
func GetBucketNameFromArn(arn string) string {
if strings.HasPrefix(arn, "arn:aws:s3:::") {
parts := strings.SplitN(arn[13:], "/", 2)
return parts[0]
}
return ""
}
// GetObjectNameFromArn extracts object name from ARN
func GetObjectNameFromArn(arn string) string {
if strings.HasPrefix(arn, "arn:aws:s3:::") {
parts := strings.SplitN(arn[13:], "/", 2)
if len(parts) > 1 {
return parts[1]
}
}
return ""
}
// 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
}
// 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 := ExtractConditionValuesFromRequest(r)
args := &PolicyEvaluationArgs{
Action: actionName,
Resource: resource,
Principal: principal,
Conditions: conditions,
}
return engine.EvaluatePolicy(bucketName, args)
}