aboutsummaryrefslogtreecommitdiff
path: root/weed/s3api/policy_engine/engine.go
diff options
context:
space:
mode:
authorChris Lu <chrislusf@users.noreply.github.com>2025-07-13 16:21:36 -0700
committerGitHub <noreply@github.com>2025-07-13 16:21:36 -0700
commit7cb1ca13082568bfdcdab974d8cefddf650443c5 (patch)
tree573b5e15d080d37b9312cade4151da9e3fb7ddee /weed/s3api/policy_engine/engine.go
parent1549ee2e154ab040e211ac7b3bc361272069abef (diff)
downloadseaweedfs-7cb1ca13082568bfdcdab974d8cefddf650443c5.tar.xz
seaweedfs-7cb1ca13082568bfdcdab974d8cefddf650443c5.zip
Add policy engine (#6970)
Diffstat (limited to 'weed/s3api/policy_engine/engine.go')
-rw-r--r--weed/s3api/policy_engine/engine.go432
1 files changed, 432 insertions, 0 deletions
diff --git a/weed/s3api/policy_engine/engine.go b/weed/s3api/policy_engine/engine.go
new file mode 100644
index 000000000..1e0126eb6
--- /dev/null
+++ b/weed/s3api/policy_engine/engine.go
@@ -0,0 +1,432 @@
+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: %v", err)
+ }
+
+ compiled, err := CompilePolicy(policy)
+ if err != nil {
+ return fmt.Errorf("failed to compile policy: %v", 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
+}
+
+// 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 explicit Allow is found, return Deny (default deny)
+
+ 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
+ }
+
+ return PolicyResultDeny // Default deny
+}
+
+// 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) {
+ 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}
+ }
+
+ // S3 object-level conditions
+ if r.Method == "GET" || r.Method == "HEAD" {
+ values["s3:ExistingObjectTag"] = extractObjectTags(r)
+ }
+
+ // 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
+}
+
+// extractObjectTags extracts object tags from request (placeholder implementation)
+func extractObjectTags(r *http.Request) []string {
+ // This would need to be implemented based on how object tags are stored
+ // For now, return empty slice
+ return []string{}
+}
+
+// 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 ""
+}
+
+// HasPolicyForBucket checks if a bucket has a policy
+func (engine *PolicyEngine) HasPolicyForBucket(bucketName string) bool {
+ engine.mutex.RLock()
+ defer engine.mutex.RUnlock()
+
+ _, exists := engine.contexts[bucketName]
+ return exists
+}
+
+// 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)
+}