* chore: remove unreachable dead code across the codebase Remove ~50,000 lines of unreachable code identified by static analysis. Major removals: - weed/filer/redis_lua: entire unused Redis Lua filer store implementation - weed/wdclient/net2, resource_pool: unused connection/resource pool packages - weed/plugin/worker/lifecycle: unused lifecycle plugin worker - weed/s3api: unused S3 policy templates, presigned URL IAM, streaming copy, multipart IAM, key rotation, and various SSE helper functions - weed/mq/kafka: unused partition mapping, compression, schema, and protocol functions - weed/mq/offset: unused SQL storage and migration code - weed/worker: unused registry, task, and monitoring functions - weed/query: unused SQL engine, parquet scanner, and type functions - weed/shell: unused EC proportional rebalance functions - weed/storage/erasure_coding/distribution: unused distribution analysis functions - Individual unreachable functions removed from 150+ files across admin, credential, filer, iam, kms, mount, mq, operation, pb, s3api, server, shell, storage, topology, and util packages * fix(s3): reset shared memory store in IAM test to prevent flaky failure TestLoadIAMManagerFromConfig_EmptyConfigWithFallbackKey was flaky because the MemoryStore credential backend is a singleton registered via init(). Earlier tests that create anonymous identities pollute the shared store, causing LookupAnonymous() to unexpectedly return true. Fix by calling Reset() on the memory store before the test runs. * style: run gofmt on changed files * fix: restore KMS functions used by integration tests * fix(plugin): prevent panic on send to closed worker session channel The Plugin.sendToWorker method could panic with "send on closed channel" when a worker disconnected while a message was being sent. The race was between streamSession.close() closing the outgoing channel and sendToWorker writing to it concurrently. Add a done channel to streamSession that is closed before the outgoing channel, and check it in sendToWorker's select to safely detect closed sessions without panicking.
1989 lines
55 KiB
Go
1989 lines
55 KiB
Go
package policy
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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
|
|
policyVariablePattern = regexp.MustCompile(`\$\{([^}]+)\}`)
|
|
safePolicyVariables = map[string]bool{
|
|
// AWS standard identity variables
|
|
"aws:username": true,
|
|
"aws:userid": true,
|
|
"aws:PrincipalArn": true,
|
|
"aws:PrincipalAccount": true,
|
|
"aws:principaltype": true,
|
|
"aws:FederatedProvider": true,
|
|
"aws:PrincipalServiceName": true,
|
|
// SAML identity variables
|
|
"saml:username": true,
|
|
"saml:sub": true,
|
|
"saml:aud": true,
|
|
"saml:iss": true,
|
|
// OIDC/JWT identity variables
|
|
"oidc:sub": true,
|
|
"oidc:aud": true,
|
|
"oidc:iss": true,
|
|
// JWT identity variables
|
|
"jwt:preferred_username": true,
|
|
"jwt:sub": true,
|
|
"jwt:iss": true,
|
|
"jwt:aud": true,
|
|
// AWS request context (not from headers)
|
|
"aws:SourceIp": true,
|
|
"aws:SecureTransport": true,
|
|
"aws:CurrentTime": true,
|
|
"s3:prefix": true,
|
|
"s3:delimiter": true,
|
|
"s3:max-keys": true,
|
|
}
|
|
)
|
|
|
|
// 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 StringList `json:"Action"`
|
|
|
|
// NotAction specifies actions this statement does NOT apply to
|
|
NotAction StringList `json:"NotAction,omitempty"`
|
|
|
|
// Resource specifies the resources this statement applies to
|
|
Resource StringList `json:"Resource"`
|
|
|
|
// NotResource specifies resources this statement does NOT apply to
|
|
NotResource StringList `json:"NotResource,omitempty"`
|
|
|
|
// Condition specifies conditions for when this statement applies
|
|
Condition map[string]map[string]interface{} `json:"Condition,omitempty"`
|
|
}
|
|
|
|
// StringList handles fields that can be a string or a list of strings
|
|
type StringList []string
|
|
|
|
// UnmarshalJSON implements custom unmarshalling for StringList
|
|
func (sl *StringList) UnmarshalJSON(data []byte) error {
|
|
var s string
|
|
if err := json.Unmarshal(data, &s); err == nil {
|
|
*sl = []string{s}
|
|
return nil
|
|
}
|
|
var sa []string
|
|
if err := json.Unmarshal(data, &sa); err == nil {
|
|
*sl = sa
|
|
return nil
|
|
}
|
|
return fmt.Errorf("invalid string list")
|
|
}
|
|
|
|
// 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:aws: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
|
|
}
|
|
|
|
// DefaultAllow returns whether the default effect is Allow
|
|
func (e *PolicyEngine) DefaultAllow() bool {
|
|
if e.config == nil {
|
|
return true // Default to Allow if not configured
|
|
}
|
|
return e.config.DefaultEffect == string(EffectAllow)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// DeletePolicy removes a policy from the configured store.
|
|
func (e *PolicyEngine) DeletePolicy(ctx context.Context, filerAddress string, name string) error {
|
|
if !e.initialized {
|
|
return fmt.Errorf("policy engine not initialized")
|
|
}
|
|
|
|
if name == "" {
|
|
return fmt.Errorf("policy name cannot be empty")
|
|
}
|
|
|
|
return e.store.DeletePolicy(ctx, filerAddress, name)
|
|
}
|
|
|
|
// StoreType returns the configured backend type for the policy store.
|
|
func (e *PolicyEngine) StoreType() string {
|
|
if e.config == nil {
|
|
return ""
|
|
}
|
|
return e.config.StoreType
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// EvaluateTrustPolicy evaluates a trust policy document directly (without storing it)
|
|
// This is used for AssumeRole/AssumeRoleWithWebIdentity trust policy validation
|
|
func (e *PolicyEngine) EvaluateTrustPolicy(ctx context.Context, trustPolicy *PolicyDocument, evalCtx *EvaluationContext) (*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")
|
|
}
|
|
|
|
if trustPolicy == nil {
|
|
return nil, fmt.Errorf("trust policy cannot be nil")
|
|
}
|
|
|
|
result := &EvaluationResult{
|
|
Effect: Effect(e.config.DefaultEffect),
|
|
EvaluationDetails: &EvaluationDetails{
|
|
Principal: evalCtx.Principal,
|
|
Action: evalCtx.Action,
|
|
Resource: evalCtx.Resource,
|
|
PoliciesEvaluated: []string{"trust-policy"},
|
|
},
|
|
}
|
|
|
|
var matchingStatements []StatementMatch
|
|
explicitDeny := false
|
|
hasAllow := false
|
|
|
|
// Evaluate each statement in the trust policy
|
|
for _, statement := range trustPolicy.Statement {
|
|
if e.statementMatches(&statement, evalCtx) {
|
|
match := StatementMatch{
|
|
PolicyName: "trust-policy",
|
|
StatementSid: statement.Sid,
|
|
Effect: Effect(statement.Effect),
|
|
Reason: "Principal, Action, 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
|
|
}
|
|
|
|
// EvaluatePolicyDocument evaluates a single policy document without storing it.
|
|
// defaultEffect controls the fallback result when no statements match.
|
|
func (e *PolicyEngine) EvaluatePolicyDocument(ctx context.Context, evalCtx *EvaluationContext, policyName string, policyDoc *PolicyDocument, defaultEffect Effect) (*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")
|
|
}
|
|
|
|
if policyDoc == nil {
|
|
return nil, fmt.Errorf("policy document cannot be nil")
|
|
}
|
|
|
|
if policyName == "" {
|
|
policyName = "inline-policy"
|
|
}
|
|
|
|
result := &EvaluationResult{
|
|
Effect: defaultEffect,
|
|
EvaluationDetails: &EvaluationDetails{
|
|
Principal: evalCtx.Principal,
|
|
Action: evalCtx.Action,
|
|
Resource: evalCtx.Resource,
|
|
PoliciesEvaluated: []string{policyName},
|
|
},
|
|
}
|
|
|
|
var matchingStatements []StatementMatch
|
|
explicitDeny := false
|
|
hasAllow := false
|
|
|
|
for _, statement := range policyDoc.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
|
|
|
|
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 principal match (for trust policies)
|
|
// If Principal field is present, it must match
|
|
if statement.Principal != nil {
|
|
if !e.matchesPrincipal(statement.Principal, evalCtx) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Check action match
|
|
if !e.matchesActions(statement.Action, evalCtx.Action, evalCtx) {
|
|
return false
|
|
}
|
|
|
|
// Check resource match (optional for trust policies)
|
|
// For STS trust policy evaluations (AssumeRole*), resource matching should be skipped
|
|
// Trust policies typically don't include Resource, and enforcing resource matching
|
|
// here may cause valid trust statements to be rejected.
|
|
if strings.HasPrefix(evalCtx.Action, "sts:") {
|
|
// Skip resource checks for trust policy evaluation
|
|
} else if len(statement.Resource) > 0 {
|
|
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
|
|
}
|
|
|
|
// matchesPrincipal checks if the principal in the statement matches the evaluation context
|
|
// This is used for trust policy evaluation (e.g., AssumeRole, AssumeRoleWithWebIdentity)
|
|
func (e *PolicyEngine) matchesPrincipal(principal interface{}, evalCtx *EvaluationContext) bool {
|
|
// Handle plain string principal (e.g., "*" or "arn:aws:iam::...")
|
|
if principalStr, ok := principal.(string); ok {
|
|
// Check wildcard FIRST before context validation
|
|
// This allows "*" to work without requiring context
|
|
if principalStr == "*" {
|
|
return true
|
|
}
|
|
|
|
// For non-wildcard string principals, we'd need specific matching logic
|
|
// For now, treat as a match if it equals the principal in context
|
|
if contextPrincipal, exists := evalCtx.RequestContext["principal"]; exists {
|
|
if contextPrincipalStr, ok := contextPrincipal.(string); ok {
|
|
return principalStr == contextPrincipalStr
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Handle structured principal (e.g., {"Federated": "*"} or {"AWS": "arn:..."})
|
|
if principalMap, ok := principal.(map[string]interface{}); ok {
|
|
// For each principal type (Federated, AWS, Service, etc.)
|
|
for principalType, principalValue := range principalMap {
|
|
// Get the context key for this principal type
|
|
contextKey := getPrincipalContextKey(principalType)
|
|
|
|
if !e.evaluatePrincipalValue(principalValue, evalCtx, contextKey) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Unknown principal format
|
|
return false
|
|
}
|
|
|
|
// evaluatePrincipalValue evaluates a principal value against the evaluation context
|
|
// This handles wildcards, arrays, and context matching
|
|
func (e *PolicyEngine) evaluatePrincipalValue(principalValue interface{}, evalCtx *EvaluationContext, contextKey string) bool {
|
|
// Handle single string value
|
|
if principalStr, ok := principalValue.(string); ok {
|
|
// Check wildcard FIRST before context validation
|
|
// This allows {"Federated": "*"} to work without requiring context
|
|
if principalStr == "*" {
|
|
return true
|
|
}
|
|
|
|
// Then check against context
|
|
contextValue, exists := evalCtx.RequestContext[contextKey]
|
|
if !exists {
|
|
return false
|
|
}
|
|
contextStr, ok := contextValue.(string)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return principalStr == contextStr
|
|
}
|
|
|
|
// Handle array of strings - convert to []interface{} for unified handling
|
|
var principalArray []interface{}
|
|
switch arr := principalValue.(type) {
|
|
case []interface{}:
|
|
principalArray = arr
|
|
case []string:
|
|
principalArray = make([]interface{}, len(arr))
|
|
for i, v := range arr {
|
|
principalArray[i] = v
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
|
|
if len(principalArray) > 0 {
|
|
for _, item := range principalArray {
|
|
if itemStr, ok := item.(string); ok {
|
|
// Wildcard in array allows any value
|
|
if itemStr == "*" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no wildcard found, check against context
|
|
contextValue, exists := evalCtx.RequestContext[contextKey]
|
|
if !exists {
|
|
return false
|
|
}
|
|
contextStr, ok := contextValue.(string)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
// Check if any array item matches the context
|
|
for _, item := range principalArray {
|
|
if itemStr, ok := item.(string); ok {
|
|
if itemStr == contextStr {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// getPrincipalContextKey returns the context key for a given principal type
|
|
// Uses AWS-compatible context keys for maximum compatibility
|
|
func getPrincipalContextKey(principalType string) string {
|
|
switch principalType {
|
|
case "Federated":
|
|
// For federated identity (OIDC/SAML), use the standard AWS context key
|
|
// This is typically populated with the identity provider ARN or URL
|
|
return "aws:FederatedProvider"
|
|
case "AWS":
|
|
// For AWS principals (IAM users/roles), use the principal ARN
|
|
return "aws:PrincipalArn"
|
|
case "Service":
|
|
// For AWS service principals
|
|
return "aws:PrincipalServiceName"
|
|
default:
|
|
// For any other principal type, use aws: prefix for compatibility
|
|
return "aws:Principal" + principalType
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
// Parse set operators (prefixes)
|
|
forAllValues := false
|
|
if strings.HasPrefix(conditionType, "ForAllValues:") {
|
|
forAllValues = true
|
|
conditionType = strings.TrimPrefix(conditionType, "ForAllValues:")
|
|
} else if strings.HasPrefix(conditionType, "ForAnyValue:") {
|
|
conditionType = strings.TrimPrefix(conditionType, "ForAnyValue:")
|
|
// ForAnyValue is the default behavior (Any context value matches Any condition value),
|
|
// so we just strip the prefix
|
|
}
|
|
|
|
switch conditionType {
|
|
// IP Address conditions
|
|
case "IpAddress":
|
|
return e.evaluateIPCondition(block, evalCtx, true, forAllValues)
|
|
case "NotIpAddress":
|
|
return e.evaluateIPCondition(block, evalCtx, false, forAllValues)
|
|
|
|
// String conditions
|
|
case "StringEquals":
|
|
return e.EvaluateStringCondition(block, evalCtx, true, false, forAllValues)
|
|
case "StringNotEquals":
|
|
return e.EvaluateStringCondition(block, evalCtx, false, false, forAllValues)
|
|
case "StringLike":
|
|
return e.EvaluateStringCondition(block, evalCtx, true, true, forAllValues)
|
|
case "StringNotLike":
|
|
return e.EvaluateStringCondition(block, evalCtx, false, true, forAllValues)
|
|
case "StringEqualsIgnoreCase":
|
|
return e.evaluateStringConditionIgnoreCase(block, evalCtx, true, false, forAllValues)
|
|
case "StringNotEqualsIgnoreCase":
|
|
return e.evaluateStringConditionIgnoreCase(block, evalCtx, false, false, forAllValues)
|
|
case "StringNotLikeIgnoreCase":
|
|
return e.evaluateStringConditionIgnoreCase(block, evalCtx, false, true, forAllValues)
|
|
case "StringLikeIgnoreCase":
|
|
return e.evaluateStringConditionIgnoreCase(block, evalCtx, true, true, forAllValues)
|
|
|
|
// Numeric conditions
|
|
case "NumericEquals":
|
|
return e.evaluateNumericCondition(block, evalCtx, "==", forAllValues)
|
|
case "NumericNotEquals":
|
|
return e.evaluateNumericCondition(block, evalCtx, "!=", forAllValues)
|
|
case "NumericLessThan":
|
|
return e.evaluateNumericCondition(block, evalCtx, "<", forAllValues)
|
|
case "NumericLessThanEquals":
|
|
return e.evaluateNumericCondition(block, evalCtx, "<=", forAllValues)
|
|
case "NumericGreaterThan":
|
|
return e.evaluateNumericCondition(block, evalCtx, ">", forAllValues)
|
|
case "NumericGreaterThanEquals":
|
|
return e.evaluateNumericCondition(block, evalCtx, ">=", forAllValues)
|
|
|
|
// Date conditions
|
|
case "DateEquals":
|
|
return e.evaluateDateCondition(block, evalCtx, "==", forAllValues)
|
|
case "DateNotEquals":
|
|
return e.evaluateDateCondition(block, evalCtx, "!=", forAllValues)
|
|
case "DateLessThan":
|
|
return e.evaluateDateCondition(block, evalCtx, "<", forAllValues)
|
|
case "DateLessThanEquals":
|
|
return e.evaluateDateCondition(block, evalCtx, "<=", forAllValues)
|
|
case "DateGreaterThan":
|
|
return e.evaluateDateCondition(block, evalCtx, ">", forAllValues)
|
|
case "DateGreaterThanEquals":
|
|
return e.evaluateDateCondition(block, evalCtx, ">=", forAllValues)
|
|
|
|
// Boolean conditions
|
|
case "Bool":
|
|
return e.evaluateBoolCondition(block, evalCtx, forAllValues)
|
|
|
|
// 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, forAllValues bool) bool {
|
|
for conditionKey, conditionValue := range block {
|
|
contextValue, exists := evalCtx.RequestContext[conditionKey]
|
|
if !exists {
|
|
// If missing key: fails positive match, skips negative match
|
|
if shouldMatch {
|
|
return false
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Normalize context values
|
|
var contextIPs []string
|
|
switch v := contextValue.(type) {
|
|
case string:
|
|
contextIPs = []string{v}
|
|
case []string:
|
|
contextIPs = v
|
|
case []interface{}:
|
|
for _, item := range v {
|
|
if s, ok := item.(string); ok {
|
|
contextIPs = append(contextIPs, s)
|
|
}
|
|
}
|
|
default:
|
|
contextIPs = []string{fmt.Sprintf("%v", contextValue)}
|
|
}
|
|
|
|
// Normalize policy ranges
|
|
expectedRanges := normalizeRanges(conditionValue)
|
|
|
|
if forAllValues {
|
|
// All context values must match at least one expected range
|
|
if len(contextIPs) == 0 {
|
|
continue // Vacuously true
|
|
}
|
|
|
|
for _, ctxIPStr := range contextIPs {
|
|
ctxIP := net.ParseIP(ctxIPStr)
|
|
if ctxIP == nil {
|
|
return false
|
|
}
|
|
|
|
itemMatchedInRange := false
|
|
for _, ipRange := range expectedRanges {
|
|
if strings.Contains(ipRange, "/") {
|
|
_, cidr, err := net.ParseCIDR(ipRange)
|
|
if err == nil && cidr.Contains(ctxIP) {
|
|
itemMatchedInRange = true
|
|
break
|
|
}
|
|
} else if ctxIPStr == ipRange {
|
|
itemMatchedInRange = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Apply operator (IPAddress vs NotIPAddress)
|
|
satisfied := itemMatchedInRange
|
|
if !shouldMatch {
|
|
satisfied = !itemMatchedInRange
|
|
}
|
|
|
|
if !satisfied {
|
|
return false
|
|
}
|
|
}
|
|
} else {
|
|
// ForAnyValue or standard: Any context value matches any expected range
|
|
if len(contextIPs) == 0 {
|
|
return false // AWS behavior for ForAnyValue with empty sets
|
|
}
|
|
|
|
anySatisfied := false
|
|
for _, ctxIPStr := range contextIPs {
|
|
ctxIP := net.ParseIP(ctxIPStr)
|
|
if ctxIP == nil {
|
|
continue
|
|
}
|
|
|
|
itemMatchedInRange := false
|
|
for _, ipRange := range expectedRanges {
|
|
if strings.Contains(ipRange, "/") {
|
|
_, cidr, err := net.ParseCIDR(ipRange)
|
|
if err == nil && cidr.Contains(ctxIP) {
|
|
itemMatchedInRange = true
|
|
break
|
|
}
|
|
} else if ctxIPStr == ipRange {
|
|
itemMatchedInRange = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Apply operator (IPAddress vs NotIPAddress)
|
|
satisfied := itemMatchedInRange
|
|
if !shouldMatch {
|
|
satisfied = !itemMatchedInRange
|
|
}
|
|
|
|
if satisfied {
|
|
anySatisfied = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !anySatisfied {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// normalizeRanges converts policy values into a []string
|
|
func normalizeRanges(value interface{}) []string {
|
|
switch v := value.(type) {
|
|
case string:
|
|
return []string{v}
|
|
case []string:
|
|
return v
|
|
case []interface{}:
|
|
var ranges []string
|
|
for _, item := range v {
|
|
if s, ok := item.(string); ok {
|
|
ranges = append(ranges, s)
|
|
}
|
|
}
|
|
return ranges
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// EvaluateStringCondition evaluates string-based conditions
|
|
func (e *PolicyEngine) EvaluateStringCondition(block map[string]interface{}, evalCtx *EvaluationContext, shouldMatch bool, useWildcard bool, forAllValues 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
|
|
if forAllValues {
|
|
// ForAllValues: Every value in the request context must match at least one value in the condition policy
|
|
// If context has no values, ForAllValues returns true (vacuously true)
|
|
if len(contextStrings) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Iterate over each context value - it MUST satisfy the operator
|
|
allSatisfied := true
|
|
for _, contextValue := range contextStrings {
|
|
contextValueMatchedSet := false
|
|
for _, expected := range expectedStrings {
|
|
expandedExpected := expandPolicyVariables(expected, evalCtx)
|
|
if useWildcard {
|
|
// Use filepath.Match for case-sensitive wildcard matching, as required by StringLike
|
|
if matched, _ := filepath.Match(expandedExpected, contextValue); matched {
|
|
contextValueMatchedSet = true
|
|
break
|
|
}
|
|
} else {
|
|
if expandedExpected == contextValue {
|
|
contextValueMatchedSet = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply operator (equals vs not-equals)
|
|
satisfied := contextValueMatchedSet
|
|
if !shouldMatch {
|
|
satisfied = !contextValueMatchedSet
|
|
}
|
|
|
|
if !satisfied {
|
|
allSatisfied = false
|
|
break
|
|
}
|
|
}
|
|
|
|
if !allSatisfied {
|
|
return false
|
|
}
|
|
|
|
} else {
|
|
// ForAnyValue (default): At least one value in the request context must match at least one value in the condition policy
|
|
// AWS IAM treats empty request sets as "no match" for ForAnyValue
|
|
if len(contextStrings) == 0 {
|
|
return false
|
|
}
|
|
|
|
anySatisfied := false
|
|
for _, contextValue := range contextStrings {
|
|
contextValueMatchedSet := false
|
|
for _, expected := range expectedStrings {
|
|
expandedExpected := expandPolicyVariables(expected, evalCtx)
|
|
if useWildcard {
|
|
// Use filepath.Match for case-sensitive wildcard matching, as required by StringLike
|
|
if matched, _ := filepath.Match(expandedExpected, contextValue); matched {
|
|
contextValueMatchedSet = true
|
|
break
|
|
}
|
|
} else {
|
|
// For StringEquals/StringNotEquals, also support policy variables but be case-sensitive
|
|
if expandedExpected == contextValue {
|
|
contextValueMatchedSet = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply operator (equals vs not-equals)
|
|
satisfied := contextValueMatchedSet
|
|
if !shouldMatch {
|
|
satisfied = !contextValueMatchedSet
|
|
}
|
|
|
|
if satisfied {
|
|
anySatisfied = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !anySatisfied {
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Use pre-compiled regexp for efficient single-pass substitution
|
|
result := policyVariablePattern.ReplaceAllStringFunc(pattern, func(match string) string {
|
|
// Extract variable name from ${variable}
|
|
variable := match[2 : len(match)-1]
|
|
|
|
// Only substitute if variable is in the safe allowlist
|
|
if !safePolicyVariables[variable] {
|
|
return match // Leave unsafe variables as-is
|
|
}
|
|
|
|
// Get value from request context
|
|
if value, exists := evalCtx.RequestContext[variable]; exists {
|
|
if str, ok := value.(string); ok {
|
|
return str
|
|
}
|
|
}
|
|
|
|
// Variable not found or not a string, leave as-is
|
|
return match
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// evaluateStringConditionIgnoreCase evaluates string conditions with case insensitivity
|
|
func (e *PolicyEngine) evaluateStringConditionIgnoreCase(block map[string]interface{}, evalCtx *EvaluationContext, shouldMatch bool, useWildcard bool, forAllValues 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
|
|
}
|
|
|
|
// Convert context value to string slice
|
|
var contextStrings []string
|
|
switch v := contextValue.(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:
|
|
// Fallback for non-string types
|
|
contextStrings = []string{fmt.Sprintf("%v", contextValue)}
|
|
}
|
|
|
|
if forAllValues {
|
|
// ForAllValues: Every value in context must match at least one expected value
|
|
if len(contextStrings) == 0 {
|
|
continue
|
|
}
|
|
|
|
allSatisfied := true
|
|
for _, ctxStr := range contextStrings {
|
|
itemMatchedSet := false
|
|
|
|
// Check against all expected values
|
|
switch v := expectedValues.(type) {
|
|
case string:
|
|
expandedPattern := expandPolicyVariables(v, evalCtx)
|
|
if useWildcard {
|
|
if AwsWildcardMatch(expandedPattern, ctxStr) {
|
|
itemMatchedSet = true
|
|
}
|
|
} else {
|
|
if strings.EqualFold(expandedPattern, ctxStr) {
|
|
itemMatchedSet = true
|
|
}
|
|
}
|
|
case []interface{}, []string:
|
|
var slice []string
|
|
if s, ok := v.([]string); ok {
|
|
slice = s
|
|
} else {
|
|
for _, item := range v.([]interface{}) {
|
|
if str, ok := item.(string); ok {
|
|
slice = append(slice, str)
|
|
}
|
|
}
|
|
}
|
|
for _, valStr := range slice {
|
|
expandedPattern := expandPolicyVariables(valStr, evalCtx)
|
|
if useWildcard {
|
|
if AwsWildcardMatch(expandedPattern, ctxStr) {
|
|
itemMatchedSet = true
|
|
break
|
|
}
|
|
} else {
|
|
if strings.EqualFold(expandedPattern, ctxStr) {
|
|
itemMatchedSet = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply operator (equals vs not-equals)
|
|
satisfied := itemMatchedSet
|
|
if !shouldMatch {
|
|
satisfied = !itemMatchedSet
|
|
}
|
|
|
|
if !satisfied {
|
|
allSatisfied = false
|
|
break
|
|
}
|
|
}
|
|
|
|
if !allSatisfied {
|
|
return false
|
|
}
|
|
|
|
} else {
|
|
// ForAnyValue (default): Any value in context must match any expected value
|
|
anySatisfied := false
|
|
for _, ctxStr := range contextStrings {
|
|
itemMatchedSet := false
|
|
|
|
// Handle different value types
|
|
switch v := expectedValues.(type) {
|
|
case string:
|
|
expandedPattern := expandPolicyVariables(v, evalCtx)
|
|
if useWildcard {
|
|
if AwsWildcardMatch(expandedPattern, ctxStr) {
|
|
itemMatchedSet = true
|
|
}
|
|
} else {
|
|
if strings.EqualFold(expandedPattern, ctxStr) {
|
|
itemMatchedSet = true
|
|
}
|
|
}
|
|
case []interface{}, []string:
|
|
var slice []string
|
|
if s, ok := v.([]string); ok {
|
|
slice = s
|
|
} else {
|
|
for _, item := range v.([]interface{}) {
|
|
if str, ok := item.(string); ok {
|
|
slice = append(slice, str)
|
|
}
|
|
}
|
|
}
|
|
for _, valStr := range slice {
|
|
expandedPattern := expandPolicyVariables(valStr, evalCtx)
|
|
if useWildcard {
|
|
if AwsWildcardMatch(expandedPattern, ctxStr) {
|
|
itemMatchedSet = true
|
|
break
|
|
}
|
|
} else {
|
|
if strings.EqualFold(expandedPattern, ctxStr) {
|
|
itemMatchedSet = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply operator (equals vs not-equals)
|
|
satisfied := itemMatchedSet
|
|
if !shouldMatch {
|
|
satisfied = !itemMatchedSet
|
|
}
|
|
|
|
if satisfied {
|
|
anySatisfied = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !anySatisfied {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// evaluateNumericCondition evaluates numeric conditions
|
|
func (e *PolicyEngine) evaluateNumericCondition(block map[string]interface{}, evalCtx *EvaluationContext, operator string, forAllValues bool) bool {
|
|
for key, expectedValues := range block {
|
|
contextValue, exists := evalCtx.RequestContext[key]
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
// Parse context values (handle single or list)
|
|
var contextNums []float64
|
|
switch v := contextValue.(type) {
|
|
case []interface{}:
|
|
for _, item := range v {
|
|
if num, err := parseNumeric(item); err == nil {
|
|
contextNums = append(contextNums, num)
|
|
}
|
|
}
|
|
case []string:
|
|
for _, item := range v {
|
|
if num, err := parseNumeric(item); err == nil {
|
|
contextNums = append(contextNums, num)
|
|
}
|
|
}
|
|
default:
|
|
if num, err := parseNumeric(v); err == nil {
|
|
contextNums = append(contextNums, num)
|
|
}
|
|
}
|
|
|
|
if len(contextNums) == 0 {
|
|
if forAllValues {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
|
|
if forAllValues {
|
|
// ForAllValues: All context nums must match at least one expected value
|
|
allMatch := true
|
|
for _, contextNum := range contextNums {
|
|
itemMatched := false
|
|
switch v := expectedValues.(type) {
|
|
case string:
|
|
if expectedNum, err := parseNumeric(v); err == nil {
|
|
itemMatched = compareNumbers(contextNum, expectedNum, operator)
|
|
}
|
|
case float64:
|
|
itemMatched = compareNumbers(contextNum, v, operator)
|
|
case int:
|
|
itemMatched = compareNumbers(contextNum, float64(v), operator)
|
|
case int64:
|
|
itemMatched = compareNumbers(contextNum, float64(v), operator)
|
|
case []interface{}, []string:
|
|
// Convert to unified slice of interface{} if it's []string
|
|
var slice []interface{}
|
|
if s, ok := v.([]string); ok {
|
|
slice = make([]interface{}, len(s))
|
|
for i, item := range s {
|
|
slice[i] = item
|
|
}
|
|
} else {
|
|
slice = v.([]interface{})
|
|
}
|
|
|
|
if operator == "!=" {
|
|
// For NotEquals, itemMatched means it matches NONE of the expected values
|
|
anyMatch := false
|
|
for _, val := range slice {
|
|
if expectedNum, err := parseNumeric(val); err == nil {
|
|
if compareNumbers(contextNum, expectedNum, "==") {
|
|
anyMatch = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
itemMatched = !anyMatch
|
|
} else {
|
|
for _, val := range slice {
|
|
if expectedNum, err := parseNumeric(val); err == nil {
|
|
if compareNumbers(contextNum, expectedNum, operator) {
|
|
itemMatched = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !itemMatched {
|
|
allMatch = false
|
|
break
|
|
}
|
|
}
|
|
if !allMatch {
|
|
return false
|
|
}
|
|
} else {
|
|
// ForAnyValue: Any context num must match any expected value
|
|
matched := false
|
|
for _, contextNum := range contextNums {
|
|
itemMatched := false
|
|
switch v := expectedValues.(type) {
|
|
case string:
|
|
if expectedNum, err := parseNumeric(v); err == nil {
|
|
itemMatched = compareNumbers(contextNum, expectedNum, operator)
|
|
}
|
|
case float64:
|
|
itemMatched = compareNumbers(contextNum, v, operator)
|
|
case int:
|
|
itemMatched = compareNumbers(contextNum, float64(v), operator)
|
|
case int64:
|
|
itemMatched = compareNumbers(contextNum, float64(v), operator)
|
|
case []interface{}, []string:
|
|
// Convert to unified slice of interface{} if it's []string
|
|
var slice []interface{}
|
|
if s, ok := v.([]string); ok {
|
|
slice = make([]interface{}, len(s))
|
|
for i, item := range s {
|
|
slice[i] = item
|
|
}
|
|
} else {
|
|
slice = v.([]interface{})
|
|
}
|
|
|
|
if operator == "!=" {
|
|
// For NotEquals, itemMatched means it matches NONE of the expected values
|
|
anyMatch := false
|
|
for _, val := range slice {
|
|
if expectedNum, err := parseNumeric(val); err == nil {
|
|
if compareNumbers(contextNum, expectedNum, "==") {
|
|
anyMatch = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
itemMatched = !anyMatch
|
|
} else {
|
|
for _, val := range slice {
|
|
if expectedNum, err := parseNumeric(val); err == nil {
|
|
if compareNumbers(contextNum, expectedNum, operator) {
|
|
itemMatched = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if itemMatched {
|
|
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, forAllValues bool) bool {
|
|
for key, expectedValues := range block {
|
|
contextValue, exists := evalCtx.RequestContext[key]
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
// Parse context values (handle single or list)
|
|
var contextTimes []time.Time
|
|
switch v := contextValue.(type) {
|
|
case []interface{}:
|
|
for _, item := range v {
|
|
if t, err := parseDateTime(item); err == nil {
|
|
contextTimes = append(contextTimes, t)
|
|
}
|
|
}
|
|
case []string:
|
|
for _, item := range v {
|
|
if t, err := parseDateTime(item); err == nil {
|
|
contextTimes = append(contextTimes, t)
|
|
}
|
|
}
|
|
default:
|
|
if t, err := parseDateTime(v); err == nil {
|
|
contextTimes = append(contextTimes, t)
|
|
}
|
|
}
|
|
|
|
if len(contextTimes) == 0 {
|
|
if forAllValues {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
|
|
if forAllValues {
|
|
allMatch := true
|
|
for _, contextTime := range contextTimes {
|
|
itemMatched := false
|
|
switch v := expectedValues.(type) {
|
|
case string:
|
|
if expectedTime, err := parseDateTime(v); err == nil {
|
|
itemMatched = compareDates(contextTime, expectedTime, operator)
|
|
}
|
|
case []interface{}, []string:
|
|
// Convert to unified slice of interface{} if it's []string
|
|
var slice []interface{}
|
|
if s, ok := v.([]string); ok {
|
|
slice = make([]interface{}, len(s))
|
|
for i, item := range s {
|
|
slice[i] = item
|
|
}
|
|
} else {
|
|
slice = v.([]interface{})
|
|
}
|
|
|
|
if operator == "!=" {
|
|
// For NotEquals, itemMatched means it matches NONE of the expected values
|
|
anyMatch := false
|
|
for _, val := range slice {
|
|
if expectedTime, err := parseDateTime(val); err == nil {
|
|
if compareDates(contextTime, expectedTime, "==") {
|
|
anyMatch = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
itemMatched = !anyMatch
|
|
} else {
|
|
for _, val := range slice {
|
|
if expectedTime, err := parseDateTime(val); err == nil {
|
|
if compareDates(contextTime, expectedTime, operator) {
|
|
itemMatched = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !itemMatched {
|
|
allMatch = false
|
|
break
|
|
}
|
|
}
|
|
if !allMatch {
|
|
return false
|
|
}
|
|
} else {
|
|
matched := false
|
|
for _, contextTime := range contextTimes {
|
|
itemMatched := false
|
|
switch v := expectedValues.(type) {
|
|
case string:
|
|
if expectedTime, err := parseDateTime(v); err == nil {
|
|
itemMatched = compareDates(contextTime, expectedTime, operator)
|
|
}
|
|
case []interface{}, []string:
|
|
// Convert to unified slice of interface{} if it's []string
|
|
var slice []interface{}
|
|
if s, ok := v.([]string); ok {
|
|
slice = make([]interface{}, len(s))
|
|
for i, item := range s {
|
|
slice[i] = item
|
|
}
|
|
} else {
|
|
slice = v.([]interface{})
|
|
}
|
|
|
|
if operator == "!=" {
|
|
// For NotEquals, itemMatched means it matches NONE of the expected values
|
|
anyMatch := false
|
|
for _, val := range slice {
|
|
if expectedTime, err := parseDateTime(val); err == nil {
|
|
if compareDates(contextTime, expectedTime, "==") {
|
|
anyMatch = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
itemMatched = !anyMatch
|
|
} else {
|
|
for _, val := range slice {
|
|
if expectedTime, err := parseDateTime(val); err == nil {
|
|
if compareDates(contextTime, expectedTime, operator) {
|
|
itemMatched = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if itemMatched {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// evaluateBoolCondition evaluates boolean conditions
|
|
func (e *PolicyEngine) evaluateBoolCondition(block map[string]interface{}, evalCtx *EvaluationContext, forAllValues bool) bool {
|
|
for key, expectedValues := range block {
|
|
contextValue, exists := evalCtx.RequestContext[key]
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
// Parse context values (handle single or list)
|
|
var contextBools []bool
|
|
switch v := contextValue.(type) {
|
|
case []interface{}:
|
|
for _, item := range v {
|
|
if b, err := parseBool(item); err == nil {
|
|
contextBools = append(contextBools, b)
|
|
}
|
|
}
|
|
case []string:
|
|
for _, item := range v {
|
|
if b, err := parseBool(item); err == nil {
|
|
contextBools = append(contextBools, b)
|
|
}
|
|
}
|
|
default:
|
|
if b, err := parseBool(v); err == nil {
|
|
contextBools = append(contextBools, b)
|
|
}
|
|
}
|
|
|
|
if len(contextBools) == 0 {
|
|
if forAllValues {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
|
|
if forAllValues {
|
|
allMatch := true
|
|
for _, contextBool := range contextBools {
|
|
itemMatched := false
|
|
switch v := expectedValues.(type) {
|
|
case string:
|
|
if expectedBool, err := parseBool(v); err == nil {
|
|
itemMatched = contextBool == expectedBool
|
|
}
|
|
case bool:
|
|
itemMatched = contextBool == v
|
|
case []interface{}, []string:
|
|
var slice []interface{}
|
|
if s, ok := v.([]string); ok {
|
|
slice = make([]interface{}, len(s))
|
|
for i, item := range s {
|
|
slice[i] = item
|
|
}
|
|
} else {
|
|
slice = v.([]interface{})
|
|
}
|
|
for _, val := range slice {
|
|
expectedBool, err := parseBool(val)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if contextBool == expectedBool {
|
|
itemMatched = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !itemMatched {
|
|
allMatch = false
|
|
break
|
|
}
|
|
}
|
|
if !allMatch {
|
|
return false
|
|
}
|
|
} else {
|
|
matched := false
|
|
for _, contextBool := range contextBools {
|
|
switch v := expectedValues.(type) {
|
|
case string:
|
|
if expectedBool, err := parseBool(v); err == nil {
|
|
matched = contextBool == expectedBool
|
|
}
|
|
case bool:
|
|
matched = contextBool == v
|
|
case []interface{}, []string:
|
|
var slice []interface{}
|
|
if s, ok := v.([]string); ok {
|
|
slice = make([]interface{}, len(s))
|
|
for i, item := range s {
|
|
slice[i] = item
|
|
}
|
|
} else {
|
|
slice = v.([]interface{})
|
|
}
|
|
for _, val := range slice {
|
|
expectedBool, err := parseBool(val)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if contextBool == expectedBool {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if matched {
|
|
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)
|
|
}
|
|
}
|