mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-11 17:10:40 +02:00
* 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.
2037 lines
57 KiB
Go
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)
|
|
}
|
|
}
|