committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 5565 additions and 195 deletions
-
23weed/admin/dash/policies_management.go
-
8weed/admin/handlers/policy_handlers.go
-
22weed/credential/credential_store.go
-
22weed/credential/filer_etc/filer_etc_policy.go
-
12weed/credential/memory/memory_policy.go
-
5weed/credential/memory/memory_store.go
-
14weed/credential/postgres/postgres_policy.go
-
25weed/credential/test/policy_test.go
-
62weed/iamapi/iamapi_management_handlers.go
-
50weed/iamapi/iamapi_management_handlers_test.go
-
3weed/iamapi/iamapi_server.go
-
3weed/iamapi/iamapi_test.go
-
13weed/s3api/auth_credentials.go
-
249weed/s3api/policy_engine/GOVERNANCE_PERMISSIONS.md
-
176weed/s3api/policy_engine/INTEGRATION_EXAMPLE.md
-
54weed/s3api/policy_engine/POLICY_EXAMPLES.md
-
279weed/s3api/policy_engine/README_POLICY_ENGINE.md
-
768weed/s3api/policy_engine/conditions.go
-
432weed/s3api/policy_engine/engine.go
-
716weed/s3api/policy_engine/engine_test.go
-
463weed/s3api/policy_engine/examples.go
-
438weed/s3api/policy_engine/integration.go
-
454weed/s3api/policy_engine/types.go
-
253weed/s3api/policy_engine/wildcard_matcher.go
-
469weed/s3api/policy_engine/wildcard_matcher_test.go
-
3weed/s3api/s3_constants/extend_key.go
-
1weed/s3api/s3_constants/header.go
-
1weed/s3api/s3_constants/s3_actions.go
-
19weed/s3api/s3api_bucket_handlers.go
-
599weed/s3api/s3api_governance_permissions_test.go
-
4weed/s3api/s3api_object_handlers_delete.go
-
2weed/s3api/s3api_object_handlers_put.go
-
82weed/s3api/s3api_object_retention.go
@ -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 |
@ -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 |
@ -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. |
@ -0,0 +1,279 @@ |
|||
# 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 |
|||
- And many more... |
|||
|
|||
## 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. |
@ -0,0 +1,768 @@ |
|||
package policy_engine |
|||
|
|||
import ( |
|||
"fmt" |
|||
"net" |
|||
"reflect" |
|||
"strconv" |
|||
"strings" |
|||
"sync" |
|||
"time" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/glog" |
|||
) |
|||
|
|||
// 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) |
|||
} |
|||
|
|||
// Clear clears all cached values
|
|||
func (c *NormalizedValueCache) Clear() { |
|||
c.mu.Lock() |
|||
defer c.mu.Unlock() |
|||
c.cache = make(map[string]*LRUNode) |
|||
c.head.next = c.tail |
|||
c.tail.prev = c.head |
|||
} |
|||
|
|||
// GetStats returns cache statistics
|
|||
func (c *NormalizedValueCache) GetStats() (size int, maxSize int) { |
|||
c.mu.RLock() |
|||
defer c.mu.RUnlock() |
|||
return len(c.cache), c.maxSize |
|||
} |
|||
|
|||
// 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 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 MatchesWildcard(pattern, 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 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 "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) |
|||
} |
|||
} |
|||
|
|||
// EvaluateConditions evaluates all conditions in a policy statement
|
|||
func EvaluateConditions(conditions PolicyConditions, contextValues map[string][]string) 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) |
|||
continue |
|||
} |
|||
|
|||
for key, value := range conditionMap { |
|||
contextVals, exists := contextValues[key] |
|||
if !exists { |
|||
contextVals = []string{} |
|||
} |
|||
|
|||
if !conditionEvaluator.Evaluate(value.Strings(), contextVals) { |
|||
return false // If any condition fails, the whole condition block fails
|
|||
} |
|||
} |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
// EvaluateConditionsLegacy evaluates conditions using the old interface{} format for backward compatibility
|
|||
func EvaluateConditionsLegacy(conditions map[string]interface{}, contextValues map[string][]string) 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) |
|||
continue |
|||
} |
|||
|
|||
conditionMapTyped, ok := conditionMap.(map[string]interface{}) |
|||
if !ok { |
|||
glog.Warningf("Invalid condition format for operator: %s", operator) |
|||
continue |
|||
} |
|||
|
|||
for key, value := range conditionMapTyped { |
|||
contextVals, exists := contextValues[key] |
|||
if !exists { |
|||
contextVals = []string{} |
|||
} |
|||
|
|||
if !conditionEvaluator.Evaluate(value, contextVals) { |
|||
return false // If any condition fails, the whole condition block fails
|
|||
} |
|||
} |
|||
} |
|||
|
|||
return true |
|||
} |
@ -0,0 +1,432 @@ |
|||
package policy_engine |
|||
|
|||
import ( |
|||
"fmt" |
|||
"net" |
|||
"net/http" |
|||
"regexp" |
|||
"strings" |
|||
"sync" |
|||
"time" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/glog" |
|||
) |
|||
|
|||
// PolicyEvaluationResult represents the result of policy evaluation
|
|||
type PolicyEvaluationResult int |
|||
|
|||
const ( |
|||
PolicyResultDeny PolicyEvaluationResult = iota |
|||
PolicyResultAllow |
|||
PolicyResultIndeterminate |
|||
) |
|||
|
|||
// PolicyEvaluationContext manages policy evaluation for a bucket
|
|||
type PolicyEvaluationContext struct { |
|||
bucketName string |
|||
policy *CompiledPolicy |
|||
cache *PolicyCache |
|||
mutex sync.RWMutex |
|||
} |
|||
|
|||
// PolicyEngine is the main policy evaluation engine
|
|||
type PolicyEngine struct { |
|||
contexts map[string]*PolicyEvaluationContext |
|||
mutex sync.RWMutex |
|||
} |
|||
|
|||
// NewPolicyEngine creates a new policy evaluation engine
|
|||
func NewPolicyEngine() *PolicyEngine { |
|||
return &PolicyEngine{ |
|||
contexts: make(map[string]*PolicyEvaluationContext), |
|||
} |
|||
} |
|||
|
|||
// SetBucketPolicy sets the policy for a bucket
|
|||
func (engine *PolicyEngine) SetBucketPolicy(bucketName string, policyJSON string) error { |
|||
policy, err := ParsePolicy(policyJSON) |
|||
if err != nil { |
|||
return fmt.Errorf("invalid policy: %v", err) |
|||
} |
|||
|
|||
compiled, err := CompilePolicy(policy) |
|||
if err != nil { |
|||
return fmt.Errorf("failed to compile policy: %v", err) |
|||
} |
|||
|
|||
engine.mutex.Lock() |
|||
defer engine.mutex.Unlock() |
|||
|
|||
context := &PolicyEvaluationContext{ |
|||
bucketName: bucketName, |
|||
policy: compiled, |
|||
cache: NewPolicyCache(), |
|||
} |
|||
|
|||
engine.contexts[bucketName] = context |
|||
glog.V(2).Infof("Set bucket policy for %s", bucketName) |
|||
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 |
|||
} |
|||
|
|||
// 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 { |
|||
return PolicyResultIndeterminate |
|||
} |
|||
|
|||
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 explicit Allow is found, return Deny (default deny)
|
|||
|
|||
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 |
|||
} |
|||
|
|||
return PolicyResultDeny // Default deny
|
|||
} |
|||
|
|||
// evaluateStatement evaluates a single policy statement
|
|||
func (engine *PolicyEngine) evaluateStatement(stmt *CompiledStatement, args *PolicyEvaluationArgs) bool { |
|||
// Check if action matches
|
|||
if !engine.matchesPatterns(stmt.ActionPatterns, args.Action) { |
|||
return false |
|||
} |
|||
|
|||
// Check if resource matches
|
|||
if !engine.matchesPatterns(stmt.ResourcePatterns, args.Resource) { |
|||
return false |
|||
} |
|||
|
|||
// Check if principal matches (if specified)
|
|||
if len(stmt.PrincipalPatterns) > 0 { |
|||
if !engine.matchesPatterns(stmt.PrincipalPatterns, args.Principal) { |
|||
return false |
|||
} |
|||
} |
|||
|
|||
// Check conditions
|
|||
if len(stmt.Statement.Condition) > 0 { |
|||
if !EvaluateConditions(stmt.Statement.Condition, args.Conditions) { |
|||
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 |
|||
} |
|||
|
|||
// ExtractConditionValuesFromRequest extracts condition values from HTTP request
|
|||
func ExtractConditionValuesFromRequest(r *http.Request) map[string][]string { |
|||
values := make(map[string][]string) |
|||
|
|||
// AWS condition keys
|
|||
// Extract IP address without port for proper IP matching
|
|||
host, _, err := net.SplitHostPort(r.RemoteAddr) |
|||
if err != nil { |
|||
// Log a warning if splitting fails
|
|||
glog.Warningf("Failed to parse IP address from RemoteAddr %q: %v", r.RemoteAddr, err) |
|||
// If splitting fails, use the original RemoteAddr (might be just IP without port)
|
|||
host = r.RemoteAddr |
|||
} |
|||
values["aws:SourceIp"] = []string{host} |
|||
values["aws:SecureTransport"] = []string{fmt.Sprintf("%t", r.TLS != nil)} |
|||
// Use AWS standard condition key for current time
|
|||
values["aws:CurrentTime"] = []string{time.Now().Format(time.RFC3339)} |
|||
// Keep RequestTime for backward compatibility
|
|||
values["aws:RequestTime"] = []string{time.Now().Format(time.RFC3339)} |
|||
|
|||
// S3 specific condition keys
|
|||
if userAgent := r.Header.Get("User-Agent"); userAgent != "" { |
|||
values["aws:UserAgent"] = []string{userAgent} |
|||
} |
|||
|
|||
if referer := r.Header.Get("Referer"); referer != "" { |
|||
values["aws:Referer"] = []string{referer} |
|||
} |
|||
|
|||
// S3 object-level conditions
|
|||
if r.Method == "GET" || r.Method == "HEAD" { |
|||
values["s3:ExistingObjectTag"] = extractObjectTags(r) |
|||
} |
|||
|
|||
// S3 bucket-level conditions
|
|||
if delimiter := r.URL.Query().Get("delimiter"); delimiter != "" { |
|||
values["s3:delimiter"] = []string{delimiter} |
|||
} |
|||
|
|||
if prefix := r.URL.Query().Get("prefix"); prefix != "" { |
|||
values["s3:prefix"] = []string{prefix} |
|||
} |
|||
|
|||
if maxKeys := r.URL.Query().Get("max-keys"); maxKeys != "" { |
|||
values["s3:max-keys"] = []string{maxKeys} |
|||
} |
|||
|
|||
// Authentication method
|
|||
if authHeader := r.Header.Get("Authorization"); authHeader != "" { |
|||
if strings.HasPrefix(authHeader, "AWS4-HMAC-SHA256") { |
|||
values["s3:authType"] = []string{"REST-HEADER"} |
|||
} else if strings.HasPrefix(authHeader, "AWS ") { |
|||
values["s3:authType"] = []string{"REST-HEADER"} |
|||
} |
|||
} else if r.URL.Query().Get("AWSAccessKeyId") != "" { |
|||
values["s3:authType"] = []string{"REST-QUERY-STRING"} |
|||
} |
|||
|
|||
// HTTP method
|
|||
values["s3:RequestMethod"] = []string{r.Method} |
|||
|
|||
// Extract custom headers
|
|||
for key, headerValues := range r.Header { |
|||
if strings.HasPrefix(strings.ToLower(key), "x-amz-") { |
|||
values[strings.ToLower(key)] = headerValues |
|||
} |
|||
} |
|||
|
|||
return values |
|||
} |
|||
|
|||
// extractObjectTags extracts object tags from request (placeholder implementation)
|
|||
func extractObjectTags(r *http.Request) []string { |
|||
// This would need to be implemented based on how object tags are stored
|
|||
// For now, return empty slice
|
|||
return []string{} |
|||
} |
|||
|
|||
// BuildResourceArn builds an ARN for the given bucket and object
|
|||
func BuildResourceArn(bucketName, objectName string) string { |
|||
if objectName == "" { |
|||
return fmt.Sprintf("arn:aws:s3:::%s", bucketName) |
|||
} |
|||
return fmt.Sprintf("arn:aws:s3:::%s/%s", bucketName, objectName) |
|||
} |
|||
|
|||
// BuildActionName builds a standardized action name
|
|||
func BuildActionName(action string) string { |
|||
if strings.HasPrefix(action, "s3:") { |
|||
return action |
|||
} |
|||
return fmt.Sprintf("s3:%s", action) |
|||
} |
|||
|
|||
// IsReadAction checks if an action is a read action
|
|||
func IsReadAction(action string) bool { |
|||
readActions := []string{ |
|||
"s3:GetObject", |
|||
"s3:GetObjectVersion", |
|||
"s3:GetObjectAcl", |
|||
"s3:GetObjectVersionAcl", |
|||
"s3:GetObjectTagging", |
|||
"s3:GetObjectVersionTagging", |
|||
"s3:ListBucket", |
|||
"s3:ListBucketVersions", |
|||
"s3:GetBucketLocation", |
|||
"s3:GetBucketVersioning", |
|||
"s3:GetBucketAcl", |
|||
"s3:GetBucketCors", |
|||
"s3:GetBucketPolicy", |
|||
"s3:GetBucketTagging", |
|||
"s3:GetBucketNotification", |
|||
"s3:GetBucketObjectLockConfiguration", |
|||
"s3:GetObjectRetention", |
|||
"s3:GetObjectLegalHold", |
|||
} |
|||
|
|||
for _, readAction := range readActions { |
|||
if action == readAction { |
|||
return true |
|||
} |
|||
} |
|||
return false |
|||
} |
|||
|
|||
// IsWriteAction checks if an action is a write action
|
|||
func IsWriteAction(action string) bool { |
|||
writeActions := []string{ |
|||
"s3:PutObject", |
|||
"s3:PutObjectAcl", |
|||
"s3:PutObjectTagging", |
|||
"s3:DeleteObject", |
|||
"s3:DeleteObjectVersion", |
|||
"s3:DeleteObjectTagging", |
|||
"s3:AbortMultipartUpload", |
|||
"s3:ListMultipartUploads", |
|||
"s3:ListParts", |
|||
"s3:PutBucketAcl", |
|||
"s3:PutBucketCors", |
|||
"s3:PutBucketPolicy", |
|||
"s3:PutBucketTagging", |
|||
"s3:PutBucketNotification", |
|||
"s3:PutBucketVersioning", |
|||
"s3:DeleteBucketPolicy", |
|||
"s3:DeleteBucketTagging", |
|||
"s3:DeleteBucketCors", |
|||
"s3:PutBucketObjectLockConfiguration", |
|||
"s3:PutObjectRetention", |
|||
"s3:PutObjectLegalHold", |
|||
"s3:BypassGovernanceRetention", |
|||
} |
|||
|
|||
for _, writeAction := range writeActions { |
|||
if action == writeAction { |
|||
return true |
|||
} |
|||
} |
|||
return false |
|||
} |
|||
|
|||
// GetBucketNameFromArn extracts bucket name from ARN
|
|||
func GetBucketNameFromArn(arn string) string { |
|||
if strings.HasPrefix(arn, "arn:aws:s3:::") { |
|||
parts := strings.SplitN(arn[13:], "/", 2) |
|||
return parts[0] |
|||
} |
|||
return "" |
|||
} |
|||
|
|||
// GetObjectNameFromArn extracts object name from ARN
|
|||
func GetObjectNameFromArn(arn string) string { |
|||
if strings.HasPrefix(arn, "arn:aws:s3:::") { |
|||
parts := strings.SplitN(arn[13:], "/", 2) |
|||
if len(parts) > 1 { |
|||
return parts[1] |
|||
} |
|||
} |
|||
return "" |
|||
} |
|||
|
|||
// HasPolicyForBucket checks if a bucket has a policy
|
|||
func (engine *PolicyEngine) HasPolicyForBucket(bucketName string) bool { |
|||
engine.mutex.RLock() |
|||
defer engine.mutex.RUnlock() |
|||
|
|||
_, exists := engine.contexts[bucketName] |
|||
return exists |
|||
} |
|||
|
|||
// GetPolicyStatements returns all policy statements for a bucket
|
|||
func (engine *PolicyEngine) GetPolicyStatements(bucketName string) []PolicyStatement { |
|||
engine.mutex.RLock() |
|||
defer engine.mutex.RUnlock() |
|||
|
|||
context, exists := engine.contexts[bucketName] |
|||
if !exists { |
|||
return nil |
|||
} |
|||
|
|||
return context.policy.Document.Statement |
|||
} |
|||
|
|||
// ValidatePolicyForBucket validates if a policy is valid for a bucket
|
|||
func (engine *PolicyEngine) ValidatePolicyForBucket(bucketName string, policyJSON string) error { |
|||
policy, err := ParsePolicy(policyJSON) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
// Additional validation specific to the bucket
|
|||
for _, stmt := range policy.Statement { |
|||
resources := normalizeToStringSlice(stmt.Resource) |
|||
for _, resource := range resources { |
|||
if resourceBucket := GetBucketFromResource(resource); resourceBucket != "" { |
|||
if resourceBucket != bucketName { |
|||
return fmt.Errorf("policy resource %s does not match bucket %s", resource, bucketName) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
// ClearAllPolicies clears all bucket policies
|
|||
func (engine *PolicyEngine) ClearAllPolicies() { |
|||
engine.mutex.Lock() |
|||
defer engine.mutex.Unlock() |
|||
|
|||
engine.contexts = make(map[string]*PolicyEvaluationContext) |
|||
glog.V(2).Info("Cleared all bucket policies") |
|||
} |
|||
|
|||
// GetAllBucketsWithPolicies returns all buckets that have policies
|
|||
func (engine *PolicyEngine) GetAllBucketsWithPolicies() []string { |
|||
engine.mutex.RLock() |
|||
defer engine.mutex.RUnlock() |
|||
|
|||
buckets := make([]string, 0, len(engine.contexts)) |
|||
for bucketName := range engine.contexts { |
|||
buckets = append(buckets, bucketName) |
|||
} |
|||
return buckets |
|||
} |
|||
|
|||
// EvaluatePolicyForRequest evaluates policy for an HTTP request
|
|||
func (engine *PolicyEngine) EvaluatePolicyForRequest(bucketName, objectName, action, principal string, r *http.Request) PolicyEvaluationResult { |
|||
resource := BuildResourceArn(bucketName, objectName) |
|||
actionName := BuildActionName(action) |
|||
conditions := ExtractConditionValuesFromRequest(r) |
|||
|
|||
args := &PolicyEvaluationArgs{ |
|||
Action: actionName, |
|||
Resource: resource, |
|||
Principal: principal, |
|||
Conditions: conditions, |
|||
} |
|||
|
|||
return engine.EvaluatePolicy(bucketName, args) |
|||
} |
@ -0,0 +1,716 @@ |
|||
package policy_engine |
|||
|
|||
import ( |
|||
"net/http" |
|||
"net/url" |
|||
"testing" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err" |
|||
) |
|||
|
|||
func TestPolicyEngine(t *testing.T) { |
|||
engine := NewPolicyEngine() |
|||
|
|||
// Test policy JSON
|
|||
policyJSON := `{ |
|||
"Version": "2012-10-17", |
|||
"Statement": [ |
|||
{ |
|||
"Effect": "Allow", |
|||
"Action": ["s3:GetObject", "s3:PutObject"], |
|||
"Resource": ["arn:aws:s3:::test-bucket/*"] |
|||
}, |
|||
{ |
|||
"Effect": "Deny", |
|||
"Action": ["s3:DeleteObject"], |
|||
"Resource": ["arn:aws:s3:::test-bucket/*"], |
|||
"Condition": { |
|||
"StringEquals": { |
|||
"s3:RequestMethod": ["DELETE"] |
|||
} |
|||
} |
|||
} |
|||
] |
|||
}` |
|||
|
|||
// Set bucket policy
|
|||
err := engine.SetBucketPolicy("test-bucket", policyJSON) |
|||
if err != nil { |
|||
t.Fatalf("Failed to set bucket policy: %v", err) |
|||
} |
|||
|
|||
// Test Allow case
|
|||
args := &PolicyEvaluationArgs{ |
|||
Action: "s3:GetObject", |
|||
Resource: "arn:aws:s3:::test-bucket/test-object", |
|||
Principal: "user1", |
|||
Conditions: map[string][]string{}, |
|||
} |
|||
|
|||
result := engine.EvaluatePolicy("test-bucket", args) |
|||
if result != PolicyResultAllow { |
|||
t.Errorf("Expected Allow, got %v", result) |
|||
} |
|||
|
|||
// Test Deny case
|
|||
args = &PolicyEvaluationArgs{ |
|||
Action: "s3:DeleteObject", |
|||
Resource: "arn:aws:s3:::test-bucket/test-object", |
|||
Principal: "user1", |
|||
Conditions: map[string][]string{ |
|||
"s3:RequestMethod": {"DELETE"}, |
|||
}, |
|||
} |
|||
|
|||
result = engine.EvaluatePolicy("test-bucket", args) |
|||
if result != PolicyResultDeny { |
|||
t.Errorf("Expected Deny, got %v", result) |
|||
} |
|||
|
|||
// Test non-matching action
|
|||
args = &PolicyEvaluationArgs{ |
|||
Action: "s3:ListBucket", |
|||
Resource: "arn:aws:s3:::test-bucket", |
|||
Principal: "user1", |
|||
Conditions: map[string][]string{}, |
|||
} |
|||
|
|||
result = engine.EvaluatePolicy("test-bucket", args) |
|||
if result != PolicyResultDeny { |
|||
t.Errorf("Expected Deny for non-matching action, got %v", result) |
|||
} |
|||
|
|||
// Test GetBucketPolicy
|
|||
policy, err := engine.GetBucketPolicy("test-bucket") |
|||
if err != nil { |
|||
t.Fatalf("Failed to get bucket policy: %v", err) |
|||
} |
|||
if policy.Version != "2012-10-17" { |
|||
t.Errorf("Expected version 2012-10-17, got %s", policy.Version) |
|||
} |
|||
|
|||
// Test DeleteBucketPolicy
|
|||
err = engine.DeleteBucketPolicy("test-bucket") |
|||
if err != nil { |
|||
t.Fatalf("Failed to delete bucket policy: %v", err) |
|||
} |
|||
|
|||
// Test policy is gone
|
|||
result = engine.EvaluatePolicy("test-bucket", args) |
|||
if result != PolicyResultIndeterminate { |
|||
t.Errorf("Expected Indeterminate after policy deletion, got %v", result) |
|||
} |
|||
} |
|||
|
|||
func TestConditionEvaluators(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
operator string |
|||
conditionValue interface{} |
|||
contextValues []string |
|||
expected bool |
|||
}{ |
|||
{ |
|||
name: "StringEquals - match", |
|||
operator: "StringEquals", |
|||
conditionValue: "test-value", |
|||
contextValues: []string{"test-value"}, |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "StringEquals - no match", |
|||
operator: "StringEquals", |
|||
conditionValue: "test-value", |
|||
contextValues: []string{"other-value"}, |
|||
expected: false, |
|||
}, |
|||
{ |
|||
name: "StringLike - wildcard match", |
|||
operator: "StringLike", |
|||
conditionValue: "test-*", |
|||
contextValues: []string{"test-value"}, |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "StringLike - wildcard no match", |
|||
operator: "StringLike", |
|||
conditionValue: "test-*", |
|||
contextValues: []string{"other-value"}, |
|||
expected: false, |
|||
}, |
|||
{ |
|||
name: "NumericEquals - match", |
|||
operator: "NumericEquals", |
|||
conditionValue: "42", |
|||
contextValues: []string{"42"}, |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "NumericLessThan - match", |
|||
operator: "NumericLessThan", |
|||
conditionValue: "100", |
|||
contextValues: []string{"50"}, |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "NumericLessThan - no match", |
|||
operator: "NumericLessThan", |
|||
conditionValue: "100", |
|||
contextValues: []string{"150"}, |
|||
expected: false, |
|||
}, |
|||
{ |
|||
name: "IpAddress - CIDR match", |
|||
operator: "IpAddress", |
|||
conditionValue: "192.168.1.0/24", |
|||
contextValues: []string{"192.168.1.100"}, |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "IpAddress - CIDR no match", |
|||
operator: "IpAddress", |
|||
conditionValue: "192.168.1.0/24", |
|||
contextValues: []string{"10.0.0.1"}, |
|||
expected: false, |
|||
}, |
|||
{ |
|||
name: "Bool - true match", |
|||
operator: "Bool", |
|||
conditionValue: "true", |
|||
contextValues: []string{"true"}, |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Bool - false match", |
|||
operator: "Bool", |
|||
conditionValue: "false", |
|||
contextValues: []string{"false"}, |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Bool - no match", |
|||
operator: "Bool", |
|||
conditionValue: "true", |
|||
contextValues: []string{"false"}, |
|||
expected: 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 TestConvertIdentityToPolicy(t *testing.T) { |
|||
identityActions := []string{ |
|||
"Read:bucket1/*", |
|||
"Write:bucket1/*", |
|||
"Admin:bucket2", |
|||
} |
|||
|
|||
policy, err := ConvertIdentityToPolicy(identityActions, "bucket1") |
|||
if err != nil { |
|||
t.Fatalf("Failed to convert identity to policy: %v", err) |
|||
} |
|||
|
|||
if policy.Version != "2012-10-17" { |
|||
t.Errorf("Expected version 2012-10-17, got %s", policy.Version) |
|||
} |
|||
|
|||
if len(policy.Statement) != 3 { |
|||
t.Errorf("Expected 3 statements, got %d", len(policy.Statement)) |
|||
} |
|||
|
|||
// Check first statement (Read)
|
|||
stmt := policy.Statement[0] |
|||
if stmt.Effect != PolicyEffectAllow { |
|||
t.Errorf("Expected Allow effect, got %s", stmt.Effect) |
|||
} |
|||
|
|||
actions := normalizeToStringSlice(stmt.Action) |
|||
if len(actions) != 3 { |
|||
t.Errorf("Expected 3 read actions, got %d", len(actions)) |
|||
} |
|||
|
|||
resources := normalizeToStringSlice(stmt.Resource) |
|||
if len(resources) != 2 { |
|||
t.Errorf("Expected 2 resources, got %d", len(resources)) |
|||
} |
|||
} |
|||
|
|||
func TestPolicyValidation(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
policyJSON string |
|||
expectError bool |
|||
}{ |
|||
{ |
|||
name: "Valid policy", |
|||
policyJSON: `{ |
|||
"Version": "2012-10-17", |
|||
"Statement": [ |
|||
{ |
|||
"Effect": "Allow", |
|||
"Action": "s3:GetObject", |
|||
"Resource": "arn:aws:s3:::test-bucket/*" |
|||
} |
|||
] |
|||
}`, |
|||
expectError: false, |
|||
}, |
|||
{ |
|||
name: "Invalid version", |
|||
policyJSON: `{ |
|||
"Version": "2008-10-17", |
|||
"Statement": [ |
|||
{ |
|||
"Effect": "Allow", |
|||
"Action": "s3:GetObject", |
|||
"Resource": "arn:aws:s3:::test-bucket/*" |
|||
} |
|||
] |
|||
}`, |
|||
expectError: true, |
|||
}, |
|||
{ |
|||
name: "Missing action", |
|||
policyJSON: `{ |
|||
"Version": "2012-10-17", |
|||
"Statement": [ |
|||
{ |
|||
"Effect": "Allow", |
|||
"Resource": "arn:aws:s3:::test-bucket/*" |
|||
} |
|||
] |
|||
}`, |
|||
expectError: true, |
|||
}, |
|||
{ |
|||
name: "Invalid JSON", |
|||
policyJSON: `{ |
|||
"Version": "2012-10-17", |
|||
"Statement": [ |
|||
{ |
|||
"Effect": "Allow", |
|||
"Action": "s3:GetObject", |
|||
"Resource": "arn:aws:s3:::test-bucket/*" |
|||
} |
|||
] |
|||
}extra`, |
|||
expectError: true, |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
_, err := ParsePolicy(tt.policyJSON) |
|||
if (err != nil) != tt.expectError { |
|||
t.Errorf("Expected error: %v, got error: %v", tt.expectError, err) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestPatternMatching(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
pattern string |
|||
value string |
|||
expected bool |
|||
}{ |
|||
{ |
|||
name: "Exact match", |
|||
pattern: "s3:GetObject", |
|||
value: "s3:GetObject", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Wildcard match", |
|||
pattern: "s3:Get*", |
|||
value: "s3:GetObject", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Wildcard no match", |
|||
pattern: "s3:Put*", |
|||
value: "s3:GetObject", |
|||
expected: false, |
|||
}, |
|||
{ |
|||
name: "Full wildcard", |
|||
pattern: "*", |
|||
value: "anything", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Question mark wildcard", |
|||
pattern: "s3:GetObjec?", |
|||
value: "s3:GetObject", |
|||
expected: true, |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
compiled, err := compilePattern(tt.pattern) |
|||
if err != nil { |
|||
t.Fatalf("Failed to compile pattern %s: %v", tt.pattern, err) |
|||
} |
|||
|
|||
result := compiled.MatchString(tt.value) |
|||
if result != tt.expected { |
|||
t.Errorf("Pattern %s against %s: expected %v, got %v", tt.pattern, tt.value, tt.expected, result) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestExtractConditionValuesFromRequest(t *testing.T) { |
|||
// Create a test request
|
|||
req := &http.Request{ |
|||
Method: "GET", |
|||
URL: &url.URL{ |
|||
Path: "/test-bucket/test-object", |
|||
RawQuery: "prefix=test&delimiter=/", |
|||
}, |
|||
Header: map[string][]string{ |
|||
"User-Agent": {"test-agent"}, |
|||
"X-Amz-Copy-Source": {"source-bucket/source-object"}, |
|||
}, |
|||
RemoteAddr: "192.168.1.100:12345", |
|||
} |
|||
|
|||
values := ExtractConditionValuesFromRequest(req) |
|||
|
|||
// Check extracted values
|
|||
if len(values["aws:SourceIp"]) != 1 || values["aws:SourceIp"][0] != "192.168.1.100" { |
|||
t.Errorf("Expected SourceIp to be 192.168.1.100, got %v", values["aws:SourceIp"]) |
|||
} |
|||
|
|||
if len(values["aws:UserAgent"]) != 1 || values["aws:UserAgent"][0] != "test-agent" { |
|||
t.Errorf("Expected UserAgent to be test-agent, got %v", values["aws:UserAgent"]) |
|||
} |
|||
|
|||
if len(values["s3:prefix"]) != 1 || values["s3:prefix"][0] != "test" { |
|||
t.Errorf("Expected prefix to be test, got %v", values["s3:prefix"]) |
|||
} |
|||
|
|||
if len(values["s3:delimiter"]) != 1 || values["s3:delimiter"][0] != "/" { |
|||
t.Errorf("Expected delimiter to be /, got %v", values["s3:delimiter"]) |
|||
} |
|||
|
|||
if len(values["s3:RequestMethod"]) != 1 || values["s3:RequestMethod"][0] != "GET" { |
|||
t.Errorf("Expected RequestMethod to be GET, got %v", values["s3:RequestMethod"]) |
|||
} |
|||
|
|||
if len(values["x-amz-copy-source"]) != 1 || values["x-amz-copy-source"][0] != "source-bucket/source-object" { |
|||
t.Errorf("Expected X-Amz-Copy-Source header to be extracted, got %v", values["x-amz-copy-source"]) |
|||
} |
|||
|
|||
// Check that aws:CurrentTime is properly set
|
|||
if len(values["aws:CurrentTime"]) != 1 { |
|||
t.Errorf("Expected aws:CurrentTime to be set, got %v", values["aws:CurrentTime"]) |
|||
} |
|||
|
|||
// Check that aws:RequestTime is still available for backward compatibility
|
|||
if len(values["aws:RequestTime"]) != 1 { |
|||
t.Errorf("Expected aws:RequestTime to be set for backward compatibility, got %v", values["aws:RequestTime"]) |
|||
} |
|||
} |
|||
|
|||
func TestPolicyEvaluationWithConditions(t *testing.T) { |
|||
engine := NewPolicyEngine() |
|||
|
|||
// Policy with IP condition
|
|||
policyJSON := `{ |
|||
"Version": "2012-10-17", |
|||
"Statement": [ |
|||
{ |
|||
"Effect": "Allow", |
|||
"Action": "s3:GetObject", |
|||
"Resource": "arn:aws:s3:::test-bucket/*", |
|||
"Condition": { |
|||
"IpAddress": { |
|||
"aws:SourceIp": "192.168.1.0/24" |
|||
} |
|||
} |
|||
} |
|||
] |
|||
}` |
|||
|
|||
err := engine.SetBucketPolicy("test-bucket", policyJSON) |
|||
if err != nil { |
|||
t.Fatalf("Failed to set bucket policy: %v", err) |
|||
} |
|||
|
|||
// Test matching IP
|
|||
args := &PolicyEvaluationArgs{ |
|||
Action: "s3:GetObject", |
|||
Resource: "arn:aws:s3:::test-bucket/test-object", |
|||
Principal: "user1", |
|||
Conditions: map[string][]string{ |
|||
"aws:SourceIp": {"192.168.1.100"}, |
|||
}, |
|||
} |
|||
|
|||
result := engine.EvaluatePolicy("test-bucket", args) |
|||
if result != PolicyResultAllow { |
|||
t.Errorf("Expected Allow for matching IP, got %v", result) |
|||
} |
|||
|
|||
// Test non-matching IP
|
|||
args.Conditions["aws:SourceIp"] = []string{"10.0.0.1"} |
|||
result = engine.EvaluatePolicy("test-bucket", args) |
|||
if result != PolicyResultDeny { |
|||
t.Errorf("Expected Deny for non-matching IP, got %v", result) |
|||
} |
|||
} |
|||
|
|||
func TestResourceArn(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
bucketName string |
|||
objectName string |
|||
expected string |
|||
}{ |
|||
{ |
|||
name: "Bucket only", |
|||
bucketName: "test-bucket", |
|||
objectName: "", |
|||
expected: "arn:aws:s3:::test-bucket", |
|||
}, |
|||
{ |
|||
name: "Bucket and object", |
|||
bucketName: "test-bucket", |
|||
objectName: "test-object", |
|||
expected: "arn:aws:s3:::test-bucket/test-object", |
|||
}, |
|||
{ |
|||
name: "Bucket and nested object", |
|||
bucketName: "test-bucket", |
|||
objectName: "folder/subfolder/test-object", |
|||
expected: "arn:aws:s3:::test-bucket/folder/subfolder/test-object", |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
result := BuildResourceArn(tt.bucketName, tt.objectName) |
|||
if result != tt.expected { |
|||
t.Errorf("Expected %s, got %s", tt.expected, result) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestActionConversion(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
action string |
|||
expected string |
|||
}{ |
|||
{ |
|||
name: "Already has s3 prefix", |
|||
action: "s3:GetObject", |
|||
expected: "s3:GetObject", |
|||
}, |
|||
{ |
|||
name: "Add s3 prefix", |
|||
action: "GetObject", |
|||
expected: "s3:GetObject", |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
result := BuildActionName(tt.action) |
|||
if result != tt.expected { |
|||
t.Errorf("Expected %s, got %s", tt.expected, result) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestPolicyEngineForRequest(t *testing.T) { |
|||
engine := NewPolicyEngine() |
|||
|
|||
// Set up a policy
|
|||
policyJSON := `{ |
|||
"Version": "2012-10-17", |
|||
"Statement": [ |
|||
{ |
|||
"Effect": "Allow", |
|||
"Action": "s3:GetObject", |
|||
"Resource": "arn:aws:s3:::test-bucket/*", |
|||
"Condition": { |
|||
"StringEquals": { |
|||
"s3:RequestMethod": "GET" |
|||
} |
|||
} |
|||
} |
|||
] |
|||
}` |
|||
|
|||
err := engine.SetBucketPolicy("test-bucket", policyJSON) |
|||
if err != nil { |
|||
t.Fatalf("Failed to set bucket policy: %v", err) |
|||
} |
|||
|
|||
// Create test request
|
|||
req := &http.Request{ |
|||
Method: "GET", |
|||
URL: &url.URL{ |
|||
Path: "/test-bucket/test-object", |
|||
}, |
|||
Header: make(map[string][]string), |
|||
RemoteAddr: "192.168.1.100:12345", |
|||
} |
|||
|
|||
// Test the request
|
|||
result := engine.EvaluatePolicyForRequest("test-bucket", "test-object", "GetObject", "user1", req) |
|||
if result != PolicyResultAllow { |
|||
t.Errorf("Expected Allow for matching request, got %v", result) |
|||
} |
|||
} |
|||
|
|||
func TestWildcardMatching(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
pattern string |
|||
str string |
|||
expected bool |
|||
}{ |
|||
{ |
|||
name: "Exact match", |
|||
pattern: "test", |
|||
str: "test", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Single wildcard", |
|||
pattern: "*", |
|||
str: "anything", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Prefix wildcard", |
|||
pattern: "test*", |
|||
str: "test123", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Suffix wildcard", |
|||
pattern: "*test", |
|||
str: "123test", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Middle wildcard", |
|||
pattern: "test*123", |
|||
str: "testABC123", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "No match", |
|||
pattern: "test*", |
|||
str: "other", |
|||
expected: false, |
|||
}, |
|||
{ |
|||
name: "Multiple wildcards", |
|||
pattern: "test*abc*123", |
|||
str: "testXYZabcDEF123", |
|||
expected: true, |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
result := MatchesWildcard(tt.pattern, tt.str) |
|||
if result != tt.expected { |
|||
t.Errorf("Pattern %s against %s: expected %v, got %v", tt.pattern, tt.str, tt.expected, result) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestCompilePolicy(t *testing.T) { |
|||
policyJSON := `{ |
|||
"Version": "2012-10-17", |
|||
"Statement": [ |
|||
{ |
|||
"Effect": "Allow", |
|||
"Action": ["s3:GetObject", "s3:PutObject"], |
|||
"Resource": "arn:aws:s3:::test-bucket/*" |
|||
} |
|||
] |
|||
}` |
|||
|
|||
policy, err := ParsePolicy(policyJSON) |
|||
if err != nil { |
|||
t.Fatalf("Failed to parse policy: %v", err) |
|||
} |
|||
|
|||
compiled, err := CompilePolicy(policy) |
|||
if err != nil { |
|||
t.Fatalf("Failed to compile policy: %v", err) |
|||
} |
|||
|
|||
if len(compiled.Statements) != 1 { |
|||
t.Errorf("Expected 1 compiled statement, got %d", len(compiled.Statements)) |
|||
} |
|||
|
|||
stmt := compiled.Statements[0] |
|||
if len(stmt.ActionPatterns) != 2 { |
|||
t.Errorf("Expected 2 action patterns, got %d", len(stmt.ActionPatterns)) |
|||
} |
|||
|
|||
if len(stmt.ResourcePatterns) != 1 { |
|||
t.Errorf("Expected 1 resource pattern, got %d", len(stmt.ResourcePatterns)) |
|||
} |
|||
} |
|||
|
|||
// TestNewPolicyBackedIAMWithLegacy tests the constructor overload
|
|||
func TestNewPolicyBackedIAMWithLegacy(t *testing.T) { |
|||
// Mock legacy IAM
|
|||
mockLegacyIAM := &MockLegacyIAM{} |
|||
|
|||
// Test the new constructor
|
|||
policyBackedIAM := NewPolicyBackedIAMWithLegacy(mockLegacyIAM) |
|||
|
|||
// Verify that the legacy IAM is set
|
|||
if policyBackedIAM.legacyIAM != mockLegacyIAM { |
|||
t.Errorf("Expected legacy IAM to be set, but it wasn't") |
|||
} |
|||
|
|||
// Verify that the policy engine is initialized
|
|||
if policyBackedIAM.policyEngine == nil { |
|||
t.Errorf("Expected policy engine to be initialized, but it wasn't") |
|||
} |
|||
|
|||
// Compare with the traditional approach
|
|||
traditionalIAM := NewPolicyBackedIAM() |
|||
traditionalIAM.SetLegacyIAM(mockLegacyIAM) |
|||
|
|||
// Both should behave the same
|
|||
if policyBackedIAM.legacyIAM != traditionalIAM.legacyIAM { |
|||
t.Errorf("Expected both approaches to result in the same legacy IAM") |
|||
} |
|||
} |
|||
|
|||
// MockLegacyIAM implements the LegacyIAM interface for testing
|
|||
type MockLegacyIAM struct{} |
|||
|
|||
func (m *MockLegacyIAM) authRequest(r *http.Request, action Action) (Identity, s3err.ErrorCode) { |
|||
return nil, s3err.ErrNone |
|||
} |
@ -0,0 +1,463 @@ |
|||
//go:build ignore
|
|||
// +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, "bucket1") |
|||
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") |
|||
} |
|||
} |
@ -0,0 +1,438 @@ |
|||
package policy_engine |
|||
|
|||
import ( |
|||
"fmt" |
|||
"net/http" |
|||
"strings" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/glog" |
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err" |
|||
) |
|||
|
|||
// Action represents an S3 action - this should match the type in auth_credentials.go
|
|||
type Action string |
|||
|
|||
// Identity represents a user identity - this should match the type in auth_credentials.go
|
|||
type Identity interface { |
|||
canDo(action Action, bucket string, objectKey string) bool |
|||
} |
|||
|
|||
// PolicyBackedIAM provides policy-based access control with fallback to legacy IAM
|
|||
type PolicyBackedIAM struct { |
|||
policyEngine *PolicyEngine |
|||
legacyIAM LegacyIAM // Interface to delegate to existing IAM system
|
|||
} |
|||
|
|||
// LegacyIAM interface for delegating to existing IAM implementation
|
|||
type LegacyIAM interface { |
|||
authRequest(r *http.Request, action Action) (Identity, s3err.ErrorCode) |
|||
} |
|||
|
|||
// NewPolicyBackedIAM creates a new policy-backed IAM system
|
|||
func NewPolicyBackedIAM() *PolicyBackedIAM { |
|||
return &PolicyBackedIAM{ |
|||
policyEngine: NewPolicyEngine(), |
|||
legacyIAM: nil, // Will be set when integrated with existing IAM
|
|||
} |
|||
} |
|||
|
|||
// NewPolicyBackedIAMWithLegacy creates a new policy-backed IAM system with legacy IAM set
|
|||
func NewPolicyBackedIAMWithLegacy(legacyIAM LegacyIAM) *PolicyBackedIAM { |
|||
return &PolicyBackedIAM{ |
|||
policyEngine: NewPolicyEngine(), |
|||
legacyIAM: legacyIAM, |
|||
} |
|||
} |
|||
|
|||
// SetLegacyIAM sets the legacy IAM system for fallback
|
|||
func (p *PolicyBackedIAM) SetLegacyIAM(legacyIAM LegacyIAM) { |
|||
p.legacyIAM = legacyIAM |
|||
} |
|||
|
|||
// SetBucketPolicy sets the policy for a bucket
|
|||
func (p *PolicyBackedIAM) SetBucketPolicy(bucketName string, policyJSON string) error { |
|||
return p.policyEngine.SetBucketPolicy(bucketName, policyJSON) |
|||
} |
|||
|
|||
// GetBucketPolicy gets the policy for a bucket
|
|||
func (p *PolicyBackedIAM) GetBucketPolicy(bucketName string) (*PolicyDocument, error) { |
|||
return p.policyEngine.GetBucketPolicy(bucketName) |
|||
} |
|||
|
|||
// DeleteBucketPolicy deletes the policy for a bucket
|
|||
func (p *PolicyBackedIAM) DeleteBucketPolicy(bucketName string) error { |
|||
return p.policyEngine.DeleteBucketPolicy(bucketName) |
|||
} |
|||
|
|||
// CanDo checks if a principal can perform an action on a resource
|
|||
func (p *PolicyBackedIAM) CanDo(action, bucketName, objectName, principal string, r *http.Request) bool { |
|||
// If there's a bucket policy, evaluate it
|
|||
if p.policyEngine.HasPolicyForBucket(bucketName) { |
|||
result := p.policyEngine.EvaluatePolicyForRequest(bucketName, objectName, action, principal, r) |
|||
switch result { |
|||
case PolicyResultAllow: |
|||
return true |
|||
case PolicyResultDeny: |
|||
return false |
|||
case PolicyResultIndeterminate: |
|||
// Fall through to legacy system
|
|||
} |
|||
} |
|||
|
|||
// No bucket policy or indeterminate result, use legacy conversion
|
|||
return p.evaluateLegacyAction(action, bucketName, objectName, principal) |
|||
} |
|||
|
|||
// evaluateLegacyAction evaluates actions using legacy identity-based rules
|
|||
func (p *PolicyBackedIAM) evaluateLegacyAction(action, bucketName, objectName, principal string) bool { |
|||
// If we have a legacy IAM system to delegate to, use it
|
|||
if p.legacyIAM != nil { |
|||
// Create a dummy request for legacy evaluation
|
|||
// In real implementation, this would use the actual request
|
|||
r := &http.Request{ |
|||
Header: make(http.Header), |
|||
} |
|||
|
|||
// Convert the action string to Action type
|
|||
legacyAction := Action(action) |
|||
|
|||
// Use legacy IAM to check permission
|
|||
identity, errCode := p.legacyIAM.authRequest(r, legacyAction) |
|||
if errCode != s3err.ErrNone { |
|||
return false |
|||
} |
|||
|
|||
// If we have an identity, check if it can perform the action
|
|||
if identity != nil { |
|||
return identity.canDo(legacyAction, bucketName, objectName) |
|||
} |
|||
} |
|||
|
|||
// No legacy IAM available, convert to policy and evaluate
|
|||
return p.evaluateUsingPolicyConversion(action, bucketName, objectName, principal) |
|||
} |
|||
|
|||
// evaluateUsingPolicyConversion converts legacy action to policy and evaluates
|
|||
func (p *PolicyBackedIAM) evaluateUsingPolicyConversion(action, bucketName, objectName, principal string) bool { |
|||
// For now, use a conservative approach for legacy actions
|
|||
// In a real implementation, this would integrate with the existing identity system
|
|||
glog.V(2).Infof("Legacy action evaluation for %s on %s/%s by %s", action, bucketName, objectName, principal) |
|||
|
|||
// Return false to maintain security until proper legacy integration is implemented
|
|||
// This ensures no unintended access is granted
|
|||
return false |
|||
} |
|||
|
|||
// ConvertIdentityToPolicy converts a legacy identity action to an AWS policy
|
|||
func ConvertIdentityToPolicy(identityActions []string, bucketName string) (*PolicyDocument, error) { |
|||
statements := make([]PolicyStatement, 0) |
|||
|
|||
for _, action := range identityActions { |
|||
stmt, err := convertSingleAction(action, bucketName) |
|||
if err != nil { |
|||
glog.Warningf("Failed to convert action %s: %v", action, err) |
|||
continue |
|||
} |
|||
if stmt != nil { |
|||
statements = append(statements, *stmt) |
|||
} |
|||
} |
|||
|
|||
if len(statements) == 0 { |
|||
return nil, fmt.Errorf("no valid statements generated") |
|||
} |
|||
|
|||
return &PolicyDocument{ |
|||
Version: PolicyVersion2012_10_17, |
|||
Statement: statements, |
|||
}, nil |
|||
} |
|||
|
|||
// convertSingleAction converts a single legacy action to a policy statement
|
|||
func convertSingleAction(action, bucketName string) (*PolicyStatement, error) { |
|||
parts := strings.Split(action, ":") |
|||
if len(parts) != 2 { |
|||
return nil, fmt.Errorf("invalid action format: %s", action) |
|||
} |
|||
|
|||
actionType := parts[0] |
|||
resourcePattern := parts[1] |
|||
|
|||
var s3Actions []string |
|||
var resources []string |
|||
|
|||
switch actionType { |
|||
case "Read": |
|||
s3Actions = []string{"s3:GetObject", "s3:GetObjectVersion", "s3:ListBucket"} |
|||
if strings.HasSuffix(resourcePattern, "/*") { |
|||
// Object-level read access
|
|||
bucket := strings.TrimSuffix(resourcePattern, "/*") |
|||
resources = []string{ |
|||
fmt.Sprintf("arn:aws:s3:::%s", bucket), |
|||
fmt.Sprintf("arn:aws:s3:::%s/*", bucket), |
|||
} |
|||
} else { |
|||
// Bucket-level read access
|
|||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s", resourcePattern)} |
|||
} |
|||
|
|||
case "Write": |
|||
s3Actions = []string{"s3:PutObject", "s3:DeleteObject", "s3:PutObjectAcl"} |
|||
if strings.HasSuffix(resourcePattern, "/*") { |
|||
// Object-level write access
|
|||
bucket := strings.TrimSuffix(resourcePattern, "/*") |
|||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)} |
|||
} else { |
|||
// Bucket-level write access
|
|||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s", resourcePattern)} |
|||
} |
|||
|
|||
case "Admin": |
|||
s3Actions = []string{"s3:*"} |
|||
resources = []string{ |
|||
fmt.Sprintf("arn:aws:s3:::%s", resourcePattern), |
|||
fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern), |
|||
} |
|||
|
|||
case "List": |
|||
s3Actions = []string{"s3:ListBucket", "s3:ListBucketVersions"} |
|||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s", resourcePattern)} |
|||
|
|||
case "Tagging": |
|||
s3Actions = []string{"s3:GetObjectTagging", "s3:PutObjectTagging", "s3:DeleteObjectTagging"} |
|||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern)} |
|||
|
|||
case "BypassGovernanceRetention": |
|||
s3Actions = []string{"s3:BypassGovernanceRetention"} |
|||
if strings.HasSuffix(resourcePattern, "/*") { |
|||
// Object-level bypass governance access
|
|||
bucket := strings.TrimSuffix(resourcePattern, "/*") |
|||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)} |
|||
} else { |
|||
// Bucket-level bypass governance access
|
|||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern)} |
|||
} |
|||
|
|||
default: |
|||
return nil, fmt.Errorf("unknown action type: %s", actionType) |
|||
} |
|||
|
|||
return &PolicyStatement{ |
|||
Effect: PolicyEffectAllow, |
|||
Action: NewStringOrStringSlice(s3Actions...), |
|||
Resource: NewStringOrStringSlice(resources...), |
|||
}, nil |
|||
} |
|||
|
|||
// GetActionMappings returns the mapping of legacy actions to S3 actions
|
|||
func GetActionMappings() map[string][]string { |
|||
return map[string][]string{ |
|||
"Read": { |
|||
"s3:GetObject", |
|||
"s3:GetObjectVersion", |
|||
"s3:GetObjectAcl", |
|||
"s3:GetObjectVersionAcl", |
|||
"s3:GetObjectTagging", |
|||
"s3:GetObjectVersionTagging", |
|||
"s3:ListBucket", |
|||
"s3:ListBucketVersions", |
|||
"s3:GetBucketLocation", |
|||
"s3:GetBucketVersioning", |
|||
"s3:GetBucketAcl", |
|||
"s3:GetBucketCors", |
|||
"s3:GetBucketTagging", |
|||
"s3:GetBucketNotification", |
|||
}, |
|||
"Write": { |
|||
"s3:PutObject", |
|||
"s3:PutObjectAcl", |
|||
"s3:PutObjectTagging", |
|||
"s3:DeleteObject", |
|||
"s3:DeleteObjectVersion", |
|||
"s3:DeleteObjectTagging", |
|||
"s3:AbortMultipartUpload", |
|||
"s3:ListMultipartUploads", |
|||
"s3:ListParts", |
|||
"s3:PutBucketAcl", |
|||
"s3:PutBucketCors", |
|||
"s3:PutBucketTagging", |
|||
"s3:PutBucketNotification", |
|||
"s3:PutBucketVersioning", |
|||
"s3:DeleteBucketTagging", |
|||
"s3:DeleteBucketCors", |
|||
}, |
|||
"Admin": { |
|||
"s3:*", |
|||
}, |
|||
"List": { |
|||
"s3:ListBucket", |
|||
"s3:ListBucketVersions", |
|||
"s3:ListAllMyBuckets", |
|||
}, |
|||
"Tagging": { |
|||
"s3:GetObjectTagging", |
|||
"s3:PutObjectTagging", |
|||
"s3:DeleteObjectTagging", |
|||
"s3:GetBucketTagging", |
|||
"s3:PutBucketTagging", |
|||
"s3:DeleteBucketTagging", |
|||
}, |
|||
"BypassGovernanceRetention": { |
|||
"s3:BypassGovernanceRetention", |
|||
}, |
|||
} |
|||
} |
|||
|
|||
// ValidateActionMapping validates that a legacy action can be mapped to S3 actions
|
|||
func ValidateActionMapping(action string) error { |
|||
mappings := GetActionMappings() |
|||
|
|||
parts := strings.Split(action, ":") |
|||
if len(parts) != 2 { |
|||
return fmt.Errorf("invalid action format: %s, expected format: 'ActionType:Resource'", action) |
|||
} |
|||
|
|||
actionType := parts[0] |
|||
resource := parts[1] |
|||
|
|||
if _, exists := mappings[actionType]; !exists { |
|||
return fmt.Errorf("unknown action type: %s", actionType) |
|||
} |
|||
|
|||
if resource == "" { |
|||
return fmt.Errorf("resource cannot be empty") |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
// ConvertLegacyActions converts an array of legacy actions to S3 actions
|
|||
func ConvertLegacyActions(legacyActions []string) ([]string, error) { |
|||
mappings := GetActionMappings() |
|||
s3Actions := make([]string, 0) |
|||
|
|||
for _, legacyAction := range legacyActions { |
|||
if err := ValidateActionMapping(legacyAction); err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
parts := strings.Split(legacyAction, ":") |
|||
actionType := parts[0] |
|||
|
|||
if actionType == "Admin" { |
|||
// Admin gives all permissions, so we can just return s3:*
|
|||
return []string{"s3:*"}, nil |
|||
} |
|||
|
|||
if mapped, exists := mappings[actionType]; exists { |
|||
s3Actions = append(s3Actions, mapped...) |
|||
} |
|||
} |
|||
|
|||
// Remove duplicates
|
|||
uniqueActions := make([]string, 0) |
|||
seen := make(map[string]bool) |
|||
for _, action := range s3Actions { |
|||
if !seen[action] { |
|||
uniqueActions = append(uniqueActions, action) |
|||
seen[action] = true |
|||
} |
|||
} |
|||
|
|||
return uniqueActions, nil |
|||
} |
|||
|
|||
// GetResourcesFromLegacyAction extracts resources from a legacy action
|
|||
func GetResourcesFromLegacyAction(legacyAction string) ([]string, error) { |
|||
parts := strings.Split(legacyAction, ":") |
|||
if len(parts) != 2 { |
|||
return nil, fmt.Errorf("invalid action format: %s", legacyAction) |
|||
} |
|||
|
|||
resourcePattern := parts[1] |
|||
resources := make([]string, 0) |
|||
|
|||
if strings.HasSuffix(resourcePattern, "/*") { |
|||
// Object-level access
|
|||
bucket := strings.TrimSuffix(resourcePattern, "/*") |
|||
resources = append(resources, fmt.Sprintf("arn:aws:s3:::%s", bucket)) |
|||
resources = append(resources, fmt.Sprintf("arn:aws:s3:::%s/*", bucket)) |
|||
} else { |
|||
// Bucket-level access
|
|||
resources = append(resources, fmt.Sprintf("arn:aws:s3:::%s", resourcePattern)) |
|||
} |
|||
|
|||
return resources, nil |
|||
} |
|||
|
|||
// CreatePolicyFromLegacyIdentity creates a policy document from legacy identity actions
|
|||
func CreatePolicyFromLegacyIdentity(identityName string, actions []string) (*PolicyDocument, error) { |
|||
statements := make([]PolicyStatement, 0) |
|||
|
|||
// Group actions by resource pattern
|
|||
resourceActions := make(map[string][]string) |
|||
|
|||
for _, action := range actions { |
|||
parts := strings.Split(action, ":") |
|||
if len(parts) != 2 { |
|||
continue |
|||
} |
|||
|
|||
resourcePattern := parts[1] |
|||
actionType := parts[0] |
|||
|
|||
if _, exists := resourceActions[resourcePattern]; !exists { |
|||
resourceActions[resourcePattern] = make([]string, 0) |
|||
} |
|||
resourceActions[resourcePattern] = append(resourceActions[resourcePattern], actionType) |
|||
} |
|||
|
|||
// Create statements for each resource pattern
|
|||
for resourcePattern, actionTypes := range resourceActions { |
|||
s3Actions := make([]string, 0) |
|||
|
|||
for _, actionType := range actionTypes { |
|||
if actionType == "Admin" { |
|||
s3Actions = []string{"s3:*"} |
|||
break |
|||
} |
|||
|
|||
if mapped, exists := GetActionMappings()[actionType]; exists { |
|||
s3Actions = append(s3Actions, mapped...) |
|||
} |
|||
} |
|||
|
|||
resources, err := GetResourcesFromLegacyAction(fmt.Sprintf("dummy:%s", resourcePattern)) |
|||
if err != nil { |
|||
continue |
|||
} |
|||
|
|||
statement := PolicyStatement{ |
|||
Sid: fmt.Sprintf("%s-%s", identityName, strings.ReplaceAll(resourcePattern, "/", "-")), |
|||
Effect: PolicyEffectAllow, |
|||
Action: NewStringOrStringSlice(s3Actions...), |
|||
Resource: NewStringOrStringSlice(resources...), |
|||
} |
|||
|
|||
statements = append(statements, statement) |
|||
} |
|||
|
|||
if len(statements) == 0 { |
|||
return nil, fmt.Errorf("no valid statements generated for identity %s", identityName) |
|||
} |
|||
|
|||
return &PolicyDocument{ |
|||
Version: PolicyVersion2012_10_17, |
|||
Statement: statements, |
|||
}, nil |
|||
} |
|||
|
|||
// HasPolicyForBucket checks if a bucket has a policy
|
|||
func (p *PolicyBackedIAM) HasPolicyForBucket(bucketName string) bool { |
|||
return p.policyEngine.HasPolicyForBucket(bucketName) |
|||
} |
|||
|
|||
// GetPolicyEngine returns the underlying policy engine
|
|||
func (p *PolicyBackedIAM) GetPolicyEngine() *PolicyEngine { |
|||
return p.policyEngine |
|||
} |
@ -0,0 +1,454 @@ |
|||
package policy_engine |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"regexp" |
|||
"strings" |
|||
"time" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/glog" |
|||
) |
|||
|
|||
// 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" |
|||
) |
|||
|
|||
// 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
|
|||
func (s StringOrStringSlice) Strings() []string { |
|||
return s.values |
|||
} |
|||
|
|||
// NewStringOrStringSlice creates a new StringOrStringSlice from strings
|
|||
func NewStringOrStringSlice(values ...string) StringOrStringSlice { |
|||
return StringOrStringSlice{values: values} |
|||
} |
|||
|
|||
// 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"` |
|||
} |
|||
|
|||
// PolicyStatement represents a single policy statement
|
|||
type PolicyStatement struct { |
|||
Sid string `json:"Sid,omitempty"` |
|||
Effect PolicyEffect `json:"Effect"` |
|||
Principal *StringOrStringSlice `json:"Principal,omitempty"` |
|||
Action StringOrStringSlice `json:"Action"` |
|||
Resource StringOrStringSlice `json:"Resource"` |
|||
Condition PolicyConditions `json:"Condition,omitempty"` |
|||
} |
|||
|
|||
// 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 |
|||
} |
|||
|
|||
// PolicyCache for caching compiled policies
|
|||
type PolicyCache struct { |
|||
policies map[string]*CompiledPolicy |
|||
lastUpdate time.Time |
|||
} |
|||
|
|||
// 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 []*WildcardMatcher |
|||
ResourceMatchers []*WildcardMatcher |
|||
PrincipalMatchers []*WildcardMatcher |
|||
// Keep regex patterns for backward compatibility
|
|||
ActionPatterns []*regexp.Regexp |
|||
ResourcePatterns []*regexp.Regexp |
|||
PrincipalPatterns []*regexp.Regexp |
|||
} |
|||
|
|||
// 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, stmt := range policyDoc.Statement { |
|||
if err := validateStatement(&stmt); 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 { |
|||
return fmt.Errorf("resource is required") |
|||
} |
|||
|
|||
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: %v", err) |
|||
} |
|||
|
|||
if err := ValidatePolicy(&policy); err != nil { |
|||
return nil, fmt.Errorf("invalid policy: %v", 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, stmt := range policy.Statement { |
|||
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) { |
|||
compiled := &CompiledStatement{ |
|||
Statement: stmt, |
|||
} |
|||
|
|||
// Compile action patterns and matchers
|
|||
for _, action := range stmt.Action.Strings() { |
|||
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 := 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 stmt.Resource.Strings() { |
|||
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 := 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() { |
|||
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 := NewWildcardMatcher(principal) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("failed to create principal matcher %s: %v", principal, err) |
|||
} |
|||
compiled.PrincipalMatchers = append(compiled.PrincipalMatchers, matcher) |
|||
} |
|||
} |
|||
|
|||
return compiled, nil |
|||
} |
|||
|
|||
// compilePattern compiles a wildcard pattern to regex
|
|||
func compilePattern(pattern string) (*regexp.Regexp, error) { |
|||
return 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 |
|||
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 "" |
|||
} |
|||
|
|||
// IsObjectResource checks if resource refers to objects
|
|||
func IsObjectResource(resource string) bool { |
|||
return strings.Contains(resource, "/") |
|||
} |
|||
|
|||
// S3Actions contains common S3 actions
|
|||
var S3Actions = map[string]string{ |
|||
"GetObject": "s3:GetObject", |
|||
"PutObject": "s3:PutObject", |
|||
"DeleteObject": "s3:DeleteObject", |
|||
"GetObjectVersion": "s3:GetObjectVersion", |
|||
"DeleteObjectVersion": "s3:DeleteObjectVersion", |
|||
"ListBucket": "s3:ListBucket", |
|||
"ListBucketVersions": "s3:ListBucketVersions", |
|||
"GetBucketLocation": "s3:GetBucketLocation", |
|||
"GetBucketVersioning": "s3:GetBucketVersioning", |
|||
"PutBucketVersioning": "s3:PutBucketVersioning", |
|||
"GetBucketAcl": "s3:GetBucketAcl", |
|||
"PutBucketAcl": "s3:PutBucketAcl", |
|||
"GetObjectAcl": "s3:GetObjectAcl", |
|||
"PutObjectAcl": "s3:PutObjectAcl", |
|||
"GetBucketPolicy": "s3:GetBucketPolicy", |
|||
"PutBucketPolicy": "s3:PutBucketPolicy", |
|||
"DeleteBucketPolicy": "s3:DeleteBucketPolicy", |
|||
"GetBucketCors": "s3:GetBucketCors", |
|||
"PutBucketCors": "s3:PutBucketCors", |
|||
"DeleteBucketCors": "s3:DeleteBucketCors", |
|||
"GetBucketNotification": "s3:GetBucketNotification", |
|||
"PutBucketNotification": "s3:PutBucketNotification", |
|||
"GetBucketTagging": "s3:GetBucketTagging", |
|||
"PutBucketTagging": "s3:PutBucketTagging", |
|||
"DeleteBucketTagging": "s3:DeleteBucketTagging", |
|||
"GetObjectTagging": "s3:GetObjectTagging", |
|||
"PutObjectTagging": "s3:PutObjectTagging", |
|||
"DeleteObjectTagging": "s3:DeleteObjectTagging", |
|||
"ListMultipartUploads": "s3:ListMultipartUploads", |
|||
"AbortMultipartUpload": "s3:AbortMultipartUpload", |
|||
"ListParts": "s3:ListParts", |
|||
"GetObjectRetention": "s3:GetObjectRetention", |
|||
"PutObjectRetention": "s3:PutObjectRetention", |
|||
"GetObjectLegalHold": "s3:GetObjectLegalHold", |
|||
"PutObjectLegalHold": "s3:PutObjectLegalHold", |
|||
"GetBucketObjectLockConfiguration": "s3:GetBucketObjectLockConfiguration", |
|||
"PutBucketObjectLockConfiguration": "s3:PutBucketObjectLockConfiguration", |
|||
"BypassGovernanceRetention": "s3:BypassGovernanceRetention", |
|||
} |
|||
|
|||
// MatchesAction checks if an action matches any of the compiled action matchers
|
|||
func (cs *CompiledStatement) MatchesAction(action string) bool { |
|||
for _, matcher := range cs.ActionMatchers { |
|||
if matcher.Match(action) { |
|||
return true |
|||
} |
|||
} |
|||
return false |
|||
} |
|||
|
|||
// 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 |
|||
} |
|||
|
|||
// 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 |
|||
} |
|||
|
|||
// Check if principal matches
|
|||
if !cs.MatchesPrincipal(args.Principal) { |
|||
return false |
|||
} |
|||
|
|||
// TODO: Add condition evaluation if needed
|
|||
// if !cs.evaluateConditions(args.Conditions) {
|
|||
// 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 := GetCachedWildcardMatcher(pattern) |
|||
if err != nil { |
|||
glog.Errorf("Error getting cached WildcardMatcher for pattern %s: %v", pattern, err) |
|||
// Fall back to the original implementation
|
|||
return MatchesWildcard(pattern, str) |
|||
} |
|||
return matcher.Match(str) |
|||
} |
@ -0,0 +1,253 @@ |
|||
package policy_engine |
|||
|
|||
import ( |
|||
"regexp" |
|||
"strings" |
|||
"sync" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/glog" |
|||
) |
|||
|
|||
// WildcardMatcher provides unified wildcard matching functionality
|
|||
type WildcardMatcher struct { |
|||
// Use regex for complex patterns with ? wildcards
|
|||
// Use string manipulation for simple * patterns (better performance)
|
|||
useRegex bool |
|||
regex *regexp.Regexp |
|||
pattern string |
|||
} |
|||
|
|||
// WildcardMatcherCache provides caching for WildcardMatcher instances
|
|||
type WildcardMatcherCache struct { |
|||
mu sync.RWMutex |
|||
matchers map[string]*WildcardMatcher |
|||
maxSize int |
|||
accessOrder []string // For LRU eviction
|
|||
} |
|||
|
|||
// NewWildcardMatcherCache creates a new WildcardMatcherCache with a configurable maxSize
|
|||
func NewWildcardMatcherCache(maxSize int) *WildcardMatcherCache { |
|||
if maxSize <= 0 { |
|||
maxSize = 1000 // Default value
|
|||
} |
|||
return &WildcardMatcherCache{ |
|||
matchers: make(map[string]*WildcardMatcher), |
|||
maxSize: maxSize, |
|||
} |
|||
} |
|||
|
|||
// Global cache instance
|
|||
var wildcardMatcherCache = NewWildcardMatcherCache(1000) // Default maxSize
|
|||
|
|||
// GetCachedWildcardMatcher gets or creates a cached WildcardMatcher for the given pattern
|
|||
func GetCachedWildcardMatcher(pattern string) (*WildcardMatcher, error) { |
|||
// Fast path: check if already in cache
|
|||
wildcardMatcherCache.mu.RLock() |
|||
if matcher, exists := wildcardMatcherCache.matchers[pattern]; exists { |
|||
wildcardMatcherCache.mu.RUnlock() |
|||
wildcardMatcherCache.updateAccessOrder(pattern) |
|||
return matcher, nil |
|||
} |
|||
wildcardMatcherCache.mu.RUnlock() |
|||
|
|||
// Slow path: create new matcher and cache it
|
|||
wildcardMatcherCache.mu.Lock() |
|||
defer wildcardMatcherCache.mu.Unlock() |
|||
|
|||
// Double-check after acquiring write lock
|
|||
if matcher, exists := wildcardMatcherCache.matchers[pattern]; exists { |
|||
wildcardMatcherCache.updateAccessOrderLocked(pattern) |
|||
return matcher, nil |
|||
} |
|||
|
|||
// Create new matcher
|
|||
matcher, err := NewWildcardMatcher(pattern) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
// Evict old entries if cache is full
|
|||
if len(wildcardMatcherCache.matchers) >= wildcardMatcherCache.maxSize { |
|||
wildcardMatcherCache.evictLeastRecentlyUsed() |
|||
} |
|||
|
|||
// Cache it
|
|||
wildcardMatcherCache.matchers[pattern] = matcher |
|||
wildcardMatcherCache.accessOrder = append(wildcardMatcherCache.accessOrder, pattern) |
|||
return matcher, nil |
|||
} |
|||
|
|||
// updateAccessOrder updates the access order for LRU eviction (with read lock)
|
|||
func (c *WildcardMatcherCache) updateAccessOrder(pattern string) { |
|||
c.mu.Lock() |
|||
defer c.mu.Unlock() |
|||
c.updateAccessOrderLocked(pattern) |
|||
} |
|||
|
|||
// updateAccessOrderLocked updates the access order for LRU eviction (without locking)
|
|||
func (c *WildcardMatcherCache) updateAccessOrderLocked(pattern string) { |
|||
// Remove pattern from its current position
|
|||
for i, p := range c.accessOrder { |
|||
if p == pattern { |
|||
c.accessOrder = append(c.accessOrder[:i], c.accessOrder[i+1:]...) |
|||
break |
|||
} |
|||
} |
|||
// Add pattern to the end (most recently used)
|
|||
c.accessOrder = append(c.accessOrder, pattern) |
|||
} |
|||
|
|||
// evictLeastRecentlyUsed removes the least recently used pattern from the cache
|
|||
func (c *WildcardMatcherCache) evictLeastRecentlyUsed() { |
|||
if len(c.accessOrder) == 0 { |
|||
return |
|||
} |
|||
|
|||
// Remove the least recently used pattern (first in the list)
|
|||
lruPattern := c.accessOrder[0] |
|||
c.accessOrder = c.accessOrder[1:] |
|||
delete(c.matchers, lruPattern) |
|||
} |
|||
|
|||
// ClearCache clears all cached patterns (useful for testing)
|
|||
func (c *WildcardMatcherCache) ClearCache() { |
|||
c.mu.Lock() |
|||
defer c.mu.Unlock() |
|||
c.matchers = make(map[string]*WildcardMatcher) |
|||
c.accessOrder = c.accessOrder[:0] |
|||
} |
|||
|
|||
// GetCacheStats returns cache statistics
|
|||
func (c *WildcardMatcherCache) GetCacheStats() (size int, maxSize int) { |
|||
c.mu.RLock() |
|||
defer c.mu.RUnlock() |
|||
return len(c.matchers), c.maxSize |
|||
} |
|||
|
|||
// NewWildcardMatcher creates a new wildcard matcher for the given pattern
|
|||
func NewWildcardMatcher(pattern string) (*WildcardMatcher, error) { |
|||
matcher := &WildcardMatcher{ |
|||
pattern: pattern, |
|||
} |
|||
|
|||
// Determine if we need regex (contains ? wildcards)
|
|||
if strings.Contains(pattern, "?") { |
|||
matcher.useRegex = true |
|||
regex, err := compileWildcardPattern(pattern) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
matcher.regex = regex |
|||
} else { |
|||
matcher.useRegex = false |
|||
} |
|||
|
|||
return matcher, nil |
|||
} |
|||
|
|||
// Match checks if a string matches the wildcard pattern
|
|||
func (m *WildcardMatcher) Match(str string) bool { |
|||
if m.useRegex { |
|||
return m.regex.MatchString(str) |
|||
} |
|||
return matchWildcardString(m.pattern, str) |
|||
} |
|||
|
|||
// MatchesWildcard provides a simple function interface for wildcard matching
|
|||
// This function consolidates the logic from the previous separate implementations
|
|||
func MatchesWildcard(pattern, str string) bool { |
|||
// Handle simple cases first
|
|||
if pattern == "*" { |
|||
return true |
|||
} |
|||
if pattern == str { |
|||
return true |
|||
} |
|||
|
|||
// Use regex for patterns with ? wildcards, string manipulation for * only
|
|||
if strings.Contains(pattern, "?") { |
|||
return matchWildcardRegex(pattern, str) |
|||
} |
|||
return matchWildcardString(pattern, str) |
|||
} |
|||
|
|||
// CompileWildcardPattern converts a wildcard pattern to a compiled regex
|
|||
// This replaces the previous compilePattern function
|
|||
func CompileWildcardPattern(pattern string) (*regexp.Regexp, error) { |
|||
return compileWildcardPattern(pattern) |
|||
} |
|||
|
|||
// matchWildcardString uses string manipulation for * wildcards only (more efficient)
|
|||
func matchWildcardString(pattern, str string) bool { |
|||
// Handle simple cases
|
|||
if pattern == "*" { |
|||
return true |
|||
} |
|||
if pattern == str { |
|||
return true |
|||
} |
|||
|
|||
// Split pattern by wildcards
|
|||
parts := strings.Split(pattern, "*") |
|||
if len(parts) == 1 { |
|||
// No wildcards, exact match
|
|||
return pattern == str |
|||
} |
|||
|
|||
// Check if string starts with first part
|
|||
if len(parts[0]) > 0 && !strings.HasPrefix(str, parts[0]) { |
|||
return false |
|||
} |
|||
|
|||
// Check if string ends with last part
|
|||
if len(parts[len(parts)-1]) > 0 && !strings.HasSuffix(str, parts[len(parts)-1]) { |
|||
return false |
|||
} |
|||
|
|||
// Check middle parts
|
|||
searchStr := str |
|||
if len(parts[0]) > 0 { |
|||
searchStr = searchStr[len(parts[0]):] |
|||
} |
|||
if len(parts[len(parts)-1]) > 0 { |
|||
searchStr = searchStr[:len(searchStr)-len(parts[len(parts)-1])] |
|||
} |
|||
|
|||
for i := 1; i < len(parts)-1; i++ { |
|||
if len(parts[i]) > 0 { |
|||
index := strings.Index(searchStr, parts[i]) |
|||
if index == -1 { |
|||
return false |
|||
} |
|||
searchStr = searchStr[index+len(parts[i]):] |
|||
} |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
// matchWildcardRegex uses WildcardMatcher for patterns with ? wildcards
|
|||
func matchWildcardRegex(pattern, str string) bool { |
|||
matcher, err := GetCachedWildcardMatcher(pattern) |
|||
if err != nil { |
|||
glog.Errorf("Error getting WildcardMatcher for pattern %s: %v. Falling back to matchWildcardString.", pattern, err) |
|||
// Fallback to matchWildcardString
|
|||
return matchWildcardString(pattern, str) |
|||
} |
|||
return matcher.Match(str) |
|||
} |
|||
|
|||
// compileWildcardPattern converts a wildcard pattern to regex
|
|||
func compileWildcardPattern(pattern string) (*regexp.Regexp, error) { |
|||
// Escape special regex characters except * and ?
|
|||
escaped := regexp.QuoteMeta(pattern) |
|||
|
|||
// Replace escaped wildcards with regex equivalents
|
|||
escaped = strings.ReplaceAll(escaped, `\*`, `.*`) |
|||
escaped = strings.ReplaceAll(escaped, `\?`, `.`) |
|||
|
|||
// Anchor the pattern
|
|||
escaped = "^" + escaped + "$" |
|||
|
|||
return regexp.Compile(escaped) |
|||
} |
@ -0,0 +1,469 @@ |
|||
package policy_engine |
|||
|
|||
import ( |
|||
"testing" |
|||
) |
|||
|
|||
func TestMatchesWildcard(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
pattern string |
|||
str string |
|||
expected bool |
|||
}{ |
|||
// Basic functionality tests
|
|||
{ |
|||
name: "Exact match", |
|||
pattern: "test", |
|||
str: "test", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Single wildcard", |
|||
pattern: "*", |
|||
str: "anything", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Empty string with wildcard", |
|||
pattern: "*", |
|||
str: "", |
|||
expected: true, |
|||
}, |
|||
|
|||
// Star (*) wildcard tests
|
|||
{ |
|||
name: "Prefix wildcard", |
|||
pattern: "test*", |
|||
str: "test123", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Suffix wildcard", |
|||
pattern: "*test", |
|||
str: "123test", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Middle wildcard", |
|||
pattern: "test*123", |
|||
str: "testABC123", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Multiple wildcards", |
|||
pattern: "test*abc*123", |
|||
str: "testXYZabcDEF123", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "No match", |
|||
pattern: "test*", |
|||
str: "other", |
|||
expected: false, |
|||
}, |
|||
|
|||
// Question mark (?) wildcard tests
|
|||
{ |
|||
name: "Single question mark", |
|||
pattern: "test?", |
|||
str: "test1", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Multiple question marks", |
|||
pattern: "test??", |
|||
str: "test12", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Question mark no match", |
|||
pattern: "test?", |
|||
str: "test12", |
|||
expected: false, |
|||
}, |
|||
{ |
|||
name: "Mixed wildcards", |
|||
pattern: "test*abc?def", |
|||
str: "testXYZabc1def", |
|||
expected: true, |
|||
}, |
|||
|
|||
// Edge cases
|
|||
{ |
|||
name: "Empty pattern", |
|||
pattern: "", |
|||
str: "", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Empty pattern with string", |
|||
pattern: "", |
|||
str: "test", |
|||
expected: false, |
|||
}, |
|||
{ |
|||
name: "Pattern with string empty", |
|||
pattern: "test", |
|||
str: "", |
|||
expected: false, |
|||
}, |
|||
|
|||
// Special characters
|
|||
{ |
|||
name: "Pattern with regex special chars", |
|||
pattern: "test[abc]", |
|||
str: "test[abc]", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Pattern with dots", |
|||
pattern: "test.txt", |
|||
str: "test.txt", |
|||
expected: true, |
|||
}, |
|||
{ |
|||
name: "Pattern with dots and wildcard", |
|||
pattern: "*.txt", |
|||
str: "test.txt", |
|||
expected: true, |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
result := MatchesWildcard(tt.pattern, tt.str) |
|||
if result != tt.expected { |
|||
t.Errorf("Pattern %s against %s: expected %v, got %v", tt.pattern, tt.str, tt.expected, result) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestWildcardMatcher(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
pattern string |
|||
strings []string |
|||
expected []bool |
|||
}{ |
|||
{ |
|||
name: "Simple star pattern", |
|||
pattern: "test*", |
|||
strings: []string{"test", "test123", "testing", "other"}, |
|||
expected: []bool{true, true, true, false}, |
|||
}, |
|||
{ |
|||
name: "Question mark pattern", |
|||
pattern: "test?", |
|||
strings: []string{"test1", "test2", "test", "test12"}, |
|||
expected: []bool{true, true, false, false}, |
|||
}, |
|||
{ |
|||
name: "Mixed pattern", |
|||
pattern: "*.txt", |
|||
strings: []string{"file.txt", "test.txt", "file.doc", "txt"}, |
|||
expected: []bool{true, true, false, false}, |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
matcher, err := NewWildcardMatcher(tt.pattern) |
|||
if err != nil { |
|||
t.Fatalf("Failed to create matcher: %v", err) |
|||
} |
|||
|
|||
for i, str := range tt.strings { |
|||
result := matcher.Match(str) |
|||
if result != tt.expected[i] { |
|||
t.Errorf("Pattern %s against %s: expected %v, got %v", tt.pattern, str, tt.expected[i], result) |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestCompileWildcardPattern(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
pattern string |
|||
input string |
|||
want bool |
|||
}{ |
|||
{"Star wildcard", "s3:Get*", "s3:GetObject", true}, |
|||
{"Question mark wildcard", "s3:Get?bject", "s3:GetObject", true}, |
|||
{"Mixed wildcards", "s3:*Object*", "s3:GetObjectAcl", true}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
regex, err := CompileWildcardPattern(tt.pattern) |
|||
if err != nil { |
|||
t.Errorf("CompileWildcardPattern() error = %v", err) |
|||
return |
|||
} |
|||
got := regex.MatchString(tt.input) |
|||
if got != tt.want { |
|||
t.Errorf("CompileWildcardPattern() = %v, want %v", got, tt.want) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// BenchmarkWildcardMatchingPerformance demonstrates the performance benefits of caching
|
|||
func BenchmarkWildcardMatchingPerformance(b *testing.B) { |
|||
patterns := []string{ |
|||
"s3:Get*", |
|||
"s3:Put*", |
|||
"s3:Delete*", |
|||
"s3:List*", |
|||
"arn:aws:s3:::bucket/*", |
|||
"arn:aws:s3:::bucket/prefix*", |
|||
"user:*", |
|||
"user:admin-*", |
|||
} |
|||
|
|||
inputs := []string{ |
|||
"s3:GetObject", |
|||
"s3:PutObject", |
|||
"s3:DeleteObject", |
|||
"s3:ListBucket", |
|||
"arn:aws:s3:::bucket/file.txt", |
|||
"arn:aws:s3:::bucket/prefix/file.txt", |
|||
"user:admin", |
|||
"user:admin-john", |
|||
} |
|||
|
|||
b.Run("WithoutCache", func(b *testing.B) { |
|||
for i := 0; i < b.N; i++ { |
|||
for _, pattern := range patterns { |
|||
for _, input := range inputs { |
|||
MatchesWildcard(pattern, input) |
|||
} |
|||
} |
|||
} |
|||
}) |
|||
|
|||
b.Run("WithCache", func(b *testing.B) { |
|||
for i := 0; i < b.N; i++ { |
|||
for _, pattern := range patterns { |
|||
for _, input := range inputs { |
|||
FastMatchesWildcard(pattern, input) |
|||
} |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// BenchmarkWildcardMatcherReuse demonstrates the performance benefits of reusing WildcardMatcher instances
|
|||
func BenchmarkWildcardMatcherReuse(b *testing.B) { |
|||
pattern := "s3:Get*" |
|||
input := "s3:GetObject" |
|||
|
|||
b.Run("NewMatcherEveryTime", func(b *testing.B) { |
|||
for i := 0; i < b.N; i++ { |
|||
matcher, _ := NewWildcardMatcher(pattern) |
|||
matcher.Match(input) |
|||
} |
|||
}) |
|||
|
|||
b.Run("CachedMatcher", func(b *testing.B) { |
|||
for i := 0; i < b.N; i++ { |
|||
matcher, _ := GetCachedWildcardMatcher(pattern) |
|||
matcher.Match(input) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// TestWildcardMatcherCaching verifies that caching works correctly
|
|||
func TestWildcardMatcherCaching(t *testing.T) { |
|||
pattern := "s3:Get*" |
|||
|
|||
// Get the first matcher
|
|||
matcher1, err := GetCachedWildcardMatcher(pattern) |
|||
if err != nil { |
|||
t.Fatalf("Failed to get cached matcher: %v", err) |
|||
} |
|||
|
|||
// Get the second matcher - should be the same instance
|
|||
matcher2, err := GetCachedWildcardMatcher(pattern) |
|||
if err != nil { |
|||
t.Fatalf("Failed to get cached matcher: %v", err) |
|||
} |
|||
|
|||
// Check that they're the same instance (same pointer)
|
|||
if matcher1 != matcher2 { |
|||
t.Errorf("Expected same matcher instance, got different instances") |
|||
} |
|||
|
|||
// Test that both matchers work correctly
|
|||
testInput := "s3:GetObject" |
|||
if !matcher1.Match(testInput) { |
|||
t.Errorf("First matcher failed to match %s", testInput) |
|||
} |
|||
if !matcher2.Match(testInput) { |
|||
t.Errorf("Second matcher failed to match %s", testInput) |
|||
} |
|||
} |
|||
|
|||
// TestFastMatchesWildcard verifies that the fast matching function works correctly
|
|||
func TestFastMatchesWildcard(t *testing.T) { |
|||
tests := []struct { |
|||
pattern string |
|||
input string |
|||
want bool |
|||
}{ |
|||
{"s3:Get*", "s3:GetObject", true}, |
|||
{"s3:Put*", "s3:GetObject", false}, |
|||
{"arn:aws:s3:::bucket/*", "arn:aws:s3:::bucket/file.txt", true}, |
|||
{"user:admin-*", "user:admin-john", true}, |
|||
{"user:admin-*", "user:guest-john", false}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.pattern+"_"+tt.input, func(t *testing.T) { |
|||
got := FastMatchesWildcard(tt.pattern, tt.input) |
|||
if got != tt.want { |
|||
t.Errorf("FastMatchesWildcard(%q, %q) = %v, want %v", tt.pattern, tt.input, got, tt.want) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// TestWildcardMatcherCacheBounding tests the bounded cache functionality
|
|||
func TestWildcardMatcherCacheBounding(t *testing.T) { |
|||
// Clear cache before test
|
|||
wildcardMatcherCache.ClearCache() |
|||
|
|||
// Get original max size
|
|||
originalMaxSize := wildcardMatcherCache.maxSize |
|||
|
|||
// Set a small max size for testing
|
|||
wildcardMatcherCache.maxSize = 3 |
|||
defer func() { |
|||
wildcardMatcherCache.maxSize = originalMaxSize |
|||
wildcardMatcherCache.ClearCache() |
|||
}() |
|||
|
|||
// Add patterns up to max size
|
|||
patterns := []string{"pattern1", "pattern2", "pattern3"} |
|||
for _, pattern := range patterns { |
|||
_, err := GetCachedWildcardMatcher(pattern) |
|||
if err != nil { |
|||
t.Fatalf("Failed to get cached matcher for %s: %v", pattern, err) |
|||
} |
|||
} |
|||
|
|||
// Verify cache size
|
|||
size, maxSize := wildcardMatcherCache.GetCacheStats() |
|||
if size != 3 { |
|||
t.Errorf("Expected cache size 3, got %d", size) |
|||
} |
|||
if maxSize != 3 { |
|||
t.Errorf("Expected max size 3, got %d", maxSize) |
|||
} |
|||
|
|||
// Add another pattern, should evict the least recently used
|
|||
_, err := GetCachedWildcardMatcher("pattern4") |
|||
if err != nil { |
|||
t.Fatalf("Failed to get cached matcher for pattern4: %v", err) |
|||
} |
|||
|
|||
// Cache should still be at max size
|
|||
size, _ = wildcardMatcherCache.GetCacheStats() |
|||
if size != 3 { |
|||
t.Errorf("Expected cache size 3 after eviction, got %d", size) |
|||
} |
|||
|
|||
// The first pattern should have been evicted
|
|||
wildcardMatcherCache.mu.RLock() |
|||
if _, exists := wildcardMatcherCache.matchers["pattern1"]; exists { |
|||
t.Errorf("Expected pattern1 to be evicted, but it still exists") |
|||
} |
|||
if _, exists := wildcardMatcherCache.matchers["pattern4"]; !exists { |
|||
t.Errorf("Expected pattern4 to be in cache, but it doesn't exist") |
|||
} |
|||
wildcardMatcherCache.mu.RUnlock() |
|||
} |
|||
|
|||
// TestWildcardMatcherCacheLRU tests the LRU eviction policy
|
|||
func TestWildcardMatcherCacheLRU(t *testing.T) { |
|||
// Clear cache before test
|
|||
wildcardMatcherCache.ClearCache() |
|||
|
|||
// Get original max size
|
|||
originalMaxSize := wildcardMatcherCache.maxSize |
|||
|
|||
// Set a small max size for testing
|
|||
wildcardMatcherCache.maxSize = 3 |
|||
defer func() { |
|||
wildcardMatcherCache.maxSize = originalMaxSize |
|||
wildcardMatcherCache.ClearCache() |
|||
}() |
|||
|
|||
// Add patterns to fill cache
|
|||
patterns := []string{"pattern1", "pattern2", "pattern3"} |
|||
for _, pattern := range patterns { |
|||
_, err := GetCachedWildcardMatcher(pattern) |
|||
if err != nil { |
|||
t.Fatalf("Failed to get cached matcher for %s: %v", pattern, err) |
|||
} |
|||
} |
|||
|
|||
// Access pattern1 to make it most recently used
|
|||
_, err := GetCachedWildcardMatcher("pattern1") |
|||
if err != nil { |
|||
t.Fatalf("Failed to access pattern1: %v", err) |
|||
} |
|||
|
|||
// Add another pattern, should evict pattern2 (now least recently used)
|
|||
_, err = GetCachedWildcardMatcher("pattern4") |
|||
if err != nil { |
|||
t.Fatalf("Failed to get cached matcher for pattern4: %v", err) |
|||
} |
|||
|
|||
// pattern1 should still be in cache (was accessed recently)
|
|||
// pattern2 should be evicted (was least recently used)
|
|||
wildcardMatcherCache.mu.RLock() |
|||
if _, exists := wildcardMatcherCache.matchers["pattern1"]; !exists { |
|||
t.Errorf("Expected pattern1 to remain in cache (most recently used)") |
|||
} |
|||
if _, exists := wildcardMatcherCache.matchers["pattern2"]; exists { |
|||
t.Errorf("Expected pattern2 to be evicted (least recently used)") |
|||
} |
|||
if _, exists := wildcardMatcherCache.matchers["pattern3"]; !exists { |
|||
t.Errorf("Expected pattern3 to remain in cache") |
|||
} |
|||
if _, exists := wildcardMatcherCache.matchers["pattern4"]; !exists { |
|||
t.Errorf("Expected pattern4 to be in cache") |
|||
} |
|||
wildcardMatcherCache.mu.RUnlock() |
|||
} |
|||
|
|||
// TestWildcardMatcherCacheClear tests the cache clearing functionality
|
|||
func TestWildcardMatcherCacheClear(t *testing.T) { |
|||
// Add some patterns to cache
|
|||
patterns := []string{"pattern1", "pattern2", "pattern3"} |
|||
for _, pattern := range patterns { |
|||
_, err := GetCachedWildcardMatcher(pattern) |
|||
if err != nil { |
|||
t.Fatalf("Failed to get cached matcher for %s: %v", pattern, err) |
|||
} |
|||
} |
|||
|
|||
// Verify cache has patterns
|
|||
size, _ := wildcardMatcherCache.GetCacheStats() |
|||
if size == 0 { |
|||
t.Errorf("Expected cache to have patterns before clearing") |
|||
} |
|||
|
|||
// Clear cache
|
|||
wildcardMatcherCache.ClearCache() |
|||
|
|||
// Verify cache is empty
|
|||
size, _ = wildcardMatcherCache.GetCacheStats() |
|||
if size != 0 { |
|||
t.Errorf("Expected cache to be empty after clearing, got size %d", size) |
|||
} |
|||
} |
@ -0,0 +1,599 @@ |
|||
package s3api |
|||
|
|||
import ( |
|||
"net/http" |
|||
"net/http/httptest" |
|||
"strings" |
|||
"testing" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" |
|||
) |
|||
|
|||
// TestCheckGovernanceBypassPermissionResourceGeneration tests that the function
|
|||
// correctly generates resource paths for the permission check
|
|||
func TestCheckGovernanceBypassPermissionResourceGeneration(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
bucket string |
|||
object string |
|||
expectedPath string |
|||
description string |
|||
}{ |
|||
{ |
|||
name: "simple_object", |
|||
bucket: "test-bucket", |
|||
object: "test-object.txt", |
|||
expectedPath: "test-bucket/test-object.txt", |
|||
description: "Simple bucket and object should be joined with slash", |
|||
}, |
|||
{ |
|||
name: "object_with_leading_slash", |
|||
bucket: "test-bucket", |
|||
object: "/test-object.txt", |
|||
expectedPath: "test-bucket/test-object.txt", |
|||
description: "Leading slash should be trimmed from object name", |
|||
}, |
|||
{ |
|||
name: "nested_object", |
|||
bucket: "test-bucket", |
|||
object: "/folder/subfolder/test-object.txt", |
|||
expectedPath: "test-bucket/folder/subfolder/test-object.txt", |
|||
description: "Nested object path should be handled correctly", |
|||
}, |
|||
{ |
|||
name: "empty_object", |
|||
bucket: "test-bucket", |
|||
object: "", |
|||
expectedPath: "test-bucket/", |
|||
description: "Empty object should result in bucket with trailing slash", |
|||
}, |
|||
{ |
|||
name: "root_object", |
|||
bucket: "test-bucket", |
|||
object: "/", |
|||
expectedPath: "test-bucket/", |
|||
description: "Root object should result in bucket with trailing slash", |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
// Test the resource generation logic used in checkGovernanceBypassPermission
|
|||
resource := strings.TrimPrefix(tt.object, "/") |
|||
actualPath := tt.bucket + "/" + resource |
|||
|
|||
if actualPath != tt.expectedPath { |
|||
t.Errorf("Resource path generation failed. Expected: %s, Got: %s. %s", |
|||
tt.expectedPath, actualPath, tt.description) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// TestCheckGovernanceBypassPermissionActionGeneration tests that the function
|
|||
// correctly generates action strings for IAM checking
|
|||
func TestCheckGovernanceBypassPermissionActionGeneration(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
bucket string |
|||
object string |
|||
expectedBypassAction string |
|||
expectedAdminAction string |
|||
description string |
|||
}{ |
|||
{ |
|||
name: "bypass_action_generation", |
|||
bucket: "test-bucket", |
|||
object: "test-object.txt", |
|||
expectedBypassAction: "BypassGovernanceRetention:test-bucket/test-object.txt", |
|||
expectedAdminAction: "Admin:test-bucket/test-object.txt", |
|||
description: "Actions should be properly formatted with resource path", |
|||
}, |
|||
{ |
|||
name: "leading_slash_handling", |
|||
bucket: "test-bucket", |
|||
object: "/test-object.txt", |
|||
expectedBypassAction: "BypassGovernanceRetention:test-bucket/test-object.txt", |
|||
expectedAdminAction: "Admin:test-bucket/test-object.txt", |
|||
description: "Leading slash should be trimmed in action generation", |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
// Test the action generation logic used in checkGovernanceBypassPermission
|
|||
resource := strings.TrimPrefix(tt.object, "/") |
|||
resourcePath := tt.bucket + "/" + resource |
|||
|
|||
bypassAction := s3_constants.ACTION_BYPASS_GOVERNANCE_RETENTION + ":" + resourcePath |
|||
adminAction := s3_constants.ACTION_ADMIN + ":" + resourcePath |
|||
|
|||
if bypassAction != tt.expectedBypassAction { |
|||
t.Errorf("Bypass action generation failed. Expected: %s, Got: %s. %s", |
|||
tt.expectedBypassAction, bypassAction, tt.description) |
|||
} |
|||
|
|||
if adminAction != tt.expectedAdminAction { |
|||
t.Errorf("Admin action generation failed. Expected: %s, Got: %s. %s", |
|||
tt.expectedAdminAction, adminAction, tt.description) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// TestCheckGovernanceBypassPermissionErrorHandling tests error handling scenarios
|
|||
func TestCheckGovernanceBypassPermissionErrorHandling(t *testing.T) { |
|||
// Note: This test demonstrates the expected behavior for different error scenarios
|
|||
// without requiring full IAM setup
|
|||
|
|||
tests := []struct { |
|||
name string |
|||
bucket string |
|||
object string |
|||
description string |
|||
}{ |
|||
{ |
|||
name: "empty_bucket", |
|||
bucket: "", |
|||
object: "test-object.txt", |
|||
description: "Empty bucket should be handled gracefully", |
|||
}, |
|||
{ |
|||
name: "special_characters", |
|||
bucket: "test-bucket", |
|||
object: "test object with spaces.txt", |
|||
description: "Objects with special characters should be handled", |
|||
}, |
|||
{ |
|||
name: "unicode_characters", |
|||
bucket: "test-bucket", |
|||
object: "测试文件.txt", |
|||
description: "Objects with unicode characters should be handled", |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
// Test that the function doesn't panic with various inputs
|
|||
// This would normally call checkGovernanceBypassPermission
|
|||
// but since we don't have a full S3ApiServer setup, we just test
|
|||
// that the resource generation logic works without panicking
|
|||
resource := strings.TrimPrefix(tt.object, "/") |
|||
resourcePath := tt.bucket + "/" + resource |
|||
|
|||
// Verify the resource path is generated
|
|||
if resourcePath == "" { |
|||
t.Errorf("Resource path should not be empty for test case: %s", tt.description) |
|||
} |
|||
|
|||
t.Logf("Generated resource path for %s: %s", tt.description, resourcePath) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// TestCheckGovernanceBypassPermissionIntegrationBehavior documents the expected behavior
|
|||
// when integrated with a full IAM system
|
|||
func TestCheckGovernanceBypassPermissionIntegrationBehavior(t *testing.T) { |
|||
t.Skip("Documentation test - describes expected behavior with full IAM integration") |
|||
|
|||
// This test documents the expected behavior when checkGovernanceBypassPermission
|
|||
// is called with a full IAM system:
|
|||
//
|
|||
// 1. Function calls s3a.iam.authRequest() with the bypass action
|
|||
// 2. If authRequest returns errCode != s3err.ErrNone, function returns false
|
|||
// 3. If authRequest succeeds, function checks identity.canDo() with the bypass action
|
|||
// 4. If canDo() returns true, function returns true
|
|||
// 5. If bypass permission fails, function checks admin action with identity.canDo()
|
|||
// 6. If admin action succeeds, function returns true and logs admin access
|
|||
// 7. If all checks fail, function returns false
|
|||
//
|
|||
// The function correctly uses:
|
|||
// - s3_constants.ACTION_BYPASS_GOVERNANCE_RETENTION for bypass permission
|
|||
// - s3_constants.ACTION_ADMIN for admin permission
|
|||
// - Proper resource path generation with bucket/object format
|
|||
// - Trimming of leading slashes from object names
|
|||
} |
|||
|
|||
// TestGovernanceBypassPermission was removed because it tested the old
|
|||
// insecure behavior of trusting the AmzIsAdmin header. The new implementation
|
|||
// uses proper IAM authentication instead of relying on client-provided headers.
|
|||
|
|||
// Test specifically for users with IAM bypass permission
|
|||
func TestGovernanceBypassWithIAMPermission(t *testing.T) { |
|||
// This test demonstrates the expected behavior for non-admin users with bypass permission
|
|||
// In a real implementation, this would integrate with the full IAM system
|
|||
|
|||
t.Skip("Integration test requires full IAM setup - demonstrates expected behavior") |
|||
|
|||
// The expected behavior would be:
|
|||
// 1. Non-admin user makes request with bypass header
|
|||
// 2. checkGovernanceBypassPermission calls s3a.iam.authRequest
|
|||
// 3. authRequest validates user identity and checks permissions
|
|||
// 4. If user has s3:BypassGovernanceRetention permission, return true
|
|||
// 5. Otherwise return false
|
|||
|
|||
// For now, the function correctly returns false for non-admin users
|
|||
// when the IAM system doesn't have the user configured with bypass permission
|
|||
} |
|||
|
|||
func TestGovernancePermissionIntegration(t *testing.T) { |
|||
// Note: This test demonstrates the expected integration behavior
|
|||
// In a real implementation, this would require setting up a proper IAM mock
|
|||
// with identities that have the bypass governance permission
|
|||
|
|||
t.Skip("Integration test requires full IAM setup - demonstrates expected behavior") |
|||
|
|||
// This test would verify:
|
|||
// 1. User with BypassGovernanceRetention permission can bypass governance
|
|||
// 2. User without permission cannot bypass governance
|
|||
// 3. Admin users can always bypass governance
|
|||
// 4. Anonymous users cannot bypass governance
|
|||
} |
|||
|
|||
func TestGovernanceBypassHeader(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
headerValue string |
|||
expectedResult bool |
|||
description string |
|||
}{ |
|||
{ |
|||
name: "bypass_header_true", |
|||
headerValue: "true", |
|||
expectedResult: true, |
|||
description: "Header with 'true' value should enable bypass", |
|||
}, |
|||
{ |
|||
name: "bypass_header_false", |
|||
headerValue: "false", |
|||
expectedResult: false, |
|||
description: "Header with 'false' value should not enable bypass", |
|||
}, |
|||
{ |
|||
name: "bypass_header_empty", |
|||
headerValue: "", |
|||
expectedResult: false, |
|||
description: "Empty header should not enable bypass", |
|||
}, |
|||
{ |
|||
name: "bypass_header_invalid", |
|||
headerValue: "invalid", |
|||
expectedResult: false, |
|||
description: "Invalid header value should not enable bypass", |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
req := httptest.NewRequest("DELETE", "/bucket/object", nil) |
|||
if tt.headerValue != "" { |
|||
req.Header.Set("x-amz-bypass-governance-retention", tt.headerValue) |
|||
} |
|||
|
|||
result := req.Header.Get("x-amz-bypass-governance-retention") == "true" |
|||
|
|||
if result != tt.expectedResult { |
|||
t.Errorf("bypass header check = %v, want %v. %s", result, tt.expectedResult, tt.description) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestGovernanceRetentionModeChecking(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
retentionMode string |
|||
bypassGovernance bool |
|||
hasPermission bool |
|||
expectedError bool |
|||
expectedErrorType string |
|||
description string |
|||
}{ |
|||
{ |
|||
name: "compliance_mode_cannot_bypass", |
|||
retentionMode: s3_constants.RetentionModeCompliance, |
|||
bypassGovernance: true, |
|||
hasPermission: true, |
|||
expectedError: true, |
|||
expectedErrorType: "compliance mode", |
|||
description: "Compliance mode should not be bypassable even with permission", |
|||
}, |
|||
{ |
|||
name: "governance_mode_without_bypass", |
|||
retentionMode: s3_constants.RetentionModeGovernance, |
|||
bypassGovernance: false, |
|||
hasPermission: false, |
|||
expectedError: true, |
|||
expectedErrorType: "governance mode", |
|||
description: "Governance mode should be blocked without bypass", |
|||
}, |
|||
{ |
|||
name: "governance_mode_with_bypass_no_permission", |
|||
retentionMode: s3_constants.RetentionModeGovernance, |
|||
bypassGovernance: true, |
|||
hasPermission: false, |
|||
expectedError: true, |
|||
expectedErrorType: "permission", |
|||
description: "Governance mode bypass should fail without permission", |
|||
}, |
|||
{ |
|||
name: "governance_mode_with_bypass_and_permission", |
|||
retentionMode: s3_constants.RetentionModeGovernance, |
|||
bypassGovernance: true, |
|||
hasPermission: true, |
|||
expectedError: false, |
|||
expectedErrorType: "", |
|||
description: "Governance mode bypass should succeed with permission", |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
// Test validates the logic without actually needing the full implementation
|
|||
// This demonstrates the expected behavior patterns
|
|||
|
|||
var hasError bool |
|||
var errorType string |
|||
|
|||
if tt.retentionMode == s3_constants.RetentionModeCompliance { |
|||
hasError = true |
|||
errorType = "compliance mode" |
|||
} else if tt.retentionMode == s3_constants.RetentionModeGovernance { |
|||
if !tt.bypassGovernance { |
|||
hasError = true |
|||
errorType = "governance mode" |
|||
} else if !tt.hasPermission { |
|||
hasError = true |
|||
errorType = "permission" |
|||
} |
|||
} |
|||
|
|||
if hasError != tt.expectedError { |
|||
t.Errorf("expected error: %v, got error: %v. %s", tt.expectedError, hasError, tt.description) |
|||
} |
|||
|
|||
if tt.expectedError && !strings.Contains(errorType, tt.expectedErrorType) { |
|||
t.Errorf("expected error type containing '%s', got '%s'. %s", tt.expectedErrorType, errorType, tt.description) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestGovernancePermissionActionGeneration(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
bucket string |
|||
object string |
|||
expectedAction string |
|||
description string |
|||
}{ |
|||
{ |
|||
name: "bucket_and_object_action", |
|||
bucket: "test-bucket", |
|||
object: "/test-object", // Object has "/" prefix from GetBucketAndObject
|
|||
expectedAction: "BypassGovernanceRetention:test-bucket/test-object", |
|||
description: "Action should be generated correctly for bucket and object", |
|||
}, |
|||
{ |
|||
name: "bucket_only_action", |
|||
bucket: "test-bucket", |
|||
object: "", |
|||
expectedAction: "BypassGovernanceRetention:test-bucket", |
|||
description: "Action should be generated correctly for bucket only", |
|||
}, |
|||
{ |
|||
name: "nested_object_action", |
|||
bucket: "test-bucket", |
|||
object: "/folder/subfolder/object", // Object has "/" prefix from GetBucketAndObject
|
|||
expectedAction: "BypassGovernanceRetention:test-bucket/folder/subfolder/object", |
|||
description: "Action should be generated correctly for nested objects", |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
action := s3_constants.ACTION_BYPASS_GOVERNANCE_RETENTION + ":" + tt.bucket + tt.object |
|||
|
|||
if action != tt.expectedAction { |
|||
t.Errorf("generated action: %s, expected: %s. %s", action, tt.expectedAction, tt.description) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// TestGovernancePermissionEndToEnd tests the complete object lock permission flow
|
|||
func TestGovernancePermissionEndToEnd(t *testing.T) { |
|||
t.Skip("End-to-end testing requires full S3 API server setup - demonstrates expected behavior") |
|||
|
|||
// This test demonstrates the end-to-end flow that would be tested in a full integration test
|
|||
// The checkObjectLockPermissions method is called by:
|
|||
// 1. DeleteObjectHandler - when versioning is enabled and object lock is configured
|
|||
// 2. DeleteMultipleObjectsHandler - for each object in versioned buckets
|
|||
// 3. PutObjectHandler - via checkObjectLockPermissionsForPut for versioned buckets
|
|||
// 4. PutObjectRetentionHandler - when setting retention on objects
|
|||
//
|
|||
// Each handler:
|
|||
// - Extracts bypassGovernance from "x-amz-bypass-governance-retention" header
|
|||
// - Calls checkObjectLockPermissions with the appropriate parameters
|
|||
// - Handles the returned errors appropriately (ErrAccessDenied, etc.)
|
|||
//
|
|||
// The method integrates with the IAM system through checkGovernanceBypassPermission
|
|||
// which validates the s3:BypassGovernanceRetention permission
|
|||
} |
|||
|
|||
// TestGovernancePermissionHTTPFlow tests the HTTP header processing and method calls
|
|||
func TestGovernancePermissionHTTPFlow(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
headerValue string |
|||
expectedBypassGovernance bool |
|||
}{ |
|||
{ |
|||
name: "bypass_header_true", |
|||
headerValue: "true", |
|||
expectedBypassGovernance: true, |
|||
}, |
|||
{ |
|||
name: "bypass_header_false", |
|||
headerValue: "false", |
|||
expectedBypassGovernance: false, |
|||
}, |
|||
{ |
|||
name: "bypass_header_missing", |
|||
headerValue: "", |
|||
expectedBypassGovernance: false, |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
// Create a mock HTTP request
|
|||
req, _ := http.NewRequest("DELETE", "/bucket/test-object", nil) |
|||
if tt.headerValue != "" { |
|||
req.Header.Set("x-amz-bypass-governance-retention", tt.headerValue) |
|||
} |
|||
|
|||
// Test the header processing logic used in handlers
|
|||
bypassGovernance := req.Header.Get("x-amz-bypass-governance-retention") == "true" |
|||
|
|||
if bypassGovernance != tt.expectedBypassGovernance { |
|||
t.Errorf("Expected bypassGovernance to be %v, got %v", tt.expectedBypassGovernance, bypassGovernance) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// TestGovernancePermissionMethodCalls tests that the governance permission methods are called correctly
|
|||
func TestGovernancePermissionMethodCalls(t *testing.T) { |
|||
// Test that demonstrates the method call pattern used in handlers
|
|||
|
|||
// This is the pattern used in DeleteObjectHandler:
|
|||
t.Run("delete_object_handler_pattern", func(t *testing.T) { |
|||
req, _ := http.NewRequest("DELETE", "/bucket/test-object", nil) |
|||
req.Header.Set("x-amz-bypass-governance-retention", "true") |
|||
|
|||
// Extract parameters as done in the handler
|
|||
bucket, object := s3_constants.GetBucketAndObject(req) |
|||
versionId := req.URL.Query().Get("versionId") |
|||
bypassGovernance := req.Header.Get("x-amz-bypass-governance-retention") == "true" |
|||
|
|||
// Verify the parameters are extracted correctly
|
|||
// Note: The actual bucket and object extraction depends on the URL structure
|
|||
t.Logf("Extracted bucket: %s, object: %s", bucket, object) |
|||
if versionId != "" { |
|||
t.Errorf("Expected versionId to be empty, got %v", versionId) |
|||
} |
|||
if !bypassGovernance { |
|||
t.Errorf("Expected bypassGovernance to be true") |
|||
} |
|||
}) |
|||
|
|||
// This is the pattern used in PutObjectHandler:
|
|||
t.Run("put_object_handler_pattern", func(t *testing.T) { |
|||
req, _ := http.NewRequest("PUT", "/bucket/test-object", nil) |
|||
req.Header.Set("x-amz-bypass-governance-retention", "true") |
|||
|
|||
// Extract parameters as done in the handler
|
|||
bucket, object := s3_constants.GetBucketAndObject(req) |
|||
bypassGovernance := req.Header.Get("x-amz-bypass-governance-retention") == "true" |
|||
versioningEnabled := true // Would be determined by isVersioningEnabled(bucket)
|
|||
|
|||
// Verify the parameters are extracted correctly
|
|||
// Note: The actual bucket and object extraction depends on the URL structure
|
|||
t.Logf("Extracted bucket: %s, object: %s", bucket, object) |
|||
if !bypassGovernance { |
|||
t.Errorf("Expected bypassGovernance to be true") |
|||
} |
|||
if !versioningEnabled { |
|||
t.Errorf("Expected versioningEnabled to be true") |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// TestGovernanceBypassNotPermittedError tests that ErrGovernanceBypassNotPermitted
|
|||
// is returned when bypass is requested but the user lacks permission
|
|||
func TestGovernanceBypassNotPermittedError(t *testing.T) { |
|||
// Test the error constant itself
|
|||
if ErrGovernanceBypassNotPermitted == nil { |
|||
t.Error("ErrGovernanceBypassNotPermitted should be defined") |
|||
} |
|||
|
|||
// Verify the error message
|
|||
expectedMessage := "user does not have permission to bypass governance retention" |
|||
if ErrGovernanceBypassNotPermitted.Error() != expectedMessage { |
|||
t.Errorf("expected error message '%s', got '%s'", |
|||
expectedMessage, ErrGovernanceBypassNotPermitted.Error()) |
|||
} |
|||
|
|||
// Test the scenario where this error should be returned
|
|||
// This documents the expected behavior when:
|
|||
// 1. Object is under governance retention
|
|||
// 2. bypassGovernance is true
|
|||
// 3. checkGovernanceBypassPermission returns false
|
|||
testCases := []struct { |
|||
name string |
|||
retentionMode string |
|||
bypassGovernance bool |
|||
hasPermission bool |
|||
expectedError error |
|||
description string |
|||
}{ |
|||
{ |
|||
name: "governance_bypass_without_permission", |
|||
retentionMode: s3_constants.RetentionModeGovernance, |
|||
bypassGovernance: true, |
|||
hasPermission: false, |
|||
expectedError: ErrGovernanceBypassNotPermitted, |
|||
description: "Should return ErrGovernanceBypassNotPermitted when bypass is requested but user lacks permission", |
|||
}, |
|||
{ |
|||
name: "governance_bypass_with_permission", |
|||
retentionMode: s3_constants.RetentionModeGovernance, |
|||
bypassGovernance: true, |
|||
hasPermission: true, |
|||
expectedError: nil, |
|||
description: "Should succeed when bypass is requested and user has permission", |
|||
}, |
|||
{ |
|||
name: "governance_no_bypass", |
|||
retentionMode: s3_constants.RetentionModeGovernance, |
|||
bypassGovernance: false, |
|||
hasPermission: false, |
|||
expectedError: ErrGovernanceModeActive, |
|||
description: "Should return ErrGovernanceModeActive when bypass is not requested", |
|||
}, |
|||
} |
|||
|
|||
for _, tc := range testCases { |
|||
t.Run(tc.name, func(t *testing.T) { |
|||
// This test documents the expected behavior pattern
|
|||
// The actual checkObjectLockPermissions method implements this logic:
|
|||
// if retention.Mode == s3_constants.RetentionModeGovernance {
|
|||
// if !bypassGovernance {
|
|||
// return ErrGovernanceModeActive
|
|||
// }
|
|||
// if !s3a.checkGovernanceBypassPermission(request, bucket, object) {
|
|||
// return ErrGovernanceBypassNotPermitted
|
|||
// }
|
|||
// }
|
|||
|
|||
var simulatedError error |
|||
if tc.retentionMode == s3_constants.RetentionModeGovernance { |
|||
if !tc.bypassGovernance { |
|||
simulatedError = ErrGovernanceModeActive |
|||
} else if !tc.hasPermission { |
|||
simulatedError = ErrGovernanceBypassNotPermitted |
|||
} |
|||
} |
|||
|
|||
if simulatedError != tc.expectedError { |
|||
t.Errorf("expected error %v, got %v. %s", tc.expectedError, simulatedError, tc.description) |
|||
} |
|||
|
|||
// Verify ErrGovernanceBypassNotPermitted is returned in the right case
|
|||
if tc.name == "governance_bypass_without_permission" && simulatedError != ErrGovernanceBypassNotPermitted { |
|||
t.Errorf("Test case should return ErrGovernanceBypassNotPermitted but got %v", simulatedError) |
|||
} |
|||
}) |
|||
} |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue