Browse Source
feat(s3api): Implement S3 Policy Variables (#8039)
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
fix-helm-fromtoml-compatibility
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 2760 additions and 1282 deletions
-
391.github/workflows/s3-policy-tests.yml
-
3test/s3/iam/empty_s3_config.json
-
46test/s3/iam/run_custom_test.sh
-
89test/s3/iam/run_tests.sh
-
20test/s3/iam/s3_iam_distributed_test.go
-
57test/s3/iam/s3_iam_framework.go
-
225test/s3/iam/s3_iam_integration_test.go
-
446test/s3/iam/s3_policy_variables_test.go
-
76test/s3/iam/test_iam_config.json
-
91weed/iam/integration/iam_manager.go
-
111weed/iam/policy/policy_engine.go
-
8weed/iam/policy/policy_engine_test.go
-
4weed/iam/sts/session_claims.go
-
30weed/iam/sts/token_utils.go
-
56weed/s3api/auth_credentials.go
-
4weed/s3api/auth_credentials_subscribe.go
-
2weed/s3api/bucket_metadata.go
-
238weed/s3api/policy_conversion.go
-
613weed/s3api/policy_conversion_test.go
-
15weed/s3api/policy_engine/conditions.go
-
220weed/s3api/policy_engine/engine.go
-
312weed/s3api/policy_engine/engine_enhanced_test.go
-
100weed/s3api/policy_engine/engine_isolation_test.go
-
65weed/s3api/policy_engine/engine_notresource_test.go
-
77weed/s3api/policy_engine/engine_paths_test.go
-
152weed/s3api/policy_engine/engine_variables_test.go
-
140weed/s3api/policy_engine/types.go
-
214weed/s3api/s3_iam_middleware.go
-
15weed/s3api/s3_iam_simple_test.go
-
8weed/s3api/s3_jwt_auth_test.go
-
50weed/s3api/s3_presigned_url_iam.go
-
12weed/s3api/s3api_bucket_config.go
-
2weed/s3api/s3api_bucket_handlers.go
-
2weed/s3api/s3api_bucket_policy_arn_test.go
-
50weed/s3api/s3api_bucket_policy_engine.go
-
52weed/s3api/s3api_bucket_policy_handlers.go
-
6weed/s3api/s3api_object_handlers_put.go
-
40weed/s3api/s3api_server.go
@ -0,0 +1,391 @@ |
|||
name: "S3 Policy Integration Tests" |
|||
|
|||
on: |
|||
pull_request: |
|||
paths: |
|||
- 'weed/s3api/s3_iam_middleware.go' |
|||
- 'weed/s3api/s3api_bucket_policy*.go' |
|||
- 'weed/s3api/s3_action_resolver.go' |
|||
- 'weed/s3api/policy/**' |
|||
- 'weed/iam/**' |
|||
- 'test/s3/iam/**' |
|||
- '.github/workflows/s3-policy-tests.yml' |
|||
push: |
|||
branches: [ master, main ] |
|||
paths: |
|||
- 'weed/s3api/s3_iam_middleware.go' |
|||
- 'weed/s3api/s3api_bucket_policy*.go' |
|||
- 'weed/s3api/s3_action_resolver.go' |
|||
- 'weed/s3api/policy/**' |
|||
- 'weed/iam/**' |
|||
- 'test/s3/iam/**' |
|||
- '.github/workflows/s3-policy-tests.yml' |
|||
|
|||
concurrency: |
|||
group: ${{ github.head_ref }}/s3-policy-tests |
|||
cancel-in-progress: true |
|||
|
|||
permissions: |
|||
contents: read |
|||
|
|||
defaults: |
|||
run: |
|||
working-directory: weed |
|||
|
|||
jobs: |
|||
# Unit tests for policy components |
|||
policy-unit-tests: |
|||
name: S3 Policy Unit Tests |
|||
runs-on: ubuntu-22.04 |
|||
timeout-minutes: 15 |
|||
|
|||
steps: |
|||
- name: Check out code |
|||
uses: actions/checkout@v6 |
|||
|
|||
- name: Set up Go |
|||
uses: actions/setup-go@v6 |
|||
with: |
|||
go-version-file: 'go.mod' |
|||
id: go |
|||
|
|||
- name: Get dependencies |
|||
run: | |
|||
go mod download |
|||
|
|||
- name: Run S3 Policy Unit Tests |
|||
timeout-minutes: 10 |
|||
run: | |
|||
set -x |
|||
echo "=== Running S3 Action Resolver Tests ===" |
|||
go test -v -timeout 5m ./s3api/... -run ".*ActionResolver.*" |
|||
|
|||
echo "=== Running S3 Bucket Policy Engine Tests ===" |
|||
go test -v -timeout 5m ./s3api/... -run ".*BucketPolicy.*|.*PolicyEngine.*" |
|||
|
|||
echo "=== Running IAM Policy Tests ===" |
|||
go test -v -timeout 5m ./iam/policy/... |
|||
|
|||
- name: Upload test results on failure |
|||
if: failure() |
|||
uses: actions/upload-artifact@v6 |
|||
with: |
|||
name: policy-unit-test-results |
|||
path: | |
|||
weed/testdata/ |
|||
weed/**/testdata/ |
|||
retention-days: 3 |
|||
|
|||
# S3 Policy Variables Integration Tests |
|||
s3-policy-variables-tests: |
|||
name: S3 Policy Variables Integration Tests |
|||
runs-on: ubuntu-22.04 |
|||
timeout-minutes: 25 |
|||
|
|||
steps: |
|||
- name: Check out code |
|||
uses: actions/checkout@v6 |
|||
|
|||
- name: Set up Go |
|||
uses: actions/setup-go@v6 |
|||
with: |
|||
go-version-file: 'go.mod' |
|||
id: go |
|||
|
|||
- name: Install SeaweedFS |
|||
run: | |
|||
go install -buildvcs=false |
|||
|
|||
- name: Run S3 Policy Variables Integration Tests |
|||
timeout-minutes: 20 |
|||
working-directory: test/s3/iam |
|||
run: | |
|||
set -x |
|||
echo "=== System Information ===" |
|||
uname -a |
|||
free -h |
|||
df -h |
|||
|
|||
echo "=== Starting S3 Policy Variables Integration Tests ===" |
|||
|
|||
# Set WEED_BINARY to use the installed version |
|||
export WEED_BINARY=$(which weed) |
|||
export TEST_TIMEOUT=15m |
|||
|
|||
# Run policy variables tests |
|||
echo "Running policy variables tests..." |
|||
|
|||
# Kill any existing weed server on port 8333 |
|||
if lsof -Pi :8333 -sTCP:LISTEN -t >/dev/null 2>&1 ; then |
|||
kill $(lsof -t -i:8333) 2>/dev/null || true |
|||
sleep 2 |
|||
fi |
|||
|
|||
# Start weed server with IAM configuration |
|||
echo "Starting weed server with IAM configuration..." |
|||
$WEED_BINARY server \ |
|||
-s3 \ |
|||
-s3.port=8333 \ |
|||
-s3.iam.config="$(pwd)/test_iam_config.json" \ |
|||
-filer \ |
|||
-volume.max=0 \ |
|||
-master.volumeSizeLimitMB=100 \ |
|||
-s3.allowDeleteBucketNotEmpty=true \ |
|||
> /tmp/weed_policy_test_server.log 2>&1 & |
|||
|
|||
SERVER_PID=$! |
|||
echo "Server started with PID: $SERVER_PID" |
|||
|
|||
# Wait for server to be ready |
|||
echo "Waiting for server to be ready..." |
|||
MAX_WAIT=30 |
|||
COUNTER=0 |
|||
while ! curl -s http://localhost:8333/status > /dev/null 2>&1; do |
|||
sleep 1 |
|||
COUNTER=$((COUNTER + 1)) |
|||
if [ $COUNTER -ge $MAX_WAIT ]; then |
|||
echo "Server failed to start within ${MAX_WAIT} seconds" |
|||
echo "Server log:" |
|||
cat /tmp/weed_policy_test_server.log |
|||
kill $SERVER_PID 2>/dev/null || true |
|||
exit 1 |
|||
fi |
|||
done |
|||
|
|||
echo "Server is ready!" |
|||
|
|||
# Trap to ensure server is killed on exit |
|||
trap "kill $SERVER_PID 2>/dev/null || true" EXIT |
|||
|
|||
# Run the tests |
|||
go test -v -timeout 15m -run TestS3PolicyVariables ./... |
|||
|
|||
- name: Show service logs on failure |
|||
if: failure() |
|||
working-directory: test/s3/iam |
|||
run: | |
|||
echo "=== Service Logs ===" |
|||
if [ -f /tmp/weed_policy_test_server.log ]; then |
|||
echo "--- Last 100 lines of Server Log ---" |
|||
tail -100 /tmp/weed_policy_test_server.log |
|||
fi |
|||
echo "" |
|||
echo "=== Process Information ===" |
|||
ps aux | grep -E "(weed|test)" || true |
|||
netstat -tlnp | grep -E "(8333|8888|9333|8080)" || true |
|||
|
|||
- name: Upload test logs on failure |
|||
if: failure() |
|||
uses: actions/upload-artifact@v6 |
|||
with: |
|||
name: s3-policy-variables-test-logs |
|||
path: /tmp/weed_policy_test_server.log |
|||
retention-days: 5 |
|||
|
|||
# S3 Policy Enforcement Integration Tests |
|||
s3-policy-enforcement-tests: |
|||
name: S3 Policy Enforcement Integration Tests |
|||
runs-on: ubuntu-22.04 |
|||
timeout-minutes: 30 |
|||
strategy: |
|||
matrix: |
|||
test-case: ["basic-policy", "contextual-policy", "advanced-policy"] |
|||
|
|||
steps: |
|||
- name: Check out code |
|||
uses: actions/checkout@v6 |
|||
|
|||
- name: Set up Go |
|||
uses: actions/setup-go@v6 |
|||
with: |
|||
go-version-file: 'go.mod' |
|||
id: go |
|||
|
|||
- name: Install SeaweedFS |
|||
run: | |
|||
go install -buildvcs=false |
|||
|
|||
- name: Run S3 Policy Enforcement Tests - ${{ matrix.test-case }} |
|||
timeout-minutes: 25 |
|||
working-directory: test/s3/iam |
|||
run: | |
|||
set -x |
|||
echo "=== System Information ===" |
|||
uname -a |
|||
free -h |
|||
df -h |
|||
|
|||
echo "=== Starting S3 Policy Enforcement Tests (${{ matrix.test-case }}) ===" |
|||
|
|||
export WEED_BINARY=$(which weed) |
|||
export TEST_TIMEOUT=20m |
|||
|
|||
# Kill any existing weed server on port 8333 |
|||
if lsof -Pi :8333 -sTCP:LISTEN -t >/dev/null 2>&1 ; then |
|||
kill $(lsof -t -i:8333) 2>/dev/null || true |
|||
sleep 2 |
|||
fi |
|||
|
|||
# Start weed server with IAM configuration |
|||
echo "Starting weed server with IAM configuration..." |
|||
$WEED_BINARY server \ |
|||
-s3 \ |
|||
-s3.port=8333 \ |
|||
-s3.iam.config="$(pwd)/test_iam_config.json" \ |
|||
-filer \ |
|||
-volume.max=0 \ |
|||
-master.volumeSizeLimitMB=100 \ |
|||
-s3.allowDeleteBucketNotEmpty=true \ |
|||
> /tmp/weed_policy_enforcement_${{ matrix.test-case }}.log 2>&1 & |
|||
|
|||
SERVER_PID=$! |
|||
echo "Server started with PID: $SERVER_PID" |
|||
|
|||
# Wait for server to be ready |
|||
echo "Waiting for server to be ready..." |
|||
MAX_WAIT=30 |
|||
COUNTER=0 |
|||
while ! curl -s http://localhost:8333/status > /dev/null 2>&1; do |
|||
sleep 1 |
|||
COUNTER=$((COUNTER + 1)) |
|||
if [ $COUNTER -ge $MAX_WAIT ]; then |
|||
echo "Server failed to start within ${MAX_WAIT} seconds" |
|||
cat /tmp/weed_policy_enforcement_${{ matrix.test-case }}.log |
|||
kill $SERVER_PID 2>/dev/null || true |
|||
exit 1 |
|||
fi |
|||
done |
|||
|
|||
echo "Server is ready!" |
|||
|
|||
# Trap to ensure server is killed on exit |
|||
trap "kill $SERVER_PID 2>/dev/null || true" EXIT |
|||
|
|||
# Run tests based on test case |
|||
case "${{ matrix.test-case }}" in |
|||
"basic-policy") |
|||
echo "Running basic policy enforcement tests..." |
|||
go test -v -timeout 20m -run "TestS3IAMBucketPolicy|TestS3IAMPolicyEnforcement" ./... |
|||
;; |
|||
"contextual-policy") |
|||
echo "Running contextual policy tests..." |
|||
go test -v -timeout 20m -run "TestS3PolicyVariables|TestS3IAMContextual" ./... |
|||
;; |
|||
"advanced-policy") |
|||
echo "Running advanced policy tests..." |
|||
go test -v -timeout 20m -run "TestS3IAMMultipart|TestS3IAMPresigned" ./... |
|||
;; |
|||
*) |
|||
echo "Unknown test case: ${{ matrix.test-case }}" |
|||
exit 1 |
|||
;; |
|||
esac |
|||
|
|||
- name: Show service logs on failure |
|||
if: failure() |
|||
working-directory: test/s3/iam |
|||
run: | |
|||
echo "=== Service Logs ===" |
|||
if [ -f /tmp/weed_policy_enforcement_${{ matrix.test-case }}.log ]; then |
|||
echo "--- Last 100 lines of Server Log ---" |
|||
tail -100 /tmp/weed_policy_enforcement_${{ matrix.test-case }}.log |
|||
fi |
|||
echo "" |
|||
echo "=== Process Information ===" |
|||
ps aux | grep -E "(weed|test)" || true |
|||
netstat -tlnp | grep -E "(8333|8888|9333|8080)" || true |
|||
|
|||
- name: Upload test logs on failure |
|||
if: failure() |
|||
uses: actions/upload-artifact@v6 |
|||
with: |
|||
name: s3-policy-enforcement-logs-${{ matrix.test-case }} |
|||
path: /tmp/weed_policy_enforcement_${{ matrix.test-case }}.log |
|||
retention-days: 5 |
|||
|
|||
# Trusted Proxy Detection Tests |
|||
trusted-proxy-tests: |
|||
name: Trusted Proxy Detection Tests |
|||
runs-on: ubuntu-22.04 |
|||
timeout-minutes: 20 |
|||
|
|||
steps: |
|||
- name: Check out code |
|||
uses: actions/checkout@v6 |
|||
|
|||
- name: Set up Go |
|||
uses: actions/setup-go@v6 |
|||
with: |
|||
go-version-file: 'go.mod' |
|||
id: go |
|||
|
|||
- name: Install SeaweedFS |
|||
run: | |
|||
go install -buildvcs=false |
|||
|
|||
- name: Run Trusted Proxy Tests |
|||
timeout-minutes: 15 |
|||
working-directory: test/s3/iam |
|||
run: | |
|||
set -x |
|||
echo "=== Running Trusted Proxy Detection Tests ===" |
|||
|
|||
export WEED_BINARY=$(which weed) |
|||
|
|||
# Kill any existing weed server on port 8333 |
|||
if lsof -Pi :8333 -sTCP:LISTEN -t >/dev/null 2>&1 ; then |
|||
kill $(lsof -t -i:8333) 2>/dev/null || true |
|||
sleep 2 |
|||
fi |
|||
|
|||
# Start weed server |
|||
echo "Starting weed server..." |
|||
$WEED_BINARY server \ |
|||
-s3 \ |
|||
-s3.port=8333 \ |
|||
-s3.iam.config="$(pwd)/test_iam_config.json" \ |
|||
-filer \ |
|||
-volume.max=0 \ |
|||
-master.volumeSizeLimitMB=100 \ |
|||
-s3.allowDeleteBucketNotEmpty=true \ |
|||
> /tmp/weed_proxy_test.log 2>&1 & |
|||
|
|||
SERVER_PID=$! |
|||
echo "Server started with PID: $SERVER_PID" |
|||
|
|||
# Wait for server to be ready |
|||
echo "Waiting for server to be ready..." |
|||
MAX_WAIT=30 |
|||
COUNTER=0 |
|||
while ! curl -s http://localhost:8333/status > /dev/null 2>&1; do |
|||
sleep 1 |
|||
COUNTER=$((COUNTER + 1)) |
|||
if [ $COUNTER -ge $MAX_WAIT ]; then |
|||
echo "Server failed to start within ${MAX_WAIT} seconds" |
|||
kill $SERVER_PID 2>/dev/null || true |
|||
exit 1 |
|||
fi |
|||
done |
|||
|
|||
# Trap to ensure server is killed on exit |
|||
trap "kill $SERVER_PID 2>/dev/null || true" EXIT |
|||
|
|||
# Run proxy tests |
|||
go test -v -timeout 10m -run "TestTrustedProxy|TestPrivateIP" ./... |
|||
|
|||
- name: Show service logs on failure |
|||
if: failure() |
|||
run: | |
|||
echo "=== Service Logs ===" |
|||
if [ -f /tmp/weed_proxy_test.log ]; then |
|||
echo "--- Last 100 lines of Server Log ---" |
|||
tail -100 /tmp/weed_proxy_test.log |
|||
fi |
|||
|
|||
- name: Upload test logs on failure |
|||
if: failure() |
|||
uses: actions/upload-artifact@v6 |
|||
with: |
|||
name: trusted-proxy-test-logs |
|||
path: /tmp/weed_proxy_test.log |
|||
retention-days: 3 |
|||
@ -0,0 +1,3 @@ |
|||
{ |
|||
"identities": [] |
|||
} |
|||
@ -0,0 +1,46 @@ |
|||
#!/bin/bash |
|||
set -e |
|||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
|||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" |
|||
|
|||
# Build weed binary |
|||
echo "Building weed binary..." |
|||
cd "$PROJECT_ROOT/weed" && go install |
|||
|
|||
# Kill existing server |
|||
if lsof -Pi :8333 -sTCP:LISTEN -t >/dev/null 2>&1 ; then |
|||
kill $(lsof -t -i:8333) 2>/dev/null || true |
|||
fi |
|||
|
|||
# Start server using weed mini for simpler all-in-one deployment |
|||
weed mini \ |
|||
-s3 \ |
|||
-s3.port=8333 \ |
|||
-s3.config="$SCRIPT_DIR/empty_s3_config.json" \ |
|||
-s3.iam.config="$SCRIPT_DIR/test_iam_config.json" \ |
|||
-s3.allowDeleteBucketNotEmpty=true \ |
|||
> /tmp/weed_test_server_custom.log 2>&1 & |
|||
SERVER_PID=$! |
|||
|
|||
# Wait for server |
|||
MAX_WAIT=30 |
|||
COUNTER=0 |
|||
while ! curl -s http://localhost:8333/status > /dev/null 2>&1; do |
|||
sleep 1 |
|||
COUNTER=$((COUNTER + 1)) |
|||
if [ $COUNTER -ge $MAX_WAIT ]; then |
|||
echo "Server failed to start" |
|||
cat /tmp/weed_test_server_custom.log |
|||
kill $SERVER_PID |
|||
exit 1 |
|||
fi |
|||
done |
|||
|
|||
trap "kill $SERVER_PID" EXIT |
|||
|
|||
cd "$SCRIPT_DIR" |
|||
if [ $# -eq 0 ]; then |
|||
go test -v -run TestS3IAMMultipartUploadPolicyEnforcement . |
|||
else |
|||
go test -v "$@" . |
|||
fi |
|||
@ -0,0 +1,89 @@ |
|||
#!/bin/bash |
|||
# Test runner for S3 policy variables integration tests |
|||
# This script starts a SeaweedFS server with the required IAM configuration |
|||
# and runs the integration tests. |
|||
|
|||
set -e |
|||
|
|||
# Colors for output |
|||
RED='\033[0;31m' |
|||
GREEN='\033[0;32m' |
|||
YELLOW='\033[1;33m' |
|||
NC='\033[0m' # No Color |
|||
|
|||
echo -e "${GREEN}=== S3 Policy Variables Integration Test Runner ===${NC}" |
|||
|
|||
# Get the directory of this script |
|||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
|||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" |
|||
|
|||
# Always build to ensure latest changes are tested |
|||
echo -e "${YELLOW}Building weed binary...${NC}" |
|||
cd "$PROJECT_ROOT/weed" && go install |
|||
if ! command -v weed &> /dev/null; then |
|||
echo -e "${RED}Failed to build weed binary${NC}" |
|||
exit 1 |
|||
fi |
|||
|
|||
# Kill any existing weed server on port 8333 |
|||
echo "Checking for existing weed server..." |
|||
if lsof -Pi :8333 -sTCP:LISTEN -t >/dev/null 2>&1 ; then |
|||
echo -e "${YELLOW}Killing existing weed server on port 8333...${NC}" |
|||
kill $(lsof -t -i:8333) 2>/dev/null || true |
|||
sleep 2 |
|||
fi |
|||
|
|||
# Start weed server with IAM configuration |
|||
echo -e "${GREEN}Starting weed server with IAM configuration...${NC}" |
|||
weed server \ |
|||
-s3 \ |
|||
-s3.port=8333 \ |
|||
-s3.iam.config="$SCRIPT_DIR/test_iam_config.json" \ |
|||
-filer \ |
|||
-volume.max=0 \ |
|||
-master.volumeSizeLimitMB=100 \ |
|||
-s3.allowDeleteBucketNotEmpty=true \ |
|||
> /tmp/weed_test_server.log 2>&1 & |
|||
|
|||
SERVER_PID=$! |
|||
echo "Server started with PID: $SERVER_PID" |
|||
|
|||
# Wait for server to be ready |
|||
echo "Waiting for server to be ready..." |
|||
MAX_WAIT=30 |
|||
COUNTER=0 |
|||
while ! curl -s http://localhost:8333/status > /dev/null 2>&1; do |
|||
sleep 1 |
|||
COUNTER=$((COUNTER + 1)) |
|||
if [ $COUNTER -ge $MAX_WAIT ]; then |
|||
echo -e "${RED}Server failed to start within ${MAX_WAIT} seconds${NC}" |
|||
echo "Server log:" |
|||
cat /tmp/weed_test_server.log |
|||
kill $SERVER_PID 2>/dev/null || true |
|||
exit 1 |
|||
fi |
|||
done |
|||
|
|||
echo -e "${GREEN}Server is ready!${NC}" |
|||
|
|||
# Run the tests |
|||
echo -e "${GREEN}Running integration tests...${NC}" |
|||
cd "$SCRIPT_DIR" |
|||
|
|||
# Trap to ensure server is killed on exit |
|||
trap "echo -e '${YELLOW}Shutting down server...${NC}'; kill $SERVER_PID 2>/dev/null || true" EXIT |
|||
|
|||
# Run the tests |
|||
go test -v -run TestS3PolicyVariables . |
|||
|
|||
TEST_RESULT=$? |
|||
|
|||
if [ $TEST_RESULT -eq 0 ]; then |
|||
echo -e "${GREEN}=== All tests passed! ===${NC}" |
|||
else |
|||
echo -e "${RED}=== Tests failed ===${NC}" |
|||
echo "Server log (last 50 lines):" |
|||
tail -50 /tmp/weed_test_server.log |
|||
fi |
|||
|
|||
exit $TEST_RESULT |
|||
@ -0,0 +1,446 @@ |
|||
package iam |
|||
|
|||
import ( |
|||
"fmt" |
|||
"strings" |
|||
"testing" |
|||
"time" |
|||
|
|||
"github.com/aws/aws-sdk-go/aws" |
|||
"github.com/aws/aws-sdk-go/service/s3" |
|||
"github.com/stretchr/testify/assert" |
|||
"github.com/stretchr/testify/require" |
|||
) |
|||
|
|||
// TestS3PolicyVariablesUsernameInResource tests ${aws:username} in resource paths
|
|||
func TestS3PolicyVariablesUsernameInResource(t *testing.T) { |
|||
framework := NewS3IAMTestFramework(t) |
|||
defer framework.Cleanup() |
|||
|
|||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole") |
|||
require.NoError(t, err) |
|||
|
|||
bucketName := framework.GenerateUniqueBucketName("test-policy-vars") |
|||
err = framework.CreateBucket(adminClient, bucketName) |
|||
require.NoError(t, err) |
|||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) |
|||
|
|||
// Policy with ${aws:username} in resource
|
|||
bucketPolicy := fmt.Sprintf(`{ |
|||
"Version": "2012-10-17", |
|||
"Statement": [{ |
|||
"Effect": "Allow", |
|||
"Principal": "*", |
|||
"Action": ["s3:GetObject", "s3:PutObject"], |
|||
"Resource": ["arn:aws:s3:::%s/${aws:username}/*"] |
|||
}, { |
|||
"Sid": "DenyOthers", |
|||
"Effect": "Deny", |
|||
"Principal": "*", |
|||
"Action": ["s3:GetObject", "s3:PutObject"], |
|||
"NotResource": ["arn:aws:s3:::%s/${aws:username}/*"] |
|||
}] |
|||
}`, bucketName, bucketName) |
|||
|
|||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Policy: aws.String(bucketPolicy), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
// Verify policy contains variable
|
|||
policyResult, err := adminClient.GetBucketPolicy(&s3.GetBucketPolicyInput{ |
|||
Bucket: aws.String(bucketName), |
|||
}) |
|||
require.NoError(t, err) |
|||
assert.Contains(t, *policyResult.Policy, "${aws:username}") |
|||
|
|||
// Test Enforcement: Alice should be able to write to her own folder
|
|||
aliceClient, err := framework.CreateS3ClientWithJWT("alice", "TestReadOnlyRole") |
|||
require.NoError(t, err) |
|||
|
|||
_, err = aliceClient.PutObject(&s3.PutObjectInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Key: aws.String("alice/file.txt"), |
|||
Body: nil, // Empty body is fine for this test
|
|||
}) |
|||
assert.NoError(t, err, "Alice should be allowed to put to alice/file.txt") |
|||
|
|||
// Test Enforcement: Alice should NOT be able to write to bob's folder
|
|||
_, err = aliceClient.PutObject(&s3.PutObjectInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Key: aws.String("bob/file.txt"), |
|||
Body: nil, |
|||
}) |
|||
assert.Error(t, err, "Alice should be denied put to bob/file.txt") |
|||
} |
|||
|
|||
// TestS3PolicyVariablesUsernameInResourcePath tests ${aws:username} in Resource/NotResource
|
|||
// This validates that policy variables are correctly substituted in resource ARNs
|
|||
func TestS3PolicyVariablesUsernameInResourcePath(t *testing.T) { |
|||
framework := NewS3IAMTestFramework(t) |
|||
defer framework.Cleanup() |
|||
|
|||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole") |
|||
require.NoError(t, err) |
|||
|
|||
bucketName := framework.GenerateUniqueBucketName("test-policy-resource") |
|||
err = framework.CreateBucket(adminClient, bucketName) |
|||
require.NoError(t, err) |
|||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) |
|||
|
|||
// Policy with variable in resource ARN
|
|||
bucketPolicy := fmt.Sprintf(`{ |
|||
"Version": "2012-10-17", |
|||
"Statement": [{ |
|||
"Effect": "Allow", |
|||
"Principal": "*", |
|||
"Action": ["s3:GetObject", "s3:PutObject"], |
|||
"Resource": ["arn:aws:s3:::%s/${aws:username}/*"] |
|||
}, { |
|||
"Sid": "DenyOthersFolders", |
|||
"Effect": "Deny", |
|||
"Principal": "*", |
|||
"Action": ["s3:GetObject", "s3:PutObject"], |
|||
"NotResource": ["arn:aws:s3:::%s/${aws:username}/*"] |
|||
}] |
|||
}`, bucketName, bucketName) |
|||
|
|||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Policy: aws.String(bucketPolicy), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
policyResult, err := adminClient.GetBucketPolicy(&s3.GetBucketPolicyInput{ |
|||
Bucket: aws.String(bucketName), |
|||
}) |
|||
require.NoError(t, err) |
|||
assert.Contains(t, *policyResult.Policy, "${aws:username}") |
|||
|
|||
// Test Enforcement: Alice should be able to write to her own folder
|
|||
aliceClient, err := framework.CreateS3ClientWithJWT("alice", "TestReadOnlyRole") |
|||
require.NoError(t, err) |
|||
|
|||
_, err = aliceClient.PutObject(&s3.PutObjectInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Key: aws.String("alice/file.txt"), |
|||
Body: nil, // Empty body is fine for this test
|
|||
}) |
|||
assert.NoError(t, err, "Alice should be allowed to put to alice/file.txt") |
|||
|
|||
// Test Enforcement: Alice should NOT be able to write to bob's folder
|
|||
_, err = aliceClient.PutObject(&s3.PutObjectInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Key: aws.String("bob/file.txt"), |
|||
Body: nil, |
|||
}) |
|||
assert.Error(t, err, "Alice should be denied put to bob/file.txt") |
|||
} |
|||
|
|||
// TestS3PolicyVariablesJWTClaims tests ${jwt:*} variables
|
|||
func TestS3PolicyVariablesJWTClaims(t *testing.T) { |
|||
framework := NewS3IAMTestFramework(t) |
|||
defer framework.Cleanup() |
|||
|
|||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole") |
|||
require.NoError(t, err) |
|||
|
|||
bucketName := framework.GenerateUniqueBucketName("test-policy-jwt") |
|||
err = framework.CreateBucket(adminClient, bucketName) |
|||
require.NoError(t, err) |
|||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) |
|||
|
|||
// Policy with JWT claim variable
|
|||
bucketPolicy := fmt.Sprintf(`{ |
|||
"Version": "2012-10-17", |
|||
"Statement": [{ |
|||
"Effect": "Allow", |
|||
"Principal": "*", |
|||
"Action": ["s3:GetObject"], |
|||
"Resource": ["arn:aws:s3:::%s/${jwt:preferred_username}/*"] |
|||
}] |
|||
}`, bucketName) |
|||
|
|||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Policy: aws.String(bucketPolicy), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
policyResult, err := adminClient.GetBucketPolicy(&s3.GetBucketPolicyInput{ |
|||
Bucket: aws.String(bucketName), |
|||
}) |
|||
require.NoError(t, err) |
|||
assert.Contains(t, *policyResult.Policy, "jwt:preferred_username") |
|||
} |
|||
|
|||
func TestS3PolicyVariablesUsernameIsolation(t *testing.T) { |
|||
framework := NewS3IAMTestFramework(t) |
|||
defer framework.Cleanup() |
|||
|
|||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole") |
|||
require.NoError(t, err) |
|||
|
|||
bucketName := framework.GenerateUniqueBucketName("test-isolation") |
|||
err = framework.CreateBucket(adminClient, bucketName) |
|||
require.NoError(t, err) |
|||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) |
|||
|
|||
bucketPolicy := 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", "s3:ListBucket"], |
|||
"NotResource": "arn:aws:s3:::%s/${aws:username}/*" |
|||
}] |
|||
}`, bucketName, bucketName, bucketName) |
|||
|
|||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Policy: aws.String(bucketPolicy), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
// Wait for policy to propagate (fix race condition)
|
|||
time.Sleep(2 * time.Second) |
|||
|
|||
aliceClient, err := framework.CreateS3ClientWithJWT("alice", "TestReadOnlyRole") |
|||
require.NoError(t, err) |
|||
|
|||
bobClient, err := framework.CreateS3ClientWithJWT("bob", "TestReadOnlyRole") |
|||
require.NoError(t, err) |
|||
|
|||
_, err = aliceClient.PutObject(&s3.PutObjectInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Key: aws.String("alice/data.txt"), |
|||
Body: strings.NewReader("Alice Private Data"), |
|||
}) |
|||
assert.NoError(t, err, "Alice should be able to upload to her own folder") |
|||
|
|||
_, err = aliceClient.PutObject(&s3.PutObjectInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Key: aws.String("bob/data.txt"), |
|||
Body: strings.NewReader("Alice Intrusion"), |
|||
}) |
|||
assert.Error(t, err, "Alice should be denied access to Bob's folder") |
|||
|
|||
_, err = bobClient.PutObject(&s3.PutObjectInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Key: aws.String("bob/data.txt"), |
|||
Body: strings.NewReader("Bob Private Data"), |
|||
}) |
|||
assert.NoError(t, err, "Bob should be able to upload to his own folder") |
|||
|
|||
_, err = bobClient.GetObject(&s3.GetObjectInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Key: aws.String("alice/data.txt"), |
|||
}) |
|||
assert.Error(t, err, "Bob should be denied access to Alice's folder") |
|||
|
|||
listAlice, err := aliceClient.ListObjects(&s3.ListObjectsInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Prefix: aws.String("alice/"), |
|||
}) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, 1, len(listAlice.Contents)) |
|||
|
|||
_, err = aliceClient.ListObjects(&s3.ListObjectsInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Prefix: aws.String("bob/"), |
|||
}) |
|||
assert.Error(t, err, "Alice should be denied listing Bob's folder") |
|||
} |
|||
|
|||
func TestS3PolicyVariablesAccountEnforcement(t *testing.T) { |
|||
framework := NewS3IAMTestFramework(t) |
|||
defer framework.Cleanup() |
|||
|
|||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole") |
|||
require.NoError(t, err) |
|||
|
|||
bucketName := framework.GenerateUniqueBucketName("test-account") |
|||
err = framework.CreateBucket(adminClient, bucketName) |
|||
require.NoError(t, err) |
|||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) |
|||
|
|||
bucketPolicy := fmt.Sprintf(`{ |
|||
"Version": "2012-10-17", |
|||
"Statement": [{ |
|||
"Effect": "Deny", |
|||
"Principal": "*", |
|||
"Action": ["s3:*"], |
|||
"Resource": ["arn:aws:s3:::%s/*"], |
|||
"Condition": { |
|||
"StringNotEquals": { |
|||
"aws:PrincipalAccount": ["999988887777"] |
|||
} |
|||
} |
|||
}, { |
|||
"Effect": "Allow", |
|||
"Principal": "*", |
|||
"Action": ["s3:*"], |
|||
"Resource": ["arn:aws:s3:::%s/*"] |
|||
}] |
|||
}`, bucketName, bucketName) |
|||
|
|||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Policy: aws.String(bucketPolicy), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
authorizedClient, err := framework.CreateS3ClientWithCustomClaims("user1", "TestAdminRole", "999988887777", nil) |
|||
require.NoError(t, err) |
|||
|
|||
unauthorizedClient, err := framework.CreateS3ClientWithCustomClaims("user2", "TestAdminRole", "111122223333", nil) |
|||
require.NoError(t, err) |
|||
|
|||
_, err = authorizedClient.PutObject(&s3.PutObjectInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Key: aws.String("test.txt"), |
|||
Body: strings.NewReader("Authorized Data"), |
|||
}) |
|||
assert.NoError(t, err, "Authorized account should be able to upload") |
|||
|
|||
_, err = unauthorizedClient.PutObject(&s3.PutObjectInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Key: aws.String("fail.txt"), |
|||
Body: strings.NewReader("Unauthorized Data"), |
|||
}) |
|||
assert.Error(t, err, "Unauthorized account should be denied") |
|||
} |
|||
|
|||
func TestS3PolicyVariablesJWTPreferredUsername(t *testing.T) { |
|||
framework := NewS3IAMTestFramework(t) |
|||
defer framework.Cleanup() |
|||
|
|||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole") |
|||
require.NoError(t, err) |
|||
|
|||
bucketName := framework.GenerateUniqueBucketName("test-jwt-claim") |
|||
err = framework.CreateBucket(adminClient, bucketName) |
|||
require.NoError(t, err) |
|||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) |
|||
|
|||
bucketPolicy := fmt.Sprintf(`{ |
|||
"Version": "2012-10-17", |
|||
"Statement": [{ |
|||
"Sid": "AllowOwnFolder", |
|||
"Effect": "Allow", |
|||
"Principal": "*", |
|||
"Action": "s3:PutObject", |
|||
"Resource": "arn:aws:s3:::%s/${jwt:preferred_username}/*" |
|||
}, { |
|||
"Sid": "DenyOtherFolders", |
|||
"Effect": "Deny", |
|||
"Principal": "*", |
|||
"Action": "s3:PutObject", |
|||
"NotResource": "arn:aws:s3:::%s/${jwt:preferred_username}/*" |
|||
}] |
|||
}`, bucketName, bucketName) |
|||
|
|||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Policy: aws.String(bucketPolicy), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
claims := map[string]interface{}{ |
|||
"preferred_username": "jdoe", |
|||
} |
|||
client, err := framework.CreateS3ClientWithCustomClaims("jdoe", "TestReadOnlyRole", "", claims) |
|||
require.NoError(t, err) |
|||
|
|||
_, err = client.PutObject(&s3.PutObjectInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Key: aws.String("jdoe/file.txt"), |
|||
Body: strings.NewReader("JWT Claim Data"), |
|||
}) |
|||
assert.NoError(t, err, "Should allow access based on jwt:preferred_username") |
|||
|
|||
_, err = client.PutObject(&s3.PutObjectInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Key: aws.String("other/file.txt"), |
|||
Body: strings.NewReader("JWT Claim Data"), |
|||
}) |
|||
assert.Error(t, err, "Should deny access if prefix doesn't match jwt:preferred_username") |
|||
} |
|||
|
|||
func TestS3PolicyVariablesLDAPClaims(t *testing.T) { |
|||
framework := NewS3IAMTestFramework(t) |
|||
defer framework.Cleanup() |
|||
|
|||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole") |
|||
require.NoError(t, err) |
|||
|
|||
bucketName := framework.GenerateUniqueBucketName("test-ldap-claim") |
|||
err = framework.CreateBucket(adminClient, bucketName) |
|||
require.NoError(t, err) |
|||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) |
|||
|
|||
bucketPolicy := fmt.Sprintf(`{ |
|||
"Version": "2012-10-17", |
|||
"Statement": [{ |
|||
"Effect": "Allow", |
|||
"Principal": "*", |
|||
"Action": ["s3:PutObject"], |
|||
"Resource": ["arn:aws:s3:::%s/${ldap:username}/*"] |
|||
}, { |
|||
"Effect": "Allow", |
|||
"Principal": "*", |
|||
"Action": ["s3:GetObject"], |
|||
"Resource": ["arn:aws:s3:::%s/*"], |
|||
"Condition": { |
|||
"StringEquals": { |
|||
"ldap:dn": ["cn=manager,dc=example,dc=org"] |
|||
} |
|||
} |
|||
}] |
|||
}`, bucketName, bucketName) |
|||
|
|||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Policy: aws.String(bucketPolicy), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
claims := map[string]interface{}{ |
|||
"ldap:username": "manager", |
|||
"ldap:dn": "cn=manager,dc=example,dc=org", |
|||
} |
|||
client, err := framework.CreateS3ClientWithCustomClaims("manager", "TestReadOnlyRole", "", claims) |
|||
require.NoError(t, err) |
|||
|
|||
_, err = client.PutObject(&s3.PutObjectInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Key: aws.String("manager/data.txt"), |
|||
Body: strings.NewReader("LDAP Upload"), |
|||
}) |
|||
assert.NoError(t, err) |
|||
|
|||
_, err = client.GetObject(&s3.GetObjectInput{ |
|||
Bucket: aws.String(bucketName), |
|||
Key: aws.String("manager/data.txt"), |
|||
}) |
|||
assert.NoError(t, err, "Should allow download based on ldap:dn condition") |
|||
} |
|||
@ -0,0 +1,76 @@ |
|||
{ |
|||
"sts": { |
|||
"issuer": "seaweedfs-sts", |
|||
"signingKey": "dGVzdC1zaWduaW5nLWtleS0zMi1jaGFyYWN0ZXJzLWxvbmc=", |
|||
"tokenDuration": "1h", |
|||
"maxSessionLength": "12h" |
|||
}, |
|||
"policy": { |
|||
"defaultEffect": "Deny", |
|||
"storeType": "memory" |
|||
}, |
|||
"roles": [ |
|||
{ |
|||
"roleName": "TestAdminRole", |
|||
"roleArn": "arn:aws:iam::123456789012:role/TestAdminRole", |
|||
"trustPolicy": { |
|||
"Version": "2012-10-17", |
|||
"Statement": [ |
|||
{ |
|||
"Effect": "Allow", |
|||
"Principal": { |
|||
"Federated": "*" |
|||
}, |
|||
"Action": [ |
|||
"sts:AssumeRoleWithWebIdentity" |
|||
] |
|||
} |
|||
] |
|||
}, |
|||
"attachedPolicies": [ |
|||
"AllowAll" |
|||
] |
|||
}, |
|||
{ |
|||
"roleName": "TestReadOnlyRole", |
|||
"roleArn": "arn:aws:iam::123456789012:role/TestReadOnlyRole", |
|||
"trustPolicy": { |
|||
"Version": "2012-10-17", |
|||
"Statement": [ |
|||
{ |
|||
"Effect": "Allow", |
|||
"Principal": { |
|||
"Federated": "*" |
|||
}, |
|||
"Action": [ |
|||
"sts:AssumeRoleWithWebIdentity" |
|||
] |
|||
} |
|||
] |
|||
}, |
|||
"attachedPolicies": [ |
|||
"AllowAll" |
|||
] |
|||
} |
|||
], |
|||
"policies": [ |
|||
{ |
|||
"name": "AllowAll", |
|||
"document": { |
|||
"version": "2012-10-17", |
|||
"statement": [ |
|||
{ |
|||
"effect": "Allow", |
|||
"action": [ |
|||
"s3:*" |
|||
], |
|||
"resource": [ |
|||
"*" |
|||
] |
|||
} |
|||
] |
|||
} |
|||
} |
|||
], |
|||
"providers": [] |
|||
} |
|||
@ -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)) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue