Files
seaweedfs/weed/iam/policy/policy_engine.go
T
Chris Lu 69cc2869ad Fixes from the review of the admin bucket policy UI (#10907)
* admin: treat a missing S3 Tables policy as an empty load, not an error

The bucket/table policy GET relayed the backend's 404 NoSuchPolicy to the
dialog, whose loader treats any non-OK response as a load failure and
keeps Save and Delete blocked. A bucket or table without a policy could
never be given one. Return policy null instead, the same contract
ShowBucketPolicy uses for classic buckets.

* admin: reject policy documents the structured editor would misread

A top-level JSON array passed the object guard (typeof [] is 'object')
and loaded as a zero-statement policy, which the next commit would
rewrite to an empty document. Object elements in Action/Resource were
coerced to '[object Object]' and saved that way on the s3tables surface,
which stores policies verbatim. Both now throw, which routes the
document to the JSON tab like other unrepresentable shapes.

* admin: let the JSON tab save documents the structured editor can't model

Save with the JSON tab active required a round-trip through
policyDocToEditorState, so exactly the documents the dialogs shunt to
'JSON tab only' mode (unrepresentable Effect, Resource+NotResource, and
the like) could never be saved - Delete was the only mutation left.
Invalid JSON still blocks; an unrepresentable document now saves and the
editor state stays marked unparsed.

* admin: pin the policy editor to what each consumer's backend supports

The s3tables evaluator has no NotResource/NotPrincipal fields - it
silently drops them, turning Allow+NotResource into allow-everything and
making Deny+NotPrincipal inert - and it only matches s3tables: actions
against s3tables ARNs, while the editor suggested s3: actions and
arn:aws:s3::: resources. New registerPolicyEditor knobs: allowNegation
hides the Not* modes and routes documents using them to the JSON tab;
resourceSuggestions pins the Resource autocomplete to the open
resource's ARN; the S3 Tables dialogs get an s3tables-only action
datalist. requirePrincipal now also hides NotPrincipal, which
policy_engine.ValidateBucketPolicy always rejects, and the client-side
check requires Principal specifically to match that server rule.

* admin: save S3 Tables policies from a button, not form submission

The multi-input structured editor sits inside a form whose Save button
was type=submit, so Enter in any single-line editor input - accepting an
autocomplete suggestion, say - implicitly submitted whatever half-built
statement the editor held, and the backend stores the document verbatim.
A lone statement with no Principal matches nobody, locking out every
non-owner. Save is now an ordinary button and the form ignores
submission.

* admin: block zero-statement policy saves

Committing the active tab before the emptiness check made 'Policy JSON
is required' dead code: an empty editor serializes to {"Statement":[]},
which the s3tables backend stores verbatim - evaluated default-deny for
every non-owner, while the statement-count column keeps showing 'Not
configured'. All three policy dialogs now refuse a save with no
statements and point at Delete instead. The classic bucket modal only
gained a clearer message; the server already rejected the document.

* admin: guard S3 Tables policy mutations against stale and overlapping requests

The save/delete completions ran against whatever resource the shared
modal happened to show by then: a slow PUT for one bucket would hide the
modal mid-edit of another and misattribute its alerts, a late DELETE
cleared the shared textarea over the newly opened resource with its
loaded flag set, and nothing stopped a double-click from firing two
overlapping mutations. Ported the classic modal's pattern: capture the
target on start, flag the mutation in flight with the buttons disabled,
and only touch the UI when the completion still matches the open
resource. Success now reloads the page, which also keeps the Policy
column's statement count honest.

* admin: confirm before deleting an S3 Tables policy

Delete Policy sat next to Save and fired on a single click; with
default-allow enabled one stray click silently dropped the resource
policy and left the bucket open to every principal. Same confirmation
the classic bucket modal already has.

* admin: let a corrupt stored bucket policy be shown, fixed, and deleted

A stored document the decoder rejects made the policy GET 500, and with
the loaded flag never set the modal blocked both Save and Delete - the
one policy an operator most needs to remove was the one they couldn't,
even though the delete path never reads the document. The GET now
returns the raw bytes alongside a null policy; the dialog hands them to
the JSON tab and unblocks the buttons.

* admin: url-encode the bucket name in the policy API calls

The filer lists any directory under the buckets path, names S3 would
never allow included; one carrying '#' or '%' broke the fetch URL or
addressed a different name than the modal shows.

* admin: drop stale edit-policy responses on the IAM policies page

The same race the bucket and S3 Tables dialogs already guard against:
open one policy's editor while its GET stalls, open another, and the
late response populates the editor under the second policy's name -
Update then saves the first policy's statements over the second.

* admin: warn before a bucket policy save drops unsupported fields

The editor tracks unmodeled top-level keys precisely so
confirmPolicyFieldDiscard can warn before the server's Version+Statement
decode discards them, but only the IAM page called it; the bucket modal
saved a pasted document with e.g. a console-generated Id without a word
while the editor kept displaying the field.

* s3: enforce the bucket policy size cap on both surfaces

The 20KB cap lived only in the admin UI, so a larger policy stored via
the S3 API displayed there but could never be re-saved, desyncing the
two writers the cap comment claimed could not desync. The constant now
lives in policy_engine next to the shared validator and PutBucketPolicy
rejects oversized documents with PolicyTooLarge, matching AWS.

* admin: ship the policy editor's fieldset styles with the editor

The .policy-stmt-* rules that undo Bootstrap's full-width legend reset
stayed behind in policies.templ when the editor markup moved to the
shared script, so the bucket and S3 Tables dialogs rendered Actions/
Resource/Principal as full-width jumbo headings. PolicyDatalists is the
component every consumer already renders once; the styles live there
now.

* s3: mirror bucket policy changes into the IAM store from the metadata subscription

The advanced-IAM path appends the bucket-policy:<bucket> document to
every STS/session evaluation, but only this gateway's own PutBucketPolicy
maintained that mirror - a policy tightened or created through the admin
UI (or another gateway) never reached it, so revoked access stayed live
indefinitely, and the delete side was an unimplemented TODO in any case.
The metadata subscription now diffs the stored policy on every bucket
entry change and updates or removes the mirror, covering all writers and
deletion with one mechanism; IAMManager gains the missing
RemoveBucketPolicy.

* admin: deduplicate the bucket policy write path

Set and Delete carried line-for-line identical filer closures;
bucketPolicyMutation already treats nil as clear-the-key. The shared
helper sits below Set's validation, since ValidatePolicy cannot take the
nil document Delete passes.

* s3: drop ValidateBucketPolicy's re-checks of ValidatePolicy rules

Both callers run ValidatePolicy first, which already enforces the
version and at-least-one-statement rules; the duplicates were dead code
with drifted error text.

* admin: seed a new statement's Resource from the pinned suggestions

A fresh statement on the S3 Tables dialogs started with no resource row
at all; seed it with the broadest pinned ARN the same way cfg.bucket
already seeds the classic modal.

* admin: refuse to save Not* fields the backend would silently drop

Hiding the NotResource/NotPrincipal modes was not enough where negation
is disallowed: the JSON tab accepts any valid document (that is its
job), and a statement's Advanced-fields box can reintroduce the keys, so
an s3tables save could still store fields the evaluator drops - turning
Allow+NotResource into allow-everything. commitPolicyActiveTab now runs
a final document-level check over what would actually be saved; Delete
stays available for cleanup.

* s3: move the IAM bucket policy mirror on a bucket rename

A same-directory rename delivers one event carrying both entries, and
the byte-equality short-circuit skipped the new name's mirror when the
policy was unchanged - while the replayed delete for the old name
removed its mirror, leaving the renamed bucket unmirrored. The mirror
decision is now a pure function that removes the old name and writes the
new one regardless of byte equality, with the rename cases unit tested.

* s3: backfill the IAM bucket policy mirror on lazy bucket loads

The metadata subscription only mirrors changes, so a policy that
predates the IAM integration never reached the bucket-policy:<bucket>
mirror and its grants did not bind on the IAM path until the policy was
next modified. The gateway is deliberately lazy at startup (nothing
lists all buckets), so the backfill hooks the same place a bucket's
policy first becomes known: the cold bucket-config load. EnsureBucketPolicy
writes only when no mirror is stored, so repeat loads cost one cached
read.

* s3: reconcile the bucket policy backfill against concurrent changes

The backfill's check-then-write could race an event-driven mirror update
or removal and re-store bytes that were already stale, with no later
event to heal it. EnsureBucketPolicy now reports whether it wrote, and a
write is reconciled against a fresh authoritative entry read: a changed
policy is re-mirrored, a removed one is removed. Anything changing after
that read fires its own event, which finds the backfill's write already
present and supersedes it. The backfill also carries the entry's raw
bytes rather than a re-marshaled document, so the reconcile can
byte-compare.

* s3: prime the bucket policy mirror before advanced-IAM authorization

The backfill ran from the lazy bucket-config load, but IAM authorization
evaluates the bucket-policy:<bucket> mirror before any handler runs - a
grant carried only by a not-yet-mirrored policy denied forever, and the
denied request never reached the code that would have loaded the bucket.
authorizeWithIAM now primes the bucket config first (an in-memory cache
hit once warm), and the backfill runs synchronously on the cold load so
the very first authorization already sees the mirror.
2026-08-24 00:52:01 -07:00

2037 lines
57 KiB
Go

package policy
import (
"context"
"encoding/json"
"fmt"
"net"
"regexp"
"strconv"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
)
// Effect represents the policy evaluation result
type Effect string
const (
EffectAllow Effect = "Allow"
EffectDeny Effect = "Deny"
)
var (
policyVariablePattern = regexp.MustCompile(`\$\{([^}]+)\}`)
// safePolicyVariables is the explicit allowlist of fixed-name variables.
// Identity claims published under jwt:/saml:/oidc: are additionally accepted
// as dynamic variables (see isSafePolicyVariable) so that any claim from a
// cryptographically verified federated token can be used in policies.
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,
// 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,
}
)
// isSafePolicyVariable reports whether a policy variable may be substituted
// with a value from RequestContext. The fixed allowlist covers AWS-defined
// variables; any jwt:/saml:/oidc:/aws:PrincipalTag/ claim is also allowed
// because those come from a validated identity token (the STS session JWT
// or federated assertion) and the claim set is controlled by the trusted
// identity provider.
func isSafePolicyVariable(variable string) bool {
if safePolicyVariables[variable] {
return true
}
return strings.HasPrefix(variable, "jwt:") ||
strings.HasPrefix(variable, "saml:") ||
strings.HasPrefix(variable, "oidc:") ||
strings.HasPrefix(variable, "aws:PrincipalTag/") ||
strings.HasPrefix(variable, "aws:RequestTag/")
}
// 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)
}
// GetPolicy returns a stored policy document, or an error when it does not
// exist in the configured store.
func (e *PolicyEngine) GetPolicy(ctx context.Context, filerAddress string, name string) (*PolicyDocument, error) {
if !e.initialized {
return nil, fmt.Errorf("policy engine not initialized")
}
if name == "" {
return nil, fmt.Errorf("policy name cannot be empty")
}
return e.store.GetPolicy(ctx, filerAddress, name)
}
// 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
}
// multipartActionSet contains lowercased S3 multipart upload actions that are
// implicitly granted when s3:PutObject is allowed, since multipart upload is an
// implementation detail of putting objects. Keys are lowercased for
// case-insensitive lookup (AWS IAM actions are case-insensitive).
var multipartActionSet = map[string]bool{
"s3:createmultipartupload": true,
"s3:uploadpart": true,
"s3:completemultipartupload": true,
"s3:abortmultipartupload": true,
"s3:listmultipartuploadparts": true,
"s3:listbucketmultipartuploads": true,
}
// matchesActions checks if any action in the list matches the requested action.
// It also implicitly grants multipart upload actions when s3:PutObject is allowed,
// mirroring the behavior in the S3 API policy engine (see PR #8445).
func (e *PolicyEngine) matchesActions(actions []string, requestedAction string, evalCtx *EvaluationContext) bool {
isMultipart := multipartActionSet[strings.ToLower(requestedAction)]
for _, action := range actions {
if awsIAMMatch(action, requestedAction, evalCtx) {
return true
}
if isMultipart && awsIAMMatch(action, "s3:PutObject", 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 {
if wildcard.MatchesWildcard(expandedExpected, contextValue) {
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 {
if wildcard.MatchesWildcard(expandedExpected, contextValue) {
contextValueMatchedSet = true
break
}
} else {
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 wildcard.MatchesWildcard(strings.ToLower(expandedPattern), strings.ToLower(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 the variable is in the allowlist or is a dynamic
// identity-claim variable (jwt:/saml:/oidc:)
if !isSafePolicyVariable(variable) {
return match // Leave unrecognised variables as-is
}
// Get value from request context
value, exists := evalCtx.RequestContext[variable]
if !exists {
return match // Variable not supplied: leave placeholder so statement won't match
}
if str, ok := stringifyClaimValue(value); ok {
return str
}
// Value is a non-scalar (array/object) we can't meaningfully substitute
return match
})
return result
}
// stringifyClaimValue converts a claim value to its string form for policy
// variable substitution. Returns (value, true) for scalar types that a JWT
// claim can produce after JSON decoding, and ("", false) for slices/maps/nil.
// JSON's generic decoder only produces float64 / json.Number for numbers, but
// RequestContext can also be populated from typed sources (e.g., custom
// providers or internal code), so all common integer widths — signed and
// unsigned — are handled explicitly.
func stringifyClaimValue(value interface{}) (string, bool) {
switch v := value.(type) {
case string:
return v, true
case bool:
return strconv.FormatBool(v), true
case float64:
// JSON-decoded numbers are float64; render integers without a decimal point
if v == float64(int64(v)) {
return strconv.FormatInt(int64(v), 10), true
}
return strconv.FormatFloat(v, 'g', -1, 64), true
case float32:
return strconv.FormatFloat(float64(v), 'g', -1, 32), true
case int:
return strconv.FormatInt(int64(v), 10), true
case int8:
return strconv.FormatInt(int64(v), 10), true
case int16:
return strconv.FormatInt(int64(v), 10), true
case int32:
return strconv.FormatInt(int64(v), 10), true
case int64:
return strconv.FormatInt(v, 10), true
case uint:
return strconv.FormatUint(uint64(v), 10), true
case uint8:
return strconv.FormatUint(uint64(v), 10), true
case uint16:
return strconv.FormatUint(uint64(v), 10), true
case uint32:
return strconv.FormatUint(uint64(v), 10), true
case uint64:
return strconv.FormatUint(v, 10), true
case json.Number:
return v.String(), true
default:
return "", false
}
}
// 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 wildcard.MatchesWildcard(strings.ToLower(expandedPattern), strings.ToLower(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 wildcard.MatchesWildcard(strings.ToLower(expandedPattern), strings.ToLower(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 wildcard.MatchesWildcard(strings.ToLower(expandedPattern), strings.ToLower(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 wildcard.MatchesWildcard(strings.ToLower(expandedPattern), strings.ToLower(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)
}
}