You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1142 lines
32 KiB
1142 lines
32 KiB
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)
|
|
}
|
|
}
|