diff options
Diffstat (limited to 'weed/iam/policy/policy_engine.go')
| -rw-r--r-- | weed/iam/policy/policy_engine.go | 1142 |
1 files changed, 1142 insertions, 0 deletions
diff --git a/weed/iam/policy/policy_engine.go b/weed/iam/policy/policy_engine.go new file mode 100644 index 000000000..5af1d7e1a --- /dev/null +++ b/weed/iam/policy/policy_engine.go @@ -0,0 +1,1142 @@ +package policy + +import ( + "context" + "fmt" + "net" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +// Effect represents the policy evaluation result +type Effect string + +const ( + EffectAllow Effect = "Allow" + EffectDeny Effect = "Deny" +) + +// Package-level regex cache for performance optimization +var ( + regexCache = make(map[string]*regexp.Regexp) + regexCacheMu sync.RWMutex +) + +// PolicyEngine evaluates policies against requests +type PolicyEngine struct { + config *PolicyEngineConfig + initialized bool + store PolicyStore +} + +// PolicyEngineConfig holds policy engine configuration +type PolicyEngineConfig struct { + // DefaultEffect when no policies match (Allow or Deny) + DefaultEffect string `json:"defaultEffect"` + + // StoreType specifies the policy store backend (memory, filer, etc.) + StoreType string `json:"storeType"` + + // StoreConfig contains store-specific configuration + StoreConfig map[string]interface{} `json:"storeConfig,omitempty"` +} + +// PolicyDocument represents an IAM policy document +type PolicyDocument struct { + // Version of the policy language (e.g., "2012-10-17") + Version string `json:"Version"` + + // Id is an optional policy identifier + Id string `json:"Id,omitempty"` + + // Statement contains the policy statements + Statement []Statement `json:"Statement"` +} + +// Statement represents a single policy statement +type Statement struct { + // Sid is an optional statement identifier + Sid string `json:"Sid,omitempty"` + + // Effect specifies whether to Allow or Deny + Effect string `json:"Effect"` + + // Principal specifies who the statement applies to (optional in role policies) + Principal interface{} `json:"Principal,omitempty"` + + // NotPrincipal specifies who the statement does NOT apply to + NotPrincipal interface{} `json:"NotPrincipal,omitempty"` + + // Action specifies the actions this statement applies to + Action []string `json:"Action"` + + // NotAction specifies actions this statement does NOT apply to + NotAction []string `json:"NotAction,omitempty"` + + // Resource specifies the resources this statement applies to + Resource []string `json:"Resource"` + + // NotResource specifies resources this statement does NOT apply to + NotResource []string `json:"NotResource,omitempty"` + + // Condition specifies conditions for when this statement applies + Condition map[string]map[string]interface{} `json:"Condition,omitempty"` +} + +// EvaluationContext provides context for policy evaluation +type EvaluationContext struct { + // Principal making the request (e.g., "user:alice", "role:admin") + Principal string `json:"principal"` + + // Action being requested (e.g., "s3:GetObject") + Action string `json:"action"` + + // Resource being accessed (e.g., "arn:seaweed:s3:::bucket/key") + Resource string `json:"resource"` + + // RequestContext contains additional request information + RequestContext map[string]interface{} `json:"requestContext,omitempty"` +} + +// EvaluationResult contains the result of policy evaluation +type EvaluationResult struct { + // Effect is the final decision (Allow or Deny) + Effect Effect `json:"effect"` + + // MatchingStatements contains statements that matched the request + MatchingStatements []StatementMatch `json:"matchingStatements,omitempty"` + + // EvaluationDetails provides detailed evaluation information + EvaluationDetails *EvaluationDetails `json:"evaluationDetails,omitempty"` +} + +// StatementMatch represents a statement that matched during evaluation +type StatementMatch struct { + // PolicyName is the name of the policy containing this statement + PolicyName string `json:"policyName"` + + // StatementSid is the statement identifier + StatementSid string `json:"statementSid,omitempty"` + + // Effect is the effect of this statement + Effect Effect `json:"effect"` + + // Reason explains why this statement matched + Reason string `json:"reason,omitempty"` +} + +// EvaluationDetails provides detailed information about policy evaluation +type EvaluationDetails struct { + // Principal that was evaluated + Principal string `json:"principal"` + + // Action that was evaluated + Action string `json:"action"` + + // Resource that was evaluated + Resource string `json:"resource"` + + // PoliciesEvaluated lists all policies that were evaluated + PoliciesEvaluated []string `json:"policiesEvaluated"` + + // ConditionsEvaluated lists all conditions that were evaluated + ConditionsEvaluated []string `json:"conditionsEvaluated,omitempty"` +} + +// PolicyStore defines the interface for storing and retrieving policies +type PolicyStore interface { + // StorePolicy stores a policy document (filerAddress ignored for memory stores) + StorePolicy(ctx context.Context, filerAddress string, name string, policy *PolicyDocument) error + + // GetPolicy retrieves a policy document (filerAddress ignored for memory stores) + GetPolicy(ctx context.Context, filerAddress string, name string) (*PolicyDocument, error) + + // DeletePolicy deletes a policy document (filerAddress ignored for memory stores) + DeletePolicy(ctx context.Context, filerAddress string, name string) error + + // ListPolicies lists all policy names (filerAddress ignored for memory stores) + ListPolicies(ctx context.Context, filerAddress string) ([]string, error) +} + +// NewPolicyEngine creates a new policy engine +func NewPolicyEngine() *PolicyEngine { + return &PolicyEngine{} +} + +// Initialize initializes the policy engine with configuration +func (e *PolicyEngine) Initialize(config *PolicyEngineConfig) error { + if config == nil { + return fmt.Errorf("config cannot be nil") + } + + if err := e.validateConfig(config); err != nil { + return fmt.Errorf("invalid configuration: %w", err) + } + + e.config = config + + // Initialize policy store + store, err := e.createPolicyStore(config) + if err != nil { + return fmt.Errorf("failed to create policy store: %w", err) + } + e.store = store + + e.initialized = true + return nil +} + +// InitializeWithProvider initializes the policy engine with configuration and a filer address provider +func (e *PolicyEngine) InitializeWithProvider(config *PolicyEngineConfig, filerAddressProvider func() string) error { + if config == nil { + return fmt.Errorf("config cannot be nil") + } + + if err := e.validateConfig(config); err != nil { + return fmt.Errorf("invalid configuration: %w", err) + } + + e.config = config + + // Initialize policy store with provider + store, err := e.createPolicyStoreWithProvider(config, filerAddressProvider) + if err != nil { + return fmt.Errorf("failed to create policy store: %w", err) + } + e.store = store + + e.initialized = true + return nil +} + +// validateConfig validates the policy engine configuration +func (e *PolicyEngine) validateConfig(config *PolicyEngineConfig) error { + if config.DefaultEffect != "Allow" && config.DefaultEffect != "Deny" { + return fmt.Errorf("invalid default effect: %s", config.DefaultEffect) + } + + if config.StoreType == "" { + config.StoreType = "filer" // Default to filer store for persistence + } + + return nil +} + +// createPolicyStore creates a policy store based on configuration +func (e *PolicyEngine) createPolicyStore(config *PolicyEngineConfig) (PolicyStore, error) { + switch config.StoreType { + case "memory": + return NewMemoryPolicyStore(), nil + case "", "filer": + // Check if caching is explicitly disabled + if config.StoreConfig != nil { + if noCache, ok := config.StoreConfig["noCache"].(bool); ok && noCache { + return NewFilerPolicyStore(config.StoreConfig, nil) + } + } + // Default to generic cached filer store for better performance + return NewGenericCachedPolicyStore(config.StoreConfig, nil) + case "cached-filer", "generic-cached": + return NewGenericCachedPolicyStore(config.StoreConfig, nil) + default: + return nil, fmt.Errorf("unsupported store type: %s", config.StoreType) + } +} + +// createPolicyStoreWithProvider creates a policy store with a filer address provider function +func (e *PolicyEngine) createPolicyStoreWithProvider(config *PolicyEngineConfig, filerAddressProvider func() string) (PolicyStore, error) { + switch config.StoreType { + case "memory": + return NewMemoryPolicyStore(), nil + case "", "filer": + // Check if caching is explicitly disabled + if config.StoreConfig != nil { + if noCache, ok := config.StoreConfig["noCache"].(bool); ok && noCache { + return NewFilerPolicyStore(config.StoreConfig, filerAddressProvider) + } + } + // Default to generic cached filer store for better performance + return NewGenericCachedPolicyStore(config.StoreConfig, filerAddressProvider) + case "cached-filer", "generic-cached": + return NewGenericCachedPolicyStore(config.StoreConfig, filerAddressProvider) + default: + return nil, fmt.Errorf("unsupported store type: %s", config.StoreType) + } +} + +// IsInitialized returns whether the engine is initialized +func (e *PolicyEngine) IsInitialized() bool { + return e.initialized +} + +// AddPolicy adds a policy to the engine (filerAddress ignored for memory stores) +func (e *PolicyEngine) AddPolicy(filerAddress string, name string, policy *PolicyDocument) error { + if !e.initialized { + return fmt.Errorf("policy engine not initialized") + } + + if name == "" { + return fmt.Errorf("policy name cannot be empty") + } + + if policy == nil { + return fmt.Errorf("policy cannot be nil") + } + + if err := ValidatePolicyDocument(policy); err != nil { + return fmt.Errorf("invalid policy document: %w", err) + } + + return e.store.StorePolicy(context.Background(), filerAddress, name, policy) +} + +// Evaluate evaluates policies against a request context (filerAddress ignored for memory stores) +func (e *PolicyEngine) Evaluate(ctx context.Context, filerAddress string, evalCtx *EvaluationContext, policyNames []string) (*EvaluationResult, error) { + if !e.initialized { + return nil, fmt.Errorf("policy engine not initialized") + } + + if evalCtx == nil { + return nil, fmt.Errorf("evaluation context cannot be nil") + } + + result := &EvaluationResult{ + Effect: Effect(e.config.DefaultEffect), + EvaluationDetails: &EvaluationDetails{ + Principal: evalCtx.Principal, + Action: evalCtx.Action, + Resource: evalCtx.Resource, + PoliciesEvaluated: policyNames, + }, + } + + var matchingStatements []StatementMatch + explicitDeny := false + hasAllow := false + + // Evaluate each policy + for _, policyName := range policyNames { + policy, err := e.store.GetPolicy(ctx, filerAddress, policyName) + if err != nil { + continue // Skip policies that can't be loaded + } + + // Evaluate each statement in the policy + for _, statement := range policy.Statement { + if e.statementMatches(&statement, evalCtx) { + match := StatementMatch{ + PolicyName: policyName, + StatementSid: statement.Sid, + Effect: Effect(statement.Effect), + Reason: "Action, Resource, and Condition matched", + } + matchingStatements = append(matchingStatements, match) + + if statement.Effect == "Deny" { + explicitDeny = true + } else if statement.Effect == "Allow" { + hasAllow = true + } + } + } + } + + result.MatchingStatements = matchingStatements + + // AWS IAM evaluation logic: + // 1. If there's an explicit Deny, the result is Deny + // 2. If there's an Allow and no Deny, the result is Allow + // 3. Otherwise, use the default effect + if explicitDeny { + result.Effect = EffectDeny + } else if hasAllow { + result.Effect = EffectAllow + } + + return result, nil +} + +// statementMatches checks if a statement matches the evaluation context +func (e *PolicyEngine) statementMatches(statement *Statement, evalCtx *EvaluationContext) bool { + // Check action match + if !e.matchesActions(statement.Action, evalCtx.Action, evalCtx) { + return false + } + + // Check resource match + if !e.matchesResources(statement.Resource, evalCtx.Resource, evalCtx) { + return false + } + + // Check conditions + if !e.matchesConditions(statement.Condition, evalCtx) { + return false + } + + return true +} + +// matchesActions checks if any action in the list matches the requested action +func (e *PolicyEngine) matchesActions(actions []string, requestedAction string, evalCtx *EvaluationContext) bool { + for _, action := range actions { + if awsIAMMatch(action, requestedAction, evalCtx) { + return true + } + } + return false +} + +// matchesResources checks if any resource in the list matches the requested resource +func (e *PolicyEngine) matchesResources(resources []string, requestedResource string, evalCtx *EvaluationContext) bool { + for _, resource := range resources { + if awsIAMMatch(resource, requestedResource, evalCtx) { + return true + } + } + return false +} + +// matchesConditions checks if all conditions are satisfied +func (e *PolicyEngine) matchesConditions(conditions map[string]map[string]interface{}, evalCtx *EvaluationContext) bool { + if len(conditions) == 0 { + return true // No conditions means always match + } + + for conditionType, conditionBlock := range conditions { + if !e.evaluateConditionBlock(conditionType, conditionBlock, evalCtx) { + return false + } + } + + return true +} + +// evaluateConditionBlock evaluates a single condition block +func (e *PolicyEngine) evaluateConditionBlock(conditionType string, block map[string]interface{}, evalCtx *EvaluationContext) bool { + switch conditionType { + // IP Address conditions + case "IpAddress": + return e.evaluateIPCondition(block, evalCtx, true) + case "NotIpAddress": + return e.evaluateIPCondition(block, evalCtx, false) + + // String conditions + case "StringEquals": + return e.EvaluateStringCondition(block, evalCtx, true, false) + case "StringNotEquals": + return e.EvaluateStringCondition(block, evalCtx, false, false) + case "StringLike": + return e.EvaluateStringCondition(block, evalCtx, true, true) + case "StringEqualsIgnoreCase": + return e.evaluateStringConditionIgnoreCase(block, evalCtx, true, false) + case "StringNotEqualsIgnoreCase": + return e.evaluateStringConditionIgnoreCase(block, evalCtx, false, false) + case "StringLikeIgnoreCase": + return e.evaluateStringConditionIgnoreCase(block, evalCtx, true, true) + + // Numeric conditions + case "NumericEquals": + return e.evaluateNumericCondition(block, evalCtx, "==") + case "NumericNotEquals": + return e.evaluateNumericCondition(block, evalCtx, "!=") + case "NumericLessThan": + return e.evaluateNumericCondition(block, evalCtx, "<") + case "NumericLessThanEquals": + return e.evaluateNumericCondition(block, evalCtx, "<=") + case "NumericGreaterThan": + return e.evaluateNumericCondition(block, evalCtx, ">") + case "NumericGreaterThanEquals": + return e.evaluateNumericCondition(block, evalCtx, ">=") + + // Date conditions + case "DateEquals": + return e.evaluateDateCondition(block, evalCtx, "==") + case "DateNotEquals": + return e.evaluateDateCondition(block, evalCtx, "!=") + case "DateLessThan": + return e.evaluateDateCondition(block, evalCtx, "<") + case "DateLessThanEquals": + return e.evaluateDateCondition(block, evalCtx, "<=") + case "DateGreaterThan": + return e.evaluateDateCondition(block, evalCtx, ">") + case "DateGreaterThanEquals": + return e.evaluateDateCondition(block, evalCtx, ">=") + + // Boolean conditions + case "Bool": + return e.evaluateBoolCondition(block, evalCtx) + + // Null conditions + case "Null": + return e.evaluateNullCondition(block, evalCtx) + + default: + // Unknown condition types default to false (more secure) + return false + } +} + +// evaluateIPCondition evaluates IP address conditions +func (e *PolicyEngine) evaluateIPCondition(block map[string]interface{}, evalCtx *EvaluationContext, shouldMatch bool) bool { + sourceIP, exists := evalCtx.RequestContext["sourceIP"] + if !exists { + return !shouldMatch // If no IP in context, condition fails for positive match + } + + sourceIPStr, ok := sourceIP.(string) + if !ok { + return !shouldMatch + } + + sourceIPAddr := net.ParseIP(sourceIPStr) + if sourceIPAddr == nil { + return !shouldMatch + } + + for key, value := range block { + if key == "seaweed:SourceIP" { + ranges, ok := value.([]string) + if !ok { + continue + } + + for _, ipRange := range ranges { + if strings.Contains(ipRange, "/") { + // CIDR range + _, cidr, err := net.ParseCIDR(ipRange) + if err != nil { + continue + } + if cidr.Contains(sourceIPAddr) { + return shouldMatch + } + } else { + // Single IP + if sourceIPStr == ipRange { + return shouldMatch + } + } + } + } + } + + return !shouldMatch +} + +// EvaluateStringCondition evaluates string-based conditions +func (e *PolicyEngine) EvaluateStringCondition(block map[string]interface{}, evalCtx *EvaluationContext, shouldMatch bool, useWildcard bool) bool { + // Iterate through all condition keys in the block + for conditionKey, conditionValue := range block { + // Get the context values for this condition key + contextValues, exists := evalCtx.RequestContext[conditionKey] + if !exists { + // If the context key doesn't exist, condition fails for positive match + if shouldMatch { + return false + } + continue + } + + // Convert context value to string slice + var contextStrings []string + switch v := contextValues.(type) { + case string: + contextStrings = []string{v} + case []string: + contextStrings = v + case []interface{}: + for _, item := range v { + if str, ok := item.(string); ok { + contextStrings = append(contextStrings, str) + } + } + default: + // Convert to string as fallback + contextStrings = []string{fmt.Sprintf("%v", v)} + } + + // Convert condition value to string slice + var expectedStrings []string + switch v := conditionValue.(type) { + case string: + expectedStrings = []string{v} + case []string: + expectedStrings = v + case []interface{}: + for _, item := range v { + if str, ok := item.(string); ok { + expectedStrings = append(expectedStrings, str) + } else { + expectedStrings = append(expectedStrings, fmt.Sprintf("%v", item)) + } + } + default: + expectedStrings = []string{fmt.Sprintf("%v", v)} + } + + // Evaluate the condition using AWS IAM-compliant matching + conditionMet := false + for _, expected := range expectedStrings { + for _, contextValue := range contextStrings { + if useWildcard { + // Use AWS IAM-compliant wildcard matching for StringLike conditions + // This handles case-insensitivity and policy variables + if awsIAMMatch(expected, contextValue, evalCtx) { + conditionMet = true + break + } + } else { + // For StringEquals/StringNotEquals, also support policy variables but be case-sensitive + expandedExpected := expandPolicyVariables(expected, evalCtx) + if expandedExpected == contextValue { + conditionMet = true + break + } + } + } + if conditionMet { + break + } + } + + // For shouldMatch=true (StringEquals, StringLike): condition must be met + // For shouldMatch=false (StringNotEquals): condition must NOT be met + if shouldMatch && !conditionMet { + return false + } + if !shouldMatch && conditionMet { + return false + } + } + + return true +} + +// ValidatePolicyDocument validates a policy document structure +func ValidatePolicyDocument(policy *PolicyDocument) error { + return ValidatePolicyDocumentWithType(policy, "resource") +} + +// ValidateTrustPolicyDocument validates a trust policy document structure +func ValidateTrustPolicyDocument(policy *PolicyDocument) error { + return ValidatePolicyDocumentWithType(policy, "trust") +} + +// ValidatePolicyDocumentWithType validates a policy document for specific type +func ValidatePolicyDocumentWithType(policy *PolicyDocument, policyType string) error { + if policy == nil { + return fmt.Errorf("policy document cannot be nil") + } + + if policy.Version == "" { + return fmt.Errorf("version is required") + } + + if len(policy.Statement) == 0 { + return fmt.Errorf("at least one statement is required") + } + + for i, statement := range policy.Statement { + if err := validateStatementWithType(&statement, policyType); err != nil { + return fmt.Errorf("statement %d is invalid: %w", i, err) + } + } + + return nil +} + +// validateStatement validates a single statement (for backward compatibility) +func validateStatement(statement *Statement) error { + return validateStatementWithType(statement, "resource") +} + +// validateStatementWithType validates a single statement based on policy type +func validateStatementWithType(statement *Statement, policyType string) error { + if statement.Effect != "Allow" && statement.Effect != "Deny" { + return fmt.Errorf("invalid effect: %s (must be Allow or Deny)", statement.Effect) + } + + if len(statement.Action) == 0 { + return fmt.Errorf("at least one action is required") + } + + // Trust policies don't require Resource field, but resource policies do + if policyType == "resource" { + if len(statement.Resource) == 0 { + return fmt.Errorf("at least one resource is required") + } + } else if policyType == "trust" { + // Trust policies should have Principal field + if statement.Principal == nil { + return fmt.Errorf("trust policy statement must have Principal field") + } + + // Trust policies typically have specific actions + validTrustActions := map[string]bool{ + "sts:AssumeRole": true, + "sts:AssumeRoleWithWebIdentity": true, + "sts:AssumeRoleWithCredentials": true, + } + + for _, action := range statement.Action { + if !validTrustActions[action] { + return fmt.Errorf("invalid action for trust policy: %s", action) + } + } + } + + return nil +} + +// matchResource checks if a resource pattern matches a requested resource +// Uses hybrid approach: simple suffix wildcards for compatibility, filepath.Match for complex patterns +func matchResource(pattern, resource string) bool { + if pattern == resource { + return true + } + + // Handle simple suffix wildcard (backward compatibility) + if strings.HasSuffix(pattern, "*") { + prefix := pattern[:len(pattern)-1] + return strings.HasPrefix(resource, prefix) + } + + // For complex patterns, use filepath.Match for advanced wildcard support (*, ?, []) + matched, err := filepath.Match(pattern, resource) + if err != nil { + // Fallback to exact match if pattern is malformed + return pattern == resource + } + + return matched +} + +// awsIAMMatch performs AWS IAM-compliant pattern matching with case-insensitivity and policy variable support +func awsIAMMatch(pattern, value string, evalCtx *EvaluationContext) bool { + // Step 1: Substitute policy variables (e.g., ${aws:username}, ${saml:username}) + expandedPattern := expandPolicyVariables(pattern, evalCtx) + + // Step 2: Handle special patterns + if expandedPattern == "*" { + return true // Universal wildcard + } + + // Step 3: Case-insensitive exact match + if strings.EqualFold(expandedPattern, value) { + return true + } + + // Step 4: Handle AWS-style wildcards (case-insensitive) + if strings.Contains(expandedPattern, "*") || strings.Contains(expandedPattern, "?") { + return AwsWildcardMatch(expandedPattern, value) + } + + return false +} + +// expandPolicyVariables substitutes AWS policy variables in the pattern +func expandPolicyVariables(pattern string, evalCtx *EvaluationContext) string { + if evalCtx == nil || evalCtx.RequestContext == nil { + return pattern + } + + expanded := pattern + + // Common AWS policy variables that might be used in SeaweedFS + variableMap := map[string]string{ + "${aws:username}": getContextValue(evalCtx, "aws:username", ""), + "${saml:username}": getContextValue(evalCtx, "saml:username", ""), + "${oidc:sub}": getContextValue(evalCtx, "oidc:sub", ""), + "${aws:userid}": getContextValue(evalCtx, "aws:userid", ""), + "${aws:principaltype}": getContextValue(evalCtx, "aws:principaltype", ""), + } + + for variable, value := range variableMap { + if value != "" { + expanded = strings.ReplaceAll(expanded, variable, value) + } + } + + return expanded +} + +// getContextValue safely gets a value from the evaluation context +func getContextValue(evalCtx *EvaluationContext, key, defaultValue string) string { + if value, exists := evalCtx.RequestContext[key]; exists { + if str, ok := value.(string); ok { + return str + } + } + return defaultValue +} + +// AwsWildcardMatch performs case-insensitive wildcard matching like AWS IAM +func AwsWildcardMatch(pattern, value string) bool { + // Create regex pattern key for caching + // First escape all regex metacharacters, then replace wildcards + regexPattern := regexp.QuoteMeta(pattern) + regexPattern = strings.ReplaceAll(regexPattern, "\\*", ".*") + regexPattern = strings.ReplaceAll(regexPattern, "\\?", ".") + regexPattern = "^" + regexPattern + "$" + regexKey := "(?i)" + regexPattern + + // Try to get compiled regex from cache + regexCacheMu.RLock() + regex, found := regexCache[regexKey] + regexCacheMu.RUnlock() + + if !found { + // Compile and cache the regex + compiledRegex, err := regexp.Compile(regexKey) + if err != nil { + // Fallback to simple case-insensitive comparison if regex fails + return strings.EqualFold(pattern, value) + } + + // Store in cache with write lock + regexCacheMu.Lock() + // Double-check in case another goroutine added it + if existingRegex, exists := regexCache[regexKey]; exists { + regex = existingRegex + } else { + regexCache[regexKey] = compiledRegex + regex = compiledRegex + } + regexCacheMu.Unlock() + } + + return regex.MatchString(value) +} + +// matchAction checks if an action pattern matches a requested action +// Uses hybrid approach: simple suffix wildcards for compatibility, filepath.Match for complex patterns +func matchAction(pattern, action string) bool { + if pattern == action { + return true + } + + // Handle simple suffix wildcard (backward compatibility) + if strings.HasSuffix(pattern, "*") { + prefix := pattern[:len(pattern)-1] + return strings.HasPrefix(action, prefix) + } + + // For complex patterns, use filepath.Match for advanced wildcard support (*, ?, []) + matched, err := filepath.Match(pattern, action) + if err != nil { + // Fallback to exact match if pattern is malformed + return pattern == action + } + + return matched +} + +// evaluateStringConditionIgnoreCase evaluates string conditions with case insensitivity +func (e *PolicyEngine) evaluateStringConditionIgnoreCase(block map[string]interface{}, evalCtx *EvaluationContext, shouldMatch bool, useWildcard bool) bool { + for key, expectedValues := range block { + contextValue, exists := evalCtx.RequestContext[key] + if !exists { + if !shouldMatch { + continue // For NotEquals, missing key is OK + } + return false + } + + contextStr, ok := contextValue.(string) + if !ok { + return false + } + + contextStr = strings.ToLower(contextStr) + matched := false + + // Handle different value types + switch v := expectedValues.(type) { + case string: + expectedStr := strings.ToLower(v) + if useWildcard { + matched, _ = filepath.Match(expectedStr, contextStr) + } else { + matched = expectedStr == contextStr + } + case []interface{}: + for _, val := range v { + if valStr, ok := val.(string); ok { + expectedStr := strings.ToLower(valStr) + if useWildcard { + if m, _ := filepath.Match(expectedStr, contextStr); m { + matched = true + break + } + } else { + if expectedStr == contextStr { + matched = true + break + } + } + } + } + } + + if shouldMatch && !matched { + return false + } + if !shouldMatch && matched { + return false + } + } + return true +} + +// evaluateNumericCondition evaluates numeric conditions +func (e *PolicyEngine) evaluateNumericCondition(block map[string]interface{}, evalCtx *EvaluationContext, operator string) bool { + for key, expectedValues := range block { + contextValue, exists := evalCtx.RequestContext[key] + if !exists { + return false + } + + contextNum, err := parseNumeric(contextValue) + if err != nil { + return false + } + + matched := false + + // Handle different value types + switch v := expectedValues.(type) { + case string: + expectedNum, err := parseNumeric(v) + if err != nil { + return false + } + matched = compareNumbers(contextNum, expectedNum, operator) + case []interface{}: + for _, val := range v { + expectedNum, err := parseNumeric(val) + if err != nil { + continue + } + if compareNumbers(contextNum, expectedNum, operator) { + matched = true + break + } + } + } + + if !matched { + return false + } + } + return true +} + +// evaluateDateCondition evaluates date conditions +func (e *PolicyEngine) evaluateDateCondition(block map[string]interface{}, evalCtx *EvaluationContext, operator string) bool { + for key, expectedValues := range block { + contextValue, exists := evalCtx.RequestContext[key] + if !exists { + return false + } + + contextTime, err := parseDateTime(contextValue) + if err != nil { + return false + } + + matched := false + + // Handle different value types + switch v := expectedValues.(type) { + case string: + expectedTime, err := parseDateTime(v) + if err != nil { + return false + } + matched = compareDates(contextTime, expectedTime, operator) + case []interface{}: + for _, val := range v { + expectedTime, err := parseDateTime(val) + if err != nil { + continue + } + if compareDates(contextTime, expectedTime, operator) { + matched = true + break + } + } + } + + if !matched { + return false + } + } + return true +} + +// evaluateBoolCondition evaluates boolean conditions +func (e *PolicyEngine) evaluateBoolCondition(block map[string]interface{}, evalCtx *EvaluationContext) bool { + for key, expectedValues := range block { + contextValue, exists := evalCtx.RequestContext[key] + if !exists { + return false + } + + contextBool, err := parseBool(contextValue) + if err != nil { + return false + } + + matched := false + + // Handle different value types + switch v := expectedValues.(type) { + case string: + expectedBool, err := parseBool(v) + if err != nil { + return false + } + matched = contextBool == expectedBool + case bool: + matched = contextBool == v + case []interface{}: + for _, val := range v { + expectedBool, err := parseBool(val) + if err != nil { + continue + } + if contextBool == expectedBool { + matched = true + break + } + } + } + + if !matched { + return false + } + } + return true +} + +// evaluateNullCondition evaluates null conditions +func (e *PolicyEngine) evaluateNullCondition(block map[string]interface{}, evalCtx *EvaluationContext) bool { + for key, expectedValues := range block { + _, exists := evalCtx.RequestContext[key] + + expectedNull := false + switch v := expectedValues.(type) { + case string: + expectedNull = v == "true" + case bool: + expectedNull = v + } + + // If we expect null (true) and key exists, or expect non-null (false) and key doesn't exist + if expectedNull == exists { + return false + } + } + return true +} + +// Helper functions for parsing and comparing values + +// parseNumeric parses a value as a float64 +func parseNumeric(value interface{}) (float64, error) { + switch v := value.(type) { + case float64: + return v, nil + case float32: + return float64(v), nil + case int: + return float64(v), nil + case int64: + return float64(v), nil + case string: + return strconv.ParseFloat(v, 64) + default: + return 0, fmt.Errorf("cannot parse %T as numeric", value) + } +} + +// compareNumbers compares two numbers using the given operator +func compareNumbers(a, b float64, operator string) bool { + switch operator { + case "==": + return a == b + case "!=": + return a != b + case "<": + return a < b + case "<=": + return a <= b + case ">": + return a > b + case ">=": + return a >= b + default: + return false + } +} + +// parseDateTime parses a value as a time.Time +func parseDateTime(value interface{}) (time.Time, error) { + switch v := value.(type) { + case string: + // Try common date formats + formats := []string{ + time.RFC3339, + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02", + } + for _, format := range formats { + if t, err := time.Parse(format, v); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("cannot parse date: %s", v) + case time.Time: + return v, nil + default: + return time.Time{}, fmt.Errorf("cannot parse %T as date", value) + } +} + +// compareDates compares two dates using the given operator +func compareDates(a, b time.Time, operator string) bool { + switch operator { + case "==": + return a.Equal(b) + case "!=": + return !a.Equal(b) + case "<": + return a.Before(b) + case "<=": + return a.Before(b) || a.Equal(b) + case ">": + return a.After(b) + case ">=": + return a.After(b) || a.Equal(b) + default: + return false + } +} + +// parseBool parses a value as a boolean +func parseBool(value interface{}) (bool, error) { + switch v := value.(type) { + case bool: + return v, nil + case string: + return strconv.ParseBool(v) + default: + return false, fmt.Errorf("cannot parse %T as boolean", value) + } +} |
