snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 03:23:42 +05:00
commit 89f5be005d
3997 changed files with 961970 additions and 0 deletions

View File

@@ -0,0 +1,249 @@
# Governance Permission Implementation
This document explains the implementation of `s3:BypassGovernanceRetention` permission in SeaweedFS, providing AWS S3-compatible governance retention bypass functionality.
## Overview
The governance permission system enables proper AWS S3-compatible object retention with governance mode bypass capabilities. This implementation ensures that only users with the appropriate permissions can bypass governance retention, while maintaining security and compliance requirements.
## Features
### 1. Permission-Based Bypass Control
- **s3:BypassGovernanceRetention**: New permission that allows users to bypass governance retention
- **Admin Override**: Admin users can always bypass governance retention
- **Header Detection**: Automatic detection of `x-amz-bypass-governance-retention` header
- **Permission Validation**: Validates user permissions before allowing bypass
### 2. Retention Mode Support
- **GOVERNANCE Mode**: Can be bypassed with proper permission and header
- **COMPLIANCE Mode**: Cannot be bypassed (highest security level)
- **Legal Hold**: Always blocks operations regardless of permissions
### 3. Integration Points
- **DELETE Operations**: Checks governance permissions before object deletion
- **PUT Operations**: Validates permissions before object overwrite
- **Retention Modification**: Ensures proper permissions for retention changes
## Implementation Details
### Core Components
1. **Permission Checker**
```go
func (s3a *S3ApiServer) checkGovernanceBypassPermission(r *http.Request, bucket, object string) bool
```
- Checks if user has `s3:BypassGovernanceRetention` permission
- Validates admin status
- Integrates with existing IAM system
2. **Object Lock Permission Validation**
```go
func (s3a *S3ApiServer) checkObjectLockPermissions(r *http.Request, bucket, object, versionId string, bypassGovernance bool) error
```
- Validates governance bypass permissions
- Checks retention mode (GOVERNANCE vs COMPLIANCE)
- Enforces legal hold restrictions
3. **IAM Integration**
- Added `ACTION_BYPASS_GOVERNANCE_RETENTION` constant
- Updated policy engine with `s3:BypassGovernanceRetention` action
- Integrated with existing identity-based access control
### Permission Flow
```
Request with x-amz-bypass-governance-retention: true
↓
Check if object is under retention
↓
If GOVERNANCE mode:
↓
Check if user has s3:BypassGovernanceRetention permission
↓
If permission granted: Allow operation
If permission denied: Deny operation
↓
If COMPLIANCE mode: Always deny
```
## Configuration
### 1. Identity-Based Configuration
Add governance bypass permission to user actions in `identities.json`:
```json
{
"identities": [
{
"name": "governance-admin",
"credentials": [{"accessKey": "admin123", "secretKey": "secret123"}],
"actions": [
"Read:my-bucket/*",
"Write:my-bucket/*",
"BypassGovernanceRetention:my-bucket/*"
]
}
]
}
```
### 2. Bucket Policy Configuration
Grant governance bypass permission via bucket policies:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:BypassGovernanceRetention",
"Resource": "arn:aws:s3:::bucket/*"
}
]
}
```
**Note**: The policy version should use the standard AWS policy version `PolicyVersion2012_10_17` constant (which equals `"2012-10-17"`).
## Usage Examples
### 1. Delete Object with Governance Bypass
```bash
# User with bypass permission
aws s3api delete-object \
--bucket my-bucket \
--key my-object \
--bypass-governance-retention
# Admin user (always allowed)
aws s3api delete-object \
--bucket my-bucket \
--key my-object \
--bypass-governance-retention
```
### 2. Update Object Retention
```bash
# Extend retention period (requires bypass permission for governance mode)
aws s3api put-object-retention \
--bucket my-bucket \
--key my-object \
--retention Mode=GOVERNANCE,RetainUntilDate=2025-01-01T00:00:00Z \
--bypass-governance-retention
```
### 3. Bulk Object Deletion
```bash
# Delete multiple objects with governance bypass
aws s3api delete-objects \
--bucket my-bucket \
--delete file://delete-objects.json \
--bypass-governance-retention
```
## Error Handling
### Permission Errors
- **ErrAccessDenied**: User lacks `s3:BypassGovernanceRetention` permission
- **ErrGovernanceModeActive**: Governance mode protection without bypass
- **ErrComplianceModeActive**: Compliance mode cannot be bypassed
### Example Error Response
```xml
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>User does not have permission to bypass governance retention</Message>
<RequestId>abc123</RequestId>
<Resource>/my-bucket/my-object</Resource>
</Error>
```
## Security Considerations
### 1. Least Privilege Principle
- Grant bypass permission only to users who absolutely need it
- Use bucket-specific permissions rather than global permissions
- Regularly audit users with bypass permissions
### 2. Compliance Mode Protection
- COMPLIANCE mode objects cannot be bypassed by any user
- Use COMPLIANCE mode for regulatory requirements
- GOVERNANCE mode provides flexibility while maintaining audit trails
### 3. Admin Privileges
- Admin users can always bypass governance retention
- Ensure admin access is properly secured
- Use admin privileges responsibly
## Testing
### Unit Tests
```bash
# Run governance permission tests
go test -v ./weed/s3api/ -run TestGovernance
# Run all object retention tests
go test -v ./weed/s3api/ -run TestObjectRetention
```
### Integration Tests
```bash
# Test with real S3 clients
cd test/s3/retention
go test -v ./... -run TestGovernanceBypass
```
## AWS Compatibility
This implementation provides full AWS S3 compatibility for:
- ✅ `x-amz-bypass-governance-retention` header support
- ✅ `s3:BypassGovernanceRetention` permission
- ✅ GOVERNANCE vs COMPLIANCE mode behavior
- ✅ Legal hold enforcement
- ✅ Error responses and codes
- ✅ Bucket policy integration
- ✅ IAM policy integration
## Troubleshooting
### Common Issues
1. **User cannot bypass governance retention**
- Check if user has `s3:BypassGovernanceRetention` permission
- Verify the header `x-amz-bypass-governance-retention: true` is set
- Ensure object is in GOVERNANCE mode (not COMPLIANCE)
2. **Admin bypass not working**
- Verify user has admin privileges in the IAM system
- Check that object is not under legal hold
- Ensure versioning is enabled on the bucket
3. **Policy not taking effect**
- Verify bucket policy JSON syntax
- Check resource ARN format
- Ensure principal has proper format
## Future Enhancements
- [ ] AWS STS integration for temporary credentials
- [ ] CloudTrail-compatible audit logging
- [ ] Advanced condition evaluation (IP, time, etc.)
- [ ] Integration with external identity providers
- [ ] Fine-grained permissions for different retention operations

View File

@@ -0,0 +1,176 @@
# Integration Example
This shows how to integrate the new policy engine with the existing S3ApiServer.
## Minimal Integration
```go
// In s3api_server.go - modify NewS3ApiServerWithStore function
func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, explicitStore string) (s3ApiServer *S3ApiServer, err error) {
// ... existing code ...
// Create traditional IAM
iam := NewIdentityAccessManagementWithStore(option, explicitStore)
s3ApiServer = &S3ApiServer{
option: option,
iam: iam, // Keep existing for compatibility
randomClientId: util.RandomInt32(),
filerGuard: security.NewGuard([]string{}, signingKey, expiresAfterSec, readSigningKey, readExpiresAfterSec),
cb: NewCircuitBreaker(option),
credentialManager: iam.credentialManager,
bucketConfigCache: NewBucketConfigCache(5 * time.Minute),
}
// Optional: Wrap with policy-backed IAM for enhanced features
if option.EnablePolicyEngine { // Add this config option
// Option 1: Create and set legacy IAM separately
policyBackedIAM := NewPolicyBackedIAM()
policyBackedIAM.SetLegacyIAM(iam)
// Option 2: Create with legacy IAM in one call (convenience method)
// policyBackedIAM := NewPolicyBackedIAMWithLegacy(iam)
// Load existing identities as policies
if err := policyBackedIAM.LoadIdentityPolicies(); err != nil {
glog.Warningf("Failed to load identity policies: %v", err)
}
// Replace IAM with policy-backed version
s3ApiServer.iam = policyBackedIAM
}
// ... rest of existing code ...
}
```
## Router Integration
```go
// In registerRouter function, replace bucket policy handlers:
// Old handlers (if they exist):
// bucket.Methods(http.MethodGet).HandlerFunc(s3a.GetBucketPolicyHandler).Queries("policy", "")
// bucket.Methods(http.MethodPut).HandlerFunc(s3a.PutBucketPolicyHandler).Queries("policy", "")
// bucket.Methods(http.MethodDelete).HandlerFunc(s3a.DeleteBucketPolicyHandler).Queries("policy", "")
// New handlers with policy engine:
if policyBackedIAM, ok := s3a.iam.(*PolicyBackedIAM); ok {
// Use policy-backed handlers
bucket.Methods(http.MethodGet).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(policyBackedIAM.GetBucketPolicyHandler, ACTION_READ)), "GET")).Queries("policy", "")
bucket.Methods(http.MethodPut).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(policyBackedIAM.PutBucketPolicyHandler, ACTION_WRITE)), "PUT")).Queries("policy", "")
bucket.Methods(http.MethodDelete).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(policyBackedIAM.DeleteBucketPolicyHandler, ACTION_WRITE)), "DELETE")).Queries("policy", "")
} else {
// Use existing/fallback handlers
bucket.Methods(http.MethodGet).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetBucketPolicyHandler, ACTION_READ)), "GET")).Queries("policy", "")
bucket.Methods(http.MethodPut).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutBucketPolicyHandler, ACTION_WRITE)), "PUT")).Queries("policy", "")
bucket.Methods(http.MethodDelete).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.DeleteBucketPolicyHandler, ACTION_WRITE)), "DELETE")).Queries("policy", "")
}
```
## Configuration Option
Add to `S3ApiServerOption`:
```go
type S3ApiServerOption struct {
// ... existing fields ...
EnablePolicyEngine bool // Add this field
}
```
## Example Usage
### 1. Existing Users (No Changes)
Your existing `identities.json` continues to work:
```json
{
"identities": [
{
"name": "user1",
"credentials": [{"accessKey": "key1", "secretKey": "secret1"}],
"actions": ["Read:bucket1/*", "Write:bucket1/uploads/*"]
}
]
}
```
### 2. New Users (Enhanced Policies)
Set bucket policies via S3 API:
```bash
# Allow public read
aws s3api put-bucket-policy --bucket my-bucket --policy file://policy.json
# Where policy.json contains:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*"
}
]
}
```
### 3. Advanced Conditions
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::secure-bucket/*",
"Condition": {
"IpAddress": {
"aws:SourceIp": "192.168.1.0/24"
},
"Bool": {
"aws:SecureTransport": "true"
}
}
}
]
}
```
## Migration Strategy
### Phase 1: Enable Policy Engine (Opt-in)
- Set `EnablePolicyEngine: true` in server options
- Existing `identities.json` automatically converted to policies
- Add bucket policies as needed
### Phase 2: Full Policy Management
- Use AWS CLI/SDK for policy management
- Gradually migrate from `identities.json` to pure IAM policies
- Take advantage of advanced conditions and features
## Testing
```bash
# Test existing functionality
go test -v -run TestCanDo
# Test new policy engine
go test -v -run TestPolicyEngine
# Test integration
go test -v -run TestPolicyBackedIAM
```
The integration is designed to be:
- **Backward compatible** - Existing setups work unchanged
- **Opt-in** - Enable policy engine only when needed
- **Gradual** - Migrate at your own pace
- **AWS compatible** - Use standard AWS tools and patterns

View File

@@ -0,0 +1,54 @@
# Policy Engine Examples
This document contains examples of how to use the SeaweedFS Policy Engine.
## Overview
The examples in `examples.go` demonstrate various policy configurations and usage patterns. The examples file is excluded from production builds using build tags to reduce binary size.
## To Use Examples
If you need to use the examples during development or testing, you can:
1. **Remove the build tag**: Remove the `//go:build ignore` and `// +build ignore` lines from `examples.go`
2. **Use during development**: The examples are available during development but not in production builds
3. **Copy specific examples**: Copy the JSON examples you need into your own code
## Example Categories
The examples file includes:
- **Legacy Identity Format**: Examples of existing identities.json format
- **Policy Documents**: Various AWS S3-compatible policy examples
- **Condition Examples**: Complex condition-based policies
- **Migration Examples**: How to migrate from legacy to policy-based IAM
- **Integration Examples**: How to integrate with existing systems
## Usage Functions
The examples file provides helper functions:
- `GetAllExamples()`: Returns all example policies
- `ValidateExamplePolicies()`: Validates all examples
- `GetExamplePolicy(name)`: Gets a specific example
- `CreateExamplePolicyDocument(name)`: Creates a policy document
- `PrintExamplePolicyPretty(name)`: Pretty-prints an example
- `ExampleUsage()`: Shows basic usage patterns
- `ExampleLegacyIntegration()`: Shows legacy integration
- `ExampleConditions()`: Shows condition usage
- `ExampleMigrationStrategy()`: Shows migration approach
## To Enable Examples in Development
```go
// Remove build tags from examples.go, then:
import "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
// Use examples
examples := policy_engine.GetAllExamples()
policy, err := policy_engine.GetExamplePolicy("read-only-user")
```
## Note
The examples are excluded from production builds to keep binary size minimal. They are available for development and testing purposes only.

View File

@@ -0,0 +1,370 @@
# SeaweedFS Policy Evaluation Engine
This document describes the comprehensive policy evaluation engine that has been added to SeaweedFS, providing AWS S3-compatible policy support while maintaining full backward compatibility with existing `identities.json` configuration.
## Overview
The policy engine provides:
- **Full AWS S3 policy compatibility** - JSON policies with conditions, wildcards, and complex logic
- **Backward compatibility** - Existing `identities.json` continues to work unchanged
- **Bucket policies** - Per-bucket access control policies
- **IAM policies** - User and group-level policies
- **Condition evaluation** - IP restrictions, time-based access, SSL-only, etc.
- **AWS-compliant evaluation order** - Explicit Deny > Explicit Allow > Default Deny
## Architecture
### Files Created
1. **`policy_engine/types.go`** - Core policy data structures and validation
2. **`policy_engine/conditions.go`** - Condition evaluators (StringEquals, IpAddress, etc.)
3. **`policy_engine/engine.go`** - Main policy evaluation engine
4. **`policy_engine/integration.go`** - Integration with existing IAM system
5. **`policy_engine/engine_test.go`** - Comprehensive tests
6. **`policy_engine/examples.go`** - Usage examples and documentation (excluded from builds)
7. **`policy_engine/wildcard_matcher.go`** - Optimized wildcard pattern matching
8. **`policy_engine/wildcard_matcher_test.go`** - Wildcard matching tests
### Key Components
```
PolicyEngine
├── Bucket Policies (per-bucket JSON policies)
├── User Policies (converted from identities.json + new IAM policies)
├── Condition Evaluators (IP, time, string, numeric, etc.)
└── Evaluation Logic (AWS-compliant precedence)
```
## Backward Compatibility
### Existing identities.json (No Changes Required)
Your existing configuration continues to work exactly as before:
```json
{
"identities": [
{
"name": "readonly_user",
"credentials": [{"accessKey": "key123", "secretKey": "secret123"}],
"actions": ["Read:public-bucket/*", "List:public-bucket"]
}
]
}
```
Legacy actions are automatically converted to AWS-style policies:
- `Read:bucket/*` → `s3:GetObject` on `arn:aws:s3:::bucket/*`
- `Write:bucket` → `s3:PutObject`, `s3:DeleteObject` on `arn:aws:s3:::bucket/*`
- `Admin` → `s3:*` on `arn:aws:s3:::*`
## New Capabilities
### 1. Bucket Policies
Set bucket-level policies using standard S3 API:
```bash
# Set bucket policy
curl -X PUT "http://localhost:8333/bucket?policy" \
-H "Authorization: AWS access_key:signature" \
-d '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/*"
}
]
}'
# Get bucket policy
curl "http://localhost:8333/bucket?policy"
# Delete bucket policy
curl -X DELETE "http://localhost:8333/bucket?policy"
```
### 2. Advanced Conditions
Support for all AWS condition operators:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::secure-bucket/*",
"Condition": {
"IpAddress": {
"aws:SourceIp": ["192.168.1.0/24", "10.0.0.0/8"]
},
"Bool": {
"aws:SecureTransport": "true"
},
"DateGreaterThan": {
"aws:CurrentTime": "2023-01-01T00:00:00Z"
}
}
}
]
}
```
### 3. Supported Condition Operators
- **String**: `StringEquals`, `StringNotEquals`, `StringLike`, `StringNotLike`
- **Numeric**: `NumericEquals`, `NumericLessThan`, `NumericGreaterThan`, etc.
- **Date**: `DateEquals`, `DateLessThan`, `DateGreaterThan`, etc.
- **IP**: `IpAddress`, `NotIpAddress` (supports CIDR notation)
- **Boolean**: `Bool`
- **ARN**: `ArnEquals`, `ArnLike`
- **Null**: `Null`
### 4. Condition Keys
Standard AWS condition keys are supported:
- `aws:CurrentTime` - Current request time
- `aws:SourceIp` - Client IP address
- `aws:SecureTransport` - Whether HTTPS is used
- `aws:UserAgent` - Client user agent
- `s3:x-amz-acl` - Requested ACL
- `s3:VersionId` - Object version ID
- `s3:ExistingObjectTag/<tag-key>` - Value of an existing object tag (see example below)
- And many more...
### 5. Object Tag-Based Access Control
You can control access based on object tags using `s3:ExistingObjectTag/<tag-key>`:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"StringEquals": {
"s3:ExistingObjectTag/status": ["public"]
}
}
}
]
}
```
This allows anonymous access only to objects that have a tag `status=public`.
**Deny access to confidential objects:**
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*"
},
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"StringEquals": {
"s3:ExistingObjectTag/classification": ["confidential", "secret"]
}
}
}
]
}
```
**Supported Operations for Tag-Based Conditions:**
Tag-based conditions (`s3:ExistingObjectTag/<key>`) are evaluated for the following operations:
- `s3:GetObject` (GET object)
- `s3:GetObjectVersion` (GET object with versionId)
- `s3:HeadObject` (HEAD object)
Note: For these conditions to be evaluated, the object must exist and the policy engine re-checks access after fetching the object metadata.
## Policy Evaluation
### Evaluation Order (AWS-Compatible)
1. **Explicit Deny** - If any policy explicitly denies access → **DENY**
2. **Explicit Allow** - If any policy explicitly allows access → **ALLOW**
3. **Default Deny** - If no policy matches → **DENY**
### Policy Sources (Evaluated Together)
1. **Bucket Policies** - Stored per-bucket, highest priority
2. **User Policies** - Converted from `identities.json` + new IAM policies
3. **Legacy IAM** - For backward compatibility (lowest priority)
## Examples
### Public Read Bucket
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicRead",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::public-bucket/*"
}
]
}
```
### IP-Restricted Bucket
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::secure-bucket/*",
"Condition": {
"IpAddress": {
"aws:SourceIp": "192.168.1.0/24"
}
}
}
]
}
```
### SSL-Only Access
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::ssl-bucket/*", "arn:aws:s3:::ssl-bucket"],
"Condition": {
"Bool": {
"aws:SecureTransport": "false"
}
}
}
]
}
```
## Integration
### For Existing SeaweedFS Users
1. **No changes required** - Your existing setup continues to work
2. **Optional enhancement** - Add bucket policies for fine-grained control
3. **Gradual migration** - Move to full AWS policies over time
### For New Users
1. Start with either `identities.json` or AWS-style policies
2. Use bucket policies for complex access patterns
3. Full feature parity with AWS S3 policies
## Testing
Run the policy engine tests:
```bash
# Core policy tests
go test -v -run TestPolicyEngine
# Condition evaluator tests
go test -v -run TestConditionEvaluators
# Legacy compatibility tests
go test -v -run TestConvertIdentityToPolicy
# Validation tests
go test -v -run TestPolicyValidation
```
## Performance
- **Compiled patterns** - Regex patterns are pre-compiled for fast matching
- **Cached policies** - Policies are cached in memory with TTL
- **Early termination** - Evaluation stops on first explicit deny
- **Minimal overhead** - Backward compatibility with minimal performance impact
## Migration Path
### Phase 1: Backward Compatible (Current)
- Keep existing `identities.json` unchanged
- Add bucket policies as needed
- Legacy actions automatically converted to AWS policies
### Phase 2: Enhanced (Optional)
- Add advanced conditions to policies
- Use full AWS S3 policy features
- Maintain backward compatibility
### Phase 3: Full Migration (Future)
- Migrate to pure IAM policies
- Use AWS CLI/SDK for policy management
- Complete AWS S3 feature parity
## Compatibility
- Full backward compatibility with existing `identities.json`
- AWS S3 API compatibility for bucket policies
- Standard condition operators and keys
- Proper evaluation precedence (Deny > Allow > Default Deny)
- Performance optimized with caching and compiled patterns
The policy engine provides a seamless upgrade path from SeaweedFS's existing simple IAM system to full AWS S3-compatible policies, giving you the best of both worlds: simplicity for basic use cases and power for complex enterprise scenarios.
## Feature Status
### Implemented
| Feature | Description |
|---------|-------------|
| Bucket Policies | Full AWS S3-compatible bucket policies |
| Condition Operators | StringEquals, IpAddress, Bool, DateGreaterThan, etc. |
| `aws:SourceIp` | IP-based access control with CIDR support |
| `aws:SecureTransport` | Require HTTPS |
| `aws:CurrentTime` | Time-based access control |
| `s3:ExistingObjectTag/<key>` | Tag-based access control for existing objects |
| Wildcard Patterns | Support for `*` and `?` in actions and resources |
| Principal Matching | `*`, account IDs, and user ARNs |
### Planned
| Feature | GitHub Issue |
|---------|--------------|
| `s3:RequestObjectTag/<key>` | For tag conditions on PUT requests |
| `s3:RequestObjectTagKeys` | Check which tag keys are in request |
| `s3:x-amz-content-sha256` | Content hash condition |
| `s3:x-amz-server-side-encryption` | SSE condition |
| `s3:x-amz-storage-class` | Storage class condition |
| Cross-account access | Access across different accounts |
| VPC Endpoint policies | Network-level policies |
For feature requests or to track progress, see the [GitHub Issues](https://github.com/seaweedfs/seaweedfs/issues).

View File

@@ -0,0 +1,90 @@
package policy_engine
import (
"fmt"
"strings"
)
// MaxBucketPolicySize mirrors AWS S3's 20 KB bucket-policy limit, enforced
// by both writers (the S3 gateway's PutBucketPolicy and the admin UI) so
// neither surface can store a document the other refuses to manage.
const MaxBucketPolicySize = 20 * 1024
// ValidateBucketPolicy performs bucket-specific policy validation, on top of
// the generic structural checks in ValidatePolicy - callers run that first,
// so the version and non-empty-statement rules are not re-checked here. It
// enforces the rules that make a policy document valid as an S3 *bucket*
// policy specifically: every statement must name a Principal, and every
// Resource/NotResource/Action must scope to the given bucket.
//
// This is shared between the S3 gateway's PutBucketPolicy handler
// (weed/s3api/s3api_bucket_policy_handlers.go) and the admin UI
// (weed/admin/dash) so both enforce identical rules.
func ValidateBucketPolicy(policyDoc *PolicyDocument, bucket string) error {
for i, statement := range policyDoc.Statement {
// Bucket policies must have Principal
if statement.Principal == nil {
return fmt.Errorf("statement %d: bucket policies must specify a Principal", i)
}
// Validate resources refer to this bucket
for _, resource := range statement.Resource.Strings() {
if !ResourceMatchesBucket(resource, bucket) {
return fmt.Errorf("statement %d: resource %s does not match bucket %s", i, resource, bucket)
}
}
// Validate NotResources refer to this bucket
if statement.NotResource != nil {
for _, notResource := range statement.NotResource.Strings() {
if !ResourceMatchesBucket(notResource, bucket) {
return fmt.Errorf("statement %d: NotResource %s does not match bucket %s", i, notResource, bucket)
}
}
}
// Validate actions are S3 actions
for _, action := range statement.Action.Strings() {
if !strings.HasPrefix(action, "s3:") {
return fmt.Errorf("statement %d: bucket policies only support S3 actions, got %s", i, action)
}
}
for operator := range statement.Condition {
if _, err := GetConditionEvaluator(operator); err != nil {
return fmt.Errorf("statement %d: unsupported condition operator %q: %v", i, operator, err)
}
}
}
return nil
}
// ResourceMatchesBucket checks if a resource ARN is valid for the given bucket.
func ResourceMatchesBucket(resource, bucket string) bool {
// Accepted formats for S3 bucket policies:
// AWS-style ARNs (standard):
// arn:aws:s3:::bucket-name
// arn:aws:s3:::bucket-name/*
// arn:aws:s3:::bucket-name/path/to/object
// Simplified formats (for convenience):
// bucket-name
// bucket-name/*
// bucket-name/path/to/object
var resourcePath string
const awsPrefix = "arn:aws:s3:::"
// Strip the optional ARN prefix to get the resource path
if path, ok := strings.CutPrefix(resource, awsPrefix); ok {
resourcePath = path
} else {
resourcePath = resource
}
// After stripping the optional ARN prefix, the resource path must
// either match the bucket name exactly, or be a path within the bucket.
return resourcePath == bucket ||
resourcePath == bucket+"/*" ||
strings.HasPrefix(resourcePath, bucket+"/")
}

View File

@@ -0,0 +1,108 @@
package policy_engine
import "testing"
func TestResourceMatchesBucket(t *testing.T) {
tests := []struct {
name string
resource string
bucket string
want bool
}{
{"bare bucket name", "my-bucket", "my-bucket", true},
{"bare wildcard", "my-bucket/*", "my-bucket", true},
{"bare object key", "my-bucket/path/to/key", "my-bucket", true},
{"arn bucket", "arn:aws:s3:::my-bucket", "my-bucket", true},
{"arn wildcard", "arn:aws:s3:::my-bucket/*", "my-bucket", true},
{"arn object key", "arn:aws:s3:::my-bucket/path/to/key", "my-bucket", true},
{"wrong bucket", "other-bucket", "my-bucket", false},
{"wrong bucket arn", "arn:aws:s3:::other-bucket/*", "my-bucket", false},
{"prefix collision", "my-bucket2", "my-bucket", false},
{"prefix collision arn", "arn:aws:s3:::my-bucket2/*", "my-bucket", false},
{"empty resource", "", "my-bucket", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ResourceMatchesBucket(tt.resource, tt.bucket); got != tt.want {
t.Errorf("ResourceMatchesBucket(%q, %q) = %v, want %v", tt.resource, tt.bucket, got, tt.want)
}
})
}
}
func simpleAwsPrincipal() *PolicyPrincipal {
return NewPolicyPrincipalPtr("*")
}
func TestValidateBucketPolicy(t *testing.T) {
bucket := "my-bucket"
validStatement := func() PolicyStatement {
return PolicyStatement{
Effect: PolicyEffectAllow,
Principal: simpleAwsPrincipal(),
Action: NewStringOrStringSlice("s3:GetObject"),
Resource: NewStringOrStringSlicePtr("arn:aws:s3:::" + bucket + "/*"),
}
}
t.Run("valid policy", func(t *testing.T) {
doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{validStatement()}}
if err := ValidateBucketPolicy(doc, bucket); err != nil {
t.Errorf("expected no error, got %v", err)
}
})
t.Run("bad version", func(t *testing.T) {
// Version and non-empty-statement rules live in ValidatePolicy,
// which callers run first.
doc := &PolicyDocument{Version: "2008-10-17", Statement: []PolicyStatement{validStatement()}}
if err := ValidatePolicy(doc); err == nil {
t.Error("expected error for bad version")
}
})
t.Run("zero statements", func(t *testing.T) {
doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{}}
if err := ValidatePolicy(doc); err == nil {
t.Error("expected error for zero statements")
}
})
t.Run("missing principal", func(t *testing.T) {
stmt := validStatement()
stmt.Principal = nil
doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{stmt}}
if err := ValidateBucketPolicy(doc, bucket); err == nil {
t.Error("expected error for missing principal")
}
})
t.Run("foreign resource", func(t *testing.T) {
stmt := validStatement()
stmt.Resource = NewStringOrStringSlicePtr("arn:aws:s3:::other-bucket/*")
doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{stmt}}
if err := ValidateBucketPolicy(doc, bucket); err == nil {
t.Error("expected error for foreign resource")
}
})
t.Run("foreign not-resource", func(t *testing.T) {
stmt := validStatement()
stmt.Resource = nil
stmt.NotResource = NewStringOrStringSlicePtr("arn:aws:s3:::other-bucket/*")
doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{stmt}}
if err := ValidateBucketPolicy(doc, bucket); err == nil {
t.Error("expected error for foreign NotResource")
}
})
t.Run("non-s3 action", func(t *testing.T) {
stmt := validStatement()
stmt.Action = NewStringOrStringSlice("iam:CreateUser")
doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{stmt}}
if err := ValidateBucketPolicy(doc, bucket); err == nil {
t.Error("expected error for non-s3 action")
}
})
}

View File

@@ -0,0 +1,789 @@
package policy_engine
import (
"fmt"
"net"
"reflect"
"strconv"
"strings"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
)
// LRUNode represents a node in the doubly-linked list for efficient LRU operations
type LRUNode struct {
key string
value []string
prev *LRUNode
next *LRUNode
}
// NormalizedValueCache provides size-limited caching for normalized values with efficient LRU eviction
type NormalizedValueCache struct {
mu sync.RWMutex
cache map[string]*LRUNode
maxSize int
head *LRUNode // Most recently used
tail *LRUNode // Least recently used
}
// NewNormalizedValueCache creates a new normalized value cache with configurable size
func NewNormalizedValueCache(maxSize int) *NormalizedValueCache {
if maxSize <= 0 {
maxSize = 1000 // Default size
}
// Create dummy head and tail nodes for easier list manipulation
head := &LRUNode{}
tail := &LRUNode{}
head.next = tail
tail.prev = head
return &NormalizedValueCache{
cache: make(map[string]*LRUNode),
maxSize: maxSize,
head: head,
tail: tail,
}
}
// Get retrieves a cached value and updates access order in O(1) time
func (c *NormalizedValueCache) Get(key string) ([]string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if node, exists := c.cache[key]; exists {
// Move to head (most recently used) - O(1) operation
c.moveToHead(node)
return node.value, true
}
return nil, false
}
// Set stores a value in the cache with size limit enforcement in O(1) time
func (c *NormalizedValueCache) Set(key string, value []string) {
c.mu.Lock()
defer c.mu.Unlock()
if node, exists := c.cache[key]; exists {
// Update existing node and move to head
node.value = value
c.moveToHead(node)
return
}
// Create new node
newNode := &LRUNode{
key: key,
value: value,
}
// If at max size, evict least recently used
if len(c.cache) >= c.maxSize {
c.evictLeastRecentlyUsed()
}
// Add to cache and move to head
c.cache[key] = newNode
c.addToHead(newNode)
}
// moveToHead moves a node to the head of the list (most recently used) - O(1)
func (c *NormalizedValueCache) moveToHead(node *LRUNode) {
c.removeNode(node)
c.addToHead(node)
}
// addToHead adds a node right after the head - O(1)
func (c *NormalizedValueCache) addToHead(node *LRUNode) {
node.prev = c.head
node.next = c.head.next
c.head.next.prev = node
c.head.next = node
}
// removeNode removes a node from the list - O(1)
func (c *NormalizedValueCache) removeNode(node *LRUNode) {
node.prev.next = node.next
node.next.prev = node.prev
}
// removeTail removes the last node before tail (least recently used) - O(1)
func (c *NormalizedValueCache) removeTail() *LRUNode {
lastNode := c.tail.prev
c.removeNode(lastNode)
return lastNode
}
// evictLeastRecentlyUsed removes the least recently used item in O(1) time
func (c *NormalizedValueCache) evictLeastRecentlyUsed() {
tail := c.removeTail()
delete(c.cache, tail.key)
}
// Global cache instance with size limit
var normalizedValueCache = NewNormalizedValueCache(1000)
// getCachedNormalizedValues returns cached normalized values or caches new ones
func getCachedNormalizedValues(value interface{}) []string {
// Create a string key for caching - more efficient than fmt.Sprintf
typeStr := reflect.TypeOf(value).String()
cacheKey := typeStr + ":" + fmt.Sprint(value)
// Try to get from cache
if cached, exists := normalizedValueCache.Get(cacheKey); exists {
return cached
}
// Not in cache, normalize and store
// Use the error-handling version for better error reporting
normalized, err := normalizeToStringSliceWithError(value)
if err != nil {
glog.Warningf("Failed to normalize policy value %v: %v", value, err)
// Fallback to string conversion for backward compatibility
normalized = []string{fmt.Sprintf("%v", value)}
}
normalizedValueCache.Set(cacheKey, normalized)
return normalized
}
// ConditionEvaluator evaluates policy conditions
type ConditionEvaluator interface {
Evaluate(conditionValue interface{}, contextValues []string) bool
}
// StringEqualsEvaluator evaluates StringEquals conditions
type StringEqualsEvaluator struct{}
func (e *StringEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
for _, contextValue := range contextValues {
if expected == contextValue {
return true
}
}
}
return false
}
// StringNotEqualsEvaluator evaluates StringNotEquals conditions
type StringNotEqualsEvaluator struct{}
func (e *StringNotEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
for _, contextValue := range contextValues {
if expected == contextValue {
return false
}
}
}
return true
}
// StringLikeEvaluator evaluates StringLike conditions (supports wildcards)
type StringLikeEvaluator struct{}
func (e *StringLikeEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
patterns := getCachedNormalizedValues(conditionValue)
for _, pattern := range patterns {
for _, contextValue := range contextValues {
if wildcard.MatchesWildcard(pattern, contextValue) {
return true
}
}
}
return false
}
// StringNotLikeEvaluator evaluates StringNotLike conditions
type StringNotLikeEvaluator struct{}
func (e *StringNotLikeEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
patterns := getCachedNormalizedValues(conditionValue)
for _, pattern := range patterns {
for _, contextValue := range contextValues {
if wildcard.MatchesWildcard(pattern, contextValue) {
return false
}
}
}
return true
}
// StringEqualsIgnoreCaseEvaluator evaluates StringEqualsIgnoreCase conditions
type StringEqualsIgnoreCaseEvaluator struct{}
func (e *StringEqualsIgnoreCaseEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
for _, contextValue := range contextValues {
if strings.EqualFold(expected, contextValue) {
return true
}
}
}
return false
}
// StringNotEqualsIgnoreCaseEvaluator evaluates StringNotEqualsIgnoreCase conditions
type StringNotEqualsIgnoreCaseEvaluator struct{}
func (e *StringNotEqualsIgnoreCaseEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
for _, contextValue := range contextValues {
if strings.EqualFold(expected, contextValue) {
return false
}
}
}
return true
}
// NumericEqualsEvaluator evaluates NumericEquals conditions
type NumericEqualsEvaluator struct{}
func (e *NumericEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
expectedFloat, err := strconv.ParseFloat(expected, 64)
if err != nil {
continue
}
for _, contextValue := range contextValues {
contextFloat, err := strconv.ParseFloat(contextValue, 64)
if err != nil {
continue
}
if expectedFloat == contextFloat {
return true
}
}
}
return false
}
// NumericNotEqualsEvaluator evaluates NumericNotEquals conditions
type NumericNotEqualsEvaluator struct{}
func (e *NumericNotEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
expectedFloat, err := strconv.ParseFloat(expected, 64)
if err != nil {
continue
}
for _, contextValue := range contextValues {
contextFloat, err := strconv.ParseFloat(contextValue, 64)
if err != nil {
continue
}
if expectedFloat == contextFloat {
return false
}
}
}
return true
}
// NumericLessThanEvaluator evaluates NumericLessThan conditions
type NumericLessThanEvaluator struct{}
func (e *NumericLessThanEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
expectedFloat, err := strconv.ParseFloat(expected, 64)
if err != nil {
continue
}
for _, contextValue := range contextValues {
contextFloat, err := strconv.ParseFloat(contextValue, 64)
if err != nil {
continue
}
if contextFloat < expectedFloat {
return true
}
}
}
return false
}
// NumericLessThanEqualsEvaluator evaluates NumericLessThanEquals conditions
type NumericLessThanEqualsEvaluator struct{}
func (e *NumericLessThanEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
expectedFloat, err := strconv.ParseFloat(expected, 64)
if err != nil {
continue
}
for _, contextValue := range contextValues {
contextFloat, err := strconv.ParseFloat(contextValue, 64)
if err != nil {
continue
}
if contextFloat <= expectedFloat {
return true
}
}
}
return false
}
// NumericGreaterThanEvaluator evaluates NumericGreaterThan conditions
type NumericGreaterThanEvaluator struct{}
func (e *NumericGreaterThanEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
expectedFloat, err := strconv.ParseFloat(expected, 64)
if err != nil {
continue
}
for _, contextValue := range contextValues {
contextFloat, err := strconv.ParseFloat(contextValue, 64)
if err != nil {
continue
}
if contextFloat > expectedFloat {
return true
}
}
}
return false
}
// NumericGreaterThanEqualsEvaluator evaluates NumericGreaterThanEquals conditions
type NumericGreaterThanEqualsEvaluator struct{}
func (e *NumericGreaterThanEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
expectedFloat, err := strconv.ParseFloat(expected, 64)
if err != nil {
continue
}
for _, contextValue := range contextValues {
contextFloat, err := strconv.ParseFloat(contextValue, 64)
if err != nil {
continue
}
if contextFloat >= expectedFloat {
return true
}
}
}
return false
}
// DateEqualsEvaluator evaluates DateEquals conditions
type DateEqualsEvaluator struct{}
func (e *DateEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
expectedTime, err := time.Parse(time.RFC3339, expected)
if err != nil {
continue
}
for _, contextValue := range contextValues {
contextTime, err := time.Parse(time.RFC3339, contextValue)
if err != nil {
continue
}
if expectedTime.Equal(contextTime) {
return true
}
}
}
return false
}
// DateNotEqualsEvaluator evaluates DateNotEquals conditions
type DateNotEqualsEvaluator struct{}
func (e *DateNotEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
expectedTime, err := time.Parse(time.RFC3339, expected)
if err != nil {
continue
}
for _, contextValue := range contextValues {
contextTime, err := time.Parse(time.RFC3339, contextValue)
if err != nil {
continue
}
if expectedTime.Equal(contextTime) {
return false
}
}
}
return true
}
// DateLessThanEvaluator evaluates DateLessThan conditions
type DateLessThanEvaluator struct{}
func (e *DateLessThanEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
expectedTime, err := time.Parse(time.RFC3339, expected)
if err != nil {
continue
}
for _, contextValue := range contextValues {
contextTime, err := time.Parse(time.RFC3339, contextValue)
if err != nil {
continue
}
if contextTime.Before(expectedTime) {
return true
}
}
}
return false
}
// DateLessThanEqualsEvaluator evaluates DateLessThanEquals conditions
type DateLessThanEqualsEvaluator struct{}
func (e *DateLessThanEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
expectedTime, err := time.Parse(time.RFC3339, expected)
if err != nil {
continue
}
for _, contextValue := range contextValues {
contextTime, err := time.Parse(time.RFC3339, contextValue)
if err != nil {
continue
}
if contextTime.Before(expectedTime) || contextTime.Equal(expectedTime) {
return true
}
}
}
return false
}
// DateGreaterThanEvaluator evaluates DateGreaterThan conditions
type DateGreaterThanEvaluator struct{}
func (e *DateGreaterThanEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
expectedTime, err := time.Parse(time.RFC3339, expected)
if err != nil {
continue
}
for _, contextValue := range contextValues {
contextTime, err := time.Parse(time.RFC3339, contextValue)
if err != nil {
continue
}
if contextTime.After(expectedTime) {
return true
}
}
}
return false
}
// DateGreaterThanEqualsEvaluator evaluates DateGreaterThanEquals conditions
type DateGreaterThanEqualsEvaluator struct{}
func (e *DateGreaterThanEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
expectedTime, err := time.Parse(time.RFC3339, expected)
if err != nil {
continue
}
for _, contextValue := range contextValues {
contextTime, err := time.Parse(time.RFC3339, contextValue)
if err != nil {
continue
}
if contextTime.After(expectedTime) || contextTime.Equal(expectedTime) {
return true
}
}
}
return false
}
// BoolEvaluator evaluates Bool conditions
type BoolEvaluator struct{}
func (e *BoolEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
for _, contextValue := range contextValues {
if strings.ToLower(expected) == strings.ToLower(contextValue) {
return true
}
}
}
return false
}
// IpAddressEvaluator evaluates IpAddress conditions
type IpAddressEvaluator struct{}
func (e *IpAddressEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
_, expectedNet, err := net.ParseCIDR(expected)
if err != nil {
// Try parsing as single IP
expectedIP := net.ParseIP(expected)
if expectedIP == nil {
glog.V(3).Infof("Failed to parse expected IP address: %s", expected)
continue
}
for _, contextValue := range contextValues {
contextIP := net.ParseIP(contextValue)
if contextIP == nil {
glog.V(3).Infof("Failed to parse IP address: %s", contextValue)
continue
}
if contextIP.Equal(expectedIP) {
return true
}
}
} else {
// CIDR network
for _, contextValue := range contextValues {
contextIP := net.ParseIP(contextValue)
if contextIP == nil {
glog.V(3).Infof("Failed to parse IP address: %s", contextValue)
continue
}
if expectedNet.Contains(contextIP) {
return true
}
}
}
}
return false
}
// NotIpAddressEvaluator evaluates NotIpAddress conditions
type NotIpAddressEvaluator struct{}
func (e *NotIpAddressEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
_, expectedNet, err := net.ParseCIDR(expected)
if err != nil {
// Try parsing as single IP
expectedIP := net.ParseIP(expected)
if expectedIP == nil {
glog.V(3).Infof("Failed to parse expected IP address: %s", expected)
continue
}
for _, contextValue := range contextValues {
contextIP := net.ParseIP(contextValue)
if contextIP == nil {
glog.V(3).Infof("Failed to parse IP address: %s", contextValue)
continue
}
if contextIP.Equal(expectedIP) {
return false
}
}
} else {
// CIDR network
for _, contextValue := range contextValues {
contextIP := net.ParseIP(contextValue)
if contextIP == nil {
glog.V(3).Infof("Failed to parse IP address: %s", contextValue)
continue
}
if expectedNet.Contains(contextIP) {
return false
}
}
}
}
return true
}
// ArnEqualsEvaluator evaluates ArnEquals conditions
type ArnEqualsEvaluator struct{}
func (e *ArnEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
for _, contextValue := range contextValues {
if expected == contextValue {
return true
}
}
}
return false
}
// ArnLikeEvaluator evaluates ArnLike conditions
type ArnLikeEvaluator struct{}
func (e *ArnLikeEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
patterns := getCachedNormalizedValues(conditionValue)
for _, pattern := range patterns {
for _, contextValue := range contextValues {
if wildcard.MatchesWildcard(pattern, contextValue) {
return true
}
}
}
return false
}
// NullEvaluator evaluates Null conditions
type NullEvaluator struct{}
func (e *NullEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
expectedValues := getCachedNormalizedValues(conditionValue)
for _, expected := range expectedValues {
expectedBool := strings.ToLower(expected) == "true"
contextExists := len(contextValues) > 0
if expectedBool && !contextExists {
return true // Key should be null and it is
}
if !expectedBool && contextExists {
return true // Key should not be null and it isn't
}
}
return false
}
// GetConditionEvaluator returns the appropriate evaluator for a condition operator
func GetConditionEvaluator(operator string) (ConditionEvaluator, error) {
switch operator {
case "StringEquals":
return &StringEqualsEvaluator{}, nil
case "StringNotEquals":
return &StringNotEqualsEvaluator{}, nil
case "StringLike":
return &StringLikeEvaluator{}, nil
case "StringNotLike":
return &StringNotLikeEvaluator{}, nil
case "StringEqualsIgnoreCase":
return &StringEqualsIgnoreCaseEvaluator{}, nil
case "StringNotEqualsIgnoreCase":
return &StringNotEqualsIgnoreCaseEvaluator{}, nil
case "NumericEquals":
return &NumericEqualsEvaluator{}, nil
case "NumericNotEquals":
return &NumericNotEqualsEvaluator{}, nil
case "NumericLessThan":
return &NumericLessThanEvaluator{}, nil
case "NumericLessThanEquals":
return &NumericLessThanEqualsEvaluator{}, nil
case "NumericGreaterThan":
return &NumericGreaterThanEvaluator{}, nil
case "NumericGreaterThanEquals":
return &NumericGreaterThanEqualsEvaluator{}, nil
case "DateEquals":
return &DateEqualsEvaluator{}, nil
case "DateNotEquals":
return &DateNotEqualsEvaluator{}, nil
case "DateLessThan":
return &DateLessThanEvaluator{}, nil
case "DateLessThanEquals":
return &DateLessThanEqualsEvaluator{}, nil
case "DateGreaterThan":
return &DateGreaterThanEvaluator{}, nil
case "DateGreaterThanEquals":
return &DateGreaterThanEqualsEvaluator{}, nil
case "Bool":
return &BoolEvaluator{}, nil
case "IpAddress":
return &IpAddressEvaluator{}, nil
case "NotIpAddress":
return &NotIpAddressEvaluator{}, nil
case "ArnEquals":
return &ArnEqualsEvaluator{}, nil
case "ArnLike":
return &ArnLikeEvaluator{}, nil
case "Null":
return &NullEvaluator{}, nil
default:
return nil, fmt.Errorf("unsupported condition operator: %s", operator)
}
}
// ExistingObjectTagPrefix is the prefix for S3 policy condition keys
const ExistingObjectTagPrefix = "s3:ExistingObjectTag/"
// getConditionContextValue resolves the value(s) for a condition key.
// For s3:ExistingObjectTag/<key> conditions, it looks up the tag in objectEntry.
// For other condition keys, it looks up the value in contextValues.
func getConditionContextValue(key string, contextValues map[string][]string, objectEntry map[string][]byte) []string {
if strings.HasPrefix(key, ExistingObjectTagPrefix) {
tagKey := key[len(ExistingObjectTagPrefix):]
if tagKey == "" {
return []string{} // Invalid: empty tag key
}
metadataKey := s3_constants.AmzObjectTaggingPrefix + tagKey
if objectEntry != nil {
if tagValue, exists := objectEntry[metadataKey]; exists {
return []string{string(tagValue)}
}
}
return []string{}
}
if vals, exists := contextValues[key]; exists {
return vals
}
return []string{}
}
// EvaluateConditions evaluates all conditions in a policy statement
// objectEntry is the object's metadata from entry.Extended (can be nil)
// claims are JWT claims for jwt:* policy variables (can be nil)
func EvaluateConditions(conditions PolicyConditions, contextValues map[string][]string, objectEntry map[string][]byte, claims map[string]interface{}) bool {
if len(conditions) == 0 {
return true // No conditions means always true
}
for operator, conditionMap := range conditions {
conditionEvaluator, err := GetConditionEvaluator(operator)
if err != nil {
glog.Warningf("Unsupported condition operator: %s", operator)
return false
}
for key, value := range conditionMap {
contextVals := getConditionContextValue(key, contextValues, objectEntry)
// Substitute variables in expected values
expectedValues := value.Strings()
substitutedValues := make([]string, len(expectedValues))
for i, v := range expectedValues {
substitutedValues[i] = SubstituteVariables(v, contextValues, claims)
}
// Pass substituted values (casted to interface{} to match signature if needed, or update evaluators to accept []string)
// The evaluators take interface{}, but getCachedNormalizedValues handles []string.
if !conditionEvaluator.Evaluate(substitutedValues, contextVals) {
return false // If any condition fails, the whole condition block fails
}
}
}
return true
}

View File

@@ -0,0 +1,18 @@
package policy_engine
import (
"testing"
)
func TestEvaluateConditionsFailsClosedOnUnsupportedOperator(t *testing.T) {
conditions := PolicyConditions{
"StringEqualsBogus": {
"aws:username": NewStringOrStringSlice("alice"),
},
}
contextValues := map[string][]string{}
got := EvaluateConditions(conditions, contextValues, nil, nil)
if got {
t.Fatalf("EvaluateConditions returned true for unsupported operator; expected false (fail closed)")
}
}

View File

@@ -0,0 +1,41 @@
package policy_engine
import (
"testing"
)
func TestConditionEvaluatorsIgnoreCase(t *testing.T) {
tests := []struct {
name string
operator string
conditionValue interface{}
contextValues []string
expected bool
}{
{"StringEqualsIgnoreCase - match", "StringEqualsIgnoreCase", "ALICE", []string{"alice"}, true},
{"StringEqualsIgnoreCase - no match", "StringEqualsIgnoreCase", "alice", []string{"bob"}, false},
{"StringNotEqualsIgnoreCase - match", "StringNotEqualsIgnoreCase", "alice", []string{"bob"}, true},
{"StringNotEqualsIgnoreCase - no match", "StringNotEqualsIgnoreCase", "ALICE", []string{"alice"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
evaluator, err := GetConditionEvaluator(tt.operator)
if err != nil {
t.Fatalf("Failed to get condition evaluator: %v", err)
}
result := evaluator.Evaluate(tt.conditionValue, tt.contextValues)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
func TestGetConditionEvaluatorRejectsNonAWSIgnoreCaseOperators(t *testing.T) {
for _, op := range []string{"StringLikeIgnoreCase", "StringNotLikeIgnoreCase"} {
if _, err := GetConditionEvaluator(op); err == nil {
t.Fatalf("GetConditionEvaluator accepted non-AWS operator %q; expected error", op)
}
}
}

View File

@@ -0,0 +1,74 @@
package policy_engine
import (
"testing"
)
func TestValidateBucketPolicyRejectsUnsupportedConditionOperator(t *testing.T) {
unsupported := `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {"StringEqualsBogus": {"aws:username": "alice"}}
}]
}`
supportedIgnoreCase := `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {"StringEqualsIgnoreCase": {"aws:username": "alice"}}
}]
}`
supportedStringEquals := `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {"StringEquals": {"aws:username": "alice"}}
}]
}`
t.Run("upload rejects unsupported operator", func(t *testing.T) {
policy, err := ParsePolicy(unsupported)
if err != nil {
t.Fatalf("ParsePolicy failed: %v", err)
}
if err := ValidateBucketPolicy(policy, "test-bucket"); err == nil {
t.Fatalf("ValidateBucketPolicy expected error for unsupported operator, got nil")
}
})
t.Run("upload accepts supported IgnoreCase operator", func(t *testing.T) {
policy, err := ParsePolicy(supportedIgnoreCase)
if err != nil {
t.Fatalf("ParsePolicy failed: %v", err)
}
if err := ValidateBucketPolicy(policy, "test-bucket"); err != nil {
t.Fatalf("ValidateBucketPolicy unexpected error: %v", err)
}
})
t.Run("upload accepts supported StringEquals operator", func(t *testing.T) {
policy, err := ParsePolicy(supportedStringEquals)
if err != nil {
t.Fatalf("ParsePolicy failed: %v", err)
}
if err := ValidateBucketPolicy(policy, "test-bucket"); err != nil {
t.Fatalf("ValidateBucketPolicy unexpected error: %v", err)
}
})
t.Run("load tolerates legacy unsupported operator", func(t *testing.T) {
if _, err := ParsePolicy(unsupported); err != nil {
t.Fatalf("ParsePolicy must not reject legacy unsupported operator at load time: %v", err)
}
})
}

View File

@@ -0,0 +1,668 @@
package policy_engine
import (
"fmt"
"net/http"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
// PolicyEvaluationResult represents the result of policy evaluation
type PolicyEvaluationResult int
const (
PolicyResultDeny PolicyEvaluationResult = iota
PolicyResultAllow
PolicyResultIndeterminate
)
// PolicyEvaluationContext manages policy evaluation for a bucket
type PolicyEvaluationContext struct {
bucketName string
policy *CompiledPolicy
cache *PolicyCache
}
// PolicyEngine is the main policy evaluation engine
type PolicyEngine struct {
contexts map[string]*PolicyEvaluationContext
mutex sync.RWMutex
trustedProxies atomic.Pointer[TrustedProxies]
}
// NewPolicyEngine creates a new policy evaluation engine
func NewPolicyEngine() *PolicyEngine {
return &PolicyEngine{
contexts: make(map[string]*PolicyEvaluationContext),
}
}
// SetTrustedProxies configures the allowlist used to decide whether
// forwarded headers are honored when extracting aws:SourceIp.
func (engine *PolicyEngine) SetTrustedProxies(tp *TrustedProxies) {
engine.trustedProxies.Store(tp)
}
// SetBucketPolicy sets the policy for a bucket
func (engine *PolicyEngine) SetBucketPolicy(bucketName string, policyJSON string) error {
policy, err := ParsePolicy(policyJSON)
if err != nil {
return fmt.Errorf("invalid policy: %w", err)
}
compiled, err := CompilePolicy(policy)
if err != nil {
return fmt.Errorf("failed to compile policy: %w", err)
}
engine.mutex.Lock()
defer engine.mutex.Unlock()
context := &PolicyEvaluationContext{
bucketName: bucketName,
policy: compiled,
cache: NewPolicyCache(),
}
engine.contexts[bucketName] = context
glog.V(4).Infof("SetBucketPolicy: Successfully cached policy for bucket=%s, statements=%d", bucketName, len(compiled.Statements))
return nil
}
// GetBucketPolicy gets the policy for a bucket
func (engine *PolicyEngine) GetBucketPolicy(bucketName string) (*PolicyDocument, error) {
engine.mutex.RLock()
defer engine.mutex.RUnlock()
context, exists := engine.contexts[bucketName]
if !exists {
return nil, fmt.Errorf("no policy found for bucket %s", bucketName)
}
return context.policy.Document, nil
}
// DeleteBucketPolicy deletes the policy for a bucket
func (engine *PolicyEngine) DeleteBucketPolicy(bucketName string) error {
engine.mutex.Lock()
defer engine.mutex.Unlock()
delete(engine.contexts, bucketName)
glog.V(2).Infof("Deleted bucket policy for %s", bucketName)
return nil
}
// HasPolicyForBucket checks if a bucket has a policy configured
func (engine *PolicyEngine) HasPolicyForBucket(bucketName string) bool {
engine.mutex.RLock()
defer engine.mutex.RUnlock()
_, exists := engine.contexts[bucketName]
return exists
}
// EvaluatePolicy evaluates a policy for the given arguments
func (engine *PolicyEngine) EvaluatePolicy(bucketName string, args *PolicyEvaluationArgs) PolicyEvaluationResult {
engine.mutex.RLock()
context, exists := engine.contexts[bucketName]
engine.mutex.RUnlock()
if !exists {
glog.V(4).Infof("EvaluatePolicy: No policy found for bucket=%s (PolicyResultIndeterminate)", bucketName)
return PolicyResultIndeterminate
}
glog.V(4).Infof("EvaluatePolicy: Found policy for bucket=%s, evaluating with action=%s resource=%s principal=%s",
bucketName, args.Action, args.Resource, args.Principal)
return engine.evaluateCompiledPolicy(context.policy, args)
}
// evaluateCompiledPolicy evaluates a compiled policy
func (engine *PolicyEngine) evaluateCompiledPolicy(policy *CompiledPolicy, args *PolicyEvaluationArgs) PolicyEvaluationResult {
// AWS Policy evaluation logic:
// 1. Check for explicit Deny - if found, return Deny
// 2. Check for explicit Allow - if found, return Allow
// 3. If no matching statements, return Indeterminate (fall through to IAM)
hasExplicitAllow := false
for _, stmt := range policy.Statements {
if engine.evaluateStatement(stmt, args) {
if stmt.Statement.Effect == PolicyEffectDeny {
return PolicyResultDeny // Explicit deny trumps everything
}
if stmt.Statement.Effect == PolicyEffectAllow {
hasExplicitAllow = true
}
}
}
if hasExplicitAllow {
return PolicyResultAllow
}
// No matching statements - return Indeterminate to fall through to IAM
// This allows IAM policies to grant access even when bucket policy doesn't mention the action
return PolicyResultIndeterminate
}
// matchesDynamicPatterns checks if a value matches any of the dynamic patterns after variable substitution
func (engine *PolicyEngine) matchesDynamicPatterns(patterns []string, value string, args *PolicyEvaluationArgs) bool {
for _, pattern := range patterns {
substituted := SubstituteVariables(pattern, args.Conditions, args.Claims)
if FastMatchesWildcard(substituted, value) {
return true
}
}
return false
}
// evaluateStatement evaluates a single policy statement
func (engine *PolicyEngine) evaluateStatement(stmt *CompiledStatement, args *PolicyEvaluationArgs) bool {
// Check if action matches
matchedAction := engine.matchesPatterns(stmt.ActionPatterns, args.Action)
if !matchedAction {
matchedAction = engine.matchesDynamicPatterns(stmt.DynamicActionPatterns, args.Action, args)
}
// Multipart upload actions (CreateMultipartUpload, UploadPart, CompleteMultipartUpload, etc.)
// are implicitly allowed by s3:PutObject, since multipart upload is an implementation
// detail of putting objects. Check if this is a multipart action and the statement
// grants s3:PutObject.
if !matchedAction && multipartActionSet[args.Action] {
matchedAction = engine.matchesPatterns(stmt.ActionPatterns, "s3:PutObject")
if !matchedAction {
matchedAction = engine.matchesDynamicPatterns(stmt.DynamicActionPatterns, "s3:PutObject", args)
}
}
if !matchedAction {
return false
}
// Check if resource matches
hasResource := len(stmt.ResourcePatterns) > 0 || len(stmt.DynamicResourcePatterns) > 0
hasNotResource := len(stmt.NotResourcePatterns) > 0 || len(stmt.DynamicNotResourcePatterns) > 0
if hasResource {
matchedResource := engine.matchesPatterns(stmt.ResourcePatterns, args.Resource)
if !matchedResource {
matchedResource = engine.matchesDynamicPatterns(stmt.DynamicResourcePatterns, args.Resource, args)
}
if !matchedResource {
return false
}
}
if hasNotResource {
matchedNotResource := false
for _, matcher := range stmt.NotResourceMatchers {
if matcher.Match(args.Resource) {
matchedNotResource = true
break
}
}
if !matchedNotResource {
matchedNotResource = engine.matchesDynamicPatterns(stmt.DynamicNotResourcePatterns, args.Resource, args)
}
if matchedNotResource {
return false
}
}
// Check if principal matches
if len(stmt.PrincipalPatterns) > 0 || len(stmt.DynamicPrincipalPatterns) > 0 {
matchedPrincipal := engine.matchesPatterns(stmt.PrincipalPatterns, args.Principal)
if !matchedPrincipal {
matchedPrincipal = engine.matchesDynamicPatterns(stmt.DynamicPrincipalPatterns, args.Principal, args)
}
if !matchedPrincipal {
return false
}
}
// Check NotPrincipal (statement applies to everyone EXCEPT these principals)
if len(stmt.NotPrincipalPatterns) > 0 || len(stmt.DynamicNotPrincipalPatterns) > 0 {
matchedNotPrincipal := engine.matchesPatterns(stmt.NotPrincipalPatterns, args.Principal)
if !matchedNotPrincipal {
matchedNotPrincipal = engine.matchesDynamicPatterns(stmt.DynamicNotPrincipalPatterns, args.Principal, args)
}
if matchedNotPrincipal {
return false
}
}
// Check conditions
if len(stmt.Statement.Condition) > 0 {
condCtx := args.Conditions
// Multipart continuation actions (UploadPart, UploadPartCopy) inherit SSE
// from CreateMultipartUpload and do not carry their own SSE header.
// Inject the real inherited algorithm so Null/StringEquals conditions
// evaluate against the value that was set at upload initiation.
if IsMultipartContinuationAction(args.Action) {
condCtx = injectSSEForMultipart(args.Conditions, args.InheritedSSEAlgorithm)
}
match := EvaluateConditions(stmt.Statement.Condition, condCtx, args.ObjectEntry, args.Claims)
if !match {
return false
}
}
return true
}
// matchesPatterns checks if a value matches any of the compiled patterns
func (engine *PolicyEngine) matchesPatterns(patterns []*regexp.Regexp, value string) bool {
for _, pattern := range patterns {
if pattern.MatchString(value) {
return true
}
}
return false
}
// SubstituteVariables replaces ${variable} in a pattern with values from context and claims
// Supports:
// - Standard context variables (aws:SourceIp, s3:prefix, etc.)
// - JWT claims (jwt:preferred_username, jwt:sub, jwt:*)
// - LDAP claims (ldap:username, ldap:dn, ldap:*)
func SubstituteVariables(pattern string, context map[string][]string, claims map[string]interface{}) string {
result := PolicyVariableRegex.ReplaceAllStringFunc(pattern, func(match string) string {
// match is like "${aws:username}"
// extract variable name "aws:username"
variable := match[2 : len(match)-1]
// Check standard context first
if values, ok := context[variable]; ok && len(values) > 0 {
return values[0]
}
// Check JWT claims for jwt:* variables
if strings.HasPrefix(variable, "jwt:") {
claimName := variable[4:] // Remove "jwt:" prefix
if claimValue, ok := claims[claimName]; ok {
switch v := claimValue.(type) {
case string:
return v
case float64:
// JWT numbers are often float64
if v == float64(int64(v)) {
return fmt.Sprintf("%d", int64(v))
}
return fmt.Sprintf("%g", v)
case bool:
return fmt.Sprintf("%t", v)
case int:
return fmt.Sprintf("%d", v)
case int32:
return fmt.Sprintf("%d", v)
case int64:
return fmt.Sprintf("%d", v)
default:
return fmt.Sprintf("%v", v)
}
}
}
// Check LDAP claims for ldap:* variables
// FALLBACK MECHANISM: Try both prefixed and unprefixed keys
// Some LDAP providers store claims with the "ldap:" prefix (e.g., "ldap:username")
// while others store them without the prefix (e.g., "username").
// We check the prefixed key first for consistency, then fall back to unprefixed.
if strings.HasPrefix(variable, "ldap:") {
claimName := variable[5:] // Remove "ldap:" prefix
// Try prefixed key first (e.g., "ldap:username"), then unprefixed
var claimValue interface{}
var ok bool
if claimValue, ok = claims[variable]; !ok {
claimValue, ok = claims[claimName]
}
if ok {
switch v := claimValue.(type) {
case string:
return v
case float64:
if v == float64(int64(v)) {
return fmt.Sprintf("%d", int64(v))
}
return fmt.Sprintf("%g", v)
case bool:
return fmt.Sprintf("%t", v)
case int:
return fmt.Sprintf("%d", v)
case int32:
return fmt.Sprintf("%d", v)
case int64:
return fmt.Sprintf("%d", v)
default:
return fmt.Sprintf("%v", v)
}
}
}
// Variable not found, leave as-is to avoid unexpected matching
return match
})
return result
}
// ExtractPrincipalVariables extracts policy variables from a principal ARN
func ExtractPrincipalVariables(principal string) map[string][]string {
vars := make(map[string][]string)
// Handle non-ARN principals (e.g., "*" or simple usernames)
if !strings.HasPrefix(principal, "arn:aws:") {
return vars
}
// Parse ARN: arn:aws:service::account:resource
parts := strings.Split(principal, ":")
if len(parts) < 6 {
return vars
}
account := parts[4] // account ID
resourcePart := parts[5] // user/username or assumed-role/role/session
// Set aws:PrincipalAccount if account is present
if account != "" {
vars["aws:PrincipalAccount"] = []string{account}
}
resourceParts := strings.Split(resourcePart, "/")
if len(resourceParts) < 2 {
return vars
}
resourceType := resourceParts[0] // "user", "role", "assumed-role"
// Set aws:principaltype and extract username/userid based on resource type
switch resourceType {
case "user":
vars["aws:principaltype"] = []string{"IAMUser"}
// For users with paths like "user/path/to/username", use the last segment
username := resourceParts[len(resourceParts)-1]
vars["aws:username"] = []string{username}
vars["aws:userid"] = []string{username} // In SeaweedFS, userid is same as username
case "role":
vars["aws:principaltype"] = []string{"IAMRole"}
// For roles with paths like "role/path/to/rolename", use the last segment
// Note: IAM Roles do NOT have aws:userid, but aws:PrincipalAccount is kept for condition evaluations
if len(resourceParts) >= 2 {
roleName := resourceParts[len(resourceParts)-1]
vars["aws:username"] = []string{roleName}
}
case "assumed-role":
vars["aws:principaltype"] = []string{"AssumedRole"}
// For assumed roles: assumed-role/RoleName/SessionName or assumed-role/path/to/RoleName/SessionName
// The session name is always the last segment
if len(resourceParts) >= 3 {
sessionName := resourceParts[len(resourceParts)-1]
vars["aws:username"] = []string{sessionName}
vars["aws:userid"] = []string{sessionName}
}
}
// Note: principaltype is already set correctly in the switch above based on resource type
return vars
}
// ExtractConditionValuesFromRequest extracts condition values from HTTP request
func (engine *PolicyEngine) ExtractConditionValuesFromRequest(r *http.Request) map[string][]string {
values := make(map[string][]string)
// AWS condition keys
values["aws:SourceIp"] = []string{engine.extractSourceIP(r)}
values["aws:SecureTransport"] = []string{fmt.Sprintf("%t", r.TLS != nil)}
// Use AWS standard condition key for current time
values["aws:CurrentTime"] = []string{time.Now().Format(time.RFC3339)}
// Keep RequestTime for backward compatibility
values["aws:RequestTime"] = []string{time.Now().Format(time.RFC3339)}
// S3 specific condition keys
if userAgent := r.Header.Get("User-Agent"); userAgent != "" {
values["aws:UserAgent"] = []string{userAgent}
}
if referer := r.Header.Get("Referer"); referer != "" {
values["aws:Referer"] = []string{referer}
}
// Note: s3:ExistingObjectTag/<key> conditions are evaluated using objectEntry
// passed to EvaluatePolicy, not extracted from the request.
// S3 bucket-level conditions
if delimiter := r.URL.Query().Get("delimiter"); delimiter != "" {
values["s3:delimiter"] = []string{delimiter}
}
if prefix := r.URL.Query().Get("prefix"); prefix != "" {
values["s3:prefix"] = []string{prefix}
}
if maxKeys := r.URL.Query().Get("max-keys"); maxKeys != "" {
values["s3:max-keys"] = []string{maxKeys}
}
// Authentication method
if authHeader := r.Header.Get("Authorization"); authHeader != "" {
if strings.HasPrefix(authHeader, "AWS4-HMAC-SHA256") {
values["s3:authType"] = []string{"REST-HEADER"}
} else if strings.HasPrefix(authHeader, "AWS ") {
values["s3:authType"] = []string{"REST-HEADER"}
}
} else if r.URL.Query().Get("AWSAccessKeyId") != "" {
values["s3:authType"] = []string{"REST-QUERY-STRING"}
}
// HTTP method
values["s3:RequestMethod"] = []string{r.Method}
// Extract custom headers with s3: prefix for AWS-compatible condition keys
for key, headerValues := range r.Header {
lowerKey := strings.ToLower(key)
if strings.HasPrefix(lowerKey, "x-amz-") {
values["s3:"+lowerKey] = headerValues
}
}
// Normalize s3:x-amz-server-side-encryption value to canonical form.
// AWS accepts "AES256" case-insensitively; normalise so that policy
// StringEquals conditions work regardless of client capitalisation.
const sseKey = "s3:x-amz-server-side-encryption"
if sseVals, ok := values[sseKey]; ok {
normalized := make([]string, len(sseVals))
for i, v := range sseVals {
switch strings.ToUpper(v) {
case "AES256":
normalized[i] = "AES256"
case "AWS:KMS":
normalized[i] = "aws:kms"
default:
normalized[i] = v
}
}
values[sseKey] = normalized
}
return values
}
// IsMultipartContinuationAction returns true for actions that do not carry
// their own SSE header because SSE is inherited from CreateMultipartUpload.
func IsMultipartContinuationAction(action string) bool {
return action == "s3:UploadPart" || action == "s3:UploadPartCopy"
}
// injectSSEForMultipart returns a condition context augmented with the
// inherited SSE algorithm for multipart continuation actions.
//
// UploadPart and UploadPartCopy do not re-send the SSE header because
// encryption is set once at CreateMultipartUpload. The caller supplies
// inheritedSSE (the canonical algorithm, e.g. "AES256" or "aws:kms") so
// that Null/StringEquals conditions on s3:x-amz-server-side-encryption
// evaluate against the real value.
//
// If inheritedSSE is empty (no SSE was requested at initiation), the
// conditions map is returned unchanged so Null("true") will correctly
// match and deny the request.
func injectSSEForMultipart(conditions map[string][]string, inheritedSSE string) map[string][]string {
const sseKey = "s3:x-amz-server-side-encryption"
if inheritedSSE == "" {
return conditions // no SSE at upload initiation; let Null("true") fire
}
if _, exists := conditions[sseKey]; exists {
return conditions // SSE header was actually sent on this request
}
modified := make(map[string][]string, len(conditions)+1)
for k, v := range conditions {
modified[k] = v
}
modified[sseKey] = []string{inheritedSSE}
return modified
}
// extractSourceIP returns the client IP for aws:SourceIp condition
// evaluation, honoring forwarded headers only when the direct TCP peer is in
// the configured trusted-proxy allowlist (see SetTrustedProxies).
func (engine *PolicyEngine) extractSourceIP(r *http.Request) string {
return engine.trustedProxies.Load().ExtractSourceIP(r)
}
// BuildResourceArn builds an ARN for the given bucket and object
func BuildResourceArn(bucketName, objectName string) string {
if objectName == "" {
return fmt.Sprintf("arn:aws:s3:::%s", bucketName)
}
return fmt.Sprintf("arn:aws:s3:::%s/%s", bucketName, objectName)
}
// BuildActionName builds a standardized action name
func BuildActionName(action string) string {
if strings.HasPrefix(action, "s3:") {
return action
}
return fmt.Sprintf("s3:%s", action)
}
// GetPolicyStatements returns all policy statements for a bucket
func (engine *PolicyEngine) GetPolicyStatements(bucketName string) []PolicyStatement {
engine.mutex.RLock()
defer engine.mutex.RUnlock()
context, exists := engine.contexts[bucketName]
if !exists {
return nil
}
return context.policy.Document.Statement
}
// BucketsAllowedForAction returns the buckets a policy names in the Allow
// statements that can match the action, and whether those names cover every
// bucket the policy can allow it on. A statement reaching buckets it does not
// name -- a wildcard resource, a policy variable, a NotResource -- leaves the
// set incomplete, as does an unknown policy name.
func (engine *PolicyEngine) BucketsAllowedForAction(policyName string, action string) (buckets []string, complete bool) {
engine.mutex.RLock()
context, exists := engine.contexts[policyName]
engine.mutex.RUnlock()
if !exists {
return nil, false
}
for _, statement := range context.policy.Document.Statement {
// A Deny only narrows what an Allow named.
if statement.Effect != PolicyEffectAllow || !statementMayAllowAction(statement.Action.Strings(), action) {
continue
}
if statement.Resource == nil || statement.NotResource != nil {
return nil, false
}
resources := statement.Resource.Strings()
if len(resources) == 0 {
return nil, false
}
for _, resource := range resources {
if PolicyVariableRegex.MatchString(resource) {
return nil, false
}
bucket := GetBucketFromResource(resource)
if bucket == "" || strings.ContainsAny(bucket, "*?") {
return nil, false
}
buckets = append(buckets, bucket)
}
}
return buckets, true
}
// ValidatePolicyForBucket validates if a policy is valid for a bucket
func (engine *PolicyEngine) ValidatePolicyForBucket(bucketName string, policyJSON string) error {
policy, err := ParsePolicy(policyJSON)
if err != nil {
return err
}
// Additional validation specific to the bucket
for _, stmt := range policy.Statement {
resources := normalizeToStringSlice(stmt.Resource)
for _, resource := range resources {
if resourceBucket := GetBucketFromResource(resource); resourceBucket != "" {
if resourceBucket != bucketName {
return fmt.Errorf("policy resource %s does not match bucket %s", resource, bucketName)
}
}
}
}
return nil
}
// ClearAllPolicies clears all bucket policies
func (engine *PolicyEngine) ClearAllPolicies() {
engine.mutex.Lock()
defer engine.mutex.Unlock()
engine.contexts = make(map[string]*PolicyEvaluationContext)
glog.V(2).Info("Cleared all bucket policies")
}
// GetAllBucketsWithPolicies returns all buckets that have policies
func (engine *PolicyEngine) GetAllBucketsWithPolicies() []string {
engine.mutex.RLock()
defer engine.mutex.RUnlock()
buckets := make([]string, 0, len(engine.contexts))
for bucketName := range engine.contexts {
buckets = append(buckets, bucketName)
}
return buckets
}
// EvaluatePolicyForRequest evaluates policy for an HTTP request
func (engine *PolicyEngine) EvaluatePolicyForRequest(bucketName, objectName, action, principal string, r *http.Request) PolicyEvaluationResult {
resource := BuildResourceArn(bucketName, objectName)
actionName := BuildActionName(action)
conditions := engine.ExtractConditionValuesFromRequest(r)
// Extract principal information for variables
principalVars := ExtractPrincipalVariables(principal)
for k, v := range principalVars {
conditions[k] = v
}
args := &PolicyEvaluationArgs{
Action: actionName,
Resource: resource,
Principal: principal,
Conditions: conditions,
}
return engine.EvaluatePolicy(bucketName, args)
}

View File

@@ -0,0 +1,52 @@
package policy_engine
import (
"strings"
"testing"
)
// BucketsAllowedForAction has to read a grant at least as loosely as the
// authorizer applies it, since a bucket it misses disappears from ListBuckets.
func TestBucketsAllowedForAction(t *testing.T) {
engine := NewPolicyEngine()
policies := map[string]string{
"put": `{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::b1/*"]}]}`,
"mixed-case": `{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["S3:LISTBUCKET"],"Resource":["arn:aws:s3:::b2"]}]}`,
"any-bucket": `{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::*"]}]}`,
}
for name, document := range policies {
if err := engine.SetBucketPolicy(name, document); err != nil {
t.Fatalf("load policy %s: %v", name, err)
}
}
tests := []struct {
name string
policy string
action string
wantBuckets string
wantComplete bool
}{
// Multipart uploads ride on s3:PutObject, in whatever case they are asked for.
{name: "multipart inherits put", policy: "put", action: "s3:UploadPart", wantBuckets: "b1", wantComplete: true},
{name: "multipart inherits put in any case", policy: "put", action: "S3:UPLOADPART", wantBuckets: "b1", wantComplete: true},
{name: "unrelated action names nothing", policy: "put", action: "s3:ListBucket", wantComplete: true},
{name: "mixed case grant is read", policy: "mixed-case", action: "s3:ListBucket", wantBuckets: "b2", wantComplete: true},
{name: "wildcard bucket is incomplete", policy: "any-bucket", action: "s3:ListBucket"},
{name: "unknown policy is incomplete", policy: "absent", action: "s3:ListBucket"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buckets, complete := engine.BucketsAllowedForAction(tt.policy, tt.action)
if complete != tt.wantComplete {
t.Fatalf("complete = %v, want %v", complete, tt.wantComplete)
}
if got := strings.Join(buckets, ","); got != tt.wantBuckets {
t.Errorf("buckets = %q, want %q", got, tt.wantBuckets)
}
})
}
}

View File

@@ -0,0 +1,312 @@
package policy_engine
import (
"testing"
)
func TestExtractPrincipalVariables(t *testing.T) {
tests := []struct {
name string
principal string
expected map[string][]string
}{
{
name: "IAM User ARN",
principal: "arn:aws:iam::123456789012:user/alice",
expected: map[string][]string{
"aws:PrincipalAccount": {"123456789012"},
"aws:principaltype": {"IAMUser"},
"aws:username": {"alice"},
"aws:userid": {"alice"},
},
},
{
name: "Assumed Role ARN",
principal: "arn:aws:sts::123456789012:assumed-role/MyRole/session-alice",
expected: map[string][]string{
"aws:PrincipalAccount": {"123456789012"},
"aws:principaltype": {"AssumedRole"},
"aws:username": {"session-alice"},
"aws:userid": {"session-alice"},
},
},
{
name: "IAM Role ARN",
principal: "arn:aws:iam::123456789012:role/MyRole",
expected: map[string][]string{
"aws:PrincipalAccount": {"123456789012"},
"aws:principaltype": {"IAMRole"},
"aws:username": {"MyRole"},
},
},
{
name: "Non-ARN principal",
principal: "user:alice",
expected: map[string][]string{},
},
{
name: "Wildcard principal",
principal: "*",
expected: map[string][]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExtractPrincipalVariables(tt.principal)
// Check that all expected keys are present with correct values
for key, expectedValues := range tt.expected {
actualValues, ok := result[key]
if !ok {
t.Errorf("Expected key %s not found in result", key)
continue
}
if len(actualValues) != len(expectedValues) {
t.Errorf("For key %s: expected %d values, got %d", key, len(expectedValues), len(actualValues))
continue
}
for i, expectedValue := range expectedValues {
if actualValues[i] != expectedValue {
t.Errorf("For key %s[%d]: expected %s, got %s", key, i, expectedValue, actualValues[i])
}
}
}
// Check that there are no unexpected keys
for key := range result {
if _, ok := tt.expected[key]; !ok {
t.Errorf("Unexpected key %s in result", key)
}
}
})
}
}
func TestSubstituteVariablesWithClaims(t *testing.T) {
tests := []struct {
name string
pattern string
context map[string][]string
claims map[string]interface{}
expected string
}{
{
name: "Standard context variable",
pattern: "arn:aws:s3:::bucket/${aws:username}/*",
context: map[string][]string{
"aws:username": {"alice"},
},
claims: nil,
expected: "arn:aws:s3:::bucket/alice/*",
},
{
name: "JWT claim substitution",
pattern: "arn:aws:s3:::bucket/${jwt:preferred_username}/*",
context: map[string][]string{},
claims: map[string]interface{}{
"preferred_username": "bob",
},
expected: "arn:aws:s3:::bucket/bob/*",
},
{
name: "Mixed variables",
pattern: "arn:aws:s3:::bucket/${jwt:sub}/files/${aws:principaltype}",
context: map[string][]string{
"aws:principaltype": {"IAMUser"},
},
claims: map[string]interface{}{
"sub": "user123",
},
expected: "arn:aws:s3:::bucket/user123/files/IAMUser",
},
{
name: "Variable not found",
pattern: "arn:aws:s3:::bucket/${jwt:missing}/*",
context: map[string][]string{},
claims: map[string]interface{}{},
expected: "arn:aws:s3:::bucket/${jwt:missing}/*",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SubstituteVariables(tt.pattern, tt.context, tt.claims)
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
}
})
}
}
func TestPolicyVariablesWithPrincipalType(t *testing.T) {
engine := NewPolicyEngine()
// Policy that requires specific principal type
policyJSON := `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "arn:aws:s3:::bucket/*",
"Condition": {
"StringEquals": {
"aws:principaltype": "IAMUser"
}
}
}]
}`
err := engine.SetBucketPolicy("bucket", policyJSON)
if err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
// Test with IAM User - should allow
args := &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: "arn:aws:s3:::bucket/file.txt",
Principal: "arn:aws:iam::123456789012:user/alice",
Conditions: map[string][]string{
"aws:principaltype": {"IAMUser"},
"aws:username": {"alice"},
"aws:userid": {"alice"},
},
}
result := engine.EvaluatePolicy("bucket", args)
if result != PolicyResultAllow {
t.Errorf("Expected Allow for IAMUser principal, got %v", result)
}
// Test with AssumedRole - should return Indeterminate (condition doesn't match)
args.Principal = "arn:aws:sts::123456789012:assumed-role/MyRole/session"
args.Conditions["aws:principaltype"] = []string{"AssumedRole"}
result = engine.EvaluatePolicy("bucket", args)
if result != PolicyResultIndeterminate {
t.Errorf("Expected Indeterminate for AssumedRole principal, got %v", result)
}
}
func TestPolicyVariablesWithJWTClaims(t *testing.T) {
engine := NewPolicyEngine()
// Policy using JWT claim in resource
policyJSON := `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "arn:aws:s3:::bucket/${jwt:preferred_username}/*"
}]
}`
err := engine.SetBucketPolicy("bucket", policyJSON)
if err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
// Test with matching JWT claim
args := &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: "arn:aws:s3:::bucket/alice/file.txt",
Principal: "arn:aws:iam::123456789012:user/alice",
Conditions: map[string][]string{},
Claims: map[string]interface{}{
"preferred_username": "alice",
},
}
result := engine.EvaluatePolicy("bucket", args)
if result != PolicyResultAllow {
t.Errorf("Expected Allow when JWT claim matches resource, got %v", result)
}
// Test with mismatched JWT claim
args.Resource = "arn:aws:s3:::bucket/bob/file.txt"
result = engine.EvaluatePolicy("bucket", args)
if result != PolicyResultIndeterminate {
t.Errorf("Expected Indeterminate when JWT claim doesn't match resource, got %v", result)
}
}
func TestExtractPrincipalVariablesWithAccount(t *testing.T) {
principal := "arn:aws:iam::123456789012:user/alice"
vars := ExtractPrincipalVariables(principal)
if account, ok := vars["aws:PrincipalAccount"]; !ok {
t.Errorf("Expected aws:PrincipalAccount to be present")
} else if len(account) == 0 {
t.Errorf("Expected aws:PrincipalAccount to have values")
} else if account[0] != "123456789012" {
t.Errorf("Expected aws:PrincipalAccount=123456789012, got %v", account[0])
}
}
func TestSubstituteVariablesWithLDAP(t *testing.T) {
pattern := "arn:aws:s3:::bucket/${ldap:username}/*"
context := map[string][]string{}
claims := map[string]interface{}{
"username": "jdoe",
}
result := SubstituteVariables(pattern, context, claims)
expected := "arn:aws:s3:::bucket/jdoe/*"
if result != expected {
t.Errorf("Expected %s, got %s", expected, result)
}
// Test ldap:dn
pattern = "arn:aws:s3:::bucket/${ldap:dn}/*"
claims = map[string]interface{}{
"dn": "uid=jdoe,ou=people,dc=example,dc=com",
}
result = SubstituteVariables(pattern, context, claims)
expected = "arn:aws:s3:::bucket/uid=jdoe,ou=people,dc=example,dc=com/*"
if result != expected {
t.Errorf("Expected %s, got %s", expected, result)
}
}
func TestSubstituteVariablesSpecialChars(t *testing.T) {
tests := []struct {
name string
pattern string
context map[string][]string
claims map[string]interface{}
expected string
}{
{
name: "Comparison operators in claims/vars",
pattern: "resource/${jwt:scope}",
context: map[string][]string{},
claims: map[string]interface{}{
"scope": "read/write",
},
expected: "resource/read/write",
},
{
name: "Path traversal attempt (should just substitute text)",
pattern: "bucket/${jwt:user}",
context: map[string][]string{},
claims: map[string]interface{}{
"user": "../../../etc/passwd",
},
expected: "bucket/../../../etc/passwd",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SubstituteVariables(tt.pattern, tt.context, tt.claims)
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
}
})
}
}

View File

@@ -0,0 +1,100 @@
package policy_engine
import (
"fmt"
"testing"
)
func TestIsolationPolicy(t *testing.T) {
engine := NewPolicyEngine()
bucketName := "test-isolation"
policyJSON := fmt.Sprintf(`{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowOwnFolder",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::%s/${aws:username}/*"
}, {
"Sid": "AllowListOwnPrefix",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::%s",
"Condition": {
"StringLike": {
"s3:prefix": ["${aws:username}/*", "${aws:username}"]
}
}
}, {
"Sid": "DenyOtherFolders",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:GetObject", "s3:PutObject"],
"NotResource": "arn:aws:s3:::%s/${aws:username}/*"
}]
}`, bucketName, bucketName, bucketName)
err := engine.SetBucketPolicy(bucketName, policyJSON)
if err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
// Case 1: Alice accesses her own folder -> should be ALLOWED
args := &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: fmt.Sprintf("arn:aws:s3:::%s/alice/data.txt", bucketName),
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/alice",
Conditions: map[string][]string{
"aws:username": {"alice"},
},
}
result := engine.EvaluatePolicy(bucketName, args)
if result != PolicyResultAllow {
t.Errorf("Alice should be ALLOWED to her own folder, got %v", result)
}
// Case 2: Alice accesses Bob's folder -> should be DENIED
args = &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: fmt.Sprintf("arn:aws:s3:::%s/bob/data.txt", bucketName),
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/alice",
Conditions: map[string][]string{
"aws:username": {"alice"},
},
}
result = engine.EvaluatePolicy(bucketName, args)
if result != PolicyResultDeny {
t.Errorf("Alice should be DENIED access to Bob's folder, got %v", result)
}
// Case 3: Bob accesses Bob's folder -> should be ALLOWED
args = &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: fmt.Sprintf("arn:aws:s3:::%s/bob/data.txt", bucketName),
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/bob",
Conditions: map[string][]string{
"aws:username": {"bob"},
},
}
result = engine.EvaluatePolicy(bucketName, args)
if result != PolicyResultAllow {
t.Errorf("Bob should be ALLOWED to his own folder, got %v", result)
}
// Case 4: Bob accesses Alice's folder -> should be DENIED
args = &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: fmt.Sprintf("arn:aws:s3:::%s/alice/data.txt", bucketName),
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/bob",
Conditions: map[string][]string{
"aws:username": {"bob"},
},
}
result = engine.EvaluatePolicy(bucketName, args)
if result != PolicyResultDeny {
t.Errorf("Bob should be DENIED access to Alice's folder, got %v", result)
}
}

View File

@@ -0,0 +1,65 @@
package policy_engine
import (
"testing"
)
func TestNotResourceWithVariables(t *testing.T) {
engine := NewPolicyEngine()
// Policy mirroring the isolation test
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowOwnFolder",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket/${aws:username}/*"
},
{
"Sid": "DenyOtherFolders",
"Effect": "Deny",
"Action": "s3:GetObject",
"NotResource": "arn:aws:s3:::bucket/${aws:username}/*"
}
]
}`
err := engine.SetBucketPolicy("test-bucket", policyJSON)
if err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
// Case 1: Alice accesses her own folder -> should match Allow, but NOT match Deny statement
// (because Deny says NotResource is own folder, and she IS accessing her own folder, so NotResource check fails, statement doesn't apply)
args := &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: "arn:aws:s3:::bucket/alice/data.txt",
Principal: "arn:aws:iam::123456789012:user/alice",
Conditions: map[string][]string{
"aws:username": {"alice"},
},
}
result := engine.EvaluatePolicy("test-bucket", args)
if result != PolicyResultAllow {
t.Errorf("Alice should be allowed to her own folder, got %v", result)
}
// Case 2: Alice accesses Bob's folder -> should NOT match Allow, and SHOULD match Deny statement
// (because Deny says NotResource is own folder, and she is NOT accessing her own folder, so NotResource matches, statement applies)
args = &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: "arn:aws:s3:::bucket/bob/data.txt",
Principal: "arn:aws:iam::123456789012:user/alice",
Conditions: map[string][]string{
"aws:username": {"alice"},
},
}
result = engine.EvaluatePolicy("test-bucket", args)
if result != PolicyResultDeny {
t.Errorf("Alice should be denied access to Bob folder, got %v", result)
}
}

View File

@@ -0,0 +1,77 @@
package policy_engine
import (
"testing"
)
// TestExtractPrincipalVariablesWithPaths tests ARN parsing with IAM path components
func TestExtractPrincipalVariablesWithPaths(t *testing.T) {
tests := []struct {
name string
principal string
expected map[string][]string
}{
{
name: "IAM User with path",
principal: "arn:aws:iam::123456789012:user/division/team/alice",
expected: map[string][]string{
"aws:PrincipalAccount": {"123456789012"},
"aws:principaltype": {"IAMUser"},
"aws:username": {"alice"},
"aws:userid": {"alice"},
},
},
{
name: "IAM Role with path",
principal: "arn:aws:iam::123456789012:role/service-role/MyRole",
expected: map[string][]string{
"aws:PrincipalAccount": {"123456789012"},
"aws:principaltype": {"IAMRole"},
"aws:username": {"MyRole"},
},
},
{
name: "Assumed Role with path",
principal: "arn:aws:sts::123456789012:assumed-role/service-role/MyRole/session-name",
expected: map[string][]string{
"aws:PrincipalAccount": {"123456789012"},
"aws:principaltype": {"AssumedRole"},
"aws:username": {"session-name"},
"aws:userid": {"session-name"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExtractPrincipalVariables(tt.principal)
// Check that all expected keys are present with correct values
for key, expectedValues := range tt.expected {
actualValues, ok := result[key]
if !ok {
t.Errorf("Expected key %s not found in result", key)
continue
}
if len(actualValues) != len(expectedValues) {
t.Errorf("For key %s: expected %d values, got %d", key, len(expectedValues), len(actualValues))
continue
}
for i, expectedValue := range expectedValues {
if actualValues[i] != expectedValue {
t.Errorf("For key %s[%d]: expected %s, got %s", key, i, expectedValue, actualValues[i])
}
}
}
// Check that there are no unexpected keys
for key := range result {
if _, ok := tt.expected[key]; !ok {
t.Errorf("Unexpected key %s in result", key)
}
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
package policy_engine
import (
"testing"
)
func TestPolicyVariables(t *testing.T) {
engine := NewPolicyEngine()
// Policy with variables in Resource and Condition
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowUserHomeDirectory",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::test-bucket",
"Condition": {
"StringLike": {
"s3:prefix": ["${aws:username}/*"]
}
}
},
{
"Sid": "AllowUserObjectAccess",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": ["arn:aws:s3:::test-bucket/${aws:username}/*"]
}
]
}`
err := engine.SetBucketPolicy("test-bucket", policyJSON)
if err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
// Case 1: Matching username for resource access
args := &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: "arn:aws:s3:::test-bucket/johndoe/file.txt",
Principal: "arn:aws:iam::123456789012:user/johndoe",
Conditions: map[string][]string{
"aws:username": {"johndoe"},
},
}
result := engine.EvaluatePolicy("test-bucket", args)
if result != PolicyResultAllow {
t.Errorf("Expected Allow for matching username in resource, got %v", result)
}
// Case 2: Mismatched username for resource access
args = &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: "arn:aws:s3:::test-bucket/janedoe/file.txt",
Principal: "arn:aws:iam::123456789012:user/johndoe",
Conditions: map[string][]string{
"aws:username": {"johndoe"},
},
}
result = engine.EvaluatePolicy("test-bucket", args)
if result != PolicyResultIndeterminate {
t.Errorf("Expected Indeterminate for mismatched username in resource, got %v", result)
}
// Case 3: ListBucket with matching prefix condition
args = &PolicyEvaluationArgs{
Action: "s3:ListBucket",
Resource: "arn:aws:s3:::test-bucket",
Principal: "arn:aws:iam::123456789012:user/johndoe",
Conditions: map[string][]string{
"aws:username": {"johndoe"},
"s3:prefix": {"johndoe/docs"},
},
}
result = engine.EvaluatePolicy("test-bucket", args)
if result != PolicyResultAllow {
t.Errorf("Expected Allow for matching prefix condition, got %v", result)
}
// Case 4: ListBucket with mismatched prefix condition
args = &PolicyEvaluationArgs{
Action: "s3:ListBucket",
Resource: "arn:aws:s3:::test-bucket",
Principal: "arn:aws:iam::123456789012:user/johndoe",
Conditions: map[string][]string{
"aws:username": {"johndoe"},
"s3:prefix": {"janedoe/docs"},
},
}
result = engine.EvaluatePolicy("test-bucket", args)
if result != PolicyResultIndeterminate {
t.Errorf("Expected Indeterminate for mismatched prefix condition, got %v", result)
}
}
func TestEvaluatePolicyForRequestVariables(t *testing.T) {
engine := NewPolicyEngine()
// Policy using aws:username
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::test-bucket/${aws:username}/*"
}
]
}`
err := engine.SetBucketPolicy("test-bucket", policyJSON)
if err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
// We need to mock the request but the EvaluatePolicyForRequest mostly runs on args extraction
// The key thing is that EvaluatePolicyForRequest should populate "aws:username" from principal
// Since we cannot easily pass a full http.Request that matches everything, we will test the extraction logic
// by simulating what EvaluatePolicyForRequest does: calling EvaluatePolicy with populated Conditions
principal := "arn:aws:iam::123456789012:user/alice"
// Should extract "alice"
// Create args manually as if extracted
args := &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: "arn:aws:s3:::test-bucket/alice/data.txt",
Principal: principal,
Conditions: map[string][]string{
"aws:username": {"alice"},
},
}
result := engine.EvaluatePolicy("test-bucket", args)
if result != PolicyResultAllow {
t.Errorf("Expected Allow when aws:username is populated, got %v", result)
}
// Now with wrong resource
args.Resource = "arn:aws:s3:::test-bucket/bob/data.txt"
result = engine.EvaluatePolicy("test-bucket", args)
if result != PolicyResultIndeterminate {
t.Errorf("Expected Indeterminate when resource doesn't match variable, got %v", result)
}
}

View File

@@ -0,0 +1,462 @@
//go:build ignore
package policy_engine
import (
"encoding/json"
"fmt"
)
// This file contains examples and documentation for the policy engine
// ExampleIdentityJSON shows the existing identities.json format (unchanged)
var ExampleIdentityJSON = `{
"identities": [
{
"name": "user1",
"credentials": [
{
"accessKey": "AKIAIOSFODNN7EXAMPLE",
"secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
],
"actions": [
"Read:bucket1/*",
"Write:bucket1/*",
"Admin:bucket2"
]
},
{
"name": "readonly-user",
"credentials": [
{
"accessKey": "AKIAI44QH8DHBEXAMPLE",
"secretKey": "je7MtGbClwBF/2Zp9Utk/h3yCo8nvbEXAMPLEKEY"
}
],
"actions": [
"Read:bucket1/*",
"List:bucket1"
]
}
]
}`
// ExampleBucketPolicy shows an AWS S3 bucket policy with conditions
var ExampleBucketPolicy = `{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowGetObjectFromSpecificIP",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"IpAddress": {
"aws:SourceIp": "192.168.1.0/24"
}
}
},
{
"Sid": "AllowPutObjectWithSSL",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"Bool": {
"aws:SecureTransport": "true"
}
}
},
{
"Sid": "DenyDeleteFromProduction",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:DeleteObject",
"Resource": "arn:aws:s3:::my-bucket/production/*"
}
]
}`
// ExampleTimeBasedPolicy shows a policy with time-based conditions
var ExampleTimeBasedPolicy = `{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAccessDuringBusinessHours",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"DateGreaterThan": {
"aws:RequestTime": "2023-01-01T08:00:00Z"
},
"DateLessThan": {
"aws:RequestTime": "2023-12-31T18:00:00Z"
}
}
}
]
}`
// ExampleIPRestrictedPolicy shows a policy with IP restrictions
var ExampleIPRestrictedPolicy = `{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowFromOfficeNetwork",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::my-bucket",
"arn:aws:s3:::my-bucket/*"
],
"Condition": {
"IpAddress": {
"aws:SourceIp": [
"203.0.113.0/24",
"198.51.100.0/24"
]
}
}
},
{
"Sid": "DenyFromRestrictedIPs",
"Effect": "Deny",
"Principal": "*",
"Action": "*",
"Resource": "*",
"Condition": {
"IpAddress": {
"aws:SourceIp": [
"192.0.2.0/24"
]
}
}
}
]
}`
// ExamplePublicReadPolicy shows a policy for public read access
var ExamplePublicReadPolicy = `{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-public-bucket/*"
}
]
}`
// ExampleCORSPolicy shows a policy with CORS-related conditions
var ExampleCORSPolicy = `{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCrossOriginRequests",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"StringLike": {
"aws:Referer": [
"https://example.com/*",
"https://*.example.com/*"
]
}
}
}
]
}`
// ExampleUserAgentPolicy shows a policy with user agent restrictions
var ExampleUserAgentPolicy = `{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowSpecificUserAgents",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"StringLike": {
"aws:UserAgent": [
"MyApp/*",
"curl/*"
]
}
}
}
]
}`
// ExamplePrefixBasedPolicy shows a policy with prefix-based access
var ExamplePrefixBasedPolicy = `{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowUserFolderAccess",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::my-bucket/${aws:username}/*",
"Condition": {
"StringEquals": {
"s3:prefix": "${aws:username}/"
}
}
}
]
}`
// ExampleMultiStatementPolicy shows a complex policy with multiple statements
var ExampleMultiStatementPolicy = `{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowListBucket",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::my-bucket",
"Condition": {
"StringEquals": {
"s3:prefix": "public/"
}
}
},
{
"Sid": "AllowGetPublicObjects",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/public/*"
},
{
"Sid": "AllowAuthenticatedUpload",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::my-bucket/uploads/*",
"Condition": {
"StringEquals": {
"s3:x-amz-acl": "private"
}
}
},
{
"Sid": "DenyInsecureConnections",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::my-bucket",
"arn:aws:s3:::my-bucket/*"
],
"Condition": {
"Bool": {
"aws:SecureTransport": "false"
}
}
}
]
}`
// GetAllExamples returns all example policies
func GetAllExamples() map[string]string {
return map[string]string{
"basic-bucket-policy": ExampleBucketPolicy,
"time-based-policy": ExampleTimeBasedPolicy,
"ip-restricted-policy": ExampleIPRestrictedPolicy,
"public-read-policy": ExamplePublicReadPolicy,
"cors-policy": ExampleCORSPolicy,
"user-agent-policy": ExampleUserAgentPolicy,
"prefix-based-policy": ExamplePrefixBasedPolicy,
"multi-statement-policy": ExampleMultiStatementPolicy,
}
}
// ValidateExamplePolicies validates all example policies
func ValidateExamplePolicies() error {
examples := GetAllExamples()
for name, policyJSON := range examples {
_, err := ParsePolicy(policyJSON)
if err != nil {
return fmt.Errorf("invalid example policy %s: %v", name, err)
}
}
return nil
}
// GetExamplePolicy returns a specific example policy
func GetExamplePolicy(name string) (string, error) {
examples := GetAllExamples()
policy, exists := examples[name]
if !exists {
return "", fmt.Errorf("example policy %s not found", name)
}
return policy, nil
}
// CreateExamplePolicyDocument creates a PolicyDocument from an example
func CreateExamplePolicyDocument(name string) (*PolicyDocument, error) {
policyJSON, err := GetExamplePolicy(name)
if err != nil {
return nil, err
}
return ParsePolicy(policyJSON)
}
// PrintExamplePolicyPretty prints an example policy in pretty format
func PrintExamplePolicyPretty(name string) error {
policyJSON, err := GetExamplePolicy(name)
if err != nil {
return err
}
var policy interface{}
if err := json.Unmarshal([]byte(policyJSON), &policy); err != nil {
return err
}
prettyJSON, err := json.MarshalIndent(policy, "", " ")
if err != nil {
return err
}
fmt.Printf("Example Policy: %s\n", name)
fmt.Printf("================\n")
fmt.Println(string(prettyJSON))
return nil
}
// ExampleUsage demonstrates how to use the policy engine
func ExampleUsage() {
// Create a new policy engine
engine := NewPolicyEngine()
// Set a bucket policy
policyJSON := ExampleBucketPolicy
err := engine.SetBucketPolicy("my-bucket", policyJSON)
if err != nil {
fmt.Printf("Error setting bucket policy: %v\n", err)
return
}
// Evaluate a policy
args := &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: "arn:aws:s3:::my-bucket/test-object",
Principal: "*",
Conditions: map[string][]string{
"aws:SourceIp": {"192.168.1.100"},
},
}
result := engine.EvaluatePolicy("my-bucket", args)
switch result {
case PolicyResultAllow:
fmt.Println("Access allowed")
case PolicyResultDeny:
fmt.Println("Access denied")
case PolicyResultIndeterminate:
fmt.Println("Access indeterminate")
}
}
// ExampleLegacyIntegration demonstrates backward compatibility
func ExampleLegacyIntegration() {
// Legacy identity actions
legacyActions := []string{
"Read:bucket1/*",
"Write:bucket1/uploads/*",
"Admin:bucket2",
}
// Convert to policy
policy, err := ConvertIdentityToPolicy(legacyActions)
if err != nil {
fmt.Printf("Error converting identity to policy: %v\n", err)
return
}
// Create policy-backed IAM
policyIAM := NewPolicyBackedIAM()
// Set the converted policy
policyJSON, _ := json.MarshalIndent(policy, "", " ")
err = policyIAM.SetBucketPolicy("bucket1", string(policyJSON))
if err != nil {
fmt.Printf("Error setting bucket policy: %v\n", err)
return
}
fmt.Println("Legacy identity successfully converted to AWS S3 policy")
}
// ExampleConditions demonstrates various condition types
func ExampleConditions() {
examples := map[string]string{
"StringEquals": `"StringEquals": {"s3:prefix": "documents/"}`,
"StringLike": `"StringLike": {"aws:UserAgent": "MyApp/*"}`,
"NumericEquals": `"NumericEquals": {"s3:max-keys": "10"}`,
"NumericLessThan": `"NumericLessThan": {"s3:max-keys": "1000"}`,
"DateGreaterThan": `"DateGreaterThan": {"aws:RequestTime": "2023-01-01T00:00:00Z"}`,
"DateLessThan": `"DateLessThan": {"aws:RequestTime": "2023-12-31T23:59:59Z"}`,
"IpAddress": `"IpAddress": {"aws:SourceIp": "192.168.1.0/24"}`,
"NotIpAddress": `"NotIpAddress": {"aws:SourceIp": "10.0.0.0/8"}`,
"Bool": `"Bool": {"aws:SecureTransport": "true"}`,
"Null": `"Null": {"s3:x-amz-server-side-encryption": "false"}`,
}
fmt.Println("Supported Condition Operators:")
fmt.Println("==============================")
for operator, example := range examples {
fmt.Printf("%s: %s\n", operator, example)
}
}
// ExampleMigrationStrategy demonstrates migration from legacy to policy-based system
func ExampleMigrationStrategy() {
fmt.Println("Migration Strategy:")
fmt.Println("==================")
fmt.Println("1. Keep existing identities.json unchanged")
fmt.Println("2. Legacy actions are automatically converted to AWS policies internally")
fmt.Println("3. Add bucket policies for advanced features:")
fmt.Println(" - IP restrictions")
fmt.Println(" - Time-based access")
fmt.Println(" - SSL-only access")
fmt.Println(" - User agent restrictions")
fmt.Println("4. Policy evaluation precedence:")
fmt.Println(" - Explicit Deny (highest priority)")
fmt.Println(" - Explicit Allow")
fmt.Println(" - Default Deny (lowest priority)")
}
// PrintAllExamples prints all example policies
func PrintAllExamples() {
examples := GetAllExamples()
for name := range examples {
fmt.Printf("\n")
PrintExamplePolicyPretty(name)
fmt.Printf("\n")
}
}

View File

@@ -0,0 +1,255 @@
package policy_engine
import (
"encoding/json"
"reflect"
"slices"
"testing"
)
// TestPolicyPrincipalUnmarshal covers every Principal shape AWS documents, plus
// the bare string/array forms SeaweedFS accepts for backward compatibility.
func TestPolicyPrincipalUnmarshal(t *testing.T) {
cases := []struct {
name string
in string
want []string
}{
{"bare wildcard", `"*"`, []string{"*"}},
{"bare arn", `"arn:aws:iam::123456789012:user/alice"`, []string{"arn:aws:iam::123456789012:user/alice"}},
{"bare array", `["arn:aws:iam::123:root","arn:aws:iam::456:root"]`, []string{"arn:aws:iam::123:root", "arn:aws:iam::456:root"}},
{"AWS single", `{"AWS":"arn:aws:iam::123456789012:root"}`, []string{"arn:aws:iam::123456789012:root"}},
{"AWS array", `{"AWS":["arn:aws:iam::123:root","999999999999"]}`, []string{"arn:aws:iam::123:root", "999999999999"}},
{"AWS wildcard", `{"AWS":"*"}`, []string{"*"}},
{"AWS array wildcard (public read)", `{"AWS":["*"]}`, []string{"*"}},
{"service", `{"Service":"s3.amazonaws.com"}`, []string{"s3.amazonaws.com"}},
{"canonical user", `{"CanonicalUser":"79a59df900b949e55d96a1e698fbace"}`, []string{"79a59df900b949e55d96a1e698fbace"}},
// Object keys are flattened in sorted-key order: AWS < CanonicalUser.
{"mixed keys", `{"CanonicalUser":"79a59","AWS":"arn:aws:iam::123:root"}`, []string{"arn:aws:iam::123:root", "79a59"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var p PolicyPrincipal
if err := json.Unmarshal([]byte(c.in), &p); err != nil {
t.Fatalf("unmarshal %s: %v", c.in, err)
}
if got := p.Strings(); !slices.Equal(got, c.want) {
t.Errorf("Strings() = %v, want %v", got, c.want)
}
})
}
}
func TestPolicyPrincipalUnmarshalInvalid(t *testing.T) {
// Includes object-form abuses: unknown key, empty array, empty string -- all
// of which must error rather than compile to zero matchers (which would let
// the match-all fallback silently make an Allow statement public).
for _, in := range []string{`123`, `{}`, `{"AWS":123}`, `{"Foo":"bar"}`, `{"AWS":[]}`, `{"AWS":""}`} {
var p PolicyPrincipal
if err := json.Unmarshal([]byte(in), &p); err == nil {
t.Errorf("expected error unmarshaling %s, got values %v", in, p.Strings())
}
}
}
// TestNotPrincipalDynamicCompiledPath guards the bug where a NotPrincipal made
// only of policy variables (no static matchers) was treated as absent in the
// compiled evaluator, bypassing the exclusion. aws:username is pinned via the
// condition context so the substituted value is independent of the principal.
func TestNotPrincipalDynamicCompiledPath(t *testing.T) {
policyJSON := `{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Principal":"*","Action":"s3:*","Resource":"arn:aws:s3:::b/*"},
{"Effect":"Deny","NotPrincipal":{"AWS":"${aws:username}"},"Action":"s3:*","Resource":"arn:aws:s3:::b/*"}
]}`
doc, err := ParsePolicy(policyJSON)
if err != nil {
t.Fatalf("ParsePolicy: %v", err)
}
compiled, err := CompilePolicy(doc)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
conds := map[string][]string{"aws:username": {"arn:aws:iam::111:user/alice"}}
allowed := func(p string) bool {
allow, _ := compiled.EvaluatePolicy(&PolicyEvaluationArgs{
Action: "s3:GetObject", Resource: "arn:aws:s3:::b/x", Principal: p, Conditions: conds,
})
return allow
}
// alice matches the substituted NotPrincipal -> deny excluded -> allowed.
if !allowed("arn:aws:iam::111:user/alice") {
t.Errorf("alice should be allowed (matched dynamic NotPrincipal)")
}
// bob does not match -> deny applies.
if allowed("arn:aws:iam::111:user/bob") {
t.Errorf("bob should be denied (dynamic NotPrincipal did not match)")
}
}
// TestPolicyPrincipalRoundTrip guards idempotency for IaC tools (Terraform,
// Ansible): GetBucketPolicy must return the same shape PutBucketPolicy received,
// otherwise those tools see perpetual drift (cf. NotResource normalization bug).
func TestPolicyPrincipalRoundTrip(t *testing.T) {
inputs := []string{
`"*"`,
`{"AWS":"arn:aws:iam::123456789012:root"}`,
`{"AWS":["arn:aws:iam::123456789012:root","arn:aws:iam::555555555555:root"]}`,
`{"AWS":["*"]}`,
`["arn:aws:iam::123:root","arn:aws:iam::456:root"]`,
}
for _, in := range inputs {
var p PolicyPrincipal
if err := json.Unmarshal([]byte(in), &p); err != nil {
t.Fatalf("unmarshal %s: %v", in, err)
}
out, err := json.Marshal(&p)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !jsonSemanticEqual(t, string(out), in) {
t.Errorf("round-trip mismatch: got %s, want %s", out, in)
}
}
}
// TestBucketPolicyAWSObjectPrincipalEndToEnd parses, compiles and evaluates a
// public-read policy written with the AWS object form {"AWS":["*"]} — the
// AWS-documented shape that previously failed to parse.
func TestBucketPolicyAWSObjectPrincipalEndToEnd(t *testing.T) {
policyJSON := `{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowPublicRead",
"Effect": "Allow",
"Principal": {"AWS": ["*"]},
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::my-bucket/*"]
}]
}`
doc, err := ParsePolicy(policyJSON)
if err != nil {
t.Fatalf("ParsePolicy: %v", err)
}
compiled, err := CompilePolicy(doc)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
for _, principal := range []string{"arn:aws:iam::123456789012:user/bob", "*"} {
allow, effect := compiled.EvaluatePolicy(&PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: "arn:aws:s3:::my-bucket/object.txt",
Principal: principal,
})
if !allow || effect != PolicyEffectAllow {
t.Errorf("principal %q: allow=%v effect=%v, want allow", principal, allow, effect)
}
}
}
// TestBucketPolicySpecificAWSPrincipal verifies a named-principal object form
// only matches the named principal.
func TestBucketPolicySpecificAWSPrincipal(t *testing.T) {
policyJSON := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow",` +
`"Principal":{"AWS":"arn:aws:iam::123456789012:user/alice"},` +
`"Action":"s3:*","Resource":"arn:aws:s3:::b/*"}]}`
doc, err := ParsePolicy(policyJSON)
if err != nil {
t.Fatalf("ParsePolicy: %v", err)
}
compiled, err := CompilePolicy(doc)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
args := func(p string) *PolicyEvaluationArgs {
return &PolicyEvaluationArgs{Action: "s3:GetObject", Resource: "arn:aws:s3:::b/x", Principal: p}
}
if allow, _ := compiled.EvaluatePolicy(args("arn:aws:iam::123456789012:user/alice")); !allow {
t.Errorf("alice should be allowed")
}
if allow, _ := compiled.EvaluatePolicy(args("arn:aws:iam::123456789012:user/bob")); allow {
t.Errorf("bob should NOT be allowed")
}
}
// TestBucketPolicyNotPrincipalDeny exercises the common "deny everyone except X"
// pattern through the real bucket-policy evaluator (PolicyEngine). A Deny with
// NotPrincipal applies to every principal NOT named, so the named principal is
// spared and everyone else is denied.
func TestBucketPolicyNotPrincipalDeny(t *testing.T) {
engine := NewPolicyEngine()
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{"Sid":"AllowAll","Effect":"Allow","Principal":"*","Action":"s3:*","Resource":"arn:aws:s3:::b/*"},
{"Sid":"DenyExceptAlice","Effect":"Deny","NotPrincipal":{"AWS":"arn:aws:iam::123456789012:user/alice"},"Action":"s3:*","Resource":"arn:aws:s3:::b/*"}
]
}`
if err := engine.SetBucketPolicy("b", policyJSON); err != nil {
t.Fatalf("SetBucketPolicy: %v", err)
}
eval := func(p string) PolicyEvaluationResult {
return engine.EvaluatePolicy("b", &PolicyEvaluationArgs{Action: "s3:GetObject", Resource: "arn:aws:s3:::b/x", Principal: p})
}
// alice is excluded from the NotPrincipal deny, so the Allow wins.
if got := eval("arn:aws:iam::123456789012:user/alice"); got != PolicyResultAllow {
t.Errorf("alice: got %v, want Allow", got)
}
// bob is not named in NotPrincipal, so the Deny applies.
if got := eval("arn:aws:iam::123456789012:user/bob"); got != PolicyResultDeny {
t.Errorf("bob: got %v, want Deny", got)
}
}
// TestBucketPolicyNotPrincipalCompiledPath covers the matcher-based evaluator
// (CompiledPolicy). An Allow-all paired with a Deny/NotPrincipal makes the
// "excluded vs denied" difference observable (a lone Deny would yield implicit
// deny for both).
func TestBucketPolicyNotPrincipalCompiledPath(t *testing.T) {
policyJSON := `{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Principal":"*","Action":"s3:*","Resource":"arn:aws:s3:::b/*"},
{"Effect":"Deny","NotPrincipal":{"AWS":["arn:aws:iam::123456789012:user/alice"]},"Action":"s3:*","Resource":"arn:aws:s3:::b/*"}
]}`
doc, err := ParsePolicy(policyJSON)
if err != nil {
t.Fatalf("ParsePolicy: %v", err)
}
compiled, err := CompilePolicy(doc)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
args := func(p string) *PolicyEvaluationArgs {
return &PolicyEvaluationArgs{Action: "s3:GetObject", Resource: "arn:aws:s3:::b/x", Principal: p}
}
// alice is named in NotPrincipal -> deny excluded -> allowed.
if allow, _ := compiled.EvaluatePolicy(args("arn:aws:iam::123456789012:user/alice")); !allow {
t.Errorf("alice should be allowed (excluded from NotPrincipal deny)")
}
// bob is not named -> deny applies.
if allow, effect := compiled.EvaluatePolicy(args("arn:aws:iam::123456789012:user/bob")); allow || effect != PolicyEffectDeny {
t.Errorf("bob should be denied, got allow=%v effect=%v", allow, effect)
}
}
// TestPolicyBothPrincipalAndNotPrincipalRejected verifies AWS's rule that a
// single statement may not contain both Principal and NotPrincipal.
func TestPolicyBothPrincipalAndNotPrincipalRejected(t *testing.T) {
policyJSON := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny",` +
`"Principal":"*","NotPrincipal":{"AWS":"arn:aws:iam::1:user/a"},` +
`"Action":"s3:*","Resource":"arn:aws:s3:::b/*"}]}`
if _, err := ParsePolicy(policyJSON); err == nil {
t.Errorf("expected error when both Principal and NotPrincipal are set")
}
}
func jsonSemanticEqual(t *testing.T, a, b string) bool {
t.Helper()
var av, bv interface{}
if err := json.Unmarshal([]byte(a), &av); err != nil {
t.Fatalf("unmarshal %s: %v", a, err)
}
if err := json.Unmarshal([]byte(b), &bv); err != nil {
t.Fatalf("unmarshal %s: %v", b, err)
}
return reflect.DeepEqual(av, bv)
}

View File

@@ -0,0 +1,249 @@
package policy_engine
import (
"net/http"
"testing"
)
// requiresSSEPolicy is a bucket policy that denies PutObject when the
// x-amz-server-side-encryption header is absent (Null == true).
const requiresSSEPolicy = `{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyUnencryptedUploads",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"Null": {
"s3:x-amz-server-side-encryption": "true"
}
}
}
]
}`
// requiresAES256Policy denies PutObject unless AES256 is explicitly requested.
const requiresAES256Policy = `{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAES256Only",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"StringEquals": {
"s3:x-amz-server-side-encryption": "AES256"
}
}
}
]
}`
// requiresKMSPolicy allows PutObject only when aws:kms encryption is requested.
const requiresKMSPolicy = `{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowKMSOnly",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"StringEquals": {
"s3:x-amz-server-side-encryption": "aws:kms"
}
}
}
]
}`
// multipartPolicy denies PutObject when SSE is absent but should NOT block UploadPart.
const multipartPolicy = `{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyUnencryptedUploads",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:PutObject", "s3:UploadPart", "s3:UploadPartCopy"],
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"Null": {
"s3:x-amz-server-side-encryption": "true"
}
}
}
]
}`
func newEngineWithPolicy(t *testing.T, policy string) *PolicyEngine {
t.Helper()
engine := NewPolicyEngine()
if err := engine.SetBucketPolicy("test-bucket", policy); err != nil {
t.Fatalf("SetBucketPolicy: %v", err)
}
return engine
}
func evalArgs(action string, conditions map[string][]string) *PolicyEvaluationArgs {
return &PolicyEvaluationArgs{
Action: action,
Resource: "arn:aws:s3:::test-bucket/object.txt",
Principal: "*",
Conditions: conditions,
}
}
func evalArgsWithSSE(action, inheritedSSE string) *PolicyEvaluationArgs {
return &PolicyEvaluationArgs{
Action: action,
Resource: "arn:aws:s3:::test-bucket/object.txt",
Principal: "*",
Conditions: map[string][]string{},
InheritedSSEAlgorithm: inheritedSSE,
}
}
// TestSSEStringEqualsPresent – StringEquals with AES256 header present should Allow.
func TestSSEStringEqualsPresent(t *testing.T) {
engine := newEngineWithPolicy(t, requiresAES256Policy)
conditions := map[string][]string{
"s3:x-amz-server-side-encryption": {"AES256"},
}
result := engine.EvaluatePolicy("test-bucket", evalArgs("s3:PutObject", conditions))
if result != PolicyResultAllow {
t.Errorf("expected Allow, got %v", result)
}
}
// TestSSEStringEqualsWrongValue – StringEquals with wrong SSE value should not Allow.
func TestSSEStringEqualsWrongValue(t *testing.T) {
engine := newEngineWithPolicy(t, requiresAES256Policy)
conditions := map[string][]string{
"s3:x-amz-server-side-encryption": {"aws:kms"},
}
result := engine.EvaluatePolicy("test-bucket", evalArgs("s3:PutObject", conditions))
if result == PolicyResultAllow {
t.Errorf("expected non-Allow, got %v", result)
}
}
// TestSSENullConditionAbsent – Null("true") matches when header is absent → Deny.
func TestSSENullConditionAbsent(t *testing.T) {
engine := newEngineWithPolicy(t, requiresSSEPolicy)
// No SSE header → condition "Null == true" matches → Deny statement fires
result := engine.EvaluatePolicy("test-bucket", evalArgs("s3:PutObject", map[string][]string{}))
if result != PolicyResultDeny {
t.Errorf("expected Deny (no SSE header), got %v", result)
}
}
// TestSSENullConditionPresent – Null("true") does NOT match when header is present → not Deny.
func TestSSENullConditionPresent(t *testing.T) {
engine := newEngineWithPolicy(t, requiresSSEPolicy)
conditions := map[string][]string{
"s3:x-amz-server-side-encryption": {"AES256"},
}
result := engine.EvaluatePolicy("test-bucket", evalArgs("s3:PutObject", conditions))
// Deny condition not matched; no explicit Allow → Indeterminate
if result == PolicyResultDeny {
t.Errorf("expected non-Deny when SSE header present, got Deny")
}
}
// TestSSECaseInsensitiveNormalizationAES256 drives the AES256 normalisation
// through EvaluatePolicyForRequest so that a regression in the production
// code path would be caught. The request carries the header in lowercase
// ("aes256"); after normalisation it must match the policy's "AES256" value.
func TestSSECaseInsensitiveNormalizationAES256(t *testing.T) {
engine := newEngineWithPolicy(t, requiresAES256Policy)
req, _ := http.NewRequest(http.MethodPut, "/", nil)
req.RemoteAddr = "1.2.3.4:1234"
req.Header.Set("X-Amz-Server-Side-Encryption", "aes256") // lowercase
result := engine.EvaluatePolicyForRequest("test-bucket", "object.txt", "PutObject", "*", req)
if result != PolicyResultAllow {
t.Errorf("expected Allow after AES256 case normalisation, got %v", result)
}
}
// TestSSECaseInsensitiveNormalizationKMS drives the aws:kms branch of the
// normalisation through the production code path. The request carries
// "AWS:KMS" (mixed case); after normalisation it must match "aws:kms".
func TestSSECaseInsensitiveNormalizationKMS(t *testing.T) {
engine := newEngineWithPolicy(t, requiresKMSPolicy)
req, _ := http.NewRequest(http.MethodPut, "/", nil)
req.RemoteAddr = "1.2.3.4:1234"
req.Header.Set("X-Amz-Server-Side-Encryption", "AWS:KMS") // mixed case
result := engine.EvaluatePolicyForRequest("test-bucket", "object.txt", "PutObject", "*", req)
if result != PolicyResultAllow {
t.Errorf("expected Allow after aws:kms case normalisation, got %v", result)
}
}
// TestSSEMultipartAES256Exempt – UploadPart with AES256 inherited from
// CreateMultipartUpload is not blocked by the Null("true") deny condition.
func TestSSEMultipartAES256Exempt(t *testing.T) {
engine := newEngineWithPolicy(t, multipartPolicy)
result := engine.EvaluatePolicy("test-bucket", evalArgsWithSSE("s3:UploadPart", "AES256"))
if result == PolicyResultDeny {
t.Errorf("UploadPart with inherited AES256 should not be Deny, got Deny")
}
}
// TestSSEMultipartKMSExempt – UploadPart with aws:kms inherited from
// CreateMultipartUpload is not blocked by the Null("true") deny condition.
func TestSSEMultipartKMSExempt(t *testing.T) {
engine := newEngineWithPolicy(t, multipartPolicy)
result := engine.EvaluatePolicy("test-bucket", evalArgsWithSSE("s3:UploadPart", "aws:kms"))
if result == PolicyResultDeny {
t.Errorf("UploadPart with inherited aws:kms should not be Deny, got Deny")
}
}
// TestSSEMultipartNoSSEDenied – UploadPart for an upload that had no SSE
// must still be denied by the Null("true") deny condition.
func TestSSEMultipartNoSSEDenied(t *testing.T) {
engine := newEngineWithPolicy(t, multipartPolicy)
// inheritedSSE="" means CreateMultipartUpload was sent without SSE
result := engine.EvaluatePolicy("test-bucket", evalArgsWithSSE("s3:UploadPart", ""))
if result != PolicyResultDeny {
t.Errorf("UploadPart with no inherited SSE should be Deny, got %v", result)
}
}
// TestSSEUploadPartCopyKMSExempt – UploadPartCopy with aws:kms is also exempt.
func TestSSEUploadPartCopyKMSExempt(t *testing.T) {
engine := newEngineWithPolicy(t, multipartPolicy)
result := engine.EvaluatePolicy("test-bucket", evalArgsWithSSE("s3:UploadPartCopy", "aws:kms"))
if result == PolicyResultDeny {
t.Errorf("UploadPartCopy with inherited aws:kms should not be Deny, got Deny")
}
}
// TestSSEPutObjectStillBlockedWithoutHeader – regular PutObject still denied without SSE.
func TestSSEPutObjectStillBlockedWithoutHeader(t *testing.T) {
engine := newEngineWithPolicy(t, multipartPolicy)
result := engine.EvaluatePolicy("test-bucket", evalArgs("s3:PutObject", map[string][]string{}))
if result != PolicyResultDeny {
t.Errorf("PutObject without SSE should be Deny, got %v", result)
}
}

View File

@@ -0,0 +1,122 @@
package policy_engine
import (
"net"
"net/http"
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
type TrustedProxies struct {
ips map[string]struct{}
cidrs map[string]*net.IPNet
}
func NewTrustedProxies(whiteList []string) *TrustedProxies {
tp := &TrustedProxies{
ips: make(map[string]struct{}),
cidrs: make(map[string]*net.IPNet),
}
for _, entry := range whiteList {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if strings.Contains(entry, "/") {
_, cidrnet, err := net.ParseCIDR(entry)
if err != nil {
glog.Errorf("Parse CIDR %s in s3 trusted_proxies failed: %v", entry, err)
continue
}
tp.cidrs[entry] = cidrnet
} else {
ip := net.ParseIP(entry)
if ip == nil {
glog.Errorf("Parse IP %s in s3 trusted_proxies failed", entry)
continue
}
tp.ips[ip.String()] = struct{}{}
}
}
return tp
}
func (tp *TrustedProxies) IsTrusted(ipStr string) bool {
if tp == nil {
return false
}
if _, ok := tp.ips[ipStr]; ok {
return true
}
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
for _, cidrnet := range tp.cidrs {
if cidrnet.Contains(ip) {
return true
}
}
return false
}
// ExtractSourceIP returns the client IP for aws:SourceIp condition evaluation.
// The direct TCP peer is used unless it is in the trusted proxy allowlist, in
// which case X-Forwarded-For (right-to-left, skipping trusted hops) then
// X-Real-Ip are honored.
func (tp *TrustedProxies) ExtractSourceIP(r *http.Request) string {
if r == nil {
return ""
}
remoteAddr := strings.TrimSpace(r.RemoteAddr)
if remoteAddr == "" {
return ""
}
if remoteAddr == "@" {
return remoteAddr
}
host := remoteAddr
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = h
}
remoteIP := net.ParseIP(host)
if remoteIP == nil {
return ""
}
if tp.IsTrusted(host) {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
entries := strings.Split(xff, ",")
malformed := false
for i := len(entries) - 1; i >= 0; i-- {
candidate := strings.TrimSpace(entries[i])
if candidate == "" {
continue
}
ip := net.ParseIP(candidate)
if ip == nil {
malformed = true
break
}
if tp.IsTrusted(ip.String()) {
continue
}
return ip.String()
}
if !malformed {
for _, candidate := range entries {
candidate = strings.TrimSpace(candidate)
if ip := net.ParseIP(candidate); ip != nil {
return ip.String()
}
}
}
}
if xRealIP := strings.TrimSpace(r.Header.Get("X-Real-Ip")); xRealIP != "" {
if ip := net.ParseIP(xRealIP); ip != nil {
return ip.String()
}
}
}
return remoteIP.String()
}

View File

@@ -0,0 +1,128 @@
package policy_engine
import (
"net/http"
"testing"
)
func newReq(remoteAddr string, xff, xRealIP string) *http.Request {
r := &http.Request{RemoteAddr: remoteAddr, Header: http.Header{}}
if xff != "" {
r.Header.Set("X-Forwarded-For", xff)
}
if xRealIP != "" {
r.Header.Set("X-Real-Ip", xRealIP)
}
return r
}
func TestTrustedProxies_IsTrusted(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.5", "192.168.0.0/16"})
if !tp.IsTrusted("10.0.0.5") {
t.Error("bare IP should be trusted")
}
if !tp.IsTrusted("192.168.1.100") {
t.Error("IP in CIDR should be trusted")
}
if tp.IsTrusted("8.8.8.8") {
t.Error("public IP should not be trusted")
}
if tp.IsTrusted("not-an-ip") {
t.Error("invalid IP should not be trusted")
}
}
func TestTrustedProxies_CanonicalizesBareIPv6(t *testing.T) {
tp := NewTrustedProxies([]string{"2001:0db8::1"})
if !tp.IsTrusted("2001:db8::1") {
t.Error("canonical IPv6 should match non-canonical allowlist entry")
}
}
func TestTrustedProxies_InvalidBareIPSkipped(t *testing.T) {
tp := NewTrustedProxies([]string{"not-an-ip", "10.0.0.5"})
if tp.IsTrusted("not-an-ip") {
t.Error("invalid entry should not be stored")
}
if !tp.IsTrusted("10.0.0.5") {
t.Error("valid entry after invalid one should still load")
}
}
func TestTrustedProxies_NilNotTrusted(t *testing.T) {
var tp *TrustedProxies
if tp.IsTrusted("127.0.0.1") {
t.Error("nil TrustedProxies should not trust any IP")
}
}
func TestTrustedProxies_ExtractSourceIP_DirectPeerWhenUntrusted(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
r := newReq("203.0.113.5:1234", "8.8.8.8", "1.1.1.1")
if got := tp.ExtractSourceIP(r); got != "203.0.113.5" {
t.Errorf("untrusted peer: want 203.0.113.5, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_XForwardedFor(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
r := newReq("10.0.0.1:1234", "8.8.8.8, 10.0.0.2", "")
if got := tp.ExtractSourceIP(r); got != "8.8.8.8" {
t.Errorf("trusted proxy: want 8.8.8.8, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_AllTrustedReturnsLeftmost(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
r := newReq("10.0.0.1:1234", "10.0.0.5, 10.0.0.6", "")
if got := tp.ExtractSourceIP(r); got != "10.0.0.5" {
t.Errorf("all-trusted chain: want leftmost 10.0.0.5, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_MalformedXFFFallsBackToPeer(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
r := newReq("10.0.0.1:1234", "8.8.8.8, garbage", "")
if got := tp.ExtractSourceIP(r); got != "10.0.0.1" {
t.Errorf("malformed XFF: want direct peer 10.0.0.1, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_XRealIP(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
r := newReq("10.0.0.1:1234", "", "8.8.8.8")
if got := tp.ExtractSourceIP(r); got != "8.8.8.8" {
t.Errorf("X-Real-Ip: want 8.8.8.8, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_XForwardedForPreferredOverXRealIP(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
r := newReq("10.0.0.1:1234", "8.8.8.8", "1.1.1.1")
if got := tp.ExtractSourceIP(r); got != "8.8.8.8" {
t.Errorf("XFF should win over X-Real-Ip: want 8.8.8.8, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_NoTrustedProxiesUsesPeer(t *testing.T) {
tp := NewTrustedProxies(nil)
r := newReq("10.0.0.1:1234", "8.8.8.8", "1.1.1.1")
if got := tp.ExtractSourceIP(r); got != "10.0.0.1" {
t.Errorf("empty allowlist: want 10.0.0.1, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_NilRequest(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
if got := tp.ExtractSourceIP(nil); got != "" {
t.Errorf("nil request: want empty, got %s", got)
}
}
func TestTrustedProxies_ExtractSourceIP_UnixSocket(t *testing.T) {
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
r := newReq("@", "", "")
if got := tp.ExtractSourceIP(r); got != "@" {
t.Errorf("unix socket: want @, got %s", got)
}
}

View File

@@ -0,0 +1,832 @@
package policy_engine
import (
"encoding/json"
"fmt"
"regexp"
"slices"
"sort"
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
s3const "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
)
// Policy Engine Types
//
// This package provides enhanced AWS S3-compatible policy types with improved type safety.
//
// MIGRATION COMPLETE:
// This is now the unified PolicyDocument type used throughout the SeaweedFS codebase.
// The previous duplicate PolicyDocument types in iamapi and credential packages have
// been migrated to use these enhanced types, providing:
// - Principal specifications
// - Complex conditions (IP, time, string patterns, etc.)
// - Flexible string/array types with proper JSON marshaling
// - Policy compilation for performance
//
// All policy operations now use this single, consistent type definition.
// Constants for policy validation
const (
// PolicyVersion2012_10_17 is the standard AWS policy version
PolicyVersion2012_10_17 = "2012-10-17"
)
var (
// PolicyVariableRegex detects AWS IAM policy variables like ${aws:username}
PolicyVariableRegex = regexp.MustCompile(`\$\{([^}]+)\}`)
// multipartActionSet contains all S3 multipart upload actions
// These are treated as equivalent to s3:PutObject for authorization purposes
multipartActionSet = map[string]bool{
s3const.S3_ACTION_CREATE_MULTIPART: true,
s3const.S3_ACTION_UPLOAD_PART: true,
s3const.S3_ACTION_UPLOAD_PART_COPY: true,
s3const.S3_ACTION_COMPLETE_MULTIPART: true,
s3const.S3_ACTION_ABORT_MULTIPART: true,
s3const.S3_ACTION_LIST_PARTS: true,
s3const.S3_ACTION_LIST_MULTIPART_UPLOADS: true,
}
// lowerMultipartActionSet keys the same actions for case-insensitive lookup.
lowerMultipartActionSet = func() map[string]bool {
lowered := make(map[string]bool, len(multipartActionSet))
for action := range multipartActionSet {
lowered[strings.ToLower(action)] = true
}
return lowered
}()
)
// StringOrStringSlice represents a value that can be either a string or []string
type StringOrStringSlice struct {
values []string
}
// UnmarshalJSON implements json.Unmarshaler for StringOrStringSlice
func (s *StringOrStringSlice) UnmarshalJSON(data []byte) error {
// Try unmarshaling as string first
var str string
if err := json.Unmarshal(data, &str); err == nil {
s.values = []string{str}
return nil
}
// Try unmarshaling as []string
var strs []string
if err := json.Unmarshal(data, &strs); err == nil {
s.values = strs
return nil
}
return fmt.Errorf("value must be string or []string")
}
// MarshalJSON implements json.Marshaler for StringOrStringSlice
func (s StringOrStringSlice) MarshalJSON() ([]byte, error) {
if len(s.values) == 1 {
return json.Marshal(s.values[0])
}
return json.Marshal(s.values)
}
// Strings returns the slice of strings. Nil-safe for pointer receivers.
func (s *StringOrStringSlice) Strings() []string {
if s == nil {
return nil
}
return s.values
}
// NewStringOrStringSlice creates a new StringOrStringSlice from strings
func NewStringOrStringSlice(values ...string) StringOrStringSlice {
return StringOrStringSlice{values: values}
}
// NewStringOrStringSlicePtr creates a new *StringOrStringSlice from strings
func NewStringOrStringSlicePtr(values ...string) *StringOrStringSlice {
return &StringOrStringSlice{values: values}
}
// CloneStringOrStringSlice returns a copy with its own backing slice.
func CloneStringOrStringSlice(value StringOrStringSlice) StringOrStringSlice {
return StringOrStringSlice{values: append([]string(nil), value.values...)}
}
// allowedPrincipalKeys is the set of principal-type keys AWS allows inside the
// Principal/NotPrincipal object form.
var allowedPrincipalKeys = map[string]struct{}{
"AWS": {},
"Service": {},
"Federated": {},
"CanonicalUser": {},
}
// PolicyPrincipal represents the Principal element of a policy statement.
//
// AWS accepts three shapes (see the IAM "Principal" element reference):
//
// "Principal": "*" // all principals (anonymous)
// "Principal": { "AWS": "arn:aws:iam::123:user/x" } // single, keyed by type
// "Principal": { "AWS": ["arn:...", "999999999999"] } // array of values
//
// The object may key on "AWS", "Service", "Federated" or "CanonicalUser", and
// each value is itself a string or an array of strings. For backward
// compatibility SeaweedFS also accepts a bare string or array of strings.
//
// Authorization matches the request principal ARN against the flat set of
// principal values, so every keyed value is flattened into one list. The
// original JSON is preserved so GetBucketPolicy returns the exact shape the
// caller submitted; this keeps PutBucketPolicy/GetBucketPolicy idempotent for
// infrastructure-as-code tools (Terraform, Ansible) that diff the returned
// policy against the submitted one.
type PolicyPrincipal struct {
values []string
raw json.RawMessage
}
// UnmarshalJSON implements json.Unmarshaler for PolicyPrincipal.
func (p *PolicyPrincipal) UnmarshalJSON(data []byte) error {
// Preserve the original encoding for a faithful round-trip on marshal.
p.raw = append(json.RawMessage(nil), data...)
// Bare string, e.g. "*"
var str string
if err := json.Unmarshal(data, &str); err == nil {
p.values = []string{str}
return nil
}
// Bare array of strings (SeaweedFS extension; not standard AWS).
var strs []string
if err := json.Unmarshal(data, &strs); err == nil {
p.values = strs
return nil
}
// AWS object form: {"AWS": <string|[]string>, "Service": ..., "Federated": ...,
// "CanonicalUser": ...}. Each value reuses StringOrStringSlice so it accepts a
// single string or an array. All keyed values are flattened for matching.
var obj map[string]StringOrStringSlice
if err := json.Unmarshal(data, &obj); err == nil && len(obj) > 0 {
// Sort keys so the flattened order is deterministic.
keys := make([]string, 0, len(obj))
for k := range obj {
if _, ok := allowedPrincipalKeys[k]; !ok {
return fmt.Errorf("unsupported Principal type %q (expected AWS, Service, Federated or CanonicalUser)", k)
}
keys = append(keys, k)
}
sort.Strings(keys)
values := make([]string, 0, len(obj))
for _, k := range keys {
v := obj[k]
items := v.Strings()
if len(items) == 0 {
return fmt.Errorf("Principal %q must list at least one value", k)
}
for _, item := range items {
if item == "" {
return fmt.Errorf("Principal %q must not contain empty values", k)
}
}
values = append(values, items...)
}
p.values = values
return nil
}
return fmt.Errorf(`Principal must be a string, an array of strings, or an object such as {"AWS": ["arn:..."]}`)
}
// MarshalJSON implements json.Marshaler for PolicyPrincipal.
func (p PolicyPrincipal) MarshalJSON() ([]byte, error) {
// Echo the original JSON when we parsed it, so the shape is preserved.
if len(p.raw) > 0 {
return p.raw, nil
}
// Programmatically-constructed principals marshal as a bare string/array.
if len(p.values) == 1 {
return json.Marshal(p.values[0])
}
return json.Marshal(p.values)
}
// Strings returns the flattened principal values. Nil-safe for pointer receivers.
func (p *PolicyPrincipal) Strings() []string {
if p == nil {
return nil
}
return p.values
}
// NewPolicyPrincipalPtr builds a *PolicyPrincipal from flat values (no object wrapping).
func NewPolicyPrincipalPtr(values ...string) *PolicyPrincipal {
return &PolicyPrincipal{values: values}
}
// ClonePolicyPrincipal deep-copies a *PolicyPrincipal (nil-safe).
func ClonePolicyPrincipal(p *PolicyPrincipal) *PolicyPrincipal {
if p == nil {
return nil
}
return &PolicyPrincipal{
values: append([]string(nil), p.values...),
raw: append(json.RawMessage(nil), p.raw...),
}
}
// PolicyConditions represents policy conditions with proper typing
type PolicyConditions map[string]map[string]StringOrStringSlice
// PolicyDocument represents an AWS S3 bucket policy document
type PolicyDocument struct {
Version string `json:"Version"`
Statement []PolicyStatement `json:"Statement"`
}
// UnmarshalJSON implements json.Unmarshaler for PolicyDocument
func (p *PolicyDocument) UnmarshalJSON(data []byte) error {
type Alias PolicyDocument
aux := &struct {
Statement json.RawMessage `json:"Statement"`
*Alias
}{
Alias: (*Alias)(p),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
// Try unmarshaling as []PolicyStatement first
var statements []PolicyStatement
if err := json.Unmarshal(aux.Statement, &statements); err == nil {
p.Statement = statements
return nil
}
// Try unmarshaling as single PolicyStatement
var statement PolicyStatement
if err := json.Unmarshal(aux.Statement, &statement); err == nil {
p.Statement = []PolicyStatement{statement}
return nil
}
return fmt.Errorf("Statement must be an array or a single object")
}
// PolicyStatement represents a single policy statement
type PolicyStatement struct {
Sid string `json:"Sid,omitempty"`
Effect PolicyEffect `json:"Effect"`
Principal *PolicyPrincipal `json:"Principal,omitempty"`
NotPrincipal *PolicyPrincipal `json:"NotPrincipal,omitempty"`
Action StringOrStringSlice `json:"Action"`
Resource *StringOrStringSlice `json:"Resource,omitempty"`
NotResource *StringOrStringSlice `json:"NotResource,omitempty"`
Condition PolicyConditions `json:"Condition,omitempty"`
}
// PolicyEffect represents Allow or Deny
type PolicyEffect string
const (
PolicyEffectAllow PolicyEffect = "Allow"
PolicyEffectDeny PolicyEffect = "Deny"
)
// PolicyEvaluationArgs contains the arguments for policy evaluation
type PolicyEvaluationArgs struct {
Action string
Resource string
Principal string
Conditions map[string][]string
// ObjectEntry is the object's metadata from entry.Extended.
// Used for evaluating conditions like s3:ExistingObjectTag/<tag-key>.
// Tags are stored with s3_constants.AmzObjectTaggingPrefix (X-Amz-Tagging-) prefix.
// Can be nil for bucket-level operations or when object doesn't exist.
ObjectEntry map[string][]byte
// Claims are JWT claims for jwt:* policy variables (can be nil)
Claims map[string]interface{}
// InheritedSSEAlgorithm is the canonical SSE algorithm ("AES256" or "aws:kms")
// inherited from the CreateMultipartUpload request for UploadPart and
// UploadPartCopy actions. The empty string means no SSE was used.
InheritedSSEAlgorithm string
}
// PolicyCache for caching compiled policies
type PolicyCache struct {
policies map[string]*CompiledPolicy
}
// CompiledPolicy represents a policy that has been compiled for efficient evaluation
type CompiledPolicy struct {
Document *PolicyDocument
Statements []*CompiledStatement
}
// CompiledStatement represents a compiled policy statement
type CompiledStatement struct {
Statement *PolicyStatement
ActionMatchers []*wildcard.WildcardMatcher
ResourceMatchers []*wildcard.WildcardMatcher
PrincipalMatchers []*wildcard.WildcardMatcher
// Keep regex patterns for backward compatibility
ActionPatterns []*regexp.Regexp
ResourcePatterns []*regexp.Regexp
PrincipalPatterns []*regexp.Regexp
// dynamic patterns that require variable substitution before matching
DynamicActionPatterns []string
DynamicResourcePatterns []string
DynamicPrincipalPatterns []string
// NotPrincipal patterns (principal should NOT match these)
NotPrincipalMatchers []*wildcard.WildcardMatcher
NotPrincipalPatterns []*regexp.Regexp
DynamicNotPrincipalPatterns []string
// NotResource patterns (resource should NOT match these)
NotResourcePatterns []*regexp.Regexp
NotResourceMatchers []*wildcard.WildcardMatcher
DynamicNotResourcePatterns []string
}
// NewPolicyCache creates a new policy cache
func NewPolicyCache() *PolicyCache {
return &PolicyCache{
policies: make(map[string]*CompiledPolicy),
}
}
// ValidatePolicy validates a policy document
func ValidatePolicy(policyDoc *PolicyDocument) error {
if policyDoc.Version != PolicyVersion2012_10_17 {
return fmt.Errorf("unsupported policy version: %s", policyDoc.Version)
}
if len(policyDoc.Statement) == 0 {
return fmt.Errorf("policy must contain at least one statement")
}
for i := range policyDoc.Statement {
if err := validateStatement(&policyDoc.Statement[i]); err != nil {
return fmt.Errorf("invalid statement %d: %v", i, err)
}
}
return nil
}
// validateStatement validates a single policy statement
func validateStatement(stmt *PolicyStatement) error {
if stmt.Effect != PolicyEffectAllow && stmt.Effect != PolicyEffectDeny {
return fmt.Errorf("invalid effect: %s", stmt.Effect)
}
if len(stmt.Action.Strings()) == 0 {
return fmt.Errorf("action is required")
}
if len(stmt.Resource.Strings()) == 0 && len(stmt.NotResource.Strings()) == 0 {
return fmt.Errorf("statement must specify Resource or NotResource")
}
// AWS does not allow both Principal and NotPrincipal in the same statement.
if stmt.Principal != nil && stmt.NotPrincipal != nil {
return fmt.Errorf("statement cannot specify both Principal and NotPrincipal")
}
return nil
}
// ParsePolicy parses a policy JSON string
func ParsePolicy(policyJSON string) (*PolicyDocument, error) {
var policy PolicyDocument
if err := json.Unmarshal([]byte(policyJSON), &policy); err != nil {
return nil, fmt.Errorf("failed to parse policy JSON: %w", err)
}
if err := ValidatePolicy(&policy); err != nil {
return nil, fmt.Errorf("invalid policy: %w", err)
}
return &policy, nil
}
// CompilePolicy compiles a policy for efficient evaluation
func CompilePolicy(policy *PolicyDocument) (*CompiledPolicy, error) {
compiled := &CompiledPolicy{
Document: policy,
Statements: make([]*CompiledStatement, len(policy.Statement)),
}
for i := range policy.Statement {
stmt := &policy.Statement[i]
compiledStmt, err := compileStatement(stmt)
if err != nil {
return nil, fmt.Errorf("failed to compile statement %d: %v", i, err)
}
compiled.Statements[i] = compiledStmt
}
return compiled, nil
}
// compileStatement compiles a single policy statement
func compileStatement(stmt *PolicyStatement) (*CompiledStatement, error) {
resStrings := slices.Clone(stmt.Resource.Strings())
notResStrings := slices.Clone(stmt.NotResource.Strings())
compiled := &CompiledStatement{
Statement: &PolicyStatement{
Sid: stmt.Sid,
Effect: stmt.Effect,
Action: stmt.Action,
},
}
// Deep clone Principal / NotPrincipal if present
if stmt.Principal != nil {
compiled.Statement.Principal = ClonePolicyPrincipal(stmt.Principal)
}
if stmt.NotPrincipal != nil {
compiled.Statement.NotPrincipal = ClonePolicyPrincipal(stmt.NotPrincipal)
}
// Deep clone Resource/NotResource into the internal statement as well for completeness
if stmt.Resource != nil {
resourceClone := *stmt.Resource
resourceClone.values = slices.Clone(stmt.Resource.values)
compiled.Statement.Resource = &resourceClone
}
if stmt.NotResource != nil {
notResourceClone := *stmt.NotResource
notResourceClone.values = slices.Clone(stmt.NotResource.values)
compiled.Statement.NotResource = &notResourceClone
}
compiled.Statement.Action.values = slices.Clone(stmt.Action.values)
// Deep clone Condition map
if stmt.Condition != nil {
compiled.Statement.Condition = make(PolicyConditions)
for k, v := range stmt.Condition {
innerMap := make(map[string]StringOrStringSlice)
for ik, iv := range v {
innerMap[ik] = StringOrStringSlice{values: slices.Clone(iv.values)}
}
compiled.Statement.Condition[k] = innerMap
}
}
// Compile action patterns and matchers
for _, action := range stmt.Action.Strings() {
if action == "" {
continue
}
// Check for dynamic variables
if PolicyVariableRegex.MatchString(action) {
compiled.DynamicActionPatterns = append(compiled.DynamicActionPatterns, action)
continue
}
pattern, err := compilePattern(action)
if err != nil {
return nil, fmt.Errorf("failed to compile action pattern %s: %v", action, err)
}
compiled.ActionPatterns = append(compiled.ActionPatterns, pattern)
matcher, err := wildcard.NewWildcardMatcher(action)
if err != nil {
return nil, fmt.Errorf("failed to create action matcher %s: %v", action, err)
}
compiled.ActionMatchers = append(compiled.ActionMatchers, matcher)
}
// Compile resource patterns and matchers
for _, resource := range resStrings {
if resource == "" {
continue
}
// Check for dynamic variables
if PolicyVariableRegex.MatchString(resource) {
compiled.DynamicResourcePatterns = append(compiled.DynamicResourcePatterns, resource)
continue
}
pattern, err := compilePattern(resource)
if err != nil {
return nil, fmt.Errorf("failed to compile resource pattern %s: %v", resource, err)
}
compiled.ResourcePatterns = append(compiled.ResourcePatterns, pattern)
matcher, err := wildcard.NewWildcardMatcher(resource)
if err != nil {
return nil, fmt.Errorf("failed to create resource matcher %s: %v", resource, err)
}
compiled.ResourceMatchers = append(compiled.ResourceMatchers, matcher)
}
// Compile principal patterns and matchers if present
if stmt.Principal != nil && len(stmt.Principal.Strings()) > 0 {
for _, principal := range stmt.Principal.Strings() {
if principal == "" {
continue
}
// Check for dynamic variables
if PolicyVariableRegex.MatchString(principal) {
compiled.DynamicPrincipalPatterns = append(compiled.DynamicPrincipalPatterns, principal)
continue
}
pattern, err := compilePattern(principal)
if err != nil {
return nil, fmt.Errorf("failed to compile principal pattern %s: %v", principal, err)
}
compiled.PrincipalPatterns = append(compiled.PrincipalPatterns, pattern)
matcher, err := wildcard.NewWildcardMatcher(principal)
if err != nil {
return nil, fmt.Errorf("failed to create principal matcher %s: %v", principal, err)
}
compiled.PrincipalMatchers = append(compiled.PrincipalMatchers, matcher)
}
}
// Compile NotPrincipal patterns and matchers (principal should NOT match these)
if stmt.NotPrincipal != nil && len(stmt.NotPrincipal.Strings()) > 0 {
for _, notPrincipal := range stmt.NotPrincipal.Strings() {
if notPrincipal == "" {
continue
}
// Check for dynamic variables
if PolicyVariableRegex.MatchString(notPrincipal) {
compiled.DynamicNotPrincipalPatterns = append(compiled.DynamicNotPrincipalPatterns, notPrincipal)
continue
}
pattern, err := compilePattern(notPrincipal)
if err != nil {
return nil, fmt.Errorf("failed to compile NotPrincipal pattern %s: %v", notPrincipal, err)
}
compiled.NotPrincipalPatterns = append(compiled.NotPrincipalPatterns, pattern)
matcher, err := wildcard.NewWildcardMatcher(notPrincipal)
if err != nil {
return nil, fmt.Errorf("failed to create NotPrincipal matcher %s: %v", notPrincipal, err)
}
compiled.NotPrincipalMatchers = append(compiled.NotPrincipalMatchers, matcher)
}
}
// Compile NotResource patterns (resource should NOT match these)
if len(notResStrings) > 0 {
for _, notResource := range notResStrings {
if notResource == "" {
continue
}
// Check for dynamic variables
if PolicyVariableRegex.MatchString(notResource) {
compiled.DynamicNotResourcePatterns = append(compiled.DynamicNotResourcePatterns, notResource)
continue
}
pattern, err := compilePattern(notResource)
if err != nil {
return nil, fmt.Errorf("failed to compile NotResource pattern %s: %v", notResource, err)
}
compiled.NotResourcePatterns = append(compiled.NotResourcePatterns, pattern)
matcher, err := wildcard.NewWildcardMatcher(notResource)
if err != nil {
return nil, fmt.Errorf("failed to create NotResource matcher %s: %v", notResource, err)
}
compiled.NotResourceMatchers = append(compiled.NotResourceMatchers, matcher)
// Debug log
// fmt.Printf("Compiled NotResource: %s\n", notResource)
}
}
return compiled, nil
}
// compilePattern compiles a wildcard pattern to regex
func compilePattern(pattern string) (*regexp.Regexp, error) {
return wildcard.CompileWildcardPattern(pattern)
}
// normalizeToStringSlice converts various types to string slice - kept for backward compatibility
func normalizeToStringSlice(value interface{}) []string {
result, err := normalizeToStringSliceWithError(value)
if err != nil {
glog.Warningf("unexpected type for policy value: %T, error: %v", value, err)
return []string{fmt.Sprintf("%v", value)}
}
return result
}
// normalizeToStringSliceWithError converts various types to string slice with proper error handling
func normalizeToStringSliceWithError(value interface{}) ([]string, error) {
switch v := value.(type) {
case string:
return []string{v}, nil
case []string:
return v, nil
case []interface{}:
result := make([]string, len(v))
for i, item := range v {
result[i] = fmt.Sprintf("%v", item)
}
return result, nil
case StringOrStringSlice:
return v.Strings(), nil
case *StringOrStringSlice:
return v.Strings(), nil
default:
return nil, fmt.Errorf("unexpected type for policy value: %T", v)
}
}
// GetBucketFromResource extracts bucket name from resource ARN
func GetBucketFromResource(resource string) string {
// Handle ARN format: arn:aws:s3:::bucket-name/object-path
if strings.HasPrefix(resource, "arn:aws:s3:::") {
parts := strings.SplitN(resource[13:], "/", 2)
return parts[0]
}
return ""
}
// MatchesAction checks if an action matches any of the compiled action matchers.
// It also implicitly grants multipart upload actions if s3:PutObject is allowed,
// since multipart upload is an implementation detail of putting objects.
func (cs *CompiledStatement) MatchesAction(action string) bool {
var matchedAction, hasPutObjectPermission bool
// Scan all matchers to check for both direct action match and s3:PutObject grant
for _, matcher := range cs.ActionMatchers {
if matcher.Match(action) {
matchedAction = true
}
if !hasPutObjectPermission && matcher.Match("s3:PutObject") {
hasPutObjectPermission = true
}
}
// Return true if action matched directly or if multipart action with PutObject permission
if matchedAction {
return true
}
// Multipart upload operations are part of s3:PutObject permission
// If s3:PutObject is allowed, implicitly allow multipart operations
if hasPutObjectPermission && multipartActionSet[action] {
return true
}
return false
}
// statementMayAllowAction reports whether a statement's actions can match the
// action, including the multipart operations that ride on s3:PutObject. It is
// looser than either evaluator on purpose: the IAM authorizer matches action
// names case-insensitively and a policy variable resolves per request, so a
// classifier that reads them strictly would miss a grant that is really there.
func statementMayAllowAction(actions []string, action string) bool {
for _, pattern := range actions {
if actionPatternMayMatch(pattern, action) {
return true
}
}
if !lowerMultipartActionSet[strings.ToLower(action)] {
return false
}
for _, pattern := range actions {
if actionPatternMayMatch(pattern, s3const.S3_ACTION_PUT_OBJECT) {
return true
}
}
return false
}
func actionPatternMayMatch(pattern, action string) bool {
if PolicyVariableRegex.MatchString(pattern) {
return true
}
if strings.EqualFold(pattern, action) {
return true
}
return wildcard.MatchesWildcard(strings.ToLower(pattern), strings.ToLower(action))
}
// MatchesResource checks if a resource matches any of the compiled resource matchers
func (cs *CompiledStatement) MatchesResource(resource string) bool {
for _, matcher := range cs.ResourceMatchers {
if matcher.Match(resource) {
return true
}
}
return false
}
// MatchesPrincipal checks if a principal matches any of the compiled principal matchers
func (cs *CompiledStatement) MatchesPrincipal(principal string) bool {
// If no principals specified, match all
if len(cs.PrincipalMatchers) == 0 {
return true
}
for _, matcher := range cs.PrincipalMatchers {
if matcher.Match(principal) {
return true
}
}
return false
}
// matchesPrincipalSet reports whether the request principal matches any static
// matcher or dynamic (policy-variable) pattern in the given set.
func (cs *CompiledStatement) matchesPrincipalSet(args *PolicyEvaluationArgs, matchers []*wildcard.WildcardMatcher, dynamic []string) bool {
for _, matcher := range matchers {
if matcher.Match(args.Principal) {
return true
}
}
for _, pattern := range dynamic {
substituted := SubstituteVariables(pattern, args.Conditions, args.Claims)
if FastMatchesWildcard(substituted, args.Principal) {
return true
}
}
return false
}
// EvaluateStatement evaluates a compiled statement against the given arguments
func (cs *CompiledStatement) EvaluateStatement(args *PolicyEvaluationArgs) bool {
// Check if action matches
if !cs.MatchesAction(args.Action) {
return false
}
// Check if resource matches
if !cs.MatchesResource(args.Resource) {
return false
}
// Principal / NotPrincipal (mutually exclusive per AWS): NotPrincipal makes
// the statement apply to everyone EXCEPT the named principals; a plain
// Principal requires a match. Both static matchers and dynamic
// (policy-variable) patterns are honored.
if len(cs.NotPrincipalMatchers) > 0 || len(cs.DynamicNotPrincipalPatterns) > 0 {
if cs.matchesPrincipalSet(args, cs.NotPrincipalMatchers, cs.DynamicNotPrincipalPatterns) {
return false
}
} else if len(cs.PrincipalMatchers) > 0 || len(cs.DynamicPrincipalPatterns) > 0 {
if !cs.matchesPrincipalSet(args, cs.PrincipalMatchers, cs.DynamicPrincipalPatterns) {
return false
}
}
return true
}
// EvaluatePolicy evaluates a compiled policy against the given arguments
func (cp *CompiledPolicy) EvaluatePolicy(args *PolicyEvaluationArgs) (bool, PolicyEffect) {
var explicitAllow, explicitDeny bool
// Evaluate each statement
for _, stmt := range cp.Statements {
if stmt.EvaluateStatement(args) {
if stmt.Statement.Effect == PolicyEffectAllow {
explicitAllow = true
} else if stmt.Statement.Effect == PolicyEffectDeny {
explicitDeny = true
}
}
}
// AWS policy evaluation logic: explicit deny overrides allow
if explicitDeny {
return false, PolicyEffectDeny
}
if explicitAllow {
return true, PolicyEffectAllow
}
// No matching statements - implicit deny
return false, PolicyEffectDeny
}
// FastMatchesWildcard uses cached WildcardMatcher for performance
func FastMatchesWildcard(pattern, str string) bool {
matcher, err := wildcard.GetCachedWildcardMatcher(pattern)
if err != nil {
glog.Errorf("Error getting cached WildcardMatcher for pattern %s: %v", pattern, err)
// Fall back to the original implementation
return wildcard.MatchesWildcard(pattern, str)
}
return matcher.Match(str)
}

View File

@@ -0,0 +1,17 @@
package policy_engine
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCloneStringOrStringSliceCopiesBackingSlice(t *testing.T) {
original := NewStringOrStringSlice("s3:GetObject", "s3:PutObject")
cloned := CloneStringOrStringSlice(original)
cloned.values[0] = "s3:DeleteObject"
assert.Equal(t, []string{"s3:GetObject", "s3:PutObject"}, original.Strings())
assert.Equal(t, []string{"s3:DeleteObject", "s3:PutObject"}, cloned.Strings())
}