* 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
627 lines
18 KiB
Go
627 lines
18 KiB
Go
package policy_engine
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
)
|
|
|
|
// PolicyEvaluationResult represents the result of policy evaluation
|
|
type PolicyEvaluationResult int
|
|
|
|
const (
|
|
PolicyResultDeny PolicyEvaluationResult = iota
|
|
PolicyResultAllow
|
|
PolicyResultIndeterminate
|
|
)
|
|
|
|
// PolicyEvaluationContext manages policy evaluation for a bucket
|
|
type PolicyEvaluationContext struct {
|
|
bucketName string
|
|
policy *CompiledPolicy
|
|
cache *PolicyCache
|
|
mutex sync.RWMutex
|
|
}
|
|
|
|
// PolicyEngine is the main policy evaluation engine
|
|
type PolicyEngine struct {
|
|
contexts map[string]*PolicyEvaluationContext
|
|
mutex sync.RWMutex
|
|
}
|
|
|
|
// NewPolicyEngine creates a new policy evaluation engine
|
|
func NewPolicyEngine() *PolicyEngine {
|
|
return &PolicyEngine{
|
|
contexts: make(map[string]*PolicyEvaluationContext),
|
|
}
|
|
}
|
|
|
|
// SetBucketPolicy sets the policy for a bucket
|
|
func (engine *PolicyEngine) SetBucketPolicy(bucketName string, policyJSON string) error {
|
|
policy, err := ParsePolicy(policyJSON)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid policy: %w", err)
|
|
}
|
|
|
|
compiled, err := CompilePolicy(policy)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to compile policy: %w", err)
|
|
}
|
|
|
|
engine.mutex.Lock()
|
|
defer engine.mutex.Unlock()
|
|
|
|
context := &PolicyEvaluationContext{
|
|
bucketName: bucketName,
|
|
policy: compiled,
|
|
cache: NewPolicyCache(),
|
|
}
|
|
|
|
engine.contexts[bucketName] = context
|
|
glog.V(4).Infof("SetBucketPolicy: Successfully cached policy for bucket=%s, statements=%d", bucketName, len(compiled.Statements))
|
|
return nil
|
|
}
|
|
|
|
// GetBucketPolicy gets the policy for a bucket
|
|
func (engine *PolicyEngine) GetBucketPolicy(bucketName string) (*PolicyDocument, error) {
|
|
engine.mutex.RLock()
|
|
defer engine.mutex.RUnlock()
|
|
|
|
context, exists := engine.contexts[bucketName]
|
|
if !exists {
|
|
return nil, fmt.Errorf("no policy found for bucket %s", bucketName)
|
|
}
|
|
|
|
return context.policy.Document, nil
|
|
}
|
|
|
|
// DeleteBucketPolicy deletes the policy for a bucket
|
|
func (engine *PolicyEngine) DeleteBucketPolicy(bucketName string) error {
|
|
engine.mutex.Lock()
|
|
defer engine.mutex.Unlock()
|
|
|
|
delete(engine.contexts, bucketName)
|
|
glog.V(2).Infof("Deleted bucket policy for %s", bucketName)
|
|
return nil
|
|
}
|
|
|
|
// HasPolicyForBucket checks if a bucket has a policy configured
|
|
func (engine *PolicyEngine) HasPolicyForBucket(bucketName string) bool {
|
|
engine.mutex.RLock()
|
|
defer engine.mutex.RUnlock()
|
|
_, exists := engine.contexts[bucketName]
|
|
return exists
|
|
}
|
|
|
|
// EvaluatePolicy evaluates a policy for the given arguments
|
|
func (engine *PolicyEngine) EvaluatePolicy(bucketName string, args *PolicyEvaluationArgs) PolicyEvaluationResult {
|
|
engine.mutex.RLock()
|
|
context, exists := engine.contexts[bucketName]
|
|
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)
|
|
}
|
|
|
|
// evaluateCompiledPolicy evaluates a compiled policy
|
|
func (engine *PolicyEngine) evaluateCompiledPolicy(policy *CompiledPolicy, args *PolicyEvaluationArgs) PolicyEvaluationResult {
|
|
// AWS Policy evaluation logic:
|
|
// 1. Check for explicit Deny - if found, return Deny
|
|
// 2. Check for explicit Allow - if found, return Allow
|
|
// 3. If no matching statements, return Indeterminate (fall through to IAM)
|
|
|
|
hasExplicitAllow := false
|
|
|
|
for _, stmt := range policy.Statements {
|
|
if engine.evaluateStatement(stmt, args) {
|
|
if stmt.Statement.Effect == PolicyEffectDeny {
|
|
return PolicyResultDeny // Explicit deny trumps everything
|
|
}
|
|
if stmt.Statement.Effect == PolicyEffectAllow {
|
|
hasExplicitAllow = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if hasExplicitAllow {
|
|
return PolicyResultAllow
|
|
}
|
|
|
|
// No matching statements - return Indeterminate to fall through to IAM
|
|
// This allows IAM policies to grant access even when bucket policy doesn't mention the action
|
|
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
|
|
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
|
|
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
|
|
}
|
|
}
|
|
|
|
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 {
|
|
match := EvaluateConditions(stmt.Statement.Condition, args.Conditions, args.ObjectEntry, args.Claims)
|
|
if !match {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// matchesPatterns checks if a value matches any of the compiled patterns
|
|
func (engine *PolicyEngine) matchesPatterns(patterns []*regexp.Regexp, value string) bool {
|
|
for _, pattern := range patterns {
|
|
if pattern.MatchString(value) {
|
|
return true
|
|
}
|
|
}
|
|
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)
|
|
|
|
// AWS condition keys
|
|
// Extract IP address without port for proper IP matching
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
// Log a warning if splitting fails
|
|
glog.Warningf("Failed to parse IP address from RemoteAddr %q: %v", r.RemoteAddr, err)
|
|
// If splitting fails, use the original RemoteAddr (might be just IP without port)
|
|
host = r.RemoteAddr
|
|
}
|
|
values["aws:SourceIp"] = []string{host}
|
|
values["aws:SecureTransport"] = []string{fmt.Sprintf("%t", r.TLS != nil)}
|
|
// Use AWS standard condition key for current time
|
|
values["aws:CurrentTime"] = []string{time.Now().Format(time.RFC3339)}
|
|
// Keep RequestTime for backward compatibility
|
|
values["aws:RequestTime"] = []string{time.Now().Format(time.RFC3339)}
|
|
|
|
// S3 specific condition keys
|
|
if userAgent := r.Header.Get("User-Agent"); userAgent != "" {
|
|
values["aws:UserAgent"] = []string{userAgent}
|
|
}
|
|
|
|
if referer := r.Header.Get("Referer"); referer != "" {
|
|
values["aws:Referer"] = []string{referer}
|
|
}
|
|
|
|
// Note: s3:ExistingObjectTag/<key> conditions are evaluated using objectEntry
|
|
// passed to EvaluatePolicy, not extracted from the request.
|
|
|
|
// S3 bucket-level conditions
|
|
if delimiter := r.URL.Query().Get("delimiter"); delimiter != "" {
|
|
values["s3:delimiter"] = []string{delimiter}
|
|
}
|
|
|
|
if prefix := r.URL.Query().Get("prefix"); prefix != "" {
|
|
values["s3:prefix"] = []string{prefix}
|
|
}
|
|
|
|
if maxKeys := r.URL.Query().Get("max-keys"); maxKeys != "" {
|
|
values["s3:max-keys"] = []string{maxKeys}
|
|
}
|
|
|
|
// Authentication method
|
|
if authHeader := r.Header.Get("Authorization"); authHeader != "" {
|
|
if strings.HasPrefix(authHeader, "AWS4-HMAC-SHA256") {
|
|
values["s3:authType"] = []string{"REST-HEADER"}
|
|
} else if strings.HasPrefix(authHeader, "AWS ") {
|
|
values["s3:authType"] = []string{"REST-HEADER"}
|
|
}
|
|
} else if r.URL.Query().Get("AWSAccessKeyId") != "" {
|
|
values["s3:authType"] = []string{"REST-QUERY-STRING"}
|
|
}
|
|
|
|
// HTTP method
|
|
values["s3:RequestMethod"] = []string{r.Method}
|
|
|
|
// Extract custom headers
|
|
for key, headerValues := range r.Header {
|
|
if strings.HasPrefix(strings.ToLower(key), "x-amz-") {
|
|
values[strings.ToLower(key)] = headerValues
|
|
}
|
|
}
|
|
|
|
return values
|
|
}
|
|
|
|
// BuildResourceArn builds an ARN for the given bucket and object
|
|
func BuildResourceArn(bucketName, objectName string) string {
|
|
if objectName == "" {
|
|
return fmt.Sprintf("arn:aws:s3:::%s", bucketName)
|
|
}
|
|
return fmt.Sprintf("arn:aws:s3:::%s/%s", bucketName, objectName)
|
|
}
|
|
|
|
// BuildActionName builds a standardized action name
|
|
func BuildActionName(action string) string {
|
|
if strings.HasPrefix(action, "s3:") {
|
|
return action
|
|
}
|
|
return fmt.Sprintf("s3:%s", action)
|
|
}
|
|
|
|
// IsReadAction checks if an action is a read action
|
|
func IsReadAction(action string) bool {
|
|
readActions := []string{
|
|
"s3:GetObject",
|
|
"s3:GetObjectVersion",
|
|
"s3:GetObjectAcl",
|
|
"s3:GetObjectVersionAcl",
|
|
"s3:GetObjectTagging",
|
|
"s3:GetObjectVersionTagging",
|
|
"s3:ListBucket",
|
|
"s3:ListBucketVersions",
|
|
"s3:GetBucketLocation",
|
|
"s3:GetBucketVersioning",
|
|
"s3:GetBucketAcl",
|
|
"s3:GetBucketCors",
|
|
"s3:GetBucketPolicy",
|
|
"s3:GetBucketTagging",
|
|
"s3:GetBucketNotification",
|
|
"s3:GetBucketObjectLockConfiguration",
|
|
"s3:GetObjectRetention",
|
|
"s3:GetObjectLegalHold",
|
|
}
|
|
|
|
for _, readAction := range readActions {
|
|
if action == readAction {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsWriteAction checks if an action is a write action
|
|
func IsWriteAction(action string) bool {
|
|
writeActions := []string{
|
|
"s3:PutObject",
|
|
"s3:PutObjectAcl",
|
|
"s3:PutObjectTagging",
|
|
"s3:DeleteObject",
|
|
"s3:DeleteObjectVersion",
|
|
"s3:DeleteObjectTagging",
|
|
"s3:AbortMultipartUpload",
|
|
"s3:ListMultipartUploads",
|
|
"s3:ListParts",
|
|
"s3:PutBucketAcl",
|
|
"s3:PutBucketCors",
|
|
"s3:PutBucketPolicy",
|
|
"s3:PutBucketTagging",
|
|
"s3:PutBucketNotification",
|
|
"s3:PutBucketVersioning",
|
|
"s3:DeleteBucketPolicy",
|
|
"s3:DeleteBucketTagging",
|
|
"s3:DeleteBucketCors",
|
|
"s3:PutBucketObjectLockConfiguration",
|
|
"s3:PutObjectRetention",
|
|
"s3:PutObjectLegalHold",
|
|
"s3:BypassGovernanceRetention",
|
|
}
|
|
|
|
for _, writeAction := range writeActions {
|
|
if action == writeAction {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GetBucketNameFromArn extracts bucket name from ARN
|
|
func GetBucketNameFromArn(arn string) string {
|
|
if strings.HasPrefix(arn, "arn:aws:s3:::") {
|
|
parts := strings.SplitN(arn[13:], "/", 2)
|
|
return parts[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetObjectNameFromArn extracts object name from ARN
|
|
func GetObjectNameFromArn(arn string) string {
|
|
if strings.HasPrefix(arn, "arn:aws:s3:::") {
|
|
parts := strings.SplitN(arn[13:], "/", 2)
|
|
if len(parts) > 1 {
|
|
return parts[1]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetPolicyStatements returns all policy statements for a bucket
|
|
func (engine *PolicyEngine) GetPolicyStatements(bucketName string) []PolicyStatement {
|
|
engine.mutex.RLock()
|
|
defer engine.mutex.RUnlock()
|
|
|
|
context, exists := engine.contexts[bucketName]
|
|
if !exists {
|
|
return nil
|
|
}
|
|
|
|
return context.policy.Document.Statement
|
|
}
|
|
|
|
// ValidatePolicyForBucket validates if a policy is valid for a bucket
|
|
func (engine *PolicyEngine) ValidatePolicyForBucket(bucketName string, policyJSON string) error {
|
|
policy, err := ParsePolicy(policyJSON)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Additional validation specific to the bucket
|
|
for _, stmt := range policy.Statement {
|
|
resources := normalizeToStringSlice(stmt.Resource)
|
|
for _, resource := range resources {
|
|
if resourceBucket := GetBucketFromResource(resource); resourceBucket != "" {
|
|
if resourceBucket != bucketName {
|
|
return fmt.Errorf("policy resource %s does not match bucket %s", resource, bucketName)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ClearAllPolicies clears all bucket policies
|
|
func (engine *PolicyEngine) ClearAllPolicies() {
|
|
engine.mutex.Lock()
|
|
defer engine.mutex.Unlock()
|
|
|
|
engine.contexts = make(map[string]*PolicyEvaluationContext)
|
|
glog.V(2).Info("Cleared all bucket policies")
|
|
}
|
|
|
|
// GetAllBucketsWithPolicies returns all buckets that have policies
|
|
func (engine *PolicyEngine) GetAllBucketsWithPolicies() []string {
|
|
engine.mutex.RLock()
|
|
defer engine.mutex.RUnlock()
|
|
|
|
buckets := make([]string, 0, len(engine.contexts))
|
|
for bucketName := range engine.contexts {
|
|
buckets = append(buckets, bucketName)
|
|
}
|
|
return buckets
|
|
}
|
|
|
|
// EvaluatePolicyForRequest evaluates policy for an HTTP request
|
|
func (engine *PolicyEngine) EvaluatePolicyForRequest(bucketName, objectName, action, principal string, r *http.Request) PolicyEvaluationResult {
|
|
resource := BuildResourceArn(bucketName, objectName)
|
|
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,
|
|
Principal: principal,
|
|
Conditions: conditions,
|
|
}
|
|
|
|
return engine.EvaluatePolicy(bucketName, args)
|
|
}
|