feat(s3api): Implement S3 Policy Variables (#8039)
* feat: Add AWS IAM Policy Variables support to S3 API
Implements policy variables for dynamic access control in bucket policies.
Supported variables:
- aws:username - Extracted from principal ARN
- aws:userid - User identifier (same as username in SeaweedFS)
- aws:principaltype - IAMUser, IAMRole, or AssumedRole
- jwt:* - Any JWT claim (e.g., jwt:preferred_username, jwt:sub)
Key changes:
- Added PolicyVariableRegex to detect ${...} patterns
- Extended CompiledStatement with DynamicResourcePatterns, DynamicPrincipalPatterns, DynamicActionPatterns
- Added Claims field to PolicyEvaluationArgs for JWT claim access
- Implemented SubstituteVariables() for variable replacement from context and JWT claims
- Implemented extractPrincipalVariables() for ARN parsing
- Updated EvaluateConditions() to support variable substitution
- Comprehensive unit and integration tests
Resolves #8037
* feat: Add LDAP and PrincipalAccount variable support
Completes future enhancements for policy variables:
- Added ldap:* variable support for LDAP claims
- ldap:username - LDAP username from claims
- ldap:dn - LDAP distinguished name from claims
- ldap:* - Any LDAP claim
- Added aws:PrincipalAccount extraction from ARN
- Extracts account ID from principal ARN
- Available as ${aws:PrincipalAccount} in policies
Updated SubstituteVariables() to check LDAP claims
Updated extractPrincipalVariables() to extract account ID
Added comprehensive tests for new variables
* feat(s3api): implement IAM policy variables core logic and optimization
* feat(s3api): integrate policy variables with S3 authentication and handlers
* test(s3api): add integration tests for policy variables
* cleanup: remove unused policy conversion files
* Add S3 policy variables integration tests and path support
- Add comprehensive integration tests for policy variables
- Test username isolation, JWT claims, LDAP claims
- Add support for IAM paths in principal ARN parsing
- Add tests for principals with paths
* Fix IAM Role principal variable extraction
IAM Roles should not have aws:userid or aws:PrincipalAccount
according to AWS behavior. Only IAM Users and Assumed Roles
should have these variables.
Fixes TestExtractPrincipalVariables test failures.
* Security fixes and bug fixes for S3 policy variables
SECURITY FIXES:
- Prevent X-SeaweedFS-Principal header spoofing by clearing internal
headers at start of authentication (auth_credentials.go)
- Restrict policy variable substitution to safe allowlist to prevent
client header injection (iam/policy/policy_engine.go)
- Add core policy validation before storing bucket policies
BUG FIXES:
- Remove unused sid variable in evaluateStatement
- Fix LDAP claim lookup to check both prefixed and unprefixed keys
- Add ValidatePolicy call in PutBucketPolicyHandler
These fixes prevent privilege escalation via header injection and
ensure only validated identity claims are used in policy evaluation.
* Additional security fixes and code cleanup
SECURITY FIXES:
- Fixed X-Forwarded-For spoofing by only trusting proxy headers from
private/localhost IPs (s3_iam_middleware.go)
- Changed context key from "sourceIP" to "aws:SourceIp" for proper
policy variable substitution
CODE IMPROVEMENTS:
- Kept aws:PrincipalAccount for IAM Roles to support condition evaluations
- Removed redundant STS principaltype override
- Removed unused service variable
- Cleaned up commented-out debug logging statements
- Updated tests to reflect new IAM Role behavior
These changes prevent IP spoofing attacks and ensure policy variables
work correctly with the safe allowlist.
* Add security documentation for ParseJWTToken
Added comprehensive security comments explaining that ParseJWTToken
is safe despite parsing without verification because:
- It's only used for routing to the correct verification method
- All code paths perform cryptographic verification before trusting claims
- OIDC tokens: validated via validateExternalOIDCToken
- STS tokens: validated via ValidateSessionToken
Enhanced function documentation with clear security warnings about
proper usage to prevent future misuse.
* Fix IP condition evaluation to use aws:SourceIp key
Fixed evaluateIPCondition in IAM policy engine to use "aws:SourceIp"
instead of "sourceIP" to match the updated extractRequestContext.
This fixes the failing IP-restricted role test where IP-based policy
conditions were not being evaluated correctly.
Updated all test cases to use the correct "aws:SourceIp" key.
* Address code review feedback: optimize and clarify
PERFORMANCE IMPROVEMENT:
- Optimized expandPolicyVariables to use regexp.ReplaceAllStringFunc
for single-pass variable substitution instead of iterating through
all safe variables. This improves performance from O(n*m) to O(m)
where n is the number of safe variables and m is the pattern length.
CODE CLARITY:
- Added detailed comment explaining LDAP claim fallback mechanism
(checks both prefixed and unprefixed keys for compatibility)
- Enhanced TODO comment for trusted proxy configuration with rationale
and recommendations for supporting cloud load balancers, CDNs, and
complex network topologies
All tests passing.
* Address Copilot code review feedback
BUG FIXES:
- Fixed type switch for int/int32/int64 - separated into individual cases
since interface type switches only match the first type in multi-type cases
- Fixed grammatically incorrect error message in types.go
CODE QUALITY:
- Removed duplicate Resource/NotResource validation (already in ValidateStatement)
- Added comprehensive comment explaining isEnabled() logic and security implications
- Improved trusted proxy NOTE comment to be more concise while noting limitations
All tests passing.
* Fix test failures after extractSourceIP security changes
Updated tests to work with the security fix that only trusts
X-Forwarded-For/X-Real-IP headers from private IP addresses:
- Set RemoteAddr to 127.0.0.1 in tests to simulate trusted proxy
- Changed context key from "sourceIP" to "aws:SourceIp"
- Added test case for untrusted proxy (public RemoteAddr)
- Removed invalid ValidateStatement call (validation happens in ValidatePolicy)
All tests now passing.
* Address remaining Gemini code review feedback
CODE SAFETY:
- Deep clone Action field in CompileStatement to prevent potential data races
if the original policy document is modified after compilation
TEST CLEANUP:
- Remove debug logging (fmt.Fprintf) from engine_notresource_test.go
- Remove unused imports in engine_notresource_test.go
All tests passing.
* Fix insecure JWT parsing in IAM auth flow
SECURITY FIX:
- Renamed ParseJWTToken to ParseUnverifiedJWTToken with explicit security warnings.
- Refactored AuthenticateJWT to use the trusted SessionInfo returned by ValidateSessionToken
instead of relying on unverified claims from the initial parse.
- Refactored ValidatePresignedURLWithIAM to reuse the robust AuthenticateJWT logic, removing
duplicated and insecure manual token parsing.
This ensures all identity information (Role, Principal, Subject) used for authorization
decisions is derived solely from cryptographically verified tokens.
* Security: Fix insecure JWT claim extraction in policy engine
- Refactored EvaluatePolicy to accept trusted claims from verified Identity instead of parsing unverified tokens
- Updated AuthenticateJWT to populate Claims in IAMIdentity from verified sources (SessionInfo/ExternalIdentity)
- Updated s3api_server and handlers to pass claims correctly
- Improved isPrivateIP to support IPv6 loopback, link-local, and ULA
- Fixed flaky distributed_session_consistency test with retry logic
* fix(iam): populate Subject in STSSessionInfo to ensure correct identity propagation
This fixes the TestS3IAMAuthentication/valid_jwt_token_authentication failure by ensuring the session subject (sub) is correctly mapped to the internal SessionInfo struct, allowing bucket ownership validation to succeed.
* Optimized isPrivateIP
* Create s3-policy-tests.yml
* fix tests
* fix tests
* tests(s3/iam): simplify policy to resource-based \ (step 1)
* tests(s3/iam): add explicit Deny NotResource for isolation (step 2)
* fixes
* policy: skip resource matching for STS trust policies to allow AssumeRole evaluation
* refactor: remove debug logging and hoist policy variables for performance
* test: fix TestS3IAMBucketPolicyIntegration cleanup to handle per-subtest object lifecycle
* test: fix bucket name generation to comply with S3 63-char limit
* test: skip TestS3IAMPolicyEnforcement until role setup is implemented
* test: use weed mini for simpler test server deployment
Replace 'weed server' with 'weed mini' for IAM tests to avoid port binding issues
and simplify the all-in-one server deployment. This improves test reliability
and execution time.
* security: prevent allocation overflow in policy evaluation
Add maxPoliciesForEvaluation constant to cap the number of policies evaluated
in a single request. This prevents potential integer overflow when allocating
slices for policy lists that may be influenced by untrusted input.
Changes:
- Add const maxPoliciesForEvaluation = 1024 to set an upper bound
- Validate len(policies) < maxPoliciesForEvaluation before appending bucket policy
- Use append() instead of make([]string, len+1) to avoid arithmetic overflow
- Apply fix to both IsActionAllowed policy evaluation paths
This commit is contained in:
@@ -735,7 +735,8 @@ func getConditionContextValue(key string, contextValues map[string][]string, obj
|
||||
|
||||
// EvaluateConditions evaluates all conditions in a policy statement
|
||||
// objectEntry is the object's metadata from entry.Extended (can be nil)
|
||||
func EvaluateConditions(conditions PolicyConditions, contextValues map[string][]string, objectEntry map[string][]byte) bool {
|
||||
// claims are JWT claims for jwt:* policy variables (can be nil)
|
||||
func EvaluateConditions(conditions PolicyConditions, contextValues map[string][]string, objectEntry map[string][]byte, claims map[string]interface{}) bool {
|
||||
if len(conditions) == 0 {
|
||||
return true // No conditions means always true
|
||||
}
|
||||
@@ -749,7 +750,17 @@ func EvaluateConditions(conditions PolicyConditions, contextValues map[string][]
|
||||
|
||||
for key, value := range conditionMap {
|
||||
contextVals := getConditionContextValue(key, contextValues, objectEntry)
|
||||
if !conditionEvaluator.Evaluate(value.Strings(), contextVals) {
|
||||
|
||||
// Substitute variables in expected values
|
||||
expectedValues := value.Strings()
|
||||
substitutedValues := make([]string, len(expectedValues))
|
||||
for i, v := range expectedValues {
|
||||
substitutedValues[i] = SubstituteVariables(v, contextValues, claims)
|
||||
}
|
||||
|
||||
// Pass substituted values (casted to interface{} to match signature if needed, or update evaluators to accept []string)
|
||||
// The evaluators take interface{}, but getCachedNormalizedValues handles []string.
|
||||
if !conditionEvaluator.Evaluate(substitutedValues, contextVals) {
|
||||
return false // If any condition fails, the whole condition block fails
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func (engine *PolicyEngine) SetBucketPolicy(bucketName string, policyJSON string
|
||||
}
|
||||
|
||||
engine.contexts[bucketName] = context
|
||||
glog.V(2).Infof("Set bucket policy for %s", bucketName)
|
||||
glog.V(4).Infof("SetBucketPolicy: Successfully cached policy for bucket=%s, statements=%d", bucketName, len(compiled.Statements))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -106,9 +106,12 @@ func (engine *PolicyEngine) EvaluatePolicy(bucketName string, args *PolicyEvalua
|
||||
engine.mutex.RUnlock()
|
||||
|
||||
if !exists {
|
||||
glog.V(4).Infof("EvaluatePolicy: No policy found for bucket=%s (PolicyResultIndeterminate)", bucketName)
|
||||
return PolicyResultIndeterminate
|
||||
}
|
||||
|
||||
glog.V(4).Infof("EvaluatePolicy: Found policy for bucket=%s, evaluating with action=%s resource=%s principal=%s",
|
||||
bucketName, args.Action, args.Resource, args.Principal)
|
||||
return engine.evaluateCompiledPolicy(context.policy, args)
|
||||
}
|
||||
|
||||
@@ -122,7 +125,7 @@ func (engine *PolicyEngine) evaluateCompiledPolicy(policy *CompiledPolicy, args
|
||||
hasExplicitAllow := false
|
||||
|
||||
for _, stmt := range policy.Statements {
|
||||
if engine.evaluateStatement(&stmt, args) {
|
||||
if engine.evaluateStatement(stmt, args) {
|
||||
if stmt.Statement.Effect == PolicyEffectDeny {
|
||||
return PolicyResultDeny // Explicit deny trumps everything
|
||||
}
|
||||
@@ -141,28 +144,74 @@ func (engine *PolicyEngine) evaluateCompiledPolicy(policy *CompiledPolicy, args
|
||||
return PolicyResultIndeterminate
|
||||
}
|
||||
|
||||
// matchesDynamicPatterns checks if a value matches any of the dynamic patterns after variable substitution
|
||||
func (engine *PolicyEngine) matchesDynamicPatterns(patterns []string, value string, args *PolicyEvaluationArgs) bool {
|
||||
for _, pattern := range patterns {
|
||||
substituted := SubstituteVariables(pattern, args.Conditions, args.Claims)
|
||||
if FastMatchesWildcard(substituted, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// evaluateStatement evaluates a single policy statement
|
||||
func (engine *PolicyEngine) evaluateStatement(stmt *CompiledStatement, args *PolicyEvaluationArgs) bool {
|
||||
// Check if action matches
|
||||
if !engine.matchesPatterns(stmt.ActionPatterns, args.Action) {
|
||||
matchedAction := engine.matchesPatterns(stmt.ActionPatterns, args.Action)
|
||||
if !matchedAction {
|
||||
matchedAction = engine.matchesDynamicPatterns(stmt.DynamicActionPatterns, args.Action, args)
|
||||
}
|
||||
if !matchedAction {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if resource matches
|
||||
if !engine.matchesPatterns(stmt.ResourcePatterns, args.Resource) {
|
||||
return false
|
||||
hasResource := len(stmt.ResourcePatterns) > 0 || len(stmt.DynamicResourcePatterns) > 0
|
||||
hasNotResource := len(stmt.NotResourcePatterns) > 0 || len(stmt.DynamicNotResourcePatterns) > 0
|
||||
if hasResource {
|
||||
matchedResource := engine.matchesPatterns(stmt.ResourcePatterns, args.Resource)
|
||||
if !matchedResource {
|
||||
matchedResource = engine.matchesDynamicPatterns(stmt.DynamicResourcePatterns, args.Resource, args)
|
||||
}
|
||||
if !matchedResource {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check if principal matches (if specified)
|
||||
if len(stmt.PrincipalPatterns) > 0 {
|
||||
if !engine.matchesPatterns(stmt.PrincipalPatterns, args.Principal) {
|
||||
if hasNotResource {
|
||||
matchedNotResource := false
|
||||
for _, matcher := range stmt.NotResourceMatchers {
|
||||
if matcher.Match(args.Resource) {
|
||||
matchedNotResource = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !matchedNotResource {
|
||||
matchedNotResource = engine.matchesDynamicPatterns(stmt.DynamicNotResourcePatterns, args.Resource, args)
|
||||
}
|
||||
|
||||
if matchedNotResource {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check if principal matches
|
||||
if len(stmt.PrincipalPatterns) > 0 || len(stmt.DynamicPrincipalPatterns) > 0 {
|
||||
matchedPrincipal := engine.matchesPatterns(stmt.PrincipalPatterns, args.Principal)
|
||||
if !matchedPrincipal {
|
||||
matchedPrincipal = engine.matchesDynamicPatterns(stmt.DynamicPrincipalPatterns, args.Principal, args)
|
||||
}
|
||||
if !matchedPrincipal {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check conditions
|
||||
if len(stmt.Statement.Condition) > 0 {
|
||||
if !EvaluateConditions(stmt.Statement.Condition, args.Conditions, args.ObjectEntry) {
|
||||
match := EvaluateConditions(stmt.Statement.Condition, args.Conditions, args.ObjectEntry, args.Claims)
|
||||
if !match {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -180,6 +229,153 @@ func (engine *PolicyEngine) matchesPatterns(patterns []*regexp.Regexp, value str
|
||||
return false
|
||||
}
|
||||
|
||||
// SubstituteVariables replaces ${variable} in a pattern with values from context and claims
|
||||
// Supports:
|
||||
// - Standard context variables (aws:SourceIp, s3:prefix, etc.)
|
||||
// - JWT claims (jwt:preferred_username, jwt:sub, jwt:*)
|
||||
// - LDAP claims (ldap:username, ldap:dn, ldap:*)
|
||||
func SubstituteVariables(pattern string, context map[string][]string, claims map[string]interface{}) string {
|
||||
result := PolicyVariableRegex.ReplaceAllStringFunc(pattern, func(match string) string {
|
||||
// match is like "${aws:username}"
|
||||
// extract variable name "aws:username"
|
||||
variable := match[2 : len(match)-1]
|
||||
|
||||
// Check standard context first
|
||||
if values, ok := context[variable]; ok && len(values) > 0 {
|
||||
return values[0]
|
||||
}
|
||||
|
||||
// Check JWT claims for jwt:* variables
|
||||
if strings.HasPrefix(variable, "jwt:") {
|
||||
claimName := variable[4:] // Remove "jwt:" prefix
|
||||
if claimValue, ok := claims[claimName]; ok {
|
||||
switch v := claimValue.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
// JWT numbers are often float64
|
||||
if v == float64(int64(v)) {
|
||||
return fmt.Sprintf("%d", int64(v))
|
||||
}
|
||||
return fmt.Sprintf("%g", v)
|
||||
case bool:
|
||||
return fmt.Sprintf("%t", v)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case int32:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", v)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check LDAP claims for ldap:* variables
|
||||
// FALLBACK MECHANISM: Try both prefixed and unprefixed keys
|
||||
// Some LDAP providers store claims with the "ldap:" prefix (e.g., "ldap:username")
|
||||
// while others store them without the prefix (e.g., "username").
|
||||
// We check the prefixed key first for consistency, then fall back to unprefixed.
|
||||
if strings.HasPrefix(variable, "ldap:") {
|
||||
claimName := variable[5:] // Remove "ldap:" prefix
|
||||
// Try prefixed key first (e.g., "ldap:username"), then unprefixed
|
||||
var claimValue interface{}
|
||||
var ok bool
|
||||
if claimValue, ok = claims[variable]; !ok {
|
||||
claimValue, ok = claims[claimName]
|
||||
}
|
||||
if ok {
|
||||
switch v := claimValue.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
if v == float64(int64(v)) {
|
||||
return fmt.Sprintf("%d", int64(v))
|
||||
}
|
||||
return fmt.Sprintf("%g", v)
|
||||
case bool:
|
||||
return fmt.Sprintf("%t", v)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case int32:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", v)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Variable not found, leave as-is to avoid unexpected matching
|
||||
return match
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// ExtractPrincipalVariables extracts policy variables from a principal ARN
|
||||
func ExtractPrincipalVariables(principal string) map[string][]string {
|
||||
vars := make(map[string][]string)
|
||||
|
||||
// Handle non-ARN principals (e.g., "*" or simple usernames)
|
||||
if !strings.HasPrefix(principal, "arn:aws:") {
|
||||
return vars
|
||||
}
|
||||
|
||||
// Parse ARN: arn:aws:service::account:resource
|
||||
parts := strings.Split(principal, ":")
|
||||
if len(parts) < 6 {
|
||||
return vars
|
||||
}
|
||||
|
||||
account := parts[4] // account ID
|
||||
resourcePart := parts[5] // user/username or assumed-role/role/session
|
||||
|
||||
// Set aws:PrincipalAccount if account is present
|
||||
if account != "" {
|
||||
vars["aws:PrincipalAccount"] = []string{account}
|
||||
}
|
||||
|
||||
resourceParts := strings.Split(resourcePart, "/")
|
||||
if len(resourceParts) < 2 {
|
||||
return vars
|
||||
}
|
||||
|
||||
resourceType := resourceParts[0] // "user", "role", "assumed-role"
|
||||
|
||||
// Set aws:principaltype and extract username/userid based on resource type
|
||||
switch resourceType {
|
||||
case "user":
|
||||
vars["aws:principaltype"] = []string{"IAMUser"}
|
||||
// For users with paths like "user/path/to/username", use the last segment
|
||||
username := resourceParts[len(resourceParts)-1]
|
||||
vars["aws:username"] = []string{username}
|
||||
vars["aws:userid"] = []string{username} // In SeaweedFS, userid is same as username
|
||||
case "role":
|
||||
vars["aws:principaltype"] = []string{"IAMRole"}
|
||||
// For roles with paths like "role/path/to/rolename", use the last segment
|
||||
// Note: IAM Roles do NOT have aws:userid, but aws:PrincipalAccount is kept for condition evaluations
|
||||
if len(resourceParts) >= 2 {
|
||||
roleName := resourceParts[len(resourceParts)-1]
|
||||
vars["aws:username"] = []string{roleName}
|
||||
}
|
||||
case "assumed-role":
|
||||
vars["aws:principaltype"] = []string{"AssumedRole"}
|
||||
// For assumed roles: assumed-role/RoleName/SessionName or assumed-role/path/to/RoleName/SessionName
|
||||
// The session name is always the last segment
|
||||
if len(resourceParts) >= 3 {
|
||||
sessionName := resourceParts[len(resourceParts)-1]
|
||||
vars["aws:username"] = []string{sessionName}
|
||||
vars["aws:userid"] = []string{sessionName}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: principaltype is already set correctly in the switch above based on resource type
|
||||
|
||||
return vars
|
||||
}
|
||||
|
||||
// ExtractConditionValuesFromRequest extracts condition values from HTTP request
|
||||
func ExtractConditionValuesFromRequest(r *http.Request) map[string][]string {
|
||||
values := make(map[string][]string)
|
||||
@@ -413,6 +609,12 @@ func (engine *PolicyEngine) EvaluatePolicyForRequest(bucketName, objectName, act
|
||||
actionName := BuildActionName(action)
|
||||
conditions := ExtractConditionValuesFromRequest(r)
|
||||
|
||||
// Extract principal information for variables
|
||||
principalVars := ExtractPrincipalVariables(principal)
|
||||
for k, v := range principalVars {
|
||||
conditions[k] = v
|
||||
}
|
||||
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: actionName,
|
||||
Resource: resource,
|
||||
|
||||
312
weed/s3api/policy_engine/engine_enhanced_test.go
Normal file
312
weed/s3api/policy_engine/engine_enhanced_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractPrincipalVariables(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
principal string
|
||||
expected map[string][]string
|
||||
}{
|
||||
{
|
||||
name: "IAM User ARN",
|
||||
principal: "arn:aws:iam::123456789012:user/alice",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"IAMUser"},
|
||||
"aws:username": {"alice"},
|
||||
"aws:userid": {"alice"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Assumed Role ARN",
|
||||
principal: "arn:aws:sts::123456789012:assumed-role/MyRole/session-alice",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"AssumedRole"},
|
||||
"aws:username": {"session-alice"},
|
||||
"aws:userid": {"session-alice"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IAM Role ARN",
|
||||
principal: "arn:aws:iam::123456789012:role/MyRole",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"IAMRole"},
|
||||
"aws:username": {"MyRole"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Non-ARN principal",
|
||||
principal: "user:alice",
|
||||
expected: map[string][]string{},
|
||||
},
|
||||
{
|
||||
name: "Wildcard principal",
|
||||
principal: "*",
|
||||
expected: map[string][]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ExtractPrincipalVariables(tt.principal)
|
||||
|
||||
// Check that all expected keys are present with correct values
|
||||
for key, expectedValues := range tt.expected {
|
||||
actualValues, ok := result[key]
|
||||
if !ok {
|
||||
t.Errorf("Expected key %s not found in result", key)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(actualValues) != len(expectedValues) {
|
||||
t.Errorf("For key %s: expected %d values, got %d", key, len(expectedValues), len(actualValues))
|
||||
continue
|
||||
}
|
||||
|
||||
for i, expectedValue := range expectedValues {
|
||||
if actualValues[i] != expectedValue {
|
||||
t.Errorf("For key %s[%d]: expected %s, got %s", key, i, expectedValue, actualValues[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check that there are no unexpected keys
|
||||
for key := range result {
|
||||
if _, ok := tt.expected[key]; !ok {
|
||||
t.Errorf("Unexpected key %s in result", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteVariablesWithClaims(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
context map[string][]string
|
||||
claims map[string]interface{}
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Standard context variable",
|
||||
pattern: "arn:aws:s3:::bucket/${aws:username}/*",
|
||||
context: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
claims: nil,
|
||||
expected: "arn:aws:s3:::bucket/alice/*",
|
||||
},
|
||||
{
|
||||
name: "JWT claim substitution",
|
||||
pattern: "arn:aws:s3:::bucket/${jwt:preferred_username}/*",
|
||||
context: map[string][]string{},
|
||||
claims: map[string]interface{}{
|
||||
"preferred_username": "bob",
|
||||
},
|
||||
expected: "arn:aws:s3:::bucket/bob/*",
|
||||
},
|
||||
{
|
||||
name: "Mixed variables",
|
||||
pattern: "arn:aws:s3:::bucket/${jwt:sub}/files/${aws:principaltype}",
|
||||
context: map[string][]string{
|
||||
"aws:principaltype": {"IAMUser"},
|
||||
},
|
||||
claims: map[string]interface{}{
|
||||
"sub": "user123",
|
||||
},
|
||||
expected: "arn:aws:s3:::bucket/user123/files/IAMUser",
|
||||
},
|
||||
{
|
||||
name: "Variable not found",
|
||||
pattern: "arn:aws:s3:::bucket/${jwt:missing}/*",
|
||||
context: map[string][]string{},
|
||||
claims: map[string]interface{}{},
|
||||
expected: "arn:aws:s3:::bucket/${jwt:missing}/*",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SubstituteVariables(tt.pattern, tt.context, tt.claims)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyVariablesWithPrincipalType(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy that requires specific principal type
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:*",
|
||||
"Resource": "arn:aws:s3:::bucket/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:principaltype": "IAMUser"
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// Test with IAM User - should allow
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket/file.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/alice",
|
||||
Conditions: map[string][]string{
|
||||
"aws:principaltype": {"IAMUser"},
|
||||
"aws:username": {"alice"},
|
||||
"aws:userid": {"alice"},
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Expected Allow for IAMUser principal, got %v", result)
|
||||
}
|
||||
|
||||
// Test with AssumedRole - should return Indeterminate (condition doesn't match)
|
||||
args.Principal = "arn:aws:sts::123456789012:assumed-role/MyRole/session"
|
||||
args.Conditions["aws:principaltype"] = []string{"AssumedRole"}
|
||||
|
||||
result = engine.EvaluatePolicy("bucket", args)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate for AssumedRole principal, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyVariablesWithJWTClaims(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy using JWT claim in resource
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:*",
|
||||
"Resource": "arn:aws:s3:::bucket/${jwt:preferred_username}/*"
|
||||
}]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// Test with matching JWT claim
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket/alice/file.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/alice",
|
||||
Conditions: map[string][]string{},
|
||||
Claims: map[string]interface{}{
|
||||
"preferred_username": "alice",
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Expected Allow when JWT claim matches resource, got %v", result)
|
||||
}
|
||||
|
||||
// Test with mismatched JWT claim
|
||||
args.Resource = "arn:aws:s3:::bucket/bob/file.txt"
|
||||
|
||||
result = engine.EvaluatePolicy("bucket", args)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate when JWT claim doesn't match resource, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPrincipalVariablesWithAccount(t *testing.T) {
|
||||
principal := "arn:aws:iam::123456789012:user/alice"
|
||||
vars := ExtractPrincipalVariables(principal)
|
||||
|
||||
if account, ok := vars["aws:PrincipalAccount"]; !ok {
|
||||
t.Errorf("Expected aws:PrincipalAccount to be present")
|
||||
} else if len(account) == 0 {
|
||||
t.Errorf("Expected aws:PrincipalAccount to have values")
|
||||
} else if account[0] != "123456789012" {
|
||||
t.Errorf("Expected aws:PrincipalAccount=123456789012, got %v", account[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteVariablesWithLDAP(t *testing.T) {
|
||||
pattern := "arn:aws:s3:::bucket/${ldap:username}/*"
|
||||
context := map[string][]string{}
|
||||
claims := map[string]interface{}{
|
||||
"username": "jdoe",
|
||||
}
|
||||
|
||||
result := SubstituteVariables(pattern, context, claims)
|
||||
expected := "arn:aws:s3:::bucket/jdoe/*"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected %s, got %s", expected, result)
|
||||
}
|
||||
|
||||
// Test ldap:dn
|
||||
pattern = "arn:aws:s3:::bucket/${ldap:dn}/*"
|
||||
claims = map[string]interface{}{
|
||||
"dn": "uid=jdoe,ou=people,dc=example,dc=com",
|
||||
}
|
||||
result = SubstituteVariables(pattern, context, claims)
|
||||
expected = "arn:aws:s3:::bucket/uid=jdoe,ou=people,dc=example,dc=com/*"
|
||||
if result != expected {
|
||||
t.Errorf("Expected %s, got %s", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteVariablesSpecialChars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
context map[string][]string
|
||||
claims map[string]interface{}
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Comparison operators in claims/vars",
|
||||
pattern: "resource/${jwt:scope}",
|
||||
context: map[string][]string{},
|
||||
claims: map[string]interface{}{
|
||||
"scope": "read/write",
|
||||
},
|
||||
expected: "resource/read/write",
|
||||
},
|
||||
{
|
||||
name: "Path traversal attempt (should just substitute text)",
|
||||
pattern: "bucket/${jwt:user}",
|
||||
context: map[string][]string{},
|
||||
claims: map[string]interface{}{
|
||||
"user": "../../../etc/passwd",
|
||||
},
|
||||
expected: "bucket/../../../etc/passwd",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SubstituteVariables(tt.pattern, tt.context, tt.claims)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
100
weed/s3api/policy_engine/engine_isolation_test.go
Normal file
100
weed/s3api/policy_engine/engine_isolation_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsolationPolicy(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
bucketName := "test-isolation"
|
||||
|
||||
policyJSON := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Sid": "AllowOwnFolder",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": "arn:aws:s3:::%s/${aws:username}/*"
|
||||
}, {
|
||||
"Sid": "AllowListOwnPrefix",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::%s",
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"s3:prefix": ["${aws:username}/*", "${aws:username}"]
|
||||
}
|
||||
}
|
||||
}, {
|
||||
"Sid": "DenyOtherFolders",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"NotResource": "arn:aws:s3:::%s/${aws:username}/*"
|
||||
}]
|
||||
}`, bucketName, bucketName, bucketName)
|
||||
|
||||
err := engine.SetBucketPolicy(bucketName, policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// Case 1: Alice accesses her own folder -> should be ALLOWED
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/alice/data.txt", bucketName),
|
||||
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/alice",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
}
|
||||
result := engine.EvaluatePolicy(bucketName, args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Alice should be ALLOWED to her own folder, got %v", result)
|
||||
}
|
||||
|
||||
// Case 2: Alice accesses Bob's folder -> should be DENIED
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/bob/data.txt", bucketName),
|
||||
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/alice",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
}
|
||||
result = engine.EvaluatePolicy(bucketName, args)
|
||||
if result != PolicyResultDeny {
|
||||
t.Errorf("Alice should be DENIED access to Bob's folder, got %v", result)
|
||||
}
|
||||
|
||||
// Case 3: Bob accesses Bob's folder -> should be ALLOWED
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/bob/data.txt", bucketName),
|
||||
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/bob",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"bob"},
|
||||
},
|
||||
}
|
||||
result = engine.EvaluatePolicy(bucketName, args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Bob should be ALLOWED to his own folder, got %v", result)
|
||||
}
|
||||
|
||||
// Case 4: Bob accesses Alice's folder -> should be DENIED
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/alice/data.txt", bucketName),
|
||||
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/bob",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"bob"},
|
||||
},
|
||||
}
|
||||
result = engine.EvaluatePolicy(bucketName, args)
|
||||
if result != PolicyResultDeny {
|
||||
t.Errorf("Bob should be DENIED access to Alice's folder, got %v", result)
|
||||
}
|
||||
}
|
||||
65
weed/s3api/policy_engine/engine_notresource_test.go
Normal file
65
weed/s3api/policy_engine/engine_notresource_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNotResourceWithVariables(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy mirroring the isolation test
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowOwnFolder",
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::bucket/${aws:username}/*"
|
||||
},
|
||||
{
|
||||
"Sid": "DenyOtherFolders",
|
||||
"Effect": "Deny",
|
||||
"Action": "s3:GetObject",
|
||||
"NotResource": "arn:aws:s3:::bucket/${aws:username}/*"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("test-bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// Case 1: Alice accesses her own folder -> should match Allow, but NOT match Deny statement
|
||||
// (because Deny says NotResource is own folder, and she IS accessing her own folder, so NotResource check fails, statement doesn't apply)
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket/alice/data.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/alice",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Alice should be allowed to her own folder, got %v", result)
|
||||
}
|
||||
|
||||
// Case 2: Alice accesses Bob's folder -> should NOT match Allow, and SHOULD match Deny statement
|
||||
// (because Deny says NotResource is own folder, and she is NOT accessing her own folder, so NotResource matches, statement applies)
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket/bob/data.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/alice",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
}
|
||||
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultDeny {
|
||||
t.Errorf("Alice should be denied access to Bob folder, got %v", result)
|
||||
}
|
||||
}
|
||||
77
weed/s3api/policy_engine/engine_paths_test.go
Normal file
77
weed/s3api/policy_engine/engine_paths_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestExtractPrincipalVariablesWithPaths tests ARN parsing with IAM path components
|
||||
func TestExtractPrincipalVariablesWithPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
principal string
|
||||
expected map[string][]string
|
||||
}{
|
||||
{
|
||||
name: "IAM User with path",
|
||||
principal: "arn:aws:iam::123456789012:user/division/team/alice",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"IAMUser"},
|
||||
"aws:username": {"alice"},
|
||||
"aws:userid": {"alice"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IAM Role with path",
|
||||
principal: "arn:aws:iam::123456789012:role/service-role/MyRole",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"IAMRole"},
|
||||
"aws:username": {"MyRole"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Assumed Role with path",
|
||||
principal: "arn:aws:sts::123456789012:assumed-role/service-role/MyRole/session-name",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"AssumedRole"},
|
||||
"aws:username": {"session-name"},
|
||||
"aws:userid": {"session-name"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ExtractPrincipalVariables(tt.principal)
|
||||
|
||||
// Check that all expected keys are present with correct values
|
||||
for key, expectedValues := range tt.expected {
|
||||
actualValues, ok := result[key]
|
||||
if !ok {
|
||||
t.Errorf("Expected key %s not found in result", key)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(actualValues) != len(expectedValues) {
|
||||
t.Errorf("For key %s: expected %d values, got %d", key, len(expectedValues), len(actualValues))
|
||||
continue
|
||||
}
|
||||
|
||||
for i, expectedValue := range expectedValues {
|
||||
if actualValues[i] != expectedValue {
|
||||
t.Errorf("For key %s[%d]: expected %s, got %s", key, i, expectedValue, actualValues[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check that there are no unexpected keys
|
||||
for key := range result {
|
||||
if _, ok := tt.expected[key]; !ok {
|
||||
t.Errorf("Unexpected key %s in result", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
152
weed/s3api/policy_engine/engine_variables_test.go
Normal file
152
weed/s3api/policy_engine/engine_variables_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPolicyVariables(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy with variables in Resource and Condition
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowUserHomeDirectory",
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::test-bucket",
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"s3:prefix": ["${aws:username}/*"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "AllowUserObjectAccess",
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": ["arn:aws:s3:::test-bucket/${aws:username}/*"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("test-bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// Case 1: Matching username for resource access
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/johndoe/file.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/johndoe",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"johndoe"},
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Expected Allow for matching username in resource, got %v", result)
|
||||
}
|
||||
|
||||
// Case 2: Mismatched username for resource access
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/janedoe/file.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/johndoe",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"johndoe"},
|
||||
},
|
||||
}
|
||||
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate for mismatched username in resource, got %v", result)
|
||||
}
|
||||
|
||||
// Case 3: ListBucket with matching prefix condition
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:ListBucket",
|
||||
Resource: "arn:aws:s3:::test-bucket",
|
||||
Principal: "arn:aws:iam::123456789012:user/johndoe",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"johndoe"},
|
||||
"s3:prefix": {"johndoe/docs"},
|
||||
},
|
||||
}
|
||||
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Expected Allow for matching prefix condition, got %v", result)
|
||||
}
|
||||
|
||||
// Case 4: ListBucket with mismatched prefix condition
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:ListBucket",
|
||||
Resource: "arn:aws:s3:::test-bucket",
|
||||
Principal: "arn:aws:iam::123456789012:user/johndoe",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"johndoe"},
|
||||
"s3:prefix": {"janedoe/docs"},
|
||||
},
|
||||
}
|
||||
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate for mismatched prefix condition, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluatePolicyForRequestVariables(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy using aws:username
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::test-bucket/${aws:username}/*"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("test-bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// We need to mock the request but the EvaluatePolicyForRequest mostly runs on args extraction
|
||||
// The key thing is that EvaluatePolicyForRequest should populate "aws:username" from principal
|
||||
|
||||
// Since we cannot easily pass a full http.Request that matches everything, we will test the extraction logic
|
||||
// by simulating what EvaluatePolicyForRequest does: calling EvaluatePolicy with populated Conditions
|
||||
|
||||
principal := "arn:aws:iam::123456789012:user/alice"
|
||||
// Should extract "alice"
|
||||
|
||||
// Create args manually as if extracted
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/alice/data.txt",
|
||||
Principal: principal,
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Expected Allow when aws:username is populated, got %v", result)
|
||||
}
|
||||
|
||||
// Now with wrong resource
|
||||
args.Resource = "arn:aws:s3:::test-bucket/bob/data.txt"
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate when resource doesn't match variable, got %v", result)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +32,11 @@ const (
|
||||
PolicyVersion2012_10_17 = "2012-10-17"
|
||||
)
|
||||
|
||||
var (
|
||||
// PolicyVariableRegex detects AWS IAM policy variables like ${aws:username}
|
||||
PolicyVariableRegex = regexp.MustCompile(`\$\{([^}]+)\}`)
|
||||
)
|
||||
|
||||
// StringOrStringSlice represents a value that can be either a string or []string
|
||||
type StringOrStringSlice struct {
|
||||
values []string
|
||||
@@ -84,12 +90,13 @@ type PolicyDocument struct {
|
||||
|
||||
// PolicyStatement represents a single policy statement
|
||||
type PolicyStatement struct {
|
||||
Sid string `json:"Sid,omitempty"`
|
||||
Effect PolicyEffect `json:"Effect"`
|
||||
Principal *StringOrStringSlice `json:"Principal,omitempty"`
|
||||
Action StringOrStringSlice `json:"Action"`
|
||||
Resource StringOrStringSlice `json:"Resource"`
|
||||
Condition PolicyConditions `json:"Condition,omitempty"`
|
||||
Sid string `json:"Sid,omitempty"`
|
||||
Effect PolicyEffect `json:"Effect"`
|
||||
Principal *StringOrStringSlice `json:"Principal,omitempty"`
|
||||
Action StringOrStringSlice `json:"Action"`
|
||||
Resource StringOrStringSlice `json:"Resource,omitempty"`
|
||||
NotResource StringOrStringSlice `json:"NotResource,omitempty"`
|
||||
Condition PolicyConditions `json:"Condition,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyEffect represents Allow or Deny
|
||||
@@ -111,6 +118,8 @@ type PolicyEvaluationArgs struct {
|
||||
// Tags are stored with s3_constants.AmzObjectTaggingPrefix (X-Amz-Tagging-) prefix.
|
||||
// Can be nil for bucket-level operations or when object doesn't exist.
|
||||
ObjectEntry map[string][]byte
|
||||
// Claims are JWT claims for jwt:* policy variables (can be nil)
|
||||
Claims map[string]interface{}
|
||||
}
|
||||
|
||||
// PolicyCache for caching compiled policies
|
||||
@@ -122,7 +131,7 @@ type PolicyCache struct {
|
||||
// CompiledPolicy represents a policy that has been compiled for efficient evaluation
|
||||
type CompiledPolicy struct {
|
||||
Document *PolicyDocument
|
||||
Statements []CompiledStatement
|
||||
Statements []*CompiledStatement
|
||||
}
|
||||
|
||||
// CompiledStatement represents a compiled policy statement
|
||||
@@ -135,6 +144,16 @@ type CompiledStatement struct {
|
||||
ActionPatterns []*regexp.Regexp
|
||||
ResourcePatterns []*regexp.Regexp
|
||||
PrincipalPatterns []*regexp.Regexp
|
||||
|
||||
// dynamic patterns that require variable substitution before matching
|
||||
DynamicActionPatterns []string
|
||||
DynamicResourcePatterns []string
|
||||
DynamicPrincipalPatterns []string
|
||||
|
||||
// NotResource patterns (resource should NOT match these)
|
||||
NotResourcePatterns []*regexp.Regexp
|
||||
NotResourceMatchers []*WildcardMatcher
|
||||
DynamicNotResourcePatterns []string
|
||||
}
|
||||
|
||||
// NewPolicyCache creates a new policy cache
|
||||
@@ -154,8 +173,8 @@ func ValidatePolicy(policyDoc *PolicyDocument) error {
|
||||
return fmt.Errorf("policy must contain at least one statement")
|
||||
}
|
||||
|
||||
for i, stmt := range policyDoc.Statement {
|
||||
if err := validateStatement(&stmt); err != nil {
|
||||
for i := range policyDoc.Statement {
|
||||
if err := validateStatement(&policyDoc.Statement[i]); err != nil {
|
||||
return fmt.Errorf("invalid statement %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
@@ -173,8 +192,8 @@ func validateStatement(stmt *PolicyStatement) error {
|
||||
return fmt.Errorf("action is required")
|
||||
}
|
||||
|
||||
if len(stmt.Resource.Strings()) == 0 {
|
||||
return fmt.Errorf("resource is required")
|
||||
if len(stmt.Resource.Strings()) == 0 && len(stmt.NotResource.Strings()) == 0 {
|
||||
return fmt.Errorf("statement must specify Resource or NotResource")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -198,15 +217,16 @@ func ParsePolicy(policyJSON string) (*PolicyDocument, error) {
|
||||
func CompilePolicy(policy *PolicyDocument) (*CompiledPolicy, error) {
|
||||
compiled := &CompiledPolicy{
|
||||
Document: policy,
|
||||
Statements: make([]CompiledStatement, len(policy.Statement)),
|
||||
Statements: make([]*CompiledStatement, len(policy.Statement)),
|
||||
}
|
||||
|
||||
for i, stmt := range policy.Statement {
|
||||
compiledStmt, err := compileStatement(&stmt)
|
||||
for i := range policy.Statement {
|
||||
stmt := &policy.Statement[i]
|
||||
compiledStmt, err := compileStatement(stmt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile statement %d: %v", i, err)
|
||||
}
|
||||
compiled.Statements[i] = *compiledStmt
|
||||
compiled.Statements[i] = compiledStmt
|
||||
}
|
||||
|
||||
return compiled, nil
|
||||
@@ -214,12 +234,51 @@ func CompilePolicy(policy *PolicyDocument) (*CompiledPolicy, error) {
|
||||
|
||||
// compileStatement compiles a single policy statement
|
||||
func compileStatement(stmt *PolicyStatement) (*CompiledStatement, error) {
|
||||
resStrings := slices.Clone(stmt.Resource.Strings())
|
||||
notResStrings := slices.Clone(stmt.NotResource.Strings())
|
||||
compiled := &CompiledStatement{
|
||||
Statement: stmt,
|
||||
Statement: &PolicyStatement{
|
||||
Sid: stmt.Sid,
|
||||
Effect: stmt.Effect,
|
||||
Action: stmt.Action,
|
||||
},
|
||||
}
|
||||
|
||||
// Deep clone Principal if present
|
||||
if stmt.Principal != nil {
|
||||
principalClone := *stmt.Principal
|
||||
principalClone.values = slices.Clone(stmt.Principal.values)
|
||||
compiled.Statement.Principal = &principalClone
|
||||
}
|
||||
|
||||
// Deep clone Resource/NotResource into the internal statement as well for completeness
|
||||
compiled.Statement.Resource.values = slices.Clone(stmt.Resource.values)
|
||||
compiled.Statement.NotResource.values = slices.Clone(stmt.NotResource.values)
|
||||
compiled.Statement.Action.values = slices.Clone(stmt.Action.values)
|
||||
|
||||
// Deep clone Condition map
|
||||
if stmt.Condition != nil {
|
||||
compiled.Statement.Condition = make(PolicyConditions)
|
||||
for k, v := range stmt.Condition {
|
||||
innerMap := make(map[string]StringOrStringSlice)
|
||||
for ik, iv := range v {
|
||||
innerMap[ik] = StringOrStringSlice{values: slices.Clone(iv.values)}
|
||||
}
|
||||
compiled.Statement.Condition[k] = innerMap
|
||||
}
|
||||
}
|
||||
|
||||
// Compile action patterns and matchers
|
||||
for _, action := range stmt.Action.Strings() {
|
||||
if action == "" {
|
||||
continue
|
||||
}
|
||||
// Check for dynamic variables
|
||||
if PolicyVariableRegex.MatchString(action) {
|
||||
compiled.DynamicActionPatterns = append(compiled.DynamicActionPatterns, action)
|
||||
continue
|
||||
}
|
||||
|
||||
pattern, err := compilePattern(action)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile action pattern %s: %v", action, err)
|
||||
@@ -234,7 +293,16 @@ func compileStatement(stmt *PolicyStatement) (*CompiledStatement, error) {
|
||||
}
|
||||
|
||||
// Compile resource patterns and matchers
|
||||
for _, resource := range stmt.Resource.Strings() {
|
||||
for _, resource := range resStrings {
|
||||
if resource == "" {
|
||||
continue
|
||||
}
|
||||
// Check for dynamic variables
|
||||
if PolicyVariableRegex.MatchString(resource) {
|
||||
compiled.DynamicResourcePatterns = append(compiled.DynamicResourcePatterns, resource)
|
||||
continue
|
||||
}
|
||||
|
||||
pattern, err := compilePattern(resource)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile resource pattern %s: %v", resource, err)
|
||||
@@ -251,6 +319,15 @@ func compileStatement(stmt *PolicyStatement) (*CompiledStatement, error) {
|
||||
// Compile principal patterns and matchers if present
|
||||
if stmt.Principal != nil && len(stmt.Principal.Strings()) > 0 {
|
||||
for _, principal := range stmt.Principal.Strings() {
|
||||
if principal == "" {
|
||||
continue
|
||||
}
|
||||
// Check for dynamic variables
|
||||
if PolicyVariableRegex.MatchString(principal) {
|
||||
compiled.DynamicPrincipalPatterns = append(compiled.DynamicPrincipalPatterns, principal)
|
||||
continue
|
||||
}
|
||||
|
||||
pattern, err := compilePattern(principal)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile principal pattern %s: %v", principal, err)
|
||||
@@ -265,6 +342,35 @@ func compileStatement(stmt *PolicyStatement) (*CompiledStatement, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Compile NotResource patterns (resource should NOT match these)
|
||||
if len(notResStrings) > 0 {
|
||||
for _, notResource := range notResStrings {
|
||||
if notResource == "" {
|
||||
continue
|
||||
}
|
||||
// Check for dynamic variables
|
||||
if PolicyVariableRegex.MatchString(notResource) {
|
||||
compiled.DynamicNotResourcePatterns = append(compiled.DynamicNotResourcePatterns, notResource)
|
||||
continue
|
||||
}
|
||||
|
||||
pattern, err := compilePattern(notResource)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile NotResource pattern %s: %v", notResource, err)
|
||||
}
|
||||
compiled.NotResourcePatterns = append(compiled.NotResourcePatterns, pattern)
|
||||
|
||||
matcher, err := NewWildcardMatcher(notResource)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create NotResource matcher %s: %v", notResource, err)
|
||||
}
|
||||
compiled.NotResourceMatchers = append(compiled.NotResourceMatchers, matcher)
|
||||
|
||||
// Debug log
|
||||
// fmt.Printf("Compiled NotResource: %s\n", notResource)
|
||||
}
|
||||
}
|
||||
|
||||
return compiled, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user