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(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) } 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 conditions if len(stmt.Statement.Condition) > 0 { match := EvaluateConditions(stmt.Statement.Condition, args.Conditions, args.ObjectEntry, args.Claims) 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 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/ 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) // 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) }