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 values["aws:SourceIp"] = []string{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/ 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 } // extractSourceIP returns the best-effort client IP address for condition evaluation. // Preference order: X-Forwarded-For (first valid IP), X-Real-Ip, then RemoteAddr. // IMPORTANT: X-Forwarded-For and X-Real-Ip are trusted without validation. // When the service is exposed directly, clients can spoof aws:SourceIp unless a // reverse proxy overwrites these headers. // isPrivateIP returns true if the given IP is considered a "trusted proxy" // address, such as loopback, link-local, or RFC1918 private ranges. // isPrivateIP returns true if the given IP is considered a "trusted proxy" // address, such as loopback, link-local, or private ranges. func isPrivateIP(ip net.IP) bool { if ip == nil { return false } return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsPrivate() } func extractSourceIP(r *http.Request) string { if r == nil { return "" } remoteAddr := strings.TrimSpace(r.RemoteAddr) if remoteAddr == "" { return "" } // Fall back to unix socket markers or other non-IP placeholders. if remoteAddr == "@" { return remoteAddr } host := remoteAddr if h, _, err := net.SplitHostPort(remoteAddr); err == nil { host = h } remoteIP := net.ParseIP(host) if remoteIP == nil { // Do not return DNS names or unparseable values. return "" } // Only trust forwarding headers when the connection appears to come from // a trusted proxy (e.g., private/loopback address). if isPrivateIP(remoteIP) { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { // Iterate right-to-left to find the first non-trusted (public) IP entries := strings.Split(xff, ",") for i := len(entries) - 1; i >= 0; i-- { candidate := strings.TrimSpace(entries[i]) if candidate == "" { continue } ip := net.ParseIP(candidate) if ip == nil { continue } // If the IP is trusted/private, we treat it as another proxy in the chain and continue if isPrivateIP(ip) { continue } // Found a public/non-trusted IP, return it as the client IP return ip.String() } // If we exhausted the list (all were private/trusted) or found no valid IPs, // fallback related logic could go here. // For now, if all are private, we continue to check X-Real-Ip or return RemoteIP? // The prompt implies we should prefer the extracted IP. // If all in XFF are private, likely the original client IS private (internal network). // The best guess for "original client" in a fully trusted chain is the left-most valid IP. for _, candidate := range entries { candidate = strings.TrimSpace(candidate) if ip := net.ParseIP(candidate); ip != nil { return ip.String() } } } if xRealIP := strings.TrimSpace(r.Header.Get("X-Real-Ip")); xRealIP != "" { if ip := net.ParseIP(xRealIP); ip != nil { return ip.String() } } } // Default to the actual peer IP when no trusted proxy is detected or the // forwarding headers are absent/invalid. return remoteIP.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 "" } // 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) }