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:
@@ -73,9 +73,10 @@ type Identity struct {
|
||||
Account *Account
|
||||
Credentials []*Credential
|
||||
Actions []Action
|
||||
PolicyNames []string // Attached IAM policy names
|
||||
PrincipalArn string // ARN for IAM authorization (e.g., "arn:aws:iam::account-id:user/username")
|
||||
Disabled bool // User status: false = enabled (default), true = disabled
|
||||
PolicyNames []string // Attached IAM policy names
|
||||
PrincipalArn string // ARN for IAM authorization (e.g., "arn:aws:iam::account-id:user/username")
|
||||
Disabled bool // User status: false = enabled (default), true = disabled
|
||||
Claims map[string]interface{} // JWT claims for policy substitution
|
||||
}
|
||||
|
||||
// Account represents a system user, a system user can
|
||||
@@ -286,7 +287,7 @@ func NewIdentityAccessManagementWithStore(option *S3ApiServerOption, explicitSto
|
||||
|
||||
if iam.isAuthEnabled {
|
||||
// Credentials were configured - enable authentication
|
||||
glog.V(0).Infof("S3 authentication enabled (%d identities configured)", len(iam.identities))
|
||||
glog.V(1).Infof("S3 authentication enabled (%d identities configured)", len(iam.identities))
|
||||
} else {
|
||||
// No credentials configured
|
||||
if startConfigFile != "" {
|
||||
@@ -294,7 +295,7 @@ func NewIdentityAccessManagementWithStore(option *S3ApiServerOption, explicitSto
|
||||
glog.Warningf("S3 config file %s specified but no identities loaded - authentication disabled", startConfigFile)
|
||||
} else {
|
||||
// No config file and no identities - this is the normal allow-all case
|
||||
glog.V(0).Infof("S3 authentication disabled - no credentials configured (allowing all access)")
|
||||
glog.V(1).Infof("S3 authentication disabled - no credentials configured (allowing all access)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,7 +482,7 @@ func (iam *IdentityAccessManagement) replaceS3ApiConfiguration(config *iam_pb.S3
|
||||
iam.m.Unlock()
|
||||
|
||||
if authJustEnabled {
|
||||
glog.V(0).Infof("S3 authentication enabled - credentials were added dynamically")
|
||||
glog.V(1).Infof("S3 authentication enabled - credentials were added dynamically")
|
||||
}
|
||||
|
||||
// Log configuration summary
|
||||
@@ -701,7 +702,7 @@ func (iam *IdentityAccessManagement) mergeS3ApiConfiguration(config *iam_pb.S3Ap
|
||||
iam.m.Unlock()
|
||||
|
||||
if authJustEnabled {
|
||||
glog.V(0).Infof("S3 authentication enabled because credentials were added dynamically")
|
||||
glog.V(1).Infof("S3 authentication enabled because credentials were added dynamically")
|
||||
}
|
||||
|
||||
// Log configuration summary
|
||||
@@ -724,8 +725,20 @@ func (iam *IdentityAccessManagement) mergeS3ApiConfiguration(config *iam_pb.S3Ap
|
||||
return nil
|
||||
}
|
||||
|
||||
// isEnabled reports whether S3 auth should be enforced for this server.
|
||||
//
|
||||
// Auth is considered enabled if either:
|
||||
// - we have any locally managed identities/credentials (iam.isAuthEnabled), or
|
||||
// - an external IAM integration has been configured (iam.iamIntegration != nil).
|
||||
//
|
||||
// The iamIntegration check is intentionally included so that when an external
|
||||
// IAM provider is configured (and the server relies solely on it), auth is
|
||||
// still treated as enabled even if there are no local identities yet or
|
||||
// before any sync logic flips isAuthEnabled to true. Removing this check or
|
||||
// relying only on isAuthEnabled would change when auth is enforced and could
|
||||
// unintentionally allow unauthenticated access in integration-only setups.
|
||||
func (iam *IdentityAccessManagement) isEnabled() bool {
|
||||
return iam.isAuthEnabled
|
||||
return iam.isAuthEnabled || iam.iamIntegration != nil
|
||||
}
|
||||
|
||||
func (iam *IdentityAccessManagement) updateAuthenticationState(identitiesCount int) bool {
|
||||
@@ -942,6 +955,12 @@ func (iam *IdentityAccessManagement) authRequestWithAuthType(r *http.Request, ac
|
||||
var found bool
|
||||
var amzAuthType string
|
||||
|
||||
// SECURITY: Prevent clients from spoofing internal IAM headers
|
||||
// These headers are only set by the server after successful JWT authentication
|
||||
// Clearing them here prevents privilege escalation via header injection
|
||||
r.Header.Del("X-SeaweedFS-Principal")
|
||||
r.Header.Del("X-SeaweedFS-Session-Token")
|
||||
|
||||
reqAuthType := getRequestAuthType(r)
|
||||
|
||||
switch reqAuthType {
|
||||
@@ -988,7 +1007,6 @@ func (iam *IdentityAccessManagement) authRequestWithAuthType(r *http.Request, ac
|
||||
return identity, s3Err, reqAuthType
|
||||
}
|
||||
|
||||
glog.V(4).Infof("user name: %v actions: %v, action: %v", identity.Name, identity.Actions, action)
|
||||
bucket, object := s3_constants.GetBucketAndObject(r)
|
||||
prefix := s3_constants.GetPrefix(r)
|
||||
|
||||
@@ -1016,11 +1034,15 @@ func (iam *IdentityAccessManagement) authRequestWithAuthType(r *http.Request, ac
|
||||
// - Explicit ALLOW in bucket policy → grant access (bypass IAM checks)
|
||||
// - No policy or indeterminate → fall through to IAM checks
|
||||
if iam.policyEngine != nil && bucket != "" {
|
||||
principal := buildPrincipalARN(identity)
|
||||
principal := buildPrincipalARN(identity, r)
|
||||
// Phase 1: Evaluate bucket policy without object entry.
|
||||
// Tag-based conditions (s3:ExistingObjectTag) are re-checked by handlers
|
||||
// after fetching the entry, which is the Phase 2 check.
|
||||
allowed, evaluated, err := iam.policyEngine.EvaluatePolicy(bucket, object, string(action), principal, r, nil)
|
||||
var claims map[string]interface{}
|
||||
if identity != nil {
|
||||
claims = identity.Claims
|
||||
}
|
||||
allowed, evaluated, err := iam.policyEngine.EvaluatePolicy(bucket, object, string(action), principal, r, claims, nil)
|
||||
|
||||
if err != nil {
|
||||
// SECURITY: Fail-close on policy evaluation errors
|
||||
@@ -1182,7 +1204,16 @@ func (identity *Identity) isAdmin() bool {
|
||||
}
|
||||
|
||||
// buildPrincipalARN builds an ARN for an identity to use in bucket policy evaluation
|
||||
func buildPrincipalARN(identity *Identity) string {
|
||||
// It first checks if a principal ARN was set by JWT authentication in request headers
|
||||
func buildPrincipalARN(identity *Identity, r *http.Request) string {
|
||||
// Check if principal ARN was already set by JWT authentication
|
||||
if r != nil {
|
||||
if principalARN := r.Header.Get("X-SeaweedFS-Principal"); principalARN != "" {
|
||||
glog.V(4).Infof("buildPrincipalARN: Using principal ARN from header: %s", principalARN)
|
||||
return principalARN
|
||||
}
|
||||
}
|
||||
|
||||
if identity == nil {
|
||||
return "*" // Anonymous
|
||||
}
|
||||
@@ -1292,6 +1323,7 @@ func (iam *IdentityAccessManagement) authenticateJWTWithIAM(r *http.Request) (*I
|
||||
Account: iamIdentity.Account,
|
||||
Actions: []Action{}, // Empty - authorization handled by policy engine
|
||||
PolicyNames: iamIdentity.PolicyNames,
|
||||
Claims: iamIdentity.Claims,
|
||||
}
|
||||
|
||||
// Store session info in request headers for later authorization
|
||||
|
||||
@@ -66,7 +66,7 @@ func (s3a *S3ApiServer) onIamConfigChange(dir string, oldEntry *filer_pb.Entry,
|
||||
|
||||
// Handle deletion: reset to empty config
|
||||
if newEntry == nil && oldEntry != nil && oldEntry.Name == filer.IamIdentityFile {
|
||||
glog.V(0).Infof("IAM config file deleted, clearing identities")
|
||||
glog.V(1).Infof("IAM config file deleted, clearing identities")
|
||||
if err := s3a.iam.LoadS3ApiConfigurationFromBytes([]byte{}); err != nil {
|
||||
glog.Warningf("failed to clear IAM config on deletion: %v", err)
|
||||
return err
|
||||
@@ -92,7 +92,7 @@ func (s3a *S3ApiServer) onCircuitBreakerConfigChange(dir string, oldEntry *filer
|
||||
|
||||
// Handle deletion: reset to empty config
|
||||
if newEntry == nil && oldEntry != nil && oldEntry.Name == s3_constants.CircuitBreakerConfigFile {
|
||||
glog.V(0).Infof("Circuit breaker config file deleted, resetting to defaults")
|
||||
glog.V(1).Infof("Circuit breaker config file deleted, resetting to defaults")
|
||||
if err := s3a.cb.LoadS3ApiConfigurationFromBytes([]byte{}); err != nil {
|
||||
glog.Warningf("failed to reset circuit breaker config on deletion: %v", err)
|
||||
return err
|
||||
|
||||
@@ -79,7 +79,7 @@ func (r *BucketRegistry) init() error {
|
||||
glog.Errorf("BucketRegistry.init: failed to list buckets: %v", err)
|
||||
return err
|
||||
}
|
||||
glog.V(0).Infof("BucketRegistry.init: warmed config cache for %d buckets", bucketCount)
|
||||
glog.V(1).Infof("BucketRegistry.init: warmed config cache for %d buckets", bucketCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
)
|
||||
|
||||
// ConvertPolicyDocumentToPolicyEngine converts a policy.PolicyDocument to policy_engine.PolicyDocument
|
||||
// This function provides type-safe conversion with explicit field mapping and error handling.
|
||||
// It handles the differences between the two types:
|
||||
// - Converts []string fields to StringOrStringSlice
|
||||
// - Maps Condition types with type validation
|
||||
// - Converts Principal fields with support for AWS principals only
|
||||
// - Handles optional fields (Id, NotPrincipal, NotAction, NotResource are ignored in policy_engine)
|
||||
//
|
||||
// Returns an error if the policy contains unsupported types or malformed data.
|
||||
func ConvertPolicyDocumentToPolicyEngine(src *policy.PolicyDocument) (*policy_engine.PolicyDocument, error) {
|
||||
if src == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Warn if the policy document Id is being dropped
|
||||
if src.Id != "" {
|
||||
glog.Warningf("policy document Id %q is not supported and will be ignored", src.Id)
|
||||
}
|
||||
|
||||
dest := &policy_engine.PolicyDocument{
|
||||
Version: src.Version,
|
||||
Statement: make([]policy_engine.PolicyStatement, len(src.Statement)),
|
||||
}
|
||||
|
||||
for i := range src.Statement {
|
||||
stmt, err := convertStatement(&src.Statement[i])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert statement %d: %w", i, err)
|
||||
}
|
||||
dest.Statement[i] = stmt
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
// convertStatement converts a policy.Statement to policy_engine.PolicyStatement
|
||||
func convertStatement(src *policy.Statement) (policy_engine.PolicyStatement, error) {
|
||||
// Check for unsupported fields that would fundamentally change policy semantics
|
||||
// These fields invert the logic and ignoring them could create security holes
|
||||
if len(src.NotAction) > 0 {
|
||||
return policy_engine.PolicyStatement{}, fmt.Errorf("statement %q: NotAction is not supported (would invert action logic, creating potential security risk)", src.Sid)
|
||||
}
|
||||
if len(src.NotResource) > 0 {
|
||||
return policy_engine.PolicyStatement{}, fmt.Errorf("statement %q: NotResource is not supported (would invert resource logic, creating potential security risk)", src.Sid)
|
||||
}
|
||||
if src.NotPrincipal != nil {
|
||||
return policy_engine.PolicyStatement{}, fmt.Errorf("statement %q: NotPrincipal is not supported (would invert principal logic, creating potential security risk)", src.Sid)
|
||||
}
|
||||
|
||||
stmt := policy_engine.PolicyStatement{
|
||||
Sid: src.Sid,
|
||||
Effect: policy_engine.PolicyEffect(src.Effect),
|
||||
}
|
||||
|
||||
// Convert Action ([]string to StringOrStringSlice)
|
||||
if len(src.Action) > 0 {
|
||||
stmt.Action = policy_engine.NewStringOrStringSlice(src.Action...)
|
||||
}
|
||||
|
||||
// Convert Resource ([]string to StringOrStringSlice)
|
||||
if len(src.Resource) > 0 {
|
||||
stmt.Resource = policy_engine.NewStringOrStringSlice(src.Resource...)
|
||||
}
|
||||
|
||||
// Convert Principal (interface{} to *StringOrStringSlice)
|
||||
if src.Principal != nil {
|
||||
principal, err := convertPrincipal(src.Principal)
|
||||
if err != nil {
|
||||
return policy_engine.PolicyStatement{}, fmt.Errorf("statement %q: failed to convert principal: %w", src.Sid, err)
|
||||
}
|
||||
stmt.Principal = principal
|
||||
}
|
||||
|
||||
// Convert Condition (map[string]map[string]interface{} to PolicyConditions)
|
||||
if len(src.Condition) > 0 {
|
||||
condition, err := convertCondition(src.Condition)
|
||||
if err != nil {
|
||||
return policy_engine.PolicyStatement{}, fmt.Errorf("statement %q: failed to convert condition: %w", src.Sid, err)
|
||||
}
|
||||
stmt.Condition = condition
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
// convertPrincipal converts a Principal field to *StringOrStringSlice
|
||||
func convertPrincipal(principal interface{}) (*policy_engine.StringOrStringSlice, error) {
|
||||
if principal == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch p := principal.(type) {
|
||||
case string:
|
||||
if p == "" {
|
||||
return nil, fmt.Errorf("principal string cannot be empty")
|
||||
}
|
||||
result := policy_engine.NewStringOrStringSlice(p)
|
||||
return &result, nil
|
||||
case []string:
|
||||
if len(p) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
for _, s := range p {
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("principal string in slice cannot be empty")
|
||||
}
|
||||
}
|
||||
result := policy_engine.NewStringOrStringSlice(p...)
|
||||
return &result, nil
|
||||
case []interface{}:
|
||||
strs := make([]string, 0, len(p))
|
||||
for _, v := range p {
|
||||
if v != nil {
|
||||
str, err := convertToString(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert principal array item: %w", err)
|
||||
}
|
||||
if str == "" {
|
||||
return nil, fmt.Errorf("principal string in slice cannot be empty")
|
||||
}
|
||||
strs = append(strs, str)
|
||||
}
|
||||
}
|
||||
if len(strs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := policy_engine.NewStringOrStringSlice(strs...)
|
||||
return &result, nil
|
||||
case map[string]interface{}:
|
||||
// Handle AWS-style principal with service/user keys
|
||||
// Example: {"AWS": "arn:aws:iam::123456789012:user/Alice"}
|
||||
// Only AWS principals are supported for now. Other types like Service or Federated need special handling.
|
||||
|
||||
awsPrincipals, ok := p["AWS"]
|
||||
if !ok || len(p) != 1 {
|
||||
glog.Warningf("unsupported principal map, only a single 'AWS' key is supported: %v", p)
|
||||
return nil, fmt.Errorf("unsupported principal map, only a single 'AWS' key is supported, got keys: %v", getMapKeys(p))
|
||||
}
|
||||
|
||||
// Recursively convert the AWS principal value
|
||||
res, err := convertPrincipal(awsPrincipals)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid 'AWS' principal value: %w", err)
|
||||
}
|
||||
return res, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported principal type: %T", p)
|
||||
}
|
||||
}
|
||||
|
||||
// convertCondition converts policy conditions to PolicyConditions
|
||||
func convertCondition(src map[string]map[string]interface{}) (policy_engine.PolicyConditions, error) {
|
||||
if len(src) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
dest := make(policy_engine.PolicyConditions)
|
||||
for condType, condBlock := range src {
|
||||
destBlock := make(map[string]policy_engine.StringOrStringSlice)
|
||||
for key, value := range condBlock {
|
||||
condValue, err := convertConditionValue(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert condition %s[%s]: %w", condType, key, err)
|
||||
}
|
||||
destBlock[key] = condValue
|
||||
}
|
||||
dest[condType] = destBlock
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
// convertConditionValue converts a condition value to StringOrStringSlice
|
||||
func convertConditionValue(value interface{}) (policy_engine.StringOrStringSlice, error) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return policy_engine.NewStringOrStringSlice(v), nil
|
||||
case []string:
|
||||
return policy_engine.NewStringOrStringSlice(v...), nil
|
||||
case []interface{}:
|
||||
strs := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if item != nil {
|
||||
str, err := convertToString(item)
|
||||
if err != nil {
|
||||
return policy_engine.StringOrStringSlice{}, fmt.Errorf("failed to convert condition array item: %w", err)
|
||||
}
|
||||
strs = append(strs, str)
|
||||
}
|
||||
}
|
||||
return policy_engine.NewStringOrStringSlice(strs...), nil
|
||||
default:
|
||||
// For non-string types, convert to string
|
||||
// This handles numbers, booleans, etc.
|
||||
str, err := convertToString(v)
|
||||
if err != nil {
|
||||
return policy_engine.StringOrStringSlice{}, err
|
||||
}
|
||||
return policy_engine.NewStringOrStringSlice(str), nil
|
||||
}
|
||||
}
|
||||
|
||||
// convertToString converts any value to string representation
|
||||
// Returns an error for unsupported types to prevent silent data corruption
|
||||
func convertToString(value interface{}) (string, error) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v, nil
|
||||
case bool,
|
||||
int, int8, int16, int32, int64,
|
||||
uint, uint8, uint16, uint32, uint64,
|
||||
float32, float64:
|
||||
// Use fmt.Sprint for supported primitive types
|
||||
return fmt.Sprint(v), nil
|
||||
default:
|
||||
glog.Warningf("unsupported type in policy conversion: %T", v)
|
||||
return "", fmt.Errorf("unsupported type in policy conversion: %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
// getMapKeys returns the keys of a map as a slice (helper for error messages)
|
||||
func getMapKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -1,613 +0,0 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
)
|
||||
|
||||
func TestConvertPolicyDocumentWithMixedTypes(t *testing.T) {
|
||||
// Test that numeric and boolean values in arrays are properly converted
|
||||
src := &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Sid: "TestMixedTypes",
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"arn:aws:s3:::bucket/*"},
|
||||
Principal: []interface{}{"user1", 123, true}, // Mixed types
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"NumericEquals": {
|
||||
"s3:max-keys": []interface{}{100, 200, "300"}, // Mixed types
|
||||
},
|
||||
"StringEquals": {
|
||||
"s3:prefix": []interface{}{"test", 123, false}, // Mixed types
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Convert
|
||||
dest, err := ConvertPolicyDocumentToPolicyEngine(src)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify document structure
|
||||
if dest == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
if dest.Version != "2012-10-17" {
|
||||
t.Errorf("Expected version '2012-10-17', got '%s'", dest.Version)
|
||||
}
|
||||
if len(dest.Statement) != 1 {
|
||||
t.Fatalf("Expected 1 statement, got %d", len(dest.Statement))
|
||||
}
|
||||
|
||||
stmt := dest.Statement[0]
|
||||
|
||||
// Verify Principal conversion (should have 3 items converted to strings)
|
||||
if stmt.Principal == nil {
|
||||
t.Fatal("Expected non-nil Principal")
|
||||
}
|
||||
principals := stmt.Principal.Strings()
|
||||
if len(principals) != 3 {
|
||||
t.Errorf("Expected 3 principals, got %d", len(principals))
|
||||
}
|
||||
// Check that numeric and boolean were converted
|
||||
expectedPrincipals := []string{"user1", "123", "true"}
|
||||
for i, expected := range expectedPrincipals {
|
||||
if principals[i] != expected {
|
||||
t.Errorf("Principal[%d]: expected '%s', got '%s'", i, expected, principals[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Condition conversion
|
||||
if len(stmt.Condition) != 2 {
|
||||
t.Errorf("Expected 2 condition blocks, got %d", len(stmt.Condition))
|
||||
}
|
||||
|
||||
// Check NumericEquals condition
|
||||
numericCond, ok := stmt.Condition["NumericEquals"]
|
||||
if !ok {
|
||||
t.Fatal("Expected NumericEquals condition")
|
||||
}
|
||||
maxKeys, ok := numericCond["s3:max-keys"]
|
||||
if !ok {
|
||||
t.Fatal("Expected s3:max-keys in NumericEquals")
|
||||
}
|
||||
maxKeysStrs := maxKeys.Strings()
|
||||
expectedMaxKeys := []string{"100", "200", "300"}
|
||||
if len(maxKeysStrs) != len(expectedMaxKeys) {
|
||||
t.Errorf("Expected %d max-keys values, got %d", len(expectedMaxKeys), len(maxKeysStrs))
|
||||
}
|
||||
for i, expected := range expectedMaxKeys {
|
||||
if maxKeysStrs[i] != expected {
|
||||
t.Errorf("max-keys[%d]: expected '%s', got '%s'", i, expected, maxKeysStrs[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Check StringEquals condition
|
||||
stringCond, ok := stmt.Condition["StringEquals"]
|
||||
if !ok {
|
||||
t.Fatal("Expected StringEquals condition")
|
||||
}
|
||||
prefix, ok := stringCond["s3:prefix"]
|
||||
if !ok {
|
||||
t.Fatal("Expected s3:prefix in StringEquals")
|
||||
}
|
||||
prefixStrs := prefix.Strings()
|
||||
expectedPrefix := []string{"test", "123", "false"}
|
||||
if len(prefixStrs) != len(expectedPrefix) {
|
||||
t.Errorf("Expected %d prefix values, got %d", len(expectedPrefix), len(prefixStrs))
|
||||
}
|
||||
for i, expected := range expectedPrefix {
|
||||
if prefixStrs[i] != expected {
|
||||
t.Errorf("prefix[%d]: expected '%s', got '%s'", i, expected, prefixStrs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalWithMapAndMixedTypes(t *testing.T) {
|
||||
// Test AWS-style principal map with mixed types
|
||||
principalMap := map[string]interface{}{
|
||||
"AWS": []interface{}{
|
||||
"arn:aws:iam::123456789012:user/Alice",
|
||||
456, // User ID as number
|
||||
true, // Some boolean value
|
||||
},
|
||||
}
|
||||
|
||||
result, err := convertPrincipal(principalMap)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
|
||||
strs := result.Strings()
|
||||
if len(strs) != 3 {
|
||||
t.Errorf("Expected 3 values, got %d", len(strs))
|
||||
}
|
||||
|
||||
expectedValues := []string{
|
||||
"arn:aws:iam::123456789012:user/Alice",
|
||||
"456",
|
||||
"true",
|
||||
}
|
||||
|
||||
for i, expected := range expectedValues {
|
||||
if strs[i] != expected {
|
||||
t.Errorf("Value[%d]: expected '%s', got '%s'", i, expected, strs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertConditionValueWithMixedTypes(t *testing.T) {
|
||||
// Test []interface{} with mixed types
|
||||
mixedValues := []interface{}{
|
||||
"string",
|
||||
123,
|
||||
true,
|
||||
456.78,
|
||||
}
|
||||
|
||||
result, err := convertConditionValue(mixedValues)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
strs := result.Strings()
|
||||
|
||||
expectedValues := []string{"string", "123", "true", "456.78"}
|
||||
if len(strs) != len(expectedValues) {
|
||||
t.Errorf("Expected %d values, got %d", len(expectedValues), len(strs))
|
||||
}
|
||||
|
||||
for i, expected := range expectedValues {
|
||||
if strs[i] != expected {
|
||||
t.Errorf("Value[%d]: expected '%s', got '%s'", i, expected, strs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPolicyDocumentNil(t *testing.T) {
|
||||
result, err := ConvertPolicyDocumentToPolicyEngine(nil)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for nil input: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("Expected nil result for nil input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalNil(t *testing.T) {
|
||||
result, err := convertPrincipal(nil)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for nil input: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("Expected nil result for nil input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalEmptyArray(t *testing.T) {
|
||||
// Empty array should return nil
|
||||
result, err := convertPrincipal([]interface{}{})
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for empty array: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("Expected nil result for empty array")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalUnknownType(t *testing.T) {
|
||||
// Unknown types should return an error
|
||||
result, err := convertPrincipal(12345) // Just a number, not valid principal
|
||||
if err == nil {
|
||||
t.Error("Expected error for unknown type")
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("Expected nil result for unknown type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalWithNilValues(t *testing.T) {
|
||||
// Test that nil values in arrays are skipped for security
|
||||
principalArray := []interface{}{
|
||||
"arn:aws:iam::123456789012:user/Alice",
|
||||
nil, // Should be skipped
|
||||
"arn:aws:iam::123456789012:user/Bob",
|
||||
nil, // Should be skipped
|
||||
}
|
||||
|
||||
result, err := convertPrincipal(principalArray)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
|
||||
strs := result.Strings()
|
||||
// Should only have 2 values (nil values skipped)
|
||||
if len(strs) != 2 {
|
||||
t.Errorf("Expected 2 values (nil values skipped), got %d", len(strs))
|
||||
}
|
||||
|
||||
expectedValues := []string{
|
||||
"arn:aws:iam::123456789012:user/Alice",
|
||||
"arn:aws:iam::123456789012:user/Bob",
|
||||
}
|
||||
|
||||
for i, expected := range expectedValues {
|
||||
if strs[i] != expected {
|
||||
t.Errorf("Value[%d]: expected '%s', got '%s'", i, expected, strs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertConditionValueWithNilValues(t *testing.T) {
|
||||
// Test that nil values in condition arrays are skipped
|
||||
mixedValues := []interface{}{
|
||||
"string",
|
||||
nil, // Should be skipped
|
||||
123,
|
||||
nil, // Should be skipped
|
||||
true,
|
||||
}
|
||||
|
||||
result, err := convertConditionValue(mixedValues)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
strs := result.Strings()
|
||||
|
||||
// Should only have 3 values (nil values skipped)
|
||||
expectedValues := []string{"string", "123", "true"}
|
||||
if len(strs) != len(expectedValues) {
|
||||
t.Errorf("Expected %d values (nil values skipped), got %d", len(expectedValues), len(strs))
|
||||
}
|
||||
|
||||
for i, expected := range expectedValues {
|
||||
if strs[i] != expected {
|
||||
t.Errorf("Value[%d]: expected '%s', got '%s'", i, expected, strs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalMapWithNilValues(t *testing.T) {
|
||||
// Test AWS-style principal map with nil values
|
||||
principalMap := map[string]interface{}{
|
||||
"AWS": []interface{}{
|
||||
"arn:aws:iam::123456789012:user/Alice",
|
||||
nil, // Should be skipped
|
||||
"arn:aws:iam::123456789012:user/Bob",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := convertPrincipal(principalMap)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
|
||||
strs := result.Strings()
|
||||
// Should only have 2 values (nil value skipped)
|
||||
if len(strs) != 2 {
|
||||
t.Errorf("Expected 2 values (nil value skipped), got %d", len(strs))
|
||||
}
|
||||
|
||||
expectedValues := []string{
|
||||
"arn:aws:iam::123456789012:user/Alice",
|
||||
"arn:aws:iam::123456789012:user/Bob",
|
||||
}
|
||||
|
||||
for i, expected := range expectedValues {
|
||||
if strs[i] != expected {
|
||||
t.Errorf("Value[%d]: expected '%s', got '%s'", i, expected, strs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToStringUnsupportedType(t *testing.T) {
|
||||
// Test that unsupported types (e.g., nested maps/slices) return empty string
|
||||
// This should trigger a warning log and return an error
|
||||
|
||||
type customStruct struct {
|
||||
Field string
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "nested map",
|
||||
input: map[string]interface{}{"key": "value"},
|
||||
expected: "", // Unsupported, returns empty string
|
||||
},
|
||||
{
|
||||
name: "nested slice",
|
||||
input: []int{1, 2, 3},
|
||||
expected: "", // Unsupported, returns empty string
|
||||
},
|
||||
{
|
||||
name: "custom struct",
|
||||
input: customStruct{Field: "test"},
|
||||
expected: "", // Unsupported, returns empty string
|
||||
},
|
||||
{
|
||||
name: "function",
|
||||
input: func() {},
|
||||
expected: "", // Unsupported, returns empty string
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := convertToString(tc.input)
|
||||
// For unsupported types, we expect an error
|
||||
if err == nil {
|
||||
t.Error("Expected error for unsupported type")
|
||||
}
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToStringSupportedTypes(t *testing.T) {
|
||||
// Test that all supported types convert correctly
|
||||
testCases := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected string
|
||||
}{
|
||||
{"string", "test", "test"},
|
||||
{"bool true", true, "true"},
|
||||
{"bool false", false, "false"},
|
||||
{"int", 123, "123"},
|
||||
{"int8", int8(127), "127"},
|
||||
{"int16", int16(32767), "32767"},
|
||||
{"int32", int32(2147483647), "2147483647"},
|
||||
{"int64", int64(9223372036854775807), "9223372036854775807"},
|
||||
{"uint", uint(123), "123"},
|
||||
{"uint8", uint8(255), "255"},
|
||||
{"uint16", uint16(65535), "65535"},
|
||||
{"uint32", uint32(4294967295), "4294967295"},
|
||||
{"uint64", uint64(18446744073709551615), "18446744073709551615"},
|
||||
{"float32", float32(3.14), "3.14"},
|
||||
{"float64", float64(3.14159265359), "3.14159265359"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := convertToString(tc.input)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for supported type %s: %v", tc.name, err)
|
||||
}
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalUnsupportedTypes(t *testing.T) {
|
||||
// Test that unsupported principal types return errors
|
||||
testCases := []struct {
|
||||
name string
|
||||
principal interface{}
|
||||
}{
|
||||
{
|
||||
name: "Service principal",
|
||||
principal: map[string]interface{}{"Service": "s3.amazonaws.com"},
|
||||
},
|
||||
{
|
||||
name: "Federated principal",
|
||||
principal: map[string]interface{}{"Federated": "arn:aws:iam::123456789012:saml-provider/ExampleProvider"},
|
||||
},
|
||||
{
|
||||
name: "Multiple keys",
|
||||
principal: map[string]interface{}{"AWS": "arn:...", "Service": "s3.amazonaws.com"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := convertPrincipal(tc.principal)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unsupported principal type")
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("Expected nil result for unsupported principal type")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalEmptyStrings(t *testing.T) {
|
||||
// Test that empty string principals are rejected for security
|
||||
testCases := []struct {
|
||||
name string
|
||||
principal interface{}
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "Empty string principal",
|
||||
principal: "",
|
||||
wantError: "principal string cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "Empty string in array",
|
||||
principal: []string{"arn:aws:iam::123456789012:user/Alice", "", "arn:aws:iam::123456789012:user/Bob"},
|
||||
wantError: "principal string in slice cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "Empty string in interface array",
|
||||
principal: []interface{}{"arn:aws:iam::123456789012:user/Alice", ""},
|
||||
wantError: "principal string in slice cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "Empty string in AWS map",
|
||||
principal: map[string]interface{}{
|
||||
"AWS": "",
|
||||
},
|
||||
wantError: "principal string cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "Empty string in AWS map array",
|
||||
principal: map[string]interface{}{
|
||||
"AWS": []string{"arn:aws:iam::123456789012:user/Alice", ""},
|
||||
},
|
||||
wantError: "principal string in slice cannot be empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := convertPrincipal(tc.principal)
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty principal string")
|
||||
} else if !strings.Contains(err.Error(), tc.wantError) {
|
||||
t.Errorf("Expected error containing %q, got: %v", tc.wantError, err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("Expected nil result for empty principal string")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertStatementWithUnsupportedFields(t *testing.T) {
|
||||
// Test that errors are returned for unsupported fields
|
||||
// These fields are critical for policy semantics and ignoring them would be a security risk
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
statement *policy.Statement
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "NotAction field",
|
||||
statement: &policy.Statement{
|
||||
Sid: "TestNotAction",
|
||||
Effect: "Deny",
|
||||
Action: []string{"s3:GetObject"},
|
||||
NotAction: []string{"s3:PutObject", "s3:DeleteObject"},
|
||||
Resource: []string{"arn:aws:s3:::bucket/*"},
|
||||
},
|
||||
wantError: "NotAction is not supported",
|
||||
},
|
||||
{
|
||||
name: "NotResource field",
|
||||
statement: &policy.Statement{
|
||||
Sid: "TestNotResource",
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:*"},
|
||||
Resource: []string{"arn:aws:s3:::bucket/*"},
|
||||
NotResource: []string{"arn:aws:s3:::bucket/secret/*"},
|
||||
},
|
||||
wantError: "NotResource is not supported",
|
||||
},
|
||||
{
|
||||
name: "NotPrincipal field",
|
||||
statement: &policy.Statement{
|
||||
Sid: "TestNotPrincipal",
|
||||
Effect: "Deny",
|
||||
Action: []string{"s3:*"},
|
||||
Resource: []string{"arn:aws:s3:::bucket/*"},
|
||||
NotPrincipal: map[string]interface{}{"AWS": "arn:aws:iam::123456789012:user/Admin"},
|
||||
},
|
||||
wantError: "NotPrincipal is not supported",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// The conversion should fail with an error for security reasons
|
||||
result, err := convertStatement(tc.statement)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unsupported field, got nil")
|
||||
} else if !strings.Contains(err.Error(), tc.wantError) {
|
||||
t.Errorf("Expected error containing %q, got: %v", tc.wantError, err)
|
||||
}
|
||||
|
||||
// Verify zero-value struct is returned on error
|
||||
if result.Sid != "" || result.Effect != "" {
|
||||
t.Error("Expected zero-value struct on error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertStatementSuccess(t *testing.T) {
|
||||
// Test successful conversion without unsupported fields
|
||||
statement := &policy.Statement{
|
||||
Sid: "AllowGetObject",
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"arn:aws:s3:::bucket/*"},
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": "arn:aws:iam::123456789012:user/Alice",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := convertStatement(statement)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.Sid != statement.Sid {
|
||||
t.Errorf("Expected Sid %q, got %q", statement.Sid, result.Sid)
|
||||
}
|
||||
if string(result.Effect) != statement.Effect {
|
||||
t.Errorf("Expected Effect %q, got %q", statement.Effect, result.Effect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPolicyDocumentWithId(t *testing.T) {
|
||||
// Test that policy document Id field triggers a warning
|
||||
src := &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Id: "MyPolicyId",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Sid: "AllowGetObject",
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"arn:aws:s3:::bucket/*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// The conversion should succeed but log a warning about Id
|
||||
dest, err := ConvertPolicyDocumentToPolicyEngine(src)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if dest == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
|
||||
// Verify basic conversion worked
|
||||
if dest.Version != src.Version {
|
||||
t.Errorf("Expected Version %q, got %q", src.Version, dest.Version)
|
||||
}
|
||||
if len(dest.Statement) != 1 {
|
||||
t.Errorf("Expected 1 statement, got %d", len(dest.Statement))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,26 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// privateNetworks contains pre-parsed private IP ranges for efficient lookups
|
||||
var privateNetworks []*net.IPNet
|
||||
|
||||
func init() {
|
||||
// Private IPv4 ranges (RFC1918) and IPv6 Unique Local Addresses (ULA)
|
||||
privateRanges := []string{
|
||||
"10.0.0.0/8", // IPv4 private
|
||||
"172.16.0.0/12", // IPv4 private
|
||||
"192.168.0.0/16", // IPv4 private
|
||||
"fc00::/7", // IPv6 Unique Local Addresses (ULA)
|
||||
}
|
||||
|
||||
for _, cidr := range privateRanges {
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err == nil {
|
||||
privateNetworks = append(privateNetworks, network)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IAMIntegration defines the interface for IAM integration
|
||||
type IAMIntegration interface {
|
||||
AuthenticateJWT(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode)
|
||||
@@ -73,14 +93,21 @@ func (s3iam *S3IAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Requ
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Try to parse as STS session token first
|
||||
tokenClaims, err := parseJWTToken(sessionToken)
|
||||
// SECURITY NOTE: ParseJWTToken parses without cryptographic verification
|
||||
// This is SAFE because we only use the unverified claims to route to the correct
|
||||
// verification method. All code paths below perform full cryptographic verification:
|
||||
// - OIDC tokens: validated via validateExternalOIDCToken (line 98)
|
||||
// - STS tokens: validated via ValidateSessionToken (line 156)
|
||||
// The unverified issuer claim is only used for routing, never for authorization.
|
||||
tokenClaims, err := ParseUnverifiedJWTToken(sessionToken)
|
||||
if err != nil {
|
||||
glog.V(3).Infof("Failed to parse JWT token: %v", err)
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Determine token type by issuer claim (more robust than checking role claim)
|
||||
// We use the unverified claims ONLY for routing to the correct verification method.
|
||||
// We DO NOT use these claims for building the identity.
|
||||
issuer, issuerOk := tokenClaims["iss"].(string)
|
||||
if !issuerOk {
|
||||
glog.V(3).Infof("Token missing issuer claim - invalid JWT")
|
||||
@@ -116,59 +143,47 @@ func (s3iam *S3IAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Requ
|
||||
EmailAddress: identity.UserID + "@oidc.local",
|
||||
Id: identity.UserID,
|
||||
},
|
||||
Claims: map[string]interface{}{
|
||||
"sub": identity.UserID,
|
||||
"role": identity.RoleArn,
|
||||
},
|
||||
}, s3err.ErrNone
|
||||
}
|
||||
|
||||
// This is an STS-issued token - extract STS session information
|
||||
|
||||
// Extract role claim from STS token
|
||||
roleName, roleOk := tokenClaims["role"].(string)
|
||||
if !roleOk || roleName == "" {
|
||||
glog.V(3).Infof("STS token missing role claim")
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
sessionName, ok := tokenClaims["snam"].(string)
|
||||
if !ok || sessionName == "" {
|
||||
sessionName = "jwt-session" // Default fallback
|
||||
}
|
||||
|
||||
subject, ok := tokenClaims["sub"].(string)
|
||||
if !ok || subject == "" {
|
||||
subject = "jwt-user" // Default fallback
|
||||
}
|
||||
|
||||
// Use the principal ARN directly from token claims, or build it if not available
|
||||
principalArn, ok := tokenClaims["principal"].(string)
|
||||
if !ok || principalArn == "" {
|
||||
// Fallback: extract role name from role ARN and build principal ARN
|
||||
roleNameOnly := roleName
|
||||
if strings.Contains(roleName, "/") {
|
||||
parts := strings.Split(roleName, "/")
|
||||
roleNameOnly = parts[len(parts)-1]
|
||||
}
|
||||
principalArn = fmt.Sprintf("arn:aws:sts::assumed-role/%s/%s", roleNameOnly, sessionName)
|
||||
}
|
||||
|
||||
// Validate the JWT token directly using STS service (avoid circular dependency)
|
||||
// Note: We don't call IsActionAllowed here because that would create a circular dependency
|
||||
// Authentication should only validate the token, authorization happens later
|
||||
_, err = s3iam.stsService.ValidateSessionToken(ctx, sessionToken)
|
||||
// This is an STS-issued token - validate with STS service
|
||||
// ValidateSessionToken performs cryptographic verification and extraction of trusted claims
|
||||
sessionInfo, err := s3iam.stsService.ValidateSessionToken(ctx, sessionToken)
|
||||
if err != nil {
|
||||
glog.V(3).Infof("STS session validation failed: %v", err)
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Create IAM identity from validated token
|
||||
// Create claims map starting with request context (which holds custom claims)
|
||||
claims := make(map[string]interface{})
|
||||
if sessionInfo.RequestContext != nil {
|
||||
for k, v := range sessionInfo.RequestContext {
|
||||
claims[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Add standard claims
|
||||
claims["sub"] = sessionInfo.Subject
|
||||
claims["role"] = sessionInfo.RoleArn
|
||||
claims["principal"] = sessionInfo.Principal
|
||||
claims["snam"] = sessionInfo.SessionName
|
||||
|
||||
// Create IAM identity from VALIDATED session info
|
||||
// We use the trusted data returned by the STS service, not the unverified token claims
|
||||
identity := &IAMIdentity{
|
||||
Name: subject,
|
||||
Principal: principalArn,
|
||||
Name: sessionInfo.Subject,
|
||||
Principal: sessionInfo.Principal,
|
||||
SessionToken: sessionToken,
|
||||
Account: &Account{
|
||||
DisplayName: roleName,
|
||||
EmailAddress: subject + "@seaweedfs.local",
|
||||
Id: subject,
|
||||
DisplayName: sessionInfo.SessionName,
|
||||
EmailAddress: sessionInfo.Subject + "@seaweedfs.local",
|
||||
Id: sessionInfo.Subject,
|
||||
},
|
||||
Claims: claims,
|
||||
}
|
||||
|
||||
glog.V(3).Infof("JWT authentication successful for principal: %s", identity.Principal)
|
||||
@@ -199,6 +214,35 @@ func (s3iam *S3IAMIntegration) AuthorizeAction(ctx context.Context, identity *IA
|
||||
// Extract request context for policy conditions
|
||||
requestContext := extractRequestContext(r)
|
||||
|
||||
// Add s3:prefix to request context based on object key
|
||||
// This ensures that policy conditions referencing s3:prefix (like StringLike)
|
||||
// work correctly for both ListObjects (where objectKey is the prefix) and
|
||||
// object operations (where we treat the object key as the prefix for matching)
|
||||
if objectKey != "" && objectKey != "/" {
|
||||
requestContext["s3:prefix"] = objectKey
|
||||
}
|
||||
|
||||
// Add identity claims to request context for policy variables
|
||||
// Only add claim keys if they don't already exist (to avoid overwriting request-derived context)
|
||||
if identity.Claims != nil {
|
||||
for k, v := range identity.Claims {
|
||||
// Only add the claim if this key doesn't already exist in request context
|
||||
if _, exists := requestContext[k]; !exists {
|
||||
requestContext[k] = v
|
||||
}
|
||||
|
||||
// If the claim doesn't have a namespace prefix (e.g. "email"), add "jwt:" prefix
|
||||
// This allows ${jwt:email} or ${jwt:preferred_username} to work
|
||||
// Only add namespaced version if it doesn't already exist
|
||||
if !strings.Contains(k, ":") {
|
||||
jwtKey := "jwt:" + k
|
||||
if _, exists := requestContext[jwtKey]; !exists {
|
||||
requestContext[jwtKey] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the specific S3 action based on the HTTP request details
|
||||
specificAction := ResolveS3Action(r, string(action), bucket, objectKey)
|
||||
|
||||
@@ -240,6 +284,7 @@ type IAMIdentity struct {
|
||||
SessionToken string
|
||||
Account *Account
|
||||
PolicyNames []string
|
||||
Claims map[string]interface{}
|
||||
}
|
||||
|
||||
// IsAdmin checks if the identity has admin privileges
|
||||
@@ -406,9 +451,10 @@ func extractRequestContext(r *http.Request) map[string]interface{} {
|
||||
context := make(map[string]interface{})
|
||||
|
||||
// Extract source IP for IP-based conditions
|
||||
// Use AWS-compatible key name for policy variable substitution
|
||||
sourceIP := extractSourceIP(r)
|
||||
if sourceIP != "" {
|
||||
context["sourceIP"] = sourceIP
|
||||
context["aws:SourceIp"] = sourceIP
|
||||
}
|
||||
|
||||
// Extract user agent
|
||||
@@ -428,42 +474,86 @@ func extractRequestContext(r *http.Request) map[string]interface{} {
|
||||
}
|
||||
|
||||
// extractSourceIP extracts the real source IP from the request
|
||||
// SECURITY: Prioritizes RemoteAddr over client-controlled headers to prevent spoofing
|
||||
// Only trusts X-Forwarded-For/X-Real-IP if RemoteAddr appears to be from a trusted proxy
|
||||
func extractSourceIP(r *http.Request) string {
|
||||
// Check X-Forwarded-For header (most common for proxied requests)
|
||||
if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" {
|
||||
// X-Forwarded-For can contain multiple IPs, take the first one
|
||||
if ips := strings.Split(forwardedFor, ","); len(ips) > 0 {
|
||||
return strings.TrimSpace(ips[0])
|
||||
// Always start with RemoteAddr as the most trustworthy source
|
||||
remoteIP := r.RemoteAddr
|
||||
if ip, _, err := net.SplitHostPort(remoteIP); err == nil {
|
||||
remoteIP = ip
|
||||
}
|
||||
|
||||
// NOTE: The current heuristic of using isPrivateIP assumes reverse proxies are on a
|
||||
// private/local network. This may be insufficient for some cloud, CDN, or multi-tier
|
||||
// proxy deployments where proxies terminate connections from public IPs. In such
|
||||
// environments, deployment-specific controls (e.g., network ACLs or proxy configs)
|
||||
// should be used to ensure only trusted components can set forwarding headers.
|
||||
// Future enhancements may introduce an explicit, configurable trusted proxy CIDR list.
|
||||
isTrustedProxy := isPrivateIP(remoteIP)
|
||||
|
||||
if isTrustedProxy {
|
||||
// Check X-Real-IP header first (single IP, more reliable than X-Forwarded-For)
|
||||
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
|
||||
return strings.TrimSpace(realIP)
|
||||
}
|
||||
|
||||
// Check X-Forwarded-For header (can contain multiple IPs, take the first one)
|
||||
if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" {
|
||||
if ips := strings.Split(forwardedFor, ","); len(ips) > 0 {
|
||||
return strings.TrimSpace(ips[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check X-Real-IP header
|
||||
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
|
||||
return strings.TrimSpace(realIP)
|
||||
}
|
||||
|
||||
// Fall back to RemoteAddr
|
||||
if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return ip
|
||||
}
|
||||
|
||||
return r.RemoteAddr
|
||||
// Fall back to RemoteAddr (most secure)
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
// parseJWTToken parses a JWT token and returns its claims without verification
|
||||
// Note: This is for extracting claims only. Verification is done by the IAM system.
|
||||
func parseJWTToken(tokenString string) (jwt.MapClaims, error) {
|
||||
// isPrivateIP checks if an IP is in a private range (localhost or RFC1918)
|
||||
func isPrivateIP(ipStr string) bool {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for localhost and link-local addresses (IPv4/IPv6)
|
||||
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check against pre-parsed private CIDR ranges
|
||||
for _, network := range privateNetworks {
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseUnverifiedJWTToken parses a JWT token and returns its claims WITHOUT cryptographic verification
|
||||
//
|
||||
// SECURITY WARNING: This function does NOT validate the token signature!
|
||||
// It should ONLY be used for:
|
||||
// 1. Routing tokens to the appropriate verification method (e.g., checking issuer to determine STS vs OIDC)
|
||||
// 2. Extracting claims for logging/debugging AFTER the token has been cryptographically verified
|
||||
//
|
||||
// NEVER use the returned claims for authorization decisions without first calling a proper
|
||||
// verification function like ValidateSessionToken() or validateExternalOIDCToken().
|
||||
func ParseUnverifiedJWTToken(tokenString string) (jwt.MapClaims, error) {
|
||||
// Parse token without verification to get claims
|
||||
// This token IS NOT VERIFIED at this stage.
|
||||
// It is only used to peek at claims (like issuer) to determine which verification key/strategy to use.
|
||||
token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JWT token: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
// minInt returns the minimum of two integers
|
||||
|
||||
@@ -405,6 +405,8 @@ func TestExtractSourceIP(t *testing.T) {
|
||||
setupReq: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test", http.NoBody)
|
||||
req.Header.Set("X-Forwarded-For", "192.168.1.100, 10.0.0.1")
|
||||
// Set RemoteAddr to private IP to simulate trusted proxy
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
return req
|
||||
},
|
||||
expectedIP: "192.168.1.100",
|
||||
@@ -414,6 +416,8 @@ func TestExtractSourceIP(t *testing.T) {
|
||||
setupReq: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test", http.NoBody)
|
||||
req.Header.Set("X-Real-IP", "192.168.1.200")
|
||||
// Set RemoteAddr to private IP to simulate trusted proxy
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
return req
|
||||
},
|
||||
expectedIP: "192.168.1.200",
|
||||
@@ -427,6 +431,17 @@ func TestExtractSourceIP(t *testing.T) {
|
||||
},
|
||||
expectedIP: "192.168.1.300",
|
||||
},
|
||||
{
|
||||
name: "Untrusted proxy - public RemoteAddr ignores X-Forwarded-For",
|
||||
setupReq: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test", http.NoBody)
|
||||
req.Header.Set("X-Forwarded-For", "192.168.1.100")
|
||||
// Public IP - headers should NOT be trusted
|
||||
req.RemoteAddr = "8.8.8.8:12345"
|
||||
return req
|
||||
},
|
||||
expectedIP: "8.8.8.8", // Should use RemoteAddr, not X-Forwarded-For
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -171,6 +171,8 @@ func TestRequestContextExtraction(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/test-bucket/test-file.txt", http.NoBody)
|
||||
req.Header.Set("X-Forwarded-For", "192.168.1.100")
|
||||
req.Header.Set("User-Agent", "aws-sdk-go/1.0")
|
||||
// Set RemoteAddr to private IP to simulate trusted proxy
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
return req
|
||||
},
|
||||
expectedIP: "192.168.1.100",
|
||||
@@ -182,6 +184,8 @@ func TestRequestContextExtraction(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/test-bucket/test-file.txt", http.NoBody)
|
||||
req.Header.Set("X-Real-IP", "10.0.0.1")
|
||||
req.Header.Set("User-Agent", "boto3/1.0")
|
||||
// Set RemoteAddr to private IP to simulate trusted proxy
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
return req
|
||||
},
|
||||
expectedIP: "10.0.0.1",
|
||||
@@ -197,7 +201,7 @@ func TestRequestContextExtraction(t *testing.T) {
|
||||
context := extractRequestContext(req)
|
||||
|
||||
if tt.expectedIP != "" {
|
||||
assert.Equal(t, tt.expectedIP, context["sourceIP"])
|
||||
assert.Equal(t, tt.expectedIP, context["aws:SourceIp"])
|
||||
}
|
||||
|
||||
if tt.expectedUA != "" {
|
||||
@@ -255,6 +259,8 @@ func TestIPBasedPolicyEnforcement(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/restricted-bucket/file.txt", http.NoBody)
|
||||
req.Header.Set("Authorization", "Bearer "+response.Credentials.SessionToken)
|
||||
req.Header.Set("X-Forwarded-For", tt.sourceIP)
|
||||
// Set RemoteAddr to private IP to simulate trusted proxy
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
|
||||
// Create IAM identity for testing
|
||||
identity := &IAMIdentity{
|
||||
|
||||
@@ -70,47 +70,21 @@ func (iam *IdentityAccessManagement) ValidatePresignedURLWithIAM(r *http.Request
|
||||
return s3err.ErrNone
|
||||
}
|
||||
|
||||
// Parse JWT token to extract role and session information
|
||||
tokenClaims, err := parseJWTToken(sessionToken)
|
||||
if err != nil {
|
||||
glog.V(3).Infof("Failed to parse JWT token in presigned URL: %v", err)
|
||||
return s3err.ErrAccessDenied
|
||||
}
|
||||
// Create a temporary cloned request with Authorization header to reuse the secure AuthenticateJWT logic
|
||||
// This ensures we use the same robust validation (STS vs OIDC, signature verification, etc.)
|
||||
// as standard requests, preventing security regressions.
|
||||
authReq := r.Clone(ctx)
|
||||
authReq.Header.Set("Authorization", "Bearer "+sessionToken)
|
||||
|
||||
// Extract role information from token claims
|
||||
roleName, ok := tokenClaims["role"].(string)
|
||||
if !ok || roleName == "" {
|
||||
glog.V(3).Info("No role found in JWT token for presigned URL")
|
||||
return s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
sessionName, ok := tokenClaims["snam"].(string)
|
||||
if !ok || sessionName == "" {
|
||||
sessionName = "presigned-session" // Default fallback
|
||||
}
|
||||
|
||||
// Use the principal ARN directly from token claims, or build it if not available
|
||||
principalArn, ok := tokenClaims["principal"].(string)
|
||||
if !ok || principalArn == "" {
|
||||
// Fallback: extract role name from role ARN and build principal ARN
|
||||
roleNameOnly := roleName
|
||||
if strings.Contains(roleName, "/") {
|
||||
parts := strings.Split(roleName, "/")
|
||||
roleNameOnly = parts[len(parts)-1]
|
||||
}
|
||||
principalArn = fmt.Sprintf("arn:aws:sts::assumed-role/%s/%s", roleNameOnly, sessionName)
|
||||
}
|
||||
|
||||
// Create IAM identity for authorization using extracted information
|
||||
iamIdentity := &IAMIdentity{
|
||||
Name: identity.Name,
|
||||
Principal: principalArn,
|
||||
SessionToken: sessionToken,
|
||||
Account: identity.Account,
|
||||
// Authenticate the token using the centralized IAM integration
|
||||
iamIdentity, errCode := iam.iamIntegration.AuthenticateJWT(ctx, authReq)
|
||||
if errCode != s3err.ErrNone {
|
||||
glog.V(3).Infof("JWT authentication failed for presigned URL: %v", errCode)
|
||||
return errCode
|
||||
}
|
||||
|
||||
// Authorize using IAM
|
||||
errCode := iam.iamIntegration.AuthorizeAction(ctx, iamIdentity, action, bucket, object, r)
|
||||
errCode = iam.iamIntegration.AuthorizeAction(ctx, iamIdentity, action, bucket, object, r)
|
||||
if errCode != s3err.ErrNone {
|
||||
glog.V(3).Infof("IAM authorization failed for presigned URL: principal=%s action=%s bucket=%s object=%s",
|
||||
iamIdentity.Principal, action, bucket, object)
|
||||
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/kms"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/cors"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
@@ -32,9 +32,9 @@ type BucketConfig struct {
|
||||
Owner string
|
||||
IsPublicRead bool // Cached flag to avoid JSON parsing on every request
|
||||
CORS *cors.CORSConfiguration
|
||||
ObjectLockConfig *ObjectLockConfiguration // Cached parsed Object Lock configuration
|
||||
BucketPolicy *policy.PolicyDocument // Cached bucket policy for performance
|
||||
KMSKeyCache *BucketKMSCache // Per-bucket KMS key cache for SSE-KMS operations
|
||||
ObjectLockConfig *ObjectLockConfiguration // Cached parsed Object Lock configuration
|
||||
BucketPolicy *policy_engine.PolicyDocument // Cached bucket policy for performance
|
||||
KMSKeyCache *BucketKMSCache // Per-bucket KMS key cache for SSE-KMS operations
|
||||
LastModified time.Time
|
||||
Entry *filer_pb.Entry
|
||||
}
|
||||
@@ -321,7 +321,7 @@ func (bcc *BucketConfigCache) RemoveNegativeCache(bucket string) {
|
||||
}
|
||||
|
||||
// loadBucketPolicyFromExtended loads and parses bucket policy from entry extended attributes
|
||||
func loadBucketPolicyFromExtended(entry *filer_pb.Entry, bucket string) *policy.PolicyDocument {
|
||||
func loadBucketPolicyFromExtended(entry *filer_pb.Entry, bucket string) *policy_engine.PolicyDocument {
|
||||
if entry.Extended == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -332,7 +332,7 @@ func loadBucketPolicyFromExtended(entry *filer_pb.Entry, bucket string) *policy.
|
||||
return nil
|
||||
}
|
||||
|
||||
var policyDoc policy.PolicyDocument
|
||||
var policyDoc policy_engine.PolicyDocument
|
||||
if err := json.Unmarshal(policyJSON, &policyDoc); err != nil {
|
||||
glog.Errorf("loadBucketPolicyFromExtended: failed to parse bucket policy for %s: %v", bucket, err)
|
||||
return nil
|
||||
|
||||
@@ -638,7 +638,7 @@ func (s3a *S3ApiServer) AuthWithPublicRead(handler http.HandlerFunc, action Acti
|
||||
// Check bucket policy for anonymous access using the policy engine
|
||||
principal := "*" // Anonymous principal
|
||||
// Evaluate bucket policy (objectEntry nil - not yet fetched)
|
||||
allowed, evaluated, err := s3a.policyEngine.EvaluatePolicy(bucket, object, string(action), principal, r, nil)
|
||||
allowed, evaluated, err := s3a.policyEngine.EvaluatePolicy(bucket, object, string(action), principal, r, nil, nil)
|
||||
if err != nil {
|
||||
// SECURITY: Fail-close on policy evaluation errors
|
||||
// If we can't evaluate the policy, deny access rather than falling through to IAM
|
||||
|
||||
@@ -116,7 +116,7 @@ func TestBuildPrincipalARN(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildPrincipalARN(tt.identity)
|
||||
result := buildPrincipalARN(tt.identity, nil)
|
||||
if result != tt.expected {
|
||||
t.Errorf("buildPrincipalARN() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
)
|
||||
@@ -48,25 +47,17 @@ func (bpe *BucketPolicyEngine) LoadBucketPolicy(bucket string, entry *filer_pb.E
|
||||
|
||||
// LoadBucketPolicyFromCache loads a bucket policy from a cached BucketConfig
|
||||
//
|
||||
// This function uses a type-safe conversion function to convert between
|
||||
// policy.PolicyDocument and policy_engine.PolicyDocument with explicit field mapping and error handling.
|
||||
func (bpe *BucketPolicyEngine) LoadBucketPolicyFromCache(bucket string, policyDoc *policy.PolicyDocument) error {
|
||||
// This function loads the policy directly into the engine
|
||||
func (bpe *BucketPolicyEngine) LoadBucketPolicyFromCache(bucket string, policyDoc *policy_engine.PolicyDocument) error {
|
||||
if policyDoc == nil {
|
||||
// No policy for this bucket - remove it if it exists
|
||||
bpe.engine.DeleteBucketPolicy(bucket)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert policy.PolicyDocument to policy_engine.PolicyDocument without a JSON round-trip
|
||||
// This removes the prior intermediate marshal/unmarshal and adds type safety
|
||||
enginePolicyDoc, err := ConvertPolicyDocumentToPolicyEngine(policyDoc)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to convert bucket policy for %s: %v", bucket, err)
|
||||
return fmt.Errorf("failed to convert bucket policy: %w", err)
|
||||
}
|
||||
|
||||
// Marshal the converted policy to JSON for storage in the engine
|
||||
policyJSON, err := json.Marshal(enginePolicyDoc)
|
||||
// Policy is already in correct format, just load it
|
||||
// We need to re-marshal to string because SetBucketPolicy expects JSON string
|
||||
policyJSON, err := json.Marshal(policyDoc)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to marshal bucket policy for %s: %v", bucket, err)
|
||||
return err
|
||||
@@ -107,7 +98,7 @@ func (bpe *BucketPolicyEngine) HasPolicyForBucket(bucket string) bool {
|
||||
// - allowed: whether the policy allows the action
|
||||
// - evaluated: whether a policy was found and evaluated (false = no policy exists)
|
||||
// - error: any error during evaluation
|
||||
func (bpe *BucketPolicyEngine) EvaluatePolicy(bucket, object, action, principal string, r *http.Request, objectEntry map[string][]byte) (allowed bool, evaluated bool, err error) {
|
||||
func (bpe *BucketPolicyEngine) EvaluatePolicy(bucket, object, action, principal string, r *http.Request, claims map[string]interface{}, objectEntry map[string][]byte) (allowed bool, evaluated bool, err error) {
|
||||
// Validate required parameters
|
||||
if bucket == "" {
|
||||
return false, false, fmt.Errorf("bucket cannot be empty")
|
||||
@@ -134,18 +125,41 @@ func (bpe *BucketPolicyEngine) EvaluatePolicy(bucket, object, action, principal
|
||||
ObjectEntry: objectEntry,
|
||||
}
|
||||
|
||||
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: bucket=%s, resource=%s, action=%s, principal=%s",
|
||||
// bucket, resource, s3Action, principal)
|
||||
|
||||
// Extract conditions and claims from request if available
|
||||
if r != nil {
|
||||
args.Conditions = policy_engine.ExtractConditionValuesFromRequest(r)
|
||||
|
||||
// Extract principal-related variables (aws:username, etc.) from principal ARN
|
||||
principalVars := policy_engine.ExtractPrincipalVariables(principal)
|
||||
for k, v := range principalVars {
|
||||
args.Conditions[k] = v
|
||||
}
|
||||
|
||||
// Extract JWT claims if authenticated via JWT or STS
|
||||
if claims != nil {
|
||||
args.Claims = claims
|
||||
} else {
|
||||
// If claims were not provided directly, try to get them from context Identity?
|
||||
// But the caller is responsible for passing them.
|
||||
// Falling back to empty claims if not provided.
|
||||
}
|
||||
}
|
||||
|
||||
result := bpe.engine.EvaluatePolicy(bucket, args)
|
||||
|
||||
switch result {
|
||||
case policy_engine.PolicyResultAllow:
|
||||
glog.V(3).Infof("EvaluatePolicy: ALLOW - bucket=%s, action=%s, principal=%s", bucket, s3Action, principal)
|
||||
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: ALLOW - bucket=%s, action=%s, principal=%s", bucket, s3Action, principal)
|
||||
return true, true, nil
|
||||
case policy_engine.PolicyResultDeny:
|
||||
glog.V(3).Infof("EvaluatePolicy: DENY - bucket=%s, action=%s, principal=%s", bucket, s3Action, principal)
|
||||
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: DENY - bucket=%s, action=%s, principal=%s", bucket, s3Action, principal)
|
||||
return false, true, nil
|
||||
case policy_engine.PolicyResultIndeterminate:
|
||||
// No policy exists for this bucket
|
||||
glog.V(4).Infof("EvaluatePolicy: INDETERMINATE (no policy) - bucket=%s", bucket)
|
||||
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: INDETERMINATE (no policy) - bucket=%s", bucket)
|
||||
return false, false, nil
|
||||
default:
|
||||
return false, false, fmt.Errorf("unknown policy result: %v", result)
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
@@ -82,16 +82,16 @@ func (s3a *S3ApiServer) PutBucketPolicyHandler(w http.ResponseWriter, r *http.Re
|
||||
defer r.Body.Close()
|
||||
|
||||
// Parse and validate policy document
|
||||
var policyDoc policy.PolicyDocument
|
||||
var policyDoc policy_engine.PolicyDocument
|
||||
if err := json.Unmarshal(body, &policyDoc); err != nil {
|
||||
glog.Errorf("Failed to parse bucket policy JSON: %v", err)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedPolicy)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate policy document structure
|
||||
if err := policy.ValidatePolicyDocument(&policyDoc); err != nil {
|
||||
glog.Errorf("Invalid bucket policy document: %v", err)
|
||||
// Validate core policy structure (Effect, Action, etc.)
|
||||
if err := policy_engine.ValidatePolicy(&policyDoc); err != nil {
|
||||
glog.Errorf("Policy validation failed: %v", err)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidPolicyDocument)
|
||||
return
|
||||
}
|
||||
@@ -190,9 +190,10 @@ func (s3a *S3ApiServer) DeleteBucketPolicyHandler(w http.ResponseWriter, r *http
|
||||
// Helper functions for bucket policy storage and retrieval
|
||||
|
||||
// getBucketPolicy retrieves a bucket policy from filer metadata
|
||||
func (s3a *S3ApiServer) getBucketPolicy(bucket string) (*policy.PolicyDocument, error) {
|
||||
// getBucketPolicy retrieves the bucket policy from filer
|
||||
func (s3a *S3ApiServer) getBucketPolicy(bucket string) (*policy_engine.PolicyDocument, error) {
|
||||
|
||||
var policyDoc policy.PolicyDocument
|
||||
var policyDoc policy_engine.PolicyDocument
|
||||
err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: s3a.option.BucketsPath,
|
||||
@@ -227,7 +228,7 @@ func (s3a *S3ApiServer) getBucketPolicy(bucket string) (*policy.PolicyDocument,
|
||||
}
|
||||
|
||||
// setBucketPolicy stores a bucket policy in filer metadata
|
||||
func (s3a *S3ApiServer) setBucketPolicy(bucket string, policyDoc *policy.PolicyDocument) error {
|
||||
func (s3a *S3ApiServer) setBucketPolicy(bucket string, policyDoc *policy_engine.PolicyDocument) error {
|
||||
// Serialize policy to JSON
|
||||
policyJSON, err := json.Marshal(policyDoc)
|
||||
if err != nil {
|
||||
@@ -293,7 +294,7 @@ func (s3a *S3ApiServer) deleteBucketPolicy(bucket string) error {
|
||||
}
|
||||
|
||||
// validateBucketPolicy performs bucket-specific policy validation
|
||||
func (s3a *S3ApiServer) validateBucketPolicy(policyDoc *policy.PolicyDocument, bucket string) error {
|
||||
func (s3a *S3ApiServer) validateBucketPolicy(policyDoc *policy_engine.PolicyDocument, bucket string) error {
|
||||
if policyDoc.Version != "2012-10-17" {
|
||||
return fmt.Errorf("unsupported policy version: %s (must be 2012-10-17)", policyDoc.Version)
|
||||
}
|
||||
@@ -309,14 +310,21 @@ func (s3a *S3ApiServer) validateBucketPolicy(policyDoc *policy.PolicyDocument, b
|
||||
}
|
||||
|
||||
// Validate resources refer to this bucket
|
||||
for _, resource := range statement.Resource {
|
||||
for _, resource := range statement.Resource.Strings() {
|
||||
if !s3a.validateResourceForBucket(resource, bucket) {
|
||||
return fmt.Errorf("statement %d: resource %s does not match bucket %s", i, resource, bucket)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate NotResources refer to this bucket
|
||||
for _, notResource := range statement.NotResource.Strings() {
|
||||
if !s3a.validateResourceForBucket(notResource, bucket) {
|
||||
return fmt.Errorf("statement %d: NotResource %s does not match bucket %s", i, notResource, bucket)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate actions are S3 actions
|
||||
for _, action := range statement.Action {
|
||||
for _, action := range statement.Action.Strings() {
|
||||
if !strings.HasPrefix(action, "s3:") {
|
||||
return fmt.Errorf("statement %d: bucket policies only support S3 actions, got %s", i, action)
|
||||
}
|
||||
@@ -358,13 +366,23 @@ func (s3a *S3ApiServer) validateResourceForBucket(resource, bucket string) bool
|
||||
// IAM integration functions
|
||||
|
||||
// updateBucketPolicyInIAM updates the IAM system with the new bucket policy
|
||||
func (s3a *S3ApiServer) updateBucketPolicyInIAM(bucket string, policyDoc *policy.PolicyDocument) error {
|
||||
// This would integrate with our advanced IAM system
|
||||
// For now, we'll just log that the policy was updated
|
||||
glog.V(2).Infof("Updated bucket policy for %s in IAM system", bucket)
|
||||
func (s3a *S3ApiServer) updateBucketPolicyInIAM(bucket string, policyDoc *policy_engine.PolicyDocument) error {
|
||||
// Update IAM integration with new bucket policy
|
||||
if s3a.iam.iamIntegration != nil {
|
||||
// Type assert to access the concrete implementation which has access to iamManager
|
||||
if s3Integration, ok := s3a.iam.iamIntegration.(*S3IAMIntegration); ok {
|
||||
if s3Integration.iamManager != nil {
|
||||
glog.V(2).Infof("Updated bucket policy for %s in IAM system", bucket)
|
||||
|
||||
// TODO: Integrate with IAM manager to store resource-based policies
|
||||
// s3a.iam.iamIntegration.iamManager.SetBucketPolicy(bucket, policyDoc)
|
||||
policyJSON, err := json.Marshal(policyDoc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal policy: %w", err)
|
||||
}
|
||||
|
||||
return s3Integration.iamManager.UpdateBucketPolicy(context.Background(), bucket, policyJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -90,6 +90,12 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Check bucket policy
|
||||
if errCode, _ := s3a.checkPolicyWithEntry(r, bucket, object, string(s3_constants.ACTION_WRITE), "", nil); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("Cache-Control") != "" {
|
||||
if _, err = cacheobject.ParseRequestCacheControl(r.Header.Get("Cache-Control")); err != nil {
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidDigest)
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
. "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
@@ -128,11 +129,12 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
|
||||
FilerGroup: option.FilerGroup,
|
||||
DiscoveryInterval: 5 * time.Minute,
|
||||
})
|
||||
glog.V(0).Infof("S3 API initialized FilerClient with %d filer(s) and discovery enabled (group: %s, masters: %v)",
|
||||
|
||||
glog.V(1).Infof("S3 API initialized FilerClient with %d filer(s) and discovery enabled (group: %s, masters: %v)",
|
||||
len(option.Filers), option.FilerGroup, option.Masters)
|
||||
} else {
|
||||
filerClient = wdclient.NewFilerClient(option.Filers, option.GrpcDialOption, option.DataCenter)
|
||||
glog.V(0).Infof("S3 API initialized FilerClient with %d filer(s) (no discovery)", len(option.Filers))
|
||||
glog.V(1).Infof("S3 API initialized FilerClient with %d filer(s) (no discovery)", len(option.Filers))
|
||||
}
|
||||
|
||||
// Update credential store to use FilerClient's current filer for HA
|
||||
@@ -178,6 +180,7 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to load IAM configuration: %v", err)
|
||||
} else {
|
||||
glog.V(1).Infof("IAM Manager loaded, creating integration")
|
||||
// Create S3 IAM integration with the loaded IAM manager
|
||||
// filerAddress not actually used, just for backward compatibility
|
||||
s3iam := NewS3IAMIntegration(iamManager, "")
|
||||
@@ -201,7 +204,7 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
|
||||
// Initialize embedded IAM API if enabled
|
||||
if option.EnableIam {
|
||||
s3ApiServer.embeddedIam = NewEmbeddedIamApi(s3ApiServer.credentialManager, iam)
|
||||
glog.V(0).Infof("Embedded IAM API initialized (use -iam=false to disable)")
|
||||
glog.V(1).Infof("Embedded IAM API initialized (use -iam=false to disable)")
|
||||
}
|
||||
|
||||
if option.Config != "" {
|
||||
@@ -261,7 +264,7 @@ func (s3a *S3ApiServer) getFilerAddress() pb.ServerAddress {
|
||||
// syncBucketPolicyToEngine syncs a bucket policy to the policy engine
|
||||
// This helper method centralizes the logic for loading bucket policies into the engine
|
||||
// to avoid duplication and ensure consistent error handling
|
||||
func (s3a *S3ApiServer) syncBucketPolicyToEngine(bucket string, policyDoc *policy.PolicyDocument) {
|
||||
func (s3a *S3ApiServer) syncBucketPolicyToEngine(bucket string, policyDoc *policy_engine.PolicyDocument) {
|
||||
if s3a.policyEngine == nil {
|
||||
return
|
||||
}
|
||||
@@ -289,11 +292,30 @@ func (s3a *S3ApiServer) checkPolicyWithEntry(r *http.Request, bucket, object, ac
|
||||
}
|
||||
|
||||
// Skip if no policy for this bucket
|
||||
if !s3a.policyEngine.HasPolicyForBucket(bucket) {
|
||||
hasPolicy := s3a.policyEngine.HasPolicyForBucket(bucket)
|
||||
// glog.V(4).Infof("checkPolicyWithEntry: bucket=%s hasPolicy=%v", bucket, hasPolicy)
|
||||
if !hasPolicy {
|
||||
return s3err.ErrNone, false
|
||||
}
|
||||
|
||||
allowed, evaluated, err := s3a.policyEngine.EvaluatePolicy(bucket, object, action, principal, r, objectEntry)
|
||||
identityRaw := GetIdentityFromContext(r)
|
||||
var identity *Identity
|
||||
if identityRaw != nil {
|
||||
if id, ok := identityRaw.(*Identity); ok {
|
||||
identity = id
|
||||
}
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if identity != nil {
|
||||
claims = identity.Claims
|
||||
}
|
||||
|
||||
if principal == "" {
|
||||
principal = buildPrincipalARN(identity, r)
|
||||
}
|
||||
|
||||
allowed, evaluated, err := s3a.policyEngine.EvaluatePolicy(bucket, object, action, principal, r, claims, objectEntry)
|
||||
if err != nil {
|
||||
glog.Errorf("checkPolicyWithEntry: error evaluating policy for %s/%s: %v", bucket, object, err)
|
||||
return s3err.ErrInternalError, true
|
||||
@@ -327,7 +349,7 @@ func (s3a *S3ApiServer) recheckPolicyWithObjectEntry(r *http.Request, bucket, ob
|
||||
return s3err.ErrInternalError
|
||||
}
|
||||
}
|
||||
principal := buildPrincipalARN(identity)
|
||||
principal := buildPrincipalARN(identity, r)
|
||||
errCode, _ := s3a.checkPolicyWithEntry(r, bucket, object, action, principal, objectEntry)
|
||||
return errCode
|
||||
}
|
||||
@@ -634,7 +656,7 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
|
||||
apiRouter.Methods(http.MethodPost).Path("/").Queries("Action", "AssumeRoleWithLDAPIdentity").
|
||||
HandlerFunc(track(s3a.stsHandlers.HandleSTSRequest, "STS-LDAP"))
|
||||
|
||||
glog.V(0).Infof("STS API enabled on S3 port (AssumeRole, AssumeRoleWithWebIdentity, AssumeRoleWithLDAPIdentity)")
|
||||
glog.V(1).Infof("STS API enabled on S3 port (AssumeRole, AssumeRoleWithWebIdentity, AssumeRoleWithLDAPIdentity)")
|
||||
}
|
||||
|
||||
// Embedded IAM API endpoint
|
||||
@@ -672,7 +694,7 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
|
||||
|
||||
apiRouter.Methods(http.MethodPost).Path("/").MatcherFunc(iamMatcher).
|
||||
HandlerFunc(track(s3a.embeddedIam.AuthIam(s3a.cb.Limit(s3a.embeddedIam.DoActions, ACTION_WRITE)), "IAM"))
|
||||
glog.V(0).Infof("Embedded IAM API enabled on S3 port")
|
||||
glog.V(1).Infof("Embedded IAM API enabled on S3 port")
|
||||
}
|
||||
|
||||
// 3. Fallback STS handler (lowest priority)
|
||||
|
||||
Reference in New Issue
Block a user