snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
This commit is contained in:
249
weed/s3api/policy_engine/GOVERNANCE_PERMISSIONS.md
Normal file
249
weed/s3api/policy_engine/GOVERNANCE_PERMISSIONS.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Governance Permission Implementation
|
||||
|
||||
This document explains the implementation of `s3:BypassGovernanceRetention` permission in SeaweedFS, providing AWS S3-compatible governance retention bypass functionality.
|
||||
|
||||
## Overview
|
||||
|
||||
The governance permission system enables proper AWS S3-compatible object retention with governance mode bypass capabilities. This implementation ensures that only users with the appropriate permissions can bypass governance retention, while maintaining security and compliance requirements.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Permission-Based Bypass Control
|
||||
|
||||
- **s3:BypassGovernanceRetention**: New permission that allows users to bypass governance retention
|
||||
- **Admin Override**: Admin users can always bypass governance retention
|
||||
- **Header Detection**: Automatic detection of `x-amz-bypass-governance-retention` header
|
||||
- **Permission Validation**: Validates user permissions before allowing bypass
|
||||
|
||||
### 2. Retention Mode Support
|
||||
|
||||
- **GOVERNANCE Mode**: Can be bypassed with proper permission and header
|
||||
- **COMPLIANCE Mode**: Cannot be bypassed (highest security level)
|
||||
- **Legal Hold**: Always blocks operations regardless of permissions
|
||||
|
||||
### 3. Integration Points
|
||||
|
||||
- **DELETE Operations**: Checks governance permissions before object deletion
|
||||
- **PUT Operations**: Validates permissions before object overwrite
|
||||
- **Retention Modification**: Ensures proper permissions for retention changes
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Permission Checker**
|
||||
```go
|
||||
func (s3a *S3ApiServer) checkGovernanceBypassPermission(r *http.Request, bucket, object string) bool
|
||||
```
|
||||
- Checks if user has `s3:BypassGovernanceRetention` permission
|
||||
- Validates admin status
|
||||
- Integrates with existing IAM system
|
||||
|
||||
2. **Object Lock Permission Validation**
|
||||
```go
|
||||
func (s3a *S3ApiServer) checkObjectLockPermissions(r *http.Request, bucket, object, versionId string, bypassGovernance bool) error
|
||||
```
|
||||
- Validates governance bypass permissions
|
||||
- Checks retention mode (GOVERNANCE vs COMPLIANCE)
|
||||
- Enforces legal hold restrictions
|
||||
|
||||
3. **IAM Integration**
|
||||
- Added `ACTION_BYPASS_GOVERNANCE_RETENTION` constant
|
||||
- Updated policy engine with `s3:BypassGovernanceRetention` action
|
||||
- Integrated with existing identity-based access control
|
||||
|
||||
### Permission Flow
|
||||
|
||||
```
|
||||
Request with x-amz-bypass-governance-retention: true
|
||||
↓
|
||||
Check if object is under retention
|
||||
↓
|
||||
If GOVERNANCE mode:
|
||||
↓
|
||||
Check if user has s3:BypassGovernanceRetention permission
|
||||
↓
|
||||
If permission granted: Allow operation
|
||||
If permission denied: Deny operation
|
||||
↓
|
||||
If COMPLIANCE mode: Always deny
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Identity-Based Configuration
|
||||
|
||||
Add governance bypass permission to user actions in `identities.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"identities": [
|
||||
{
|
||||
"name": "governance-admin",
|
||||
"credentials": [{"accessKey": "admin123", "secretKey": "secret123"}],
|
||||
"actions": [
|
||||
"Read:my-bucket/*",
|
||||
"Write:my-bucket/*",
|
||||
"BypassGovernanceRetention:my-bucket/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Bucket Policy Configuration
|
||||
|
||||
Grant governance bypass permission via bucket policies:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:BypassGovernanceRetention",
|
||||
"Resource": "arn:aws:s3:::bucket/*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The policy version should use the standard AWS policy version `PolicyVersion2012_10_17` constant (which equals `"2012-10-17"`).
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### 1. Delete Object with Governance Bypass
|
||||
|
||||
```bash
|
||||
# User with bypass permission
|
||||
aws s3api delete-object \
|
||||
--bucket my-bucket \
|
||||
--key my-object \
|
||||
--bypass-governance-retention
|
||||
|
||||
# Admin user (always allowed)
|
||||
aws s3api delete-object \
|
||||
--bucket my-bucket \
|
||||
--key my-object \
|
||||
--bypass-governance-retention
|
||||
```
|
||||
|
||||
### 2. Update Object Retention
|
||||
|
||||
```bash
|
||||
# Extend retention period (requires bypass permission for governance mode)
|
||||
aws s3api put-object-retention \
|
||||
--bucket my-bucket \
|
||||
--key my-object \
|
||||
--retention Mode=GOVERNANCE,RetainUntilDate=2025-01-01T00:00:00Z \
|
||||
--bypass-governance-retention
|
||||
```
|
||||
|
||||
### 3. Bulk Object Deletion
|
||||
|
||||
```bash
|
||||
# Delete multiple objects with governance bypass
|
||||
aws s3api delete-objects \
|
||||
--bucket my-bucket \
|
||||
--delete file://delete-objects.json \
|
||||
--bypass-governance-retention
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Permission Errors
|
||||
|
||||
- **ErrAccessDenied**: User lacks `s3:BypassGovernanceRetention` permission
|
||||
- **ErrGovernanceModeActive**: Governance mode protection without bypass
|
||||
- **ErrComplianceModeActive**: Compliance mode cannot be bypassed
|
||||
|
||||
### Example Error Response
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Error>
|
||||
<Code>AccessDenied</Code>
|
||||
<Message>User does not have permission to bypass governance retention</Message>
|
||||
<RequestId>abc123</RequestId>
|
||||
<Resource>/my-bucket/my-object</Resource>
|
||||
</Error>
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### 1. Least Privilege Principle
|
||||
|
||||
- Grant bypass permission only to users who absolutely need it
|
||||
- Use bucket-specific permissions rather than global permissions
|
||||
- Regularly audit users with bypass permissions
|
||||
|
||||
### 2. Compliance Mode Protection
|
||||
|
||||
- COMPLIANCE mode objects cannot be bypassed by any user
|
||||
- Use COMPLIANCE mode for regulatory requirements
|
||||
- GOVERNANCE mode provides flexibility while maintaining audit trails
|
||||
|
||||
### 3. Admin Privileges
|
||||
|
||||
- Admin users can always bypass governance retention
|
||||
- Ensure admin access is properly secured
|
||||
- Use admin privileges responsibly
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
# Run governance permission tests
|
||||
go test -v ./weed/s3api/ -run TestGovernance
|
||||
|
||||
# Run all object retention tests
|
||||
go test -v ./weed/s3api/ -run TestObjectRetention
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```bash
|
||||
# Test with real S3 clients
|
||||
cd test/s3/retention
|
||||
go test -v ./... -run TestGovernanceBypass
|
||||
```
|
||||
|
||||
## AWS Compatibility
|
||||
|
||||
This implementation provides full AWS S3 compatibility for:
|
||||
|
||||
- ✅ `x-amz-bypass-governance-retention` header support
|
||||
- ✅ `s3:BypassGovernanceRetention` permission
|
||||
- ✅ GOVERNANCE vs COMPLIANCE mode behavior
|
||||
- ✅ Legal hold enforcement
|
||||
- ✅ Error responses and codes
|
||||
- ✅ Bucket policy integration
|
||||
- ✅ IAM policy integration
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **User cannot bypass governance retention**
|
||||
- Check if user has `s3:BypassGovernanceRetention` permission
|
||||
- Verify the header `x-amz-bypass-governance-retention: true` is set
|
||||
- Ensure object is in GOVERNANCE mode (not COMPLIANCE)
|
||||
|
||||
2. **Admin bypass not working**
|
||||
- Verify user has admin privileges in the IAM system
|
||||
- Check that object is not under legal hold
|
||||
- Ensure versioning is enabled on the bucket
|
||||
|
||||
3. **Policy not taking effect**
|
||||
- Verify bucket policy JSON syntax
|
||||
- Check resource ARN format
|
||||
- Ensure principal has proper format
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] AWS STS integration for temporary credentials
|
||||
- [ ] CloudTrail-compatible audit logging
|
||||
- [ ] Advanced condition evaluation (IP, time, etc.)
|
||||
- [ ] Integration with external identity providers
|
||||
- [ ] Fine-grained permissions for different retention operations
|
||||
176
weed/s3api/policy_engine/INTEGRATION_EXAMPLE.md
Normal file
176
weed/s3api/policy_engine/INTEGRATION_EXAMPLE.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Integration Example
|
||||
|
||||
This shows how to integrate the new policy engine with the existing S3ApiServer.
|
||||
|
||||
## Minimal Integration
|
||||
|
||||
```go
|
||||
// In s3api_server.go - modify NewS3ApiServerWithStore function
|
||||
|
||||
func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, explicitStore string) (s3ApiServer *S3ApiServer, err error) {
|
||||
// ... existing code ...
|
||||
|
||||
// Create traditional IAM
|
||||
iam := NewIdentityAccessManagementWithStore(option, explicitStore)
|
||||
|
||||
s3ApiServer = &S3ApiServer{
|
||||
option: option,
|
||||
iam: iam, // Keep existing for compatibility
|
||||
randomClientId: util.RandomInt32(),
|
||||
filerGuard: security.NewGuard([]string{}, signingKey, expiresAfterSec, readSigningKey, readExpiresAfterSec),
|
||||
cb: NewCircuitBreaker(option),
|
||||
credentialManager: iam.credentialManager,
|
||||
bucketConfigCache: NewBucketConfigCache(5 * time.Minute),
|
||||
}
|
||||
|
||||
// Optional: Wrap with policy-backed IAM for enhanced features
|
||||
if option.EnablePolicyEngine { // Add this config option
|
||||
// Option 1: Create and set legacy IAM separately
|
||||
policyBackedIAM := NewPolicyBackedIAM()
|
||||
policyBackedIAM.SetLegacyIAM(iam)
|
||||
|
||||
// Option 2: Create with legacy IAM in one call (convenience method)
|
||||
// policyBackedIAM := NewPolicyBackedIAMWithLegacy(iam)
|
||||
|
||||
// Load existing identities as policies
|
||||
if err := policyBackedIAM.LoadIdentityPolicies(); err != nil {
|
||||
glog.Warningf("Failed to load identity policies: %v", err)
|
||||
}
|
||||
|
||||
// Replace IAM with policy-backed version
|
||||
s3ApiServer.iam = policyBackedIAM
|
||||
}
|
||||
|
||||
// ... rest of existing code ...
|
||||
}
|
||||
```
|
||||
|
||||
## Router Integration
|
||||
|
||||
```go
|
||||
// In registerRouter function, replace bucket policy handlers:
|
||||
|
||||
// Old handlers (if they exist):
|
||||
// bucket.Methods(http.MethodGet).HandlerFunc(s3a.GetBucketPolicyHandler).Queries("policy", "")
|
||||
// bucket.Methods(http.MethodPut).HandlerFunc(s3a.PutBucketPolicyHandler).Queries("policy", "")
|
||||
// bucket.Methods(http.MethodDelete).HandlerFunc(s3a.DeleteBucketPolicyHandler).Queries("policy", "")
|
||||
|
||||
// New handlers with policy engine:
|
||||
if policyBackedIAM, ok := s3a.iam.(*PolicyBackedIAM); ok {
|
||||
// Use policy-backed handlers
|
||||
bucket.Methods(http.MethodGet).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(policyBackedIAM.GetBucketPolicyHandler, ACTION_READ)), "GET")).Queries("policy", "")
|
||||
bucket.Methods(http.MethodPut).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(policyBackedIAM.PutBucketPolicyHandler, ACTION_WRITE)), "PUT")).Queries("policy", "")
|
||||
bucket.Methods(http.MethodDelete).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(policyBackedIAM.DeleteBucketPolicyHandler, ACTION_WRITE)), "DELETE")).Queries("policy", "")
|
||||
} else {
|
||||
// Use existing/fallback handlers
|
||||
bucket.Methods(http.MethodGet).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetBucketPolicyHandler, ACTION_READ)), "GET")).Queries("policy", "")
|
||||
bucket.Methods(http.MethodPut).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutBucketPolicyHandler, ACTION_WRITE)), "PUT")).Queries("policy", "")
|
||||
bucket.Methods(http.MethodDelete).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.DeleteBucketPolicyHandler, ACTION_WRITE)), "DELETE")).Queries("policy", "")
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Option
|
||||
|
||||
Add to `S3ApiServerOption`:
|
||||
|
||||
```go
|
||||
type S3ApiServerOption struct {
|
||||
// ... existing fields ...
|
||||
EnablePolicyEngine bool // Add this field
|
||||
}
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
|
||||
### 1. Existing Users (No Changes)
|
||||
|
||||
Your existing `identities.json` continues to work:
|
||||
|
||||
```json
|
||||
{
|
||||
"identities": [
|
||||
{
|
||||
"name": "user1",
|
||||
"credentials": [{"accessKey": "key1", "secretKey": "secret1"}],
|
||||
"actions": ["Read:bucket1/*", "Write:bucket1/uploads/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. New Users (Enhanced Policies)
|
||||
|
||||
Set bucket policies via S3 API:
|
||||
|
||||
```bash
|
||||
# Allow public read
|
||||
aws s3api put-bucket-policy --bucket my-bucket --policy file://policy.json
|
||||
|
||||
# Where policy.json contains:
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::my-bucket/*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Advanced Conditions
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::secure-bucket/*",
|
||||
"Condition": {
|
||||
"IpAddress": {
|
||||
"aws:SourceIp": "192.168.1.0/24"
|
||||
},
|
||||
"Bool": {
|
||||
"aws:SecureTransport": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Enable Policy Engine (Opt-in)
|
||||
- Set `EnablePolicyEngine: true` in server options
|
||||
- Existing `identities.json` automatically converted to policies
|
||||
- Add bucket policies as needed
|
||||
|
||||
### Phase 2: Full Policy Management
|
||||
- Use AWS CLI/SDK for policy management
|
||||
- Gradually migrate from `identities.json` to pure IAM policies
|
||||
- Take advantage of advanced conditions and features
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Test existing functionality
|
||||
go test -v -run TestCanDo
|
||||
|
||||
# Test new policy engine
|
||||
go test -v -run TestPolicyEngine
|
||||
|
||||
# Test integration
|
||||
go test -v -run TestPolicyBackedIAM
|
||||
```
|
||||
|
||||
The integration is designed to be:
|
||||
- **Backward compatible** - Existing setups work unchanged
|
||||
- **Opt-in** - Enable policy engine only when needed
|
||||
- **Gradual** - Migrate at your own pace
|
||||
- **AWS compatible** - Use standard AWS tools and patterns
|
||||
54
weed/s3api/policy_engine/POLICY_EXAMPLES.md
Normal file
54
weed/s3api/policy_engine/POLICY_EXAMPLES.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Policy Engine Examples
|
||||
|
||||
This document contains examples of how to use the SeaweedFS Policy Engine.
|
||||
|
||||
## Overview
|
||||
|
||||
The examples in `examples.go` demonstrate various policy configurations and usage patterns. The examples file is excluded from production builds using build tags to reduce binary size.
|
||||
|
||||
## To Use Examples
|
||||
|
||||
If you need to use the examples during development or testing, you can:
|
||||
|
||||
1. **Remove the build tag**: Remove the `//go:build ignore` and `// +build ignore` lines from `examples.go`
|
||||
2. **Use during development**: The examples are available during development but not in production builds
|
||||
3. **Copy specific examples**: Copy the JSON examples you need into your own code
|
||||
|
||||
## Example Categories
|
||||
|
||||
The examples file includes:
|
||||
|
||||
- **Legacy Identity Format**: Examples of existing identities.json format
|
||||
- **Policy Documents**: Various AWS S3-compatible policy examples
|
||||
- **Condition Examples**: Complex condition-based policies
|
||||
- **Migration Examples**: How to migrate from legacy to policy-based IAM
|
||||
- **Integration Examples**: How to integrate with existing systems
|
||||
|
||||
## Usage Functions
|
||||
|
||||
The examples file provides helper functions:
|
||||
|
||||
- `GetAllExamples()`: Returns all example policies
|
||||
- `ValidateExamplePolicies()`: Validates all examples
|
||||
- `GetExamplePolicy(name)`: Gets a specific example
|
||||
- `CreateExamplePolicyDocument(name)`: Creates a policy document
|
||||
- `PrintExamplePolicyPretty(name)`: Pretty-prints an example
|
||||
- `ExampleUsage()`: Shows basic usage patterns
|
||||
- `ExampleLegacyIntegration()`: Shows legacy integration
|
||||
- `ExampleConditions()`: Shows condition usage
|
||||
- `ExampleMigrationStrategy()`: Shows migration approach
|
||||
|
||||
## To Enable Examples in Development
|
||||
|
||||
```go
|
||||
// Remove build tags from examples.go, then:
|
||||
import "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
|
||||
// Use examples
|
||||
examples := policy_engine.GetAllExamples()
|
||||
policy, err := policy_engine.GetExamplePolicy("read-only-user")
|
||||
```
|
||||
|
||||
## Note
|
||||
|
||||
The examples are excluded from production builds to keep binary size minimal. They are available for development and testing purposes only.
|
||||
370
weed/s3api/policy_engine/README_POLICY_ENGINE.md
Normal file
370
weed/s3api/policy_engine/README_POLICY_ENGINE.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# SeaweedFS Policy Evaluation Engine
|
||||
|
||||
This document describes the comprehensive policy evaluation engine that has been added to SeaweedFS, providing AWS S3-compatible policy support while maintaining full backward compatibility with existing `identities.json` configuration.
|
||||
|
||||
## Overview
|
||||
|
||||
The policy engine provides:
|
||||
- **Full AWS S3 policy compatibility** - JSON policies with conditions, wildcards, and complex logic
|
||||
- **Backward compatibility** - Existing `identities.json` continues to work unchanged
|
||||
- **Bucket policies** - Per-bucket access control policies
|
||||
- **IAM policies** - User and group-level policies
|
||||
- **Condition evaluation** - IP restrictions, time-based access, SSL-only, etc.
|
||||
- **AWS-compliant evaluation order** - Explicit Deny > Explicit Allow > Default Deny
|
||||
|
||||
## Architecture
|
||||
|
||||
### Files Created
|
||||
|
||||
1. **`policy_engine/types.go`** - Core policy data structures and validation
|
||||
2. **`policy_engine/conditions.go`** - Condition evaluators (StringEquals, IpAddress, etc.)
|
||||
3. **`policy_engine/engine.go`** - Main policy evaluation engine
|
||||
4. **`policy_engine/integration.go`** - Integration with existing IAM system
|
||||
5. **`policy_engine/engine_test.go`** - Comprehensive tests
|
||||
6. **`policy_engine/examples.go`** - Usage examples and documentation (excluded from builds)
|
||||
7. **`policy_engine/wildcard_matcher.go`** - Optimized wildcard pattern matching
|
||||
8. **`policy_engine/wildcard_matcher_test.go`** - Wildcard matching tests
|
||||
|
||||
### Key Components
|
||||
|
||||
```
|
||||
PolicyEngine
|
||||
├── Bucket Policies (per-bucket JSON policies)
|
||||
├── User Policies (converted from identities.json + new IAM policies)
|
||||
├── Condition Evaluators (IP, time, string, numeric, etc.)
|
||||
└── Evaluation Logic (AWS-compliant precedence)
|
||||
```
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
### Existing identities.json (No Changes Required)
|
||||
|
||||
Your existing configuration continues to work exactly as before:
|
||||
|
||||
```json
|
||||
{
|
||||
"identities": [
|
||||
{
|
||||
"name": "readonly_user",
|
||||
"credentials": [{"accessKey": "key123", "secretKey": "secret123"}],
|
||||
"actions": ["Read:public-bucket/*", "List:public-bucket"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Legacy actions are automatically converted to AWS-style policies:
|
||||
- `Read:bucket/*` → `s3:GetObject` on `arn:aws:s3:::bucket/*`
|
||||
- `Write:bucket` → `s3:PutObject`, `s3:DeleteObject` on `arn:aws:s3:::bucket/*`
|
||||
- `Admin` → `s3:*` on `arn:aws:s3:::*`
|
||||
|
||||
## New Capabilities
|
||||
|
||||
### 1. Bucket Policies
|
||||
|
||||
Set bucket-level policies using standard S3 API:
|
||||
|
||||
```bash
|
||||
# Set bucket policy
|
||||
curl -X PUT "http://localhost:8333/bucket?policy" \
|
||||
-H "Authorization: AWS access_key:signature" \
|
||||
-d '{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::bucket/*"
|
||||
}
|
||||
]
|
||||
}'
|
||||
|
||||
# Get bucket policy
|
||||
curl "http://localhost:8333/bucket?policy"
|
||||
|
||||
# Delete bucket policy
|
||||
curl -X DELETE "http://localhost:8333/bucket?policy"
|
||||
```
|
||||
|
||||
### 2. Advanced Conditions
|
||||
|
||||
Support for all AWS condition operators:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::secure-bucket/*",
|
||||
"Condition": {
|
||||
"IpAddress": {
|
||||
"aws:SourceIp": ["192.168.1.0/24", "10.0.0.0/8"]
|
||||
},
|
||||
"Bool": {
|
||||
"aws:SecureTransport": "true"
|
||||
},
|
||||
"DateGreaterThan": {
|
||||
"aws:CurrentTime": "2023-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Supported Condition Operators
|
||||
|
||||
- **String**: `StringEquals`, `StringNotEquals`, `StringLike`, `StringNotLike`
|
||||
- **Numeric**: `NumericEquals`, `NumericLessThan`, `NumericGreaterThan`, etc.
|
||||
- **Date**: `DateEquals`, `DateLessThan`, `DateGreaterThan`, etc.
|
||||
- **IP**: `IpAddress`, `NotIpAddress` (supports CIDR notation)
|
||||
- **Boolean**: `Bool`
|
||||
- **ARN**: `ArnEquals`, `ArnLike`
|
||||
- **Null**: `Null`
|
||||
|
||||
### 4. Condition Keys
|
||||
|
||||
Standard AWS condition keys are supported:
|
||||
- `aws:CurrentTime` - Current request time
|
||||
- `aws:SourceIp` - Client IP address
|
||||
- `aws:SecureTransport` - Whether HTTPS is used
|
||||
- `aws:UserAgent` - Client user agent
|
||||
- `s3:x-amz-acl` - Requested ACL
|
||||
- `s3:VersionId` - Object version ID
|
||||
- `s3:ExistingObjectTag/<tag-key>` - Value of an existing object tag (see example below)
|
||||
- And many more...
|
||||
|
||||
### 5. Object Tag-Based Access Control
|
||||
|
||||
You can control access based on object tags using `s3:ExistingObjectTag/<tag-key>`:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::my-bucket/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:ExistingObjectTag/status": ["public"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This allows anonymous access only to objects that have a tag `status=public`.
|
||||
|
||||
**Deny access to confidential objects:**
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::my-bucket/*"
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::my-bucket/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:ExistingObjectTag/classification": ["confidential", "secret"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Supported Operations for Tag-Based Conditions:**
|
||||
|
||||
Tag-based conditions (`s3:ExistingObjectTag/<key>`) are evaluated for the following operations:
|
||||
- `s3:GetObject` (GET object)
|
||||
- `s3:GetObjectVersion` (GET object with versionId)
|
||||
- `s3:HeadObject` (HEAD object)
|
||||
|
||||
Note: For these conditions to be evaluated, the object must exist and the policy engine re-checks access after fetching the object metadata.
|
||||
|
||||
## Policy Evaluation
|
||||
|
||||
### Evaluation Order (AWS-Compatible)
|
||||
|
||||
1. **Explicit Deny** - If any policy explicitly denies access → **DENY**
|
||||
2. **Explicit Allow** - If any policy explicitly allows access → **ALLOW**
|
||||
3. **Default Deny** - If no policy matches → **DENY**
|
||||
|
||||
### Policy Sources (Evaluated Together)
|
||||
|
||||
1. **Bucket Policies** - Stored per-bucket, highest priority
|
||||
2. **User Policies** - Converted from `identities.json` + new IAM policies
|
||||
3. **Legacy IAM** - For backward compatibility (lowest priority)
|
||||
|
||||
## Examples
|
||||
|
||||
### Public Read Bucket
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "PublicRead",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::public-bucket/*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### IP-Restricted Bucket
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": "arn:aws:s3:::secure-bucket/*",
|
||||
"Condition": {
|
||||
"IpAddress": {
|
||||
"aws:SourceIp": "192.168.1.0/24"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### SSL-Only Access
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": "s3:*",
|
||||
"Resource": ["arn:aws:s3:::ssl-bucket/*", "arn:aws:s3:::ssl-bucket"],
|
||||
"Condition": {
|
||||
"Bool": {
|
||||
"aws:SecureTransport": "false"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
### For Existing SeaweedFS Users
|
||||
|
||||
1. **No changes required** - Your existing setup continues to work
|
||||
2. **Optional enhancement** - Add bucket policies for fine-grained control
|
||||
3. **Gradual migration** - Move to full AWS policies over time
|
||||
|
||||
### For New Users
|
||||
|
||||
1. Start with either `identities.json` or AWS-style policies
|
||||
2. Use bucket policies for complex access patterns
|
||||
3. Full feature parity with AWS S3 policies
|
||||
|
||||
## Testing
|
||||
|
||||
Run the policy engine tests:
|
||||
|
||||
```bash
|
||||
# Core policy tests
|
||||
go test -v -run TestPolicyEngine
|
||||
|
||||
# Condition evaluator tests
|
||||
go test -v -run TestConditionEvaluators
|
||||
|
||||
# Legacy compatibility tests
|
||||
go test -v -run TestConvertIdentityToPolicy
|
||||
|
||||
# Validation tests
|
||||
go test -v -run TestPolicyValidation
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
- **Compiled patterns** - Regex patterns are pre-compiled for fast matching
|
||||
- **Cached policies** - Policies are cached in memory with TTL
|
||||
- **Early termination** - Evaluation stops on first explicit deny
|
||||
- **Minimal overhead** - Backward compatibility with minimal performance impact
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Backward Compatible (Current)
|
||||
- Keep existing `identities.json` unchanged
|
||||
- Add bucket policies as needed
|
||||
- Legacy actions automatically converted to AWS policies
|
||||
|
||||
### Phase 2: Enhanced (Optional)
|
||||
- Add advanced conditions to policies
|
||||
- Use full AWS S3 policy features
|
||||
- Maintain backward compatibility
|
||||
|
||||
### Phase 3: Full Migration (Future)
|
||||
- Migrate to pure IAM policies
|
||||
- Use AWS CLI/SDK for policy management
|
||||
- Complete AWS S3 feature parity
|
||||
|
||||
## Compatibility
|
||||
|
||||
- Full backward compatibility with existing `identities.json`
|
||||
- AWS S3 API compatibility for bucket policies
|
||||
- Standard condition operators and keys
|
||||
- Proper evaluation precedence (Deny > Allow > Default Deny)
|
||||
- Performance optimized with caching and compiled patterns
|
||||
|
||||
The policy engine provides a seamless upgrade path from SeaweedFS's existing simple IAM system to full AWS S3-compatible policies, giving you the best of both worlds: simplicity for basic use cases and power for complex enterprise scenarios.
|
||||
|
||||
## Feature Status
|
||||
|
||||
### Implemented
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Bucket Policies | Full AWS S3-compatible bucket policies |
|
||||
| Condition Operators | StringEquals, IpAddress, Bool, DateGreaterThan, etc. |
|
||||
| `aws:SourceIp` | IP-based access control with CIDR support |
|
||||
| `aws:SecureTransport` | Require HTTPS |
|
||||
| `aws:CurrentTime` | Time-based access control |
|
||||
| `s3:ExistingObjectTag/<key>` | Tag-based access control for existing objects |
|
||||
| Wildcard Patterns | Support for `*` and `?` in actions and resources |
|
||||
| Principal Matching | `*`, account IDs, and user ARNs |
|
||||
|
||||
### Planned
|
||||
|
||||
| Feature | GitHub Issue |
|
||||
|---------|--------------|
|
||||
| `s3:RequestObjectTag/<key>` | For tag conditions on PUT requests |
|
||||
| `s3:RequestObjectTagKeys` | Check which tag keys are in request |
|
||||
| `s3:x-amz-content-sha256` | Content hash condition |
|
||||
| `s3:x-amz-server-side-encryption` | SSE condition |
|
||||
| `s3:x-amz-storage-class` | Storage class condition |
|
||||
| Cross-account access | Access across different accounts |
|
||||
| VPC Endpoint policies | Network-level policies |
|
||||
|
||||
For feature requests or to track progress, see the [GitHub Issues](https://github.com/seaweedfs/seaweedfs/issues).
|
||||
90
weed/s3api/policy_engine/bucket_policy.go
Normal file
90
weed/s3api/policy_engine/bucket_policy.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxBucketPolicySize mirrors AWS S3's 20 KB bucket-policy limit, enforced
|
||||
// by both writers (the S3 gateway's PutBucketPolicy and the admin UI) so
|
||||
// neither surface can store a document the other refuses to manage.
|
||||
const MaxBucketPolicySize = 20 * 1024
|
||||
|
||||
// ValidateBucketPolicy performs bucket-specific policy validation, on top of
|
||||
// the generic structural checks in ValidatePolicy - callers run that first,
|
||||
// so the version and non-empty-statement rules are not re-checked here. It
|
||||
// enforces the rules that make a policy document valid as an S3 *bucket*
|
||||
// policy specifically: every statement must name a Principal, and every
|
||||
// Resource/NotResource/Action must scope to the given bucket.
|
||||
//
|
||||
// This is shared between the S3 gateway's PutBucketPolicy handler
|
||||
// (weed/s3api/s3api_bucket_policy_handlers.go) and the admin UI
|
||||
// (weed/admin/dash) so both enforce identical rules.
|
||||
func ValidateBucketPolicy(policyDoc *PolicyDocument, bucket string) error {
|
||||
for i, statement := range policyDoc.Statement {
|
||||
// Bucket policies must have Principal
|
||||
if statement.Principal == nil {
|
||||
return fmt.Errorf("statement %d: bucket policies must specify a Principal", i)
|
||||
}
|
||||
|
||||
// Validate resources refer to this bucket
|
||||
for _, resource := range statement.Resource.Strings() {
|
||||
if !ResourceMatchesBucket(resource, bucket) {
|
||||
return fmt.Errorf("statement %d: resource %s does not match bucket %s", i, resource, bucket)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate NotResources refer to this bucket
|
||||
if statement.NotResource != nil {
|
||||
for _, notResource := range statement.NotResource.Strings() {
|
||||
if !ResourceMatchesBucket(notResource, bucket) {
|
||||
return fmt.Errorf("statement %d: NotResource %s does not match bucket %s", i, notResource, bucket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate actions are S3 actions
|
||||
for _, action := range statement.Action.Strings() {
|
||||
if !strings.HasPrefix(action, "s3:") {
|
||||
return fmt.Errorf("statement %d: bucket policies only support S3 actions, got %s", i, action)
|
||||
}
|
||||
}
|
||||
|
||||
for operator := range statement.Condition {
|
||||
if _, err := GetConditionEvaluator(operator); err != nil {
|
||||
return fmt.Errorf("statement %d: unsupported condition operator %q: %v", i, operator, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResourceMatchesBucket checks if a resource ARN is valid for the given bucket.
|
||||
func ResourceMatchesBucket(resource, bucket string) bool {
|
||||
// Accepted formats for S3 bucket policies:
|
||||
// AWS-style ARNs (standard):
|
||||
// arn:aws:s3:::bucket-name
|
||||
// arn:aws:s3:::bucket-name/*
|
||||
// arn:aws:s3:::bucket-name/path/to/object
|
||||
// Simplified formats (for convenience):
|
||||
// bucket-name
|
||||
// bucket-name/*
|
||||
// bucket-name/path/to/object
|
||||
|
||||
var resourcePath string
|
||||
const awsPrefix = "arn:aws:s3:::"
|
||||
|
||||
// Strip the optional ARN prefix to get the resource path
|
||||
if path, ok := strings.CutPrefix(resource, awsPrefix); ok {
|
||||
resourcePath = path
|
||||
} else {
|
||||
resourcePath = resource
|
||||
}
|
||||
|
||||
// After stripping the optional ARN prefix, the resource path must
|
||||
// either match the bucket name exactly, or be a path within the bucket.
|
||||
return resourcePath == bucket ||
|
||||
resourcePath == bucket+"/*" ||
|
||||
strings.HasPrefix(resourcePath, bucket+"/")
|
||||
}
|
||||
108
weed/s3api/policy_engine/bucket_policy_test.go
Normal file
108
weed/s3api/policy_engine/bucket_policy_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package policy_engine
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResourceMatchesBucket(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resource string
|
||||
bucket string
|
||||
want bool
|
||||
}{
|
||||
{"bare bucket name", "my-bucket", "my-bucket", true},
|
||||
{"bare wildcard", "my-bucket/*", "my-bucket", true},
|
||||
{"bare object key", "my-bucket/path/to/key", "my-bucket", true},
|
||||
{"arn bucket", "arn:aws:s3:::my-bucket", "my-bucket", true},
|
||||
{"arn wildcard", "arn:aws:s3:::my-bucket/*", "my-bucket", true},
|
||||
{"arn object key", "arn:aws:s3:::my-bucket/path/to/key", "my-bucket", true},
|
||||
{"wrong bucket", "other-bucket", "my-bucket", false},
|
||||
{"wrong bucket arn", "arn:aws:s3:::other-bucket/*", "my-bucket", false},
|
||||
{"prefix collision", "my-bucket2", "my-bucket", false},
|
||||
{"prefix collision arn", "arn:aws:s3:::my-bucket2/*", "my-bucket", false},
|
||||
{"empty resource", "", "my-bucket", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ResourceMatchesBucket(tt.resource, tt.bucket); got != tt.want {
|
||||
t.Errorf("ResourceMatchesBucket(%q, %q) = %v, want %v", tt.resource, tt.bucket, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func simpleAwsPrincipal() *PolicyPrincipal {
|
||||
return NewPolicyPrincipalPtr("*")
|
||||
}
|
||||
|
||||
func TestValidateBucketPolicy(t *testing.T) {
|
||||
bucket := "my-bucket"
|
||||
|
||||
validStatement := func() PolicyStatement {
|
||||
return PolicyStatement{
|
||||
Effect: PolicyEffectAllow,
|
||||
Principal: simpleAwsPrincipal(),
|
||||
Action: NewStringOrStringSlice("s3:GetObject"),
|
||||
Resource: NewStringOrStringSlicePtr("arn:aws:s3:::" + bucket + "/*"),
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("valid policy", func(t *testing.T) {
|
||||
doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{validStatement()}}
|
||||
if err := ValidateBucketPolicy(doc, bucket); err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bad version", func(t *testing.T) {
|
||||
// Version and non-empty-statement rules live in ValidatePolicy,
|
||||
// which callers run first.
|
||||
doc := &PolicyDocument{Version: "2008-10-17", Statement: []PolicyStatement{validStatement()}}
|
||||
if err := ValidatePolicy(doc); err == nil {
|
||||
t.Error("expected error for bad version")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero statements", func(t *testing.T) {
|
||||
doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{}}
|
||||
if err := ValidatePolicy(doc); err == nil {
|
||||
t.Error("expected error for zero statements")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing principal", func(t *testing.T) {
|
||||
stmt := validStatement()
|
||||
stmt.Principal = nil
|
||||
doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{stmt}}
|
||||
if err := ValidateBucketPolicy(doc, bucket); err == nil {
|
||||
t.Error("expected error for missing principal")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("foreign resource", func(t *testing.T) {
|
||||
stmt := validStatement()
|
||||
stmt.Resource = NewStringOrStringSlicePtr("arn:aws:s3:::other-bucket/*")
|
||||
doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{stmt}}
|
||||
if err := ValidateBucketPolicy(doc, bucket); err == nil {
|
||||
t.Error("expected error for foreign resource")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("foreign not-resource", func(t *testing.T) {
|
||||
stmt := validStatement()
|
||||
stmt.Resource = nil
|
||||
stmt.NotResource = NewStringOrStringSlicePtr("arn:aws:s3:::other-bucket/*")
|
||||
doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{stmt}}
|
||||
if err := ValidateBucketPolicy(doc, bucket); err == nil {
|
||||
t.Error("expected error for foreign NotResource")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-s3 action", func(t *testing.T) {
|
||||
stmt := validStatement()
|
||||
stmt.Action = NewStringOrStringSlice("iam:CreateUser")
|
||||
doc := &PolicyDocument{Version: PolicyVersion2012_10_17, Statement: []PolicyStatement{stmt}}
|
||||
if err := ValidateBucketPolicy(doc, bucket); err == nil {
|
||||
t.Error("expected error for non-s3 action")
|
||||
}
|
||||
})
|
||||
}
|
||||
789
weed/s3api/policy_engine/conditions.go
Normal file
789
weed/s3api/policy_engine/conditions.go
Normal file
@@ -0,0 +1,789 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
)
|
||||
|
||||
// LRUNode represents a node in the doubly-linked list for efficient LRU operations
|
||||
type LRUNode struct {
|
||||
key string
|
||||
value []string
|
||||
prev *LRUNode
|
||||
next *LRUNode
|
||||
}
|
||||
|
||||
// NormalizedValueCache provides size-limited caching for normalized values with efficient LRU eviction
|
||||
type NormalizedValueCache struct {
|
||||
mu sync.RWMutex
|
||||
cache map[string]*LRUNode
|
||||
maxSize int
|
||||
head *LRUNode // Most recently used
|
||||
tail *LRUNode // Least recently used
|
||||
}
|
||||
|
||||
// NewNormalizedValueCache creates a new normalized value cache with configurable size
|
||||
func NewNormalizedValueCache(maxSize int) *NormalizedValueCache {
|
||||
if maxSize <= 0 {
|
||||
maxSize = 1000 // Default size
|
||||
}
|
||||
|
||||
// Create dummy head and tail nodes for easier list manipulation
|
||||
head := &LRUNode{}
|
||||
tail := &LRUNode{}
|
||||
head.next = tail
|
||||
tail.prev = head
|
||||
|
||||
return &NormalizedValueCache{
|
||||
cache: make(map[string]*LRUNode),
|
||||
maxSize: maxSize,
|
||||
head: head,
|
||||
tail: tail,
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a cached value and updates access order in O(1) time
|
||||
func (c *NormalizedValueCache) Get(key string) ([]string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if node, exists := c.cache[key]; exists {
|
||||
// Move to head (most recently used) - O(1) operation
|
||||
c.moveToHead(node)
|
||||
return node.value, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Set stores a value in the cache with size limit enforcement in O(1) time
|
||||
func (c *NormalizedValueCache) Set(key string, value []string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if node, exists := c.cache[key]; exists {
|
||||
// Update existing node and move to head
|
||||
node.value = value
|
||||
c.moveToHead(node)
|
||||
return
|
||||
}
|
||||
|
||||
// Create new node
|
||||
newNode := &LRUNode{
|
||||
key: key,
|
||||
value: value,
|
||||
}
|
||||
|
||||
// If at max size, evict least recently used
|
||||
if len(c.cache) >= c.maxSize {
|
||||
c.evictLeastRecentlyUsed()
|
||||
}
|
||||
|
||||
// Add to cache and move to head
|
||||
c.cache[key] = newNode
|
||||
c.addToHead(newNode)
|
||||
}
|
||||
|
||||
// moveToHead moves a node to the head of the list (most recently used) - O(1)
|
||||
func (c *NormalizedValueCache) moveToHead(node *LRUNode) {
|
||||
c.removeNode(node)
|
||||
c.addToHead(node)
|
||||
}
|
||||
|
||||
// addToHead adds a node right after the head - O(1)
|
||||
func (c *NormalizedValueCache) addToHead(node *LRUNode) {
|
||||
node.prev = c.head
|
||||
node.next = c.head.next
|
||||
c.head.next.prev = node
|
||||
c.head.next = node
|
||||
}
|
||||
|
||||
// removeNode removes a node from the list - O(1)
|
||||
func (c *NormalizedValueCache) removeNode(node *LRUNode) {
|
||||
node.prev.next = node.next
|
||||
node.next.prev = node.prev
|
||||
}
|
||||
|
||||
// removeTail removes the last node before tail (least recently used) - O(1)
|
||||
func (c *NormalizedValueCache) removeTail() *LRUNode {
|
||||
lastNode := c.tail.prev
|
||||
c.removeNode(lastNode)
|
||||
return lastNode
|
||||
}
|
||||
|
||||
// evictLeastRecentlyUsed removes the least recently used item in O(1) time
|
||||
func (c *NormalizedValueCache) evictLeastRecentlyUsed() {
|
||||
tail := c.removeTail()
|
||||
delete(c.cache, tail.key)
|
||||
}
|
||||
|
||||
// Global cache instance with size limit
|
||||
var normalizedValueCache = NewNormalizedValueCache(1000)
|
||||
|
||||
// getCachedNormalizedValues returns cached normalized values or caches new ones
|
||||
func getCachedNormalizedValues(value interface{}) []string {
|
||||
// Create a string key for caching - more efficient than fmt.Sprintf
|
||||
typeStr := reflect.TypeOf(value).String()
|
||||
cacheKey := typeStr + ":" + fmt.Sprint(value)
|
||||
|
||||
// Try to get from cache
|
||||
if cached, exists := normalizedValueCache.Get(cacheKey); exists {
|
||||
return cached
|
||||
}
|
||||
|
||||
// Not in cache, normalize and store
|
||||
// Use the error-handling version for better error reporting
|
||||
normalized, err := normalizeToStringSliceWithError(value)
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to normalize policy value %v: %v", value, err)
|
||||
// Fallback to string conversion for backward compatibility
|
||||
normalized = []string{fmt.Sprintf("%v", value)}
|
||||
}
|
||||
|
||||
normalizedValueCache.Set(cacheKey, normalized)
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
// ConditionEvaluator evaluates policy conditions
|
||||
type ConditionEvaluator interface {
|
||||
Evaluate(conditionValue interface{}, contextValues []string) bool
|
||||
}
|
||||
|
||||
// StringEqualsEvaluator evaluates StringEquals conditions
|
||||
type StringEqualsEvaluator struct{}
|
||||
|
||||
func (e *StringEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
for _, contextValue := range contextValues {
|
||||
if expected == contextValue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StringNotEqualsEvaluator evaluates StringNotEquals conditions
|
||||
type StringNotEqualsEvaluator struct{}
|
||||
|
||||
func (e *StringNotEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
for _, contextValue := range contextValues {
|
||||
if expected == contextValue {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// StringLikeEvaluator evaluates StringLike conditions (supports wildcards)
|
||||
type StringLikeEvaluator struct{}
|
||||
|
||||
func (e *StringLikeEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
patterns := getCachedNormalizedValues(conditionValue)
|
||||
for _, pattern := range patterns {
|
||||
for _, contextValue := range contextValues {
|
||||
if wildcard.MatchesWildcard(pattern, contextValue) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StringNotLikeEvaluator evaluates StringNotLike conditions
|
||||
type StringNotLikeEvaluator struct{}
|
||||
|
||||
func (e *StringNotLikeEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
patterns := getCachedNormalizedValues(conditionValue)
|
||||
for _, pattern := range patterns {
|
||||
for _, contextValue := range contextValues {
|
||||
if wildcard.MatchesWildcard(pattern, contextValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// StringEqualsIgnoreCaseEvaluator evaluates StringEqualsIgnoreCase conditions
|
||||
type StringEqualsIgnoreCaseEvaluator struct{}
|
||||
|
||||
func (e *StringEqualsIgnoreCaseEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
for _, contextValue := range contextValues {
|
||||
if strings.EqualFold(expected, contextValue) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StringNotEqualsIgnoreCaseEvaluator evaluates StringNotEqualsIgnoreCase conditions
|
||||
type StringNotEqualsIgnoreCaseEvaluator struct{}
|
||||
|
||||
func (e *StringNotEqualsIgnoreCaseEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
for _, contextValue := range contextValues {
|
||||
if strings.EqualFold(expected, contextValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// NumericEqualsEvaluator evaluates NumericEquals conditions
|
||||
type NumericEqualsEvaluator struct{}
|
||||
|
||||
func (e *NumericEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
expectedFloat, err := strconv.ParseFloat(expected, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, contextValue := range contextValues {
|
||||
contextFloat, err := strconv.ParseFloat(contextValue, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if expectedFloat == contextFloat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NumericNotEqualsEvaluator evaluates NumericNotEquals conditions
|
||||
type NumericNotEqualsEvaluator struct{}
|
||||
|
||||
func (e *NumericNotEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
expectedFloat, err := strconv.ParseFloat(expected, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, contextValue := range contextValues {
|
||||
contextFloat, err := strconv.ParseFloat(contextValue, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if expectedFloat == contextFloat {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// NumericLessThanEvaluator evaluates NumericLessThan conditions
|
||||
type NumericLessThanEvaluator struct{}
|
||||
|
||||
func (e *NumericLessThanEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
expectedFloat, err := strconv.ParseFloat(expected, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, contextValue := range contextValues {
|
||||
contextFloat, err := strconv.ParseFloat(contextValue, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if contextFloat < expectedFloat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NumericLessThanEqualsEvaluator evaluates NumericLessThanEquals conditions
|
||||
type NumericLessThanEqualsEvaluator struct{}
|
||||
|
||||
func (e *NumericLessThanEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
expectedFloat, err := strconv.ParseFloat(expected, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, contextValue := range contextValues {
|
||||
contextFloat, err := strconv.ParseFloat(contextValue, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if contextFloat <= expectedFloat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NumericGreaterThanEvaluator evaluates NumericGreaterThan conditions
|
||||
type NumericGreaterThanEvaluator struct{}
|
||||
|
||||
func (e *NumericGreaterThanEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
expectedFloat, err := strconv.ParseFloat(expected, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, contextValue := range contextValues {
|
||||
contextFloat, err := strconv.ParseFloat(contextValue, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if contextFloat > expectedFloat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NumericGreaterThanEqualsEvaluator evaluates NumericGreaterThanEquals conditions
|
||||
type NumericGreaterThanEqualsEvaluator struct{}
|
||||
|
||||
func (e *NumericGreaterThanEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
expectedFloat, err := strconv.ParseFloat(expected, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, contextValue := range contextValues {
|
||||
contextFloat, err := strconv.ParseFloat(contextValue, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if contextFloat >= expectedFloat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DateEqualsEvaluator evaluates DateEquals conditions
|
||||
type DateEqualsEvaluator struct{}
|
||||
|
||||
func (e *DateEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
expectedTime, err := time.Parse(time.RFC3339, expected)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, contextValue := range contextValues {
|
||||
contextTime, err := time.Parse(time.RFC3339, contextValue)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if expectedTime.Equal(contextTime) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DateNotEqualsEvaluator evaluates DateNotEquals conditions
|
||||
type DateNotEqualsEvaluator struct{}
|
||||
|
||||
func (e *DateNotEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
expectedTime, err := time.Parse(time.RFC3339, expected)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, contextValue := range contextValues {
|
||||
contextTime, err := time.Parse(time.RFC3339, contextValue)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if expectedTime.Equal(contextTime) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// DateLessThanEvaluator evaluates DateLessThan conditions
|
||||
type DateLessThanEvaluator struct{}
|
||||
|
||||
func (e *DateLessThanEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
expectedTime, err := time.Parse(time.RFC3339, expected)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, contextValue := range contextValues {
|
||||
contextTime, err := time.Parse(time.RFC3339, contextValue)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if contextTime.Before(expectedTime) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DateLessThanEqualsEvaluator evaluates DateLessThanEquals conditions
|
||||
type DateLessThanEqualsEvaluator struct{}
|
||||
|
||||
func (e *DateLessThanEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
expectedTime, err := time.Parse(time.RFC3339, expected)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, contextValue := range contextValues {
|
||||
contextTime, err := time.Parse(time.RFC3339, contextValue)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if contextTime.Before(expectedTime) || contextTime.Equal(expectedTime) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DateGreaterThanEvaluator evaluates DateGreaterThan conditions
|
||||
type DateGreaterThanEvaluator struct{}
|
||||
|
||||
func (e *DateGreaterThanEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
expectedTime, err := time.Parse(time.RFC3339, expected)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, contextValue := range contextValues {
|
||||
contextTime, err := time.Parse(time.RFC3339, contextValue)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if contextTime.After(expectedTime) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DateGreaterThanEqualsEvaluator evaluates DateGreaterThanEquals conditions
|
||||
type DateGreaterThanEqualsEvaluator struct{}
|
||||
|
||||
func (e *DateGreaterThanEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
expectedTime, err := time.Parse(time.RFC3339, expected)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, contextValue := range contextValues {
|
||||
contextTime, err := time.Parse(time.RFC3339, contextValue)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if contextTime.After(expectedTime) || contextTime.Equal(expectedTime) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BoolEvaluator evaluates Bool conditions
|
||||
type BoolEvaluator struct{}
|
||||
|
||||
func (e *BoolEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
for _, contextValue := range contextValues {
|
||||
if strings.ToLower(expected) == strings.ToLower(contextValue) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IpAddressEvaluator evaluates IpAddress conditions
|
||||
type IpAddressEvaluator struct{}
|
||||
|
||||
func (e *IpAddressEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
_, expectedNet, err := net.ParseCIDR(expected)
|
||||
if err != nil {
|
||||
// Try parsing as single IP
|
||||
expectedIP := net.ParseIP(expected)
|
||||
if expectedIP == nil {
|
||||
glog.V(3).Infof("Failed to parse expected IP address: %s", expected)
|
||||
continue
|
||||
}
|
||||
for _, contextValue := range contextValues {
|
||||
contextIP := net.ParseIP(contextValue)
|
||||
if contextIP == nil {
|
||||
glog.V(3).Infof("Failed to parse IP address: %s", contextValue)
|
||||
continue
|
||||
}
|
||||
if contextIP.Equal(expectedIP) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// CIDR network
|
||||
for _, contextValue := range contextValues {
|
||||
contextIP := net.ParseIP(contextValue)
|
||||
if contextIP == nil {
|
||||
glog.V(3).Infof("Failed to parse IP address: %s", contextValue)
|
||||
continue
|
||||
}
|
||||
if expectedNet.Contains(contextIP) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NotIpAddressEvaluator evaluates NotIpAddress conditions
|
||||
type NotIpAddressEvaluator struct{}
|
||||
|
||||
func (e *NotIpAddressEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
_, expectedNet, err := net.ParseCIDR(expected)
|
||||
if err != nil {
|
||||
// Try parsing as single IP
|
||||
expectedIP := net.ParseIP(expected)
|
||||
if expectedIP == nil {
|
||||
glog.V(3).Infof("Failed to parse expected IP address: %s", expected)
|
||||
continue
|
||||
}
|
||||
for _, contextValue := range contextValues {
|
||||
contextIP := net.ParseIP(contextValue)
|
||||
if contextIP == nil {
|
||||
glog.V(3).Infof("Failed to parse IP address: %s", contextValue)
|
||||
continue
|
||||
}
|
||||
if contextIP.Equal(expectedIP) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// CIDR network
|
||||
for _, contextValue := range contextValues {
|
||||
contextIP := net.ParseIP(contextValue)
|
||||
if contextIP == nil {
|
||||
glog.V(3).Infof("Failed to parse IP address: %s", contextValue)
|
||||
continue
|
||||
}
|
||||
if expectedNet.Contains(contextIP) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ArnEqualsEvaluator evaluates ArnEquals conditions
|
||||
type ArnEqualsEvaluator struct{}
|
||||
|
||||
func (e *ArnEqualsEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
for _, contextValue := range contextValues {
|
||||
if expected == contextValue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ArnLikeEvaluator evaluates ArnLike conditions
|
||||
type ArnLikeEvaluator struct{}
|
||||
|
||||
func (e *ArnLikeEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
patterns := getCachedNormalizedValues(conditionValue)
|
||||
for _, pattern := range patterns {
|
||||
for _, contextValue := range contextValues {
|
||||
if wildcard.MatchesWildcard(pattern, contextValue) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NullEvaluator evaluates Null conditions
|
||||
type NullEvaluator struct{}
|
||||
|
||||
func (e *NullEvaluator) Evaluate(conditionValue interface{}, contextValues []string) bool {
|
||||
expectedValues := getCachedNormalizedValues(conditionValue)
|
||||
for _, expected := range expectedValues {
|
||||
expectedBool := strings.ToLower(expected) == "true"
|
||||
contextExists := len(contextValues) > 0
|
||||
if expectedBool && !contextExists {
|
||||
return true // Key should be null and it is
|
||||
}
|
||||
if !expectedBool && contextExists {
|
||||
return true // Key should not be null and it isn't
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetConditionEvaluator returns the appropriate evaluator for a condition operator
|
||||
func GetConditionEvaluator(operator string) (ConditionEvaluator, error) {
|
||||
switch operator {
|
||||
case "StringEquals":
|
||||
return &StringEqualsEvaluator{}, nil
|
||||
case "StringNotEquals":
|
||||
return &StringNotEqualsEvaluator{}, nil
|
||||
case "StringLike":
|
||||
return &StringLikeEvaluator{}, nil
|
||||
case "StringNotLike":
|
||||
return &StringNotLikeEvaluator{}, nil
|
||||
case "StringEqualsIgnoreCase":
|
||||
return &StringEqualsIgnoreCaseEvaluator{}, nil
|
||||
case "StringNotEqualsIgnoreCase":
|
||||
return &StringNotEqualsIgnoreCaseEvaluator{}, nil
|
||||
case "NumericEquals":
|
||||
return &NumericEqualsEvaluator{}, nil
|
||||
case "NumericNotEquals":
|
||||
return &NumericNotEqualsEvaluator{}, nil
|
||||
case "NumericLessThan":
|
||||
return &NumericLessThanEvaluator{}, nil
|
||||
case "NumericLessThanEquals":
|
||||
return &NumericLessThanEqualsEvaluator{}, nil
|
||||
case "NumericGreaterThan":
|
||||
return &NumericGreaterThanEvaluator{}, nil
|
||||
case "NumericGreaterThanEquals":
|
||||
return &NumericGreaterThanEqualsEvaluator{}, nil
|
||||
case "DateEquals":
|
||||
return &DateEqualsEvaluator{}, nil
|
||||
case "DateNotEquals":
|
||||
return &DateNotEqualsEvaluator{}, nil
|
||||
case "DateLessThan":
|
||||
return &DateLessThanEvaluator{}, nil
|
||||
case "DateLessThanEquals":
|
||||
return &DateLessThanEqualsEvaluator{}, nil
|
||||
case "DateGreaterThan":
|
||||
return &DateGreaterThanEvaluator{}, nil
|
||||
case "DateGreaterThanEquals":
|
||||
return &DateGreaterThanEqualsEvaluator{}, nil
|
||||
case "Bool":
|
||||
return &BoolEvaluator{}, nil
|
||||
case "IpAddress":
|
||||
return &IpAddressEvaluator{}, nil
|
||||
case "NotIpAddress":
|
||||
return &NotIpAddressEvaluator{}, nil
|
||||
case "ArnEquals":
|
||||
return &ArnEqualsEvaluator{}, nil
|
||||
case "ArnLike":
|
||||
return &ArnLikeEvaluator{}, nil
|
||||
case "Null":
|
||||
return &NullEvaluator{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported condition operator: %s", operator)
|
||||
}
|
||||
}
|
||||
|
||||
// ExistingObjectTagPrefix is the prefix for S3 policy condition keys
|
||||
const ExistingObjectTagPrefix = "s3:ExistingObjectTag/"
|
||||
|
||||
// getConditionContextValue resolves the value(s) for a condition key.
|
||||
// For s3:ExistingObjectTag/<key> conditions, it looks up the tag in objectEntry.
|
||||
// For other condition keys, it looks up the value in contextValues.
|
||||
func getConditionContextValue(key string, contextValues map[string][]string, objectEntry map[string][]byte) []string {
|
||||
if strings.HasPrefix(key, ExistingObjectTagPrefix) {
|
||||
tagKey := key[len(ExistingObjectTagPrefix):]
|
||||
if tagKey == "" {
|
||||
return []string{} // Invalid: empty tag key
|
||||
}
|
||||
metadataKey := s3_constants.AmzObjectTaggingPrefix + tagKey
|
||||
if objectEntry != nil {
|
||||
if tagValue, exists := objectEntry[metadataKey]; exists {
|
||||
return []string{string(tagValue)}
|
||||
}
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
if vals, exists := contextValues[key]; exists {
|
||||
return vals
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// EvaluateConditions evaluates all conditions in a policy statement
|
||||
// objectEntry is the object's metadata from entry.Extended (can be nil)
|
||||
// claims are JWT claims for jwt:* policy variables (can be nil)
|
||||
func EvaluateConditions(conditions PolicyConditions, contextValues map[string][]string, objectEntry map[string][]byte, claims map[string]interface{}) bool {
|
||||
if len(conditions) == 0 {
|
||||
return true // No conditions means always true
|
||||
}
|
||||
|
||||
for operator, conditionMap := range conditions {
|
||||
conditionEvaluator, err := GetConditionEvaluator(operator)
|
||||
if err != nil {
|
||||
glog.Warningf("Unsupported condition operator: %s", operator)
|
||||
return false
|
||||
}
|
||||
|
||||
for key, value := range conditionMap {
|
||||
contextVals := getConditionContextValue(key, contextValues, objectEntry)
|
||||
|
||||
// Substitute variables in expected values
|
||||
expectedValues := value.Strings()
|
||||
substitutedValues := make([]string, len(expectedValues))
|
||||
for i, v := range expectedValues {
|
||||
substitutedValues[i] = SubstituteVariables(v, contextValues, claims)
|
||||
}
|
||||
|
||||
// Pass substituted values (casted to interface{} to match signature if needed, or update evaluators to accept []string)
|
||||
// The evaluators take interface{}, but getCachedNormalizedValues handles []string.
|
||||
if !conditionEvaluator.Evaluate(substitutedValues, contextVals) {
|
||||
return false // If any condition fails, the whole condition block fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
18
weed/s3api/policy_engine/conditions_failclosed_test.go
Normal file
18
weed/s3api/policy_engine/conditions_failclosed_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvaluateConditionsFailsClosedOnUnsupportedOperator(t *testing.T) {
|
||||
conditions := PolicyConditions{
|
||||
"StringEqualsBogus": {
|
||||
"aws:username": NewStringOrStringSlice("alice"),
|
||||
},
|
||||
}
|
||||
contextValues := map[string][]string{}
|
||||
got := EvaluateConditions(conditions, contextValues, nil, nil)
|
||||
if got {
|
||||
t.Fatalf("EvaluateConditions returned true for unsupported operator; expected false (fail closed)")
|
||||
}
|
||||
}
|
||||
41
weed/s3api/policy_engine/conditions_ignorecase_test.go
Normal file
41
weed/s3api/policy_engine/conditions_ignorecase_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConditionEvaluatorsIgnoreCase(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
operator string
|
||||
conditionValue interface{}
|
||||
contextValues []string
|
||||
expected bool
|
||||
}{
|
||||
{"StringEqualsIgnoreCase - match", "StringEqualsIgnoreCase", "ALICE", []string{"alice"}, true},
|
||||
{"StringEqualsIgnoreCase - no match", "StringEqualsIgnoreCase", "alice", []string{"bob"}, false},
|
||||
{"StringNotEqualsIgnoreCase - match", "StringNotEqualsIgnoreCase", "alice", []string{"bob"}, true},
|
||||
{"StringNotEqualsIgnoreCase - no match", "StringNotEqualsIgnoreCase", "ALICE", []string{"alice"}, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
evaluator, err := GetConditionEvaluator(tt.operator)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get condition evaluator: %v", err)
|
||||
}
|
||||
result := evaluator.Evaluate(tt.conditionValue, tt.contextValues)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConditionEvaluatorRejectsNonAWSIgnoreCaseOperators(t *testing.T) {
|
||||
for _, op := range []string{"StringLikeIgnoreCase", "StringNotLikeIgnoreCase"} {
|
||||
if _, err := GetConditionEvaluator(op); err == nil {
|
||||
t.Fatalf("GetConditionEvaluator accepted non-AWS operator %q; expected error", op)
|
||||
}
|
||||
}
|
||||
}
|
||||
74
weed/s3api/policy_engine/conditions_validation_test.go
Normal file
74
weed/s3api/policy_engine/conditions_validation_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateBucketPolicyRejectsUnsupportedConditionOperator(t *testing.T) {
|
||||
unsupported := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::test-bucket/*",
|
||||
"Condition": {"StringEqualsBogus": {"aws:username": "alice"}}
|
||||
}]
|
||||
}`
|
||||
supportedIgnoreCase := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::test-bucket/*",
|
||||
"Condition": {"StringEqualsIgnoreCase": {"aws:username": "alice"}}
|
||||
}]
|
||||
}`
|
||||
supportedStringEquals := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::test-bucket/*",
|
||||
"Condition": {"StringEquals": {"aws:username": "alice"}}
|
||||
}]
|
||||
}`
|
||||
|
||||
t.Run("upload rejects unsupported operator", func(t *testing.T) {
|
||||
policy, err := ParsePolicy(unsupported)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePolicy failed: %v", err)
|
||||
}
|
||||
if err := ValidateBucketPolicy(policy, "test-bucket"); err == nil {
|
||||
t.Fatalf("ValidateBucketPolicy expected error for unsupported operator, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload accepts supported IgnoreCase operator", func(t *testing.T) {
|
||||
policy, err := ParsePolicy(supportedIgnoreCase)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePolicy failed: %v", err)
|
||||
}
|
||||
if err := ValidateBucketPolicy(policy, "test-bucket"); err != nil {
|
||||
t.Fatalf("ValidateBucketPolicy unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload accepts supported StringEquals operator", func(t *testing.T) {
|
||||
policy, err := ParsePolicy(supportedStringEquals)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePolicy failed: %v", err)
|
||||
}
|
||||
if err := ValidateBucketPolicy(policy, "test-bucket"); err != nil {
|
||||
t.Fatalf("ValidateBucketPolicy unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("load tolerates legacy unsupported operator", func(t *testing.T) {
|
||||
if _, err := ParsePolicy(unsupported); err != nil {
|
||||
t.Fatalf("ParsePolicy must not reject legacy unsupported operator at load time: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
668
weed/s3api/policy_engine/engine.go
Normal file
668
weed/s3api/policy_engine/engine.go
Normal file
@@ -0,0 +1,668 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
)
|
||||
|
||||
// PolicyEvaluationResult represents the result of policy evaluation
|
||||
type PolicyEvaluationResult int
|
||||
|
||||
const (
|
||||
PolicyResultDeny PolicyEvaluationResult = iota
|
||||
PolicyResultAllow
|
||||
PolicyResultIndeterminate
|
||||
)
|
||||
|
||||
// PolicyEvaluationContext manages policy evaluation for a bucket
|
||||
type PolicyEvaluationContext struct {
|
||||
bucketName string
|
||||
policy *CompiledPolicy
|
||||
cache *PolicyCache
|
||||
}
|
||||
|
||||
// PolicyEngine is the main policy evaluation engine
|
||||
type PolicyEngine struct {
|
||||
contexts map[string]*PolicyEvaluationContext
|
||||
mutex sync.RWMutex
|
||||
trustedProxies atomic.Pointer[TrustedProxies]
|
||||
}
|
||||
|
||||
// NewPolicyEngine creates a new policy evaluation engine
|
||||
func NewPolicyEngine() *PolicyEngine {
|
||||
return &PolicyEngine{
|
||||
contexts: make(map[string]*PolicyEvaluationContext),
|
||||
}
|
||||
}
|
||||
|
||||
// SetTrustedProxies configures the allowlist used to decide whether
|
||||
// forwarded headers are honored when extracting aws:SourceIp.
|
||||
func (engine *PolicyEngine) SetTrustedProxies(tp *TrustedProxies) {
|
||||
engine.trustedProxies.Store(tp)
|
||||
}
|
||||
|
||||
// SetBucketPolicy sets the policy for a bucket
|
||||
func (engine *PolicyEngine) SetBucketPolicy(bucketName string, policyJSON string) error {
|
||||
policy, err := ParsePolicy(policyJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid policy: %w", err)
|
||||
}
|
||||
|
||||
compiled, err := CompilePolicy(policy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile policy: %w", err)
|
||||
}
|
||||
|
||||
engine.mutex.Lock()
|
||||
defer engine.mutex.Unlock()
|
||||
|
||||
context := &PolicyEvaluationContext{
|
||||
bucketName: bucketName,
|
||||
policy: compiled,
|
||||
cache: NewPolicyCache(),
|
||||
}
|
||||
|
||||
engine.contexts[bucketName] = context
|
||||
glog.V(4).Infof("SetBucketPolicy: Successfully cached policy for bucket=%s, statements=%d", bucketName, len(compiled.Statements))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBucketPolicy gets the policy for a bucket
|
||||
func (engine *PolicyEngine) GetBucketPolicy(bucketName string) (*PolicyDocument, error) {
|
||||
engine.mutex.RLock()
|
||||
defer engine.mutex.RUnlock()
|
||||
|
||||
context, exists := engine.contexts[bucketName]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("no policy found for bucket %s", bucketName)
|
||||
}
|
||||
|
||||
return context.policy.Document, nil
|
||||
}
|
||||
|
||||
// DeleteBucketPolicy deletes the policy for a bucket
|
||||
func (engine *PolicyEngine) DeleteBucketPolicy(bucketName string) error {
|
||||
engine.mutex.Lock()
|
||||
defer engine.mutex.Unlock()
|
||||
|
||||
delete(engine.contexts, bucketName)
|
||||
glog.V(2).Infof("Deleted bucket policy for %s", bucketName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasPolicyForBucket checks if a bucket has a policy configured
|
||||
func (engine *PolicyEngine) HasPolicyForBucket(bucketName string) bool {
|
||||
engine.mutex.RLock()
|
||||
defer engine.mutex.RUnlock()
|
||||
_, exists := engine.contexts[bucketName]
|
||||
return exists
|
||||
}
|
||||
|
||||
// EvaluatePolicy evaluates a policy for the given arguments
|
||||
func (engine *PolicyEngine) EvaluatePolicy(bucketName string, args *PolicyEvaluationArgs) PolicyEvaluationResult {
|
||||
engine.mutex.RLock()
|
||||
context, exists := engine.contexts[bucketName]
|
||||
engine.mutex.RUnlock()
|
||||
|
||||
if !exists {
|
||||
glog.V(4).Infof("EvaluatePolicy: No policy found for bucket=%s (PolicyResultIndeterminate)", bucketName)
|
||||
return PolicyResultIndeterminate
|
||||
}
|
||||
|
||||
glog.V(4).Infof("EvaluatePolicy: Found policy for bucket=%s, evaluating with action=%s resource=%s principal=%s",
|
||||
bucketName, args.Action, args.Resource, args.Principal)
|
||||
return engine.evaluateCompiledPolicy(context.policy, args)
|
||||
}
|
||||
|
||||
// evaluateCompiledPolicy evaluates a compiled policy
|
||||
func (engine *PolicyEngine) evaluateCompiledPolicy(policy *CompiledPolicy, args *PolicyEvaluationArgs) PolicyEvaluationResult {
|
||||
// AWS Policy evaluation logic:
|
||||
// 1. Check for explicit Deny - if found, return Deny
|
||||
// 2. Check for explicit Allow - if found, return Allow
|
||||
// 3. If no matching statements, return Indeterminate (fall through to IAM)
|
||||
|
||||
hasExplicitAllow := false
|
||||
|
||||
for _, stmt := range policy.Statements {
|
||||
if engine.evaluateStatement(stmt, args) {
|
||||
if stmt.Statement.Effect == PolicyEffectDeny {
|
||||
return PolicyResultDeny // Explicit deny trumps everything
|
||||
}
|
||||
if stmt.Statement.Effect == PolicyEffectAllow {
|
||||
hasExplicitAllow = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasExplicitAllow {
|
||||
return PolicyResultAllow
|
||||
}
|
||||
|
||||
// No matching statements - return Indeterminate to fall through to IAM
|
||||
// This allows IAM policies to grant access even when bucket policy doesn't mention the action
|
||||
return PolicyResultIndeterminate
|
||||
}
|
||||
|
||||
// matchesDynamicPatterns checks if a value matches any of the dynamic patterns after variable substitution
|
||||
func (engine *PolicyEngine) matchesDynamicPatterns(patterns []string, value string, args *PolicyEvaluationArgs) bool {
|
||||
for _, pattern := range patterns {
|
||||
substituted := SubstituteVariables(pattern, args.Conditions, args.Claims)
|
||||
if FastMatchesWildcard(substituted, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// evaluateStatement evaluates a single policy statement
|
||||
func (engine *PolicyEngine) evaluateStatement(stmt *CompiledStatement, args *PolicyEvaluationArgs) bool {
|
||||
// Check if action matches
|
||||
matchedAction := engine.matchesPatterns(stmt.ActionPatterns, args.Action)
|
||||
if !matchedAction {
|
||||
matchedAction = engine.matchesDynamicPatterns(stmt.DynamicActionPatterns, args.Action, args)
|
||||
}
|
||||
// Multipart upload actions (CreateMultipartUpload, UploadPart, CompleteMultipartUpload, etc.)
|
||||
// are implicitly allowed by s3:PutObject, since multipart upload is an implementation
|
||||
// detail of putting objects. Check if this is a multipart action and the statement
|
||||
// grants s3:PutObject.
|
||||
if !matchedAction && multipartActionSet[args.Action] {
|
||||
matchedAction = engine.matchesPatterns(stmt.ActionPatterns, "s3:PutObject")
|
||||
if !matchedAction {
|
||||
matchedAction = engine.matchesDynamicPatterns(stmt.DynamicActionPatterns, "s3:PutObject", args)
|
||||
}
|
||||
}
|
||||
if !matchedAction {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if resource matches
|
||||
hasResource := len(stmt.ResourcePatterns) > 0 || len(stmt.DynamicResourcePatterns) > 0
|
||||
hasNotResource := len(stmt.NotResourcePatterns) > 0 || len(stmt.DynamicNotResourcePatterns) > 0
|
||||
if hasResource {
|
||||
matchedResource := engine.matchesPatterns(stmt.ResourcePatterns, args.Resource)
|
||||
if !matchedResource {
|
||||
matchedResource = engine.matchesDynamicPatterns(stmt.DynamicResourcePatterns, args.Resource, args)
|
||||
}
|
||||
if !matchedResource {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if hasNotResource {
|
||||
matchedNotResource := false
|
||||
for _, matcher := range stmt.NotResourceMatchers {
|
||||
if matcher.Match(args.Resource) {
|
||||
matchedNotResource = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !matchedNotResource {
|
||||
matchedNotResource = engine.matchesDynamicPatterns(stmt.DynamicNotResourcePatterns, args.Resource, args)
|
||||
}
|
||||
|
||||
if matchedNotResource {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check if principal matches
|
||||
if len(stmt.PrincipalPatterns) > 0 || len(stmt.DynamicPrincipalPatterns) > 0 {
|
||||
matchedPrincipal := engine.matchesPatterns(stmt.PrincipalPatterns, args.Principal)
|
||||
if !matchedPrincipal {
|
||||
matchedPrincipal = engine.matchesDynamicPatterns(stmt.DynamicPrincipalPatterns, args.Principal, args)
|
||||
}
|
||||
if !matchedPrincipal {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check NotPrincipal (statement applies to everyone EXCEPT these principals)
|
||||
if len(stmt.NotPrincipalPatterns) > 0 || len(stmt.DynamicNotPrincipalPatterns) > 0 {
|
||||
matchedNotPrincipal := engine.matchesPatterns(stmt.NotPrincipalPatterns, args.Principal)
|
||||
if !matchedNotPrincipal {
|
||||
matchedNotPrincipal = engine.matchesDynamicPatterns(stmt.DynamicNotPrincipalPatterns, args.Principal, args)
|
||||
}
|
||||
if matchedNotPrincipal {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check conditions
|
||||
if len(stmt.Statement.Condition) > 0 {
|
||||
condCtx := args.Conditions
|
||||
// Multipart continuation actions (UploadPart, UploadPartCopy) inherit SSE
|
||||
// from CreateMultipartUpload and do not carry their own SSE header.
|
||||
// Inject the real inherited algorithm so Null/StringEquals conditions
|
||||
// evaluate against the value that was set at upload initiation.
|
||||
if IsMultipartContinuationAction(args.Action) {
|
||||
condCtx = injectSSEForMultipart(args.Conditions, args.InheritedSSEAlgorithm)
|
||||
}
|
||||
match := EvaluateConditions(stmt.Statement.Condition, condCtx, args.ObjectEntry, args.Claims)
|
||||
if !match {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// matchesPatterns checks if a value matches any of the compiled patterns
|
||||
func (engine *PolicyEngine) matchesPatterns(patterns []*regexp.Regexp, value string) bool {
|
||||
for _, pattern := range patterns {
|
||||
if pattern.MatchString(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SubstituteVariables replaces ${variable} in a pattern with values from context and claims
|
||||
// Supports:
|
||||
// - Standard context variables (aws:SourceIp, s3:prefix, etc.)
|
||||
// - JWT claims (jwt:preferred_username, jwt:sub, jwt:*)
|
||||
// - LDAP claims (ldap:username, ldap:dn, ldap:*)
|
||||
func SubstituteVariables(pattern string, context map[string][]string, claims map[string]interface{}) string {
|
||||
result := PolicyVariableRegex.ReplaceAllStringFunc(pattern, func(match string) string {
|
||||
// match is like "${aws:username}"
|
||||
// extract variable name "aws:username"
|
||||
variable := match[2 : len(match)-1]
|
||||
|
||||
// Check standard context first
|
||||
if values, ok := context[variable]; ok && len(values) > 0 {
|
||||
return values[0]
|
||||
}
|
||||
|
||||
// Check JWT claims for jwt:* variables
|
||||
if strings.HasPrefix(variable, "jwt:") {
|
||||
claimName := variable[4:] // Remove "jwt:" prefix
|
||||
if claimValue, ok := claims[claimName]; ok {
|
||||
switch v := claimValue.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
// JWT numbers are often float64
|
||||
if v == float64(int64(v)) {
|
||||
return fmt.Sprintf("%d", int64(v))
|
||||
}
|
||||
return fmt.Sprintf("%g", v)
|
||||
case bool:
|
||||
return fmt.Sprintf("%t", v)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case int32:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", v)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check LDAP claims for ldap:* variables
|
||||
// FALLBACK MECHANISM: Try both prefixed and unprefixed keys
|
||||
// Some LDAP providers store claims with the "ldap:" prefix (e.g., "ldap:username")
|
||||
// while others store them without the prefix (e.g., "username").
|
||||
// We check the prefixed key first for consistency, then fall back to unprefixed.
|
||||
if strings.HasPrefix(variable, "ldap:") {
|
||||
claimName := variable[5:] // Remove "ldap:" prefix
|
||||
// Try prefixed key first (e.g., "ldap:username"), then unprefixed
|
||||
var claimValue interface{}
|
||||
var ok bool
|
||||
if claimValue, ok = claims[variable]; !ok {
|
||||
claimValue, ok = claims[claimName]
|
||||
}
|
||||
if ok {
|
||||
switch v := claimValue.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
if v == float64(int64(v)) {
|
||||
return fmt.Sprintf("%d", int64(v))
|
||||
}
|
||||
return fmt.Sprintf("%g", v)
|
||||
case bool:
|
||||
return fmt.Sprintf("%t", v)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case int32:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", v)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Variable not found, leave as-is to avoid unexpected matching
|
||||
return match
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// ExtractPrincipalVariables extracts policy variables from a principal ARN
|
||||
func ExtractPrincipalVariables(principal string) map[string][]string {
|
||||
vars := make(map[string][]string)
|
||||
|
||||
// Handle non-ARN principals (e.g., "*" or simple usernames)
|
||||
if !strings.HasPrefix(principal, "arn:aws:") {
|
||||
return vars
|
||||
}
|
||||
|
||||
// Parse ARN: arn:aws:service::account:resource
|
||||
parts := strings.Split(principal, ":")
|
||||
if len(parts) < 6 {
|
||||
return vars
|
||||
}
|
||||
|
||||
account := parts[4] // account ID
|
||||
resourcePart := parts[5] // user/username or assumed-role/role/session
|
||||
|
||||
// Set aws:PrincipalAccount if account is present
|
||||
if account != "" {
|
||||
vars["aws:PrincipalAccount"] = []string{account}
|
||||
}
|
||||
|
||||
resourceParts := strings.Split(resourcePart, "/")
|
||||
if len(resourceParts) < 2 {
|
||||
return vars
|
||||
}
|
||||
|
||||
resourceType := resourceParts[0] // "user", "role", "assumed-role"
|
||||
|
||||
// Set aws:principaltype and extract username/userid based on resource type
|
||||
switch resourceType {
|
||||
case "user":
|
||||
vars["aws:principaltype"] = []string{"IAMUser"}
|
||||
// For users with paths like "user/path/to/username", use the last segment
|
||||
username := resourceParts[len(resourceParts)-1]
|
||||
vars["aws:username"] = []string{username}
|
||||
vars["aws:userid"] = []string{username} // In SeaweedFS, userid is same as username
|
||||
case "role":
|
||||
vars["aws:principaltype"] = []string{"IAMRole"}
|
||||
// For roles with paths like "role/path/to/rolename", use the last segment
|
||||
// Note: IAM Roles do NOT have aws:userid, but aws:PrincipalAccount is kept for condition evaluations
|
||||
if len(resourceParts) >= 2 {
|
||||
roleName := resourceParts[len(resourceParts)-1]
|
||||
vars["aws:username"] = []string{roleName}
|
||||
}
|
||||
case "assumed-role":
|
||||
vars["aws:principaltype"] = []string{"AssumedRole"}
|
||||
// For assumed roles: assumed-role/RoleName/SessionName or assumed-role/path/to/RoleName/SessionName
|
||||
// The session name is always the last segment
|
||||
if len(resourceParts) >= 3 {
|
||||
sessionName := resourceParts[len(resourceParts)-1]
|
||||
vars["aws:username"] = []string{sessionName}
|
||||
vars["aws:userid"] = []string{sessionName}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: principaltype is already set correctly in the switch above based on resource type
|
||||
|
||||
return vars
|
||||
}
|
||||
|
||||
// ExtractConditionValuesFromRequest extracts condition values from HTTP request
|
||||
func (engine *PolicyEngine) ExtractConditionValuesFromRequest(r *http.Request) map[string][]string {
|
||||
values := make(map[string][]string)
|
||||
|
||||
// AWS condition keys
|
||||
values["aws:SourceIp"] = []string{engine.extractSourceIP(r)}
|
||||
values["aws:SecureTransport"] = []string{fmt.Sprintf("%t", r.TLS != nil)}
|
||||
// Use AWS standard condition key for current time
|
||||
values["aws:CurrentTime"] = []string{time.Now().Format(time.RFC3339)}
|
||||
// Keep RequestTime for backward compatibility
|
||||
values["aws:RequestTime"] = []string{time.Now().Format(time.RFC3339)}
|
||||
|
||||
// S3 specific condition keys
|
||||
if userAgent := r.Header.Get("User-Agent"); userAgent != "" {
|
||||
values["aws:UserAgent"] = []string{userAgent}
|
||||
}
|
||||
|
||||
if referer := r.Header.Get("Referer"); referer != "" {
|
||||
values["aws:Referer"] = []string{referer}
|
||||
}
|
||||
|
||||
// Note: s3:ExistingObjectTag/<key> conditions are evaluated using objectEntry
|
||||
// passed to EvaluatePolicy, not extracted from the request.
|
||||
|
||||
// S3 bucket-level conditions
|
||||
if delimiter := r.URL.Query().Get("delimiter"); delimiter != "" {
|
||||
values["s3:delimiter"] = []string{delimiter}
|
||||
}
|
||||
|
||||
if prefix := r.URL.Query().Get("prefix"); prefix != "" {
|
||||
values["s3:prefix"] = []string{prefix}
|
||||
}
|
||||
|
||||
if maxKeys := r.URL.Query().Get("max-keys"); maxKeys != "" {
|
||||
values["s3:max-keys"] = []string{maxKeys}
|
||||
}
|
||||
|
||||
// Authentication method
|
||||
if authHeader := r.Header.Get("Authorization"); authHeader != "" {
|
||||
if strings.HasPrefix(authHeader, "AWS4-HMAC-SHA256") {
|
||||
values["s3:authType"] = []string{"REST-HEADER"}
|
||||
} else if strings.HasPrefix(authHeader, "AWS ") {
|
||||
values["s3:authType"] = []string{"REST-HEADER"}
|
||||
}
|
||||
} else if r.URL.Query().Get("AWSAccessKeyId") != "" {
|
||||
values["s3:authType"] = []string{"REST-QUERY-STRING"}
|
||||
}
|
||||
|
||||
// HTTP method
|
||||
values["s3:RequestMethod"] = []string{r.Method}
|
||||
|
||||
// Extract custom headers with s3: prefix for AWS-compatible condition keys
|
||||
for key, headerValues := range r.Header {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if strings.HasPrefix(lowerKey, "x-amz-") {
|
||||
values["s3:"+lowerKey] = headerValues
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize s3:x-amz-server-side-encryption value to canonical form.
|
||||
// AWS accepts "AES256" case-insensitively; normalise so that policy
|
||||
// StringEquals conditions work regardless of client capitalisation.
|
||||
const sseKey = "s3:x-amz-server-side-encryption"
|
||||
if sseVals, ok := values[sseKey]; ok {
|
||||
normalized := make([]string, len(sseVals))
|
||||
for i, v := range sseVals {
|
||||
switch strings.ToUpper(v) {
|
||||
case "AES256":
|
||||
normalized[i] = "AES256"
|
||||
case "AWS:KMS":
|
||||
normalized[i] = "aws:kms"
|
||||
default:
|
||||
normalized[i] = v
|
||||
}
|
||||
}
|
||||
values[sseKey] = normalized
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
// IsMultipartContinuationAction returns true for actions that do not carry
|
||||
// their own SSE header because SSE is inherited from CreateMultipartUpload.
|
||||
func IsMultipartContinuationAction(action string) bool {
|
||||
return action == "s3:UploadPart" || action == "s3:UploadPartCopy"
|
||||
}
|
||||
|
||||
// injectSSEForMultipart returns a condition context augmented with the
|
||||
// inherited SSE algorithm for multipart continuation actions.
|
||||
//
|
||||
// UploadPart and UploadPartCopy do not re-send the SSE header because
|
||||
// encryption is set once at CreateMultipartUpload. The caller supplies
|
||||
// inheritedSSE (the canonical algorithm, e.g. "AES256" or "aws:kms") so
|
||||
// that Null/StringEquals conditions on s3:x-amz-server-side-encryption
|
||||
// evaluate against the real value.
|
||||
//
|
||||
// If inheritedSSE is empty (no SSE was requested at initiation), the
|
||||
// conditions map is returned unchanged so Null("true") will correctly
|
||||
// match and deny the request.
|
||||
func injectSSEForMultipart(conditions map[string][]string, inheritedSSE string) map[string][]string {
|
||||
const sseKey = "s3:x-amz-server-side-encryption"
|
||||
if inheritedSSE == "" {
|
||||
return conditions // no SSE at upload initiation; let Null("true") fire
|
||||
}
|
||||
if _, exists := conditions[sseKey]; exists {
|
||||
return conditions // SSE header was actually sent on this request
|
||||
}
|
||||
modified := make(map[string][]string, len(conditions)+1)
|
||||
for k, v := range conditions {
|
||||
modified[k] = v
|
||||
}
|
||||
modified[sseKey] = []string{inheritedSSE}
|
||||
return modified
|
||||
}
|
||||
|
||||
// extractSourceIP returns the client IP for aws:SourceIp condition
|
||||
// evaluation, honoring forwarded headers only when the direct TCP peer is in
|
||||
// the configured trusted-proxy allowlist (see SetTrustedProxies).
|
||||
func (engine *PolicyEngine) extractSourceIP(r *http.Request) string {
|
||||
return engine.trustedProxies.Load().ExtractSourceIP(r)
|
||||
}
|
||||
|
||||
// BuildResourceArn builds an ARN for the given bucket and object
|
||||
func BuildResourceArn(bucketName, objectName string) string {
|
||||
if objectName == "" {
|
||||
return fmt.Sprintf("arn:aws:s3:::%s", bucketName)
|
||||
}
|
||||
return fmt.Sprintf("arn:aws:s3:::%s/%s", bucketName, objectName)
|
||||
}
|
||||
|
||||
// BuildActionName builds a standardized action name
|
||||
func BuildActionName(action string) string {
|
||||
if strings.HasPrefix(action, "s3:") {
|
||||
return action
|
||||
}
|
||||
return fmt.Sprintf("s3:%s", action)
|
||||
}
|
||||
|
||||
// GetPolicyStatements returns all policy statements for a bucket
|
||||
func (engine *PolicyEngine) GetPolicyStatements(bucketName string) []PolicyStatement {
|
||||
engine.mutex.RLock()
|
||||
defer engine.mutex.RUnlock()
|
||||
|
||||
context, exists := engine.contexts[bucketName]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
return context.policy.Document.Statement
|
||||
}
|
||||
|
||||
// BucketsAllowedForAction returns the buckets a policy names in the Allow
|
||||
// statements that can match the action, and whether those names cover every
|
||||
// bucket the policy can allow it on. A statement reaching buckets it does not
|
||||
// name -- a wildcard resource, a policy variable, a NotResource -- leaves the
|
||||
// set incomplete, as does an unknown policy name.
|
||||
func (engine *PolicyEngine) BucketsAllowedForAction(policyName string, action string) (buckets []string, complete bool) {
|
||||
engine.mutex.RLock()
|
||||
context, exists := engine.contexts[policyName]
|
||||
engine.mutex.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for _, statement := range context.policy.Document.Statement {
|
||||
// A Deny only narrows what an Allow named.
|
||||
if statement.Effect != PolicyEffectAllow || !statementMayAllowAction(statement.Action.Strings(), action) {
|
||||
continue
|
||||
}
|
||||
if statement.Resource == nil || statement.NotResource != nil {
|
||||
return nil, false
|
||||
}
|
||||
resources := statement.Resource.Strings()
|
||||
if len(resources) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
for _, resource := range resources {
|
||||
if PolicyVariableRegex.MatchString(resource) {
|
||||
return nil, false
|
||||
}
|
||||
bucket := GetBucketFromResource(resource)
|
||||
if bucket == "" || strings.ContainsAny(bucket, "*?") {
|
||||
return nil, false
|
||||
}
|
||||
buckets = append(buckets, bucket)
|
||||
}
|
||||
}
|
||||
return buckets, true
|
||||
}
|
||||
|
||||
// ValidatePolicyForBucket validates if a policy is valid for a bucket
|
||||
func (engine *PolicyEngine) ValidatePolicyForBucket(bucketName string, policyJSON string) error {
|
||||
policy, err := ParsePolicy(policyJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Additional validation specific to the bucket
|
||||
for _, stmt := range policy.Statement {
|
||||
resources := normalizeToStringSlice(stmt.Resource)
|
||||
for _, resource := range resources {
|
||||
if resourceBucket := GetBucketFromResource(resource); resourceBucket != "" {
|
||||
if resourceBucket != bucketName {
|
||||
return fmt.Errorf("policy resource %s does not match bucket %s", resource, bucketName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearAllPolicies clears all bucket policies
|
||||
func (engine *PolicyEngine) ClearAllPolicies() {
|
||||
engine.mutex.Lock()
|
||||
defer engine.mutex.Unlock()
|
||||
|
||||
engine.contexts = make(map[string]*PolicyEvaluationContext)
|
||||
glog.V(2).Info("Cleared all bucket policies")
|
||||
}
|
||||
|
||||
// GetAllBucketsWithPolicies returns all buckets that have policies
|
||||
func (engine *PolicyEngine) GetAllBucketsWithPolicies() []string {
|
||||
engine.mutex.RLock()
|
||||
defer engine.mutex.RUnlock()
|
||||
|
||||
buckets := make([]string, 0, len(engine.contexts))
|
||||
for bucketName := range engine.contexts {
|
||||
buckets = append(buckets, bucketName)
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
// EvaluatePolicyForRequest evaluates policy for an HTTP request
|
||||
func (engine *PolicyEngine) EvaluatePolicyForRequest(bucketName, objectName, action, principal string, r *http.Request) PolicyEvaluationResult {
|
||||
resource := BuildResourceArn(bucketName, objectName)
|
||||
actionName := BuildActionName(action)
|
||||
conditions := engine.ExtractConditionValuesFromRequest(r)
|
||||
|
||||
// Extract principal information for variables
|
||||
principalVars := ExtractPrincipalVariables(principal)
|
||||
for k, v := range principalVars {
|
||||
conditions[k] = v
|
||||
}
|
||||
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: actionName,
|
||||
Resource: resource,
|
||||
Principal: principal,
|
||||
Conditions: conditions,
|
||||
}
|
||||
|
||||
return engine.EvaluatePolicy(bucketName, args)
|
||||
}
|
||||
52
weed/s3api/policy_engine/engine_buckets_for_action_test.go
Normal file
52
weed/s3api/policy_engine/engine_buckets_for_action_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// BucketsAllowedForAction has to read a grant at least as loosely as the
|
||||
// authorizer applies it, since a bucket it misses disappears from ListBuckets.
|
||||
func TestBucketsAllowedForAction(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
policies := map[string]string{
|
||||
"put": `{"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::b1/*"]}]}`,
|
||||
"mixed-case": `{"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Action":["S3:LISTBUCKET"],"Resource":["arn:aws:s3:::b2"]}]}`,
|
||||
"any-bucket": `{"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::*"]}]}`,
|
||||
}
|
||||
for name, document := range policies {
|
||||
if err := engine.SetBucketPolicy(name, document); err != nil {
|
||||
t.Fatalf("load policy %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
policy string
|
||||
action string
|
||||
wantBuckets string
|
||||
wantComplete bool
|
||||
}{
|
||||
// Multipart uploads ride on s3:PutObject, in whatever case they are asked for.
|
||||
{name: "multipart inherits put", policy: "put", action: "s3:UploadPart", wantBuckets: "b1", wantComplete: true},
|
||||
{name: "multipart inherits put in any case", policy: "put", action: "S3:UPLOADPART", wantBuckets: "b1", wantComplete: true},
|
||||
{name: "unrelated action names nothing", policy: "put", action: "s3:ListBucket", wantComplete: true},
|
||||
{name: "mixed case grant is read", policy: "mixed-case", action: "s3:ListBucket", wantBuckets: "b2", wantComplete: true},
|
||||
{name: "wildcard bucket is incomplete", policy: "any-bucket", action: "s3:ListBucket"},
|
||||
{name: "unknown policy is incomplete", policy: "absent", action: "s3:ListBucket"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buckets, complete := engine.BucketsAllowedForAction(tt.policy, tt.action)
|
||||
if complete != tt.wantComplete {
|
||||
t.Fatalf("complete = %v, want %v", complete, tt.wantComplete)
|
||||
}
|
||||
if got := strings.Join(buckets, ","); got != tt.wantBuckets {
|
||||
t.Errorf("buckets = %q, want %q", got, tt.wantBuckets)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
312
weed/s3api/policy_engine/engine_enhanced_test.go
Normal file
312
weed/s3api/policy_engine/engine_enhanced_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractPrincipalVariables(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
principal string
|
||||
expected map[string][]string
|
||||
}{
|
||||
{
|
||||
name: "IAM User ARN",
|
||||
principal: "arn:aws:iam::123456789012:user/alice",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"IAMUser"},
|
||||
"aws:username": {"alice"},
|
||||
"aws:userid": {"alice"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Assumed Role ARN",
|
||||
principal: "arn:aws:sts::123456789012:assumed-role/MyRole/session-alice",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"AssumedRole"},
|
||||
"aws:username": {"session-alice"},
|
||||
"aws:userid": {"session-alice"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IAM Role ARN",
|
||||
principal: "arn:aws:iam::123456789012:role/MyRole",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"IAMRole"},
|
||||
"aws:username": {"MyRole"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Non-ARN principal",
|
||||
principal: "user:alice",
|
||||
expected: map[string][]string{},
|
||||
},
|
||||
{
|
||||
name: "Wildcard principal",
|
||||
principal: "*",
|
||||
expected: map[string][]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ExtractPrincipalVariables(tt.principal)
|
||||
|
||||
// Check that all expected keys are present with correct values
|
||||
for key, expectedValues := range tt.expected {
|
||||
actualValues, ok := result[key]
|
||||
if !ok {
|
||||
t.Errorf("Expected key %s not found in result", key)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(actualValues) != len(expectedValues) {
|
||||
t.Errorf("For key %s: expected %d values, got %d", key, len(expectedValues), len(actualValues))
|
||||
continue
|
||||
}
|
||||
|
||||
for i, expectedValue := range expectedValues {
|
||||
if actualValues[i] != expectedValue {
|
||||
t.Errorf("For key %s[%d]: expected %s, got %s", key, i, expectedValue, actualValues[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check that there are no unexpected keys
|
||||
for key := range result {
|
||||
if _, ok := tt.expected[key]; !ok {
|
||||
t.Errorf("Unexpected key %s in result", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteVariablesWithClaims(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
context map[string][]string
|
||||
claims map[string]interface{}
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Standard context variable",
|
||||
pattern: "arn:aws:s3:::bucket/${aws:username}/*",
|
||||
context: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
claims: nil,
|
||||
expected: "arn:aws:s3:::bucket/alice/*",
|
||||
},
|
||||
{
|
||||
name: "JWT claim substitution",
|
||||
pattern: "arn:aws:s3:::bucket/${jwt:preferred_username}/*",
|
||||
context: map[string][]string{},
|
||||
claims: map[string]interface{}{
|
||||
"preferred_username": "bob",
|
||||
},
|
||||
expected: "arn:aws:s3:::bucket/bob/*",
|
||||
},
|
||||
{
|
||||
name: "Mixed variables",
|
||||
pattern: "arn:aws:s3:::bucket/${jwt:sub}/files/${aws:principaltype}",
|
||||
context: map[string][]string{
|
||||
"aws:principaltype": {"IAMUser"},
|
||||
},
|
||||
claims: map[string]interface{}{
|
||||
"sub": "user123",
|
||||
},
|
||||
expected: "arn:aws:s3:::bucket/user123/files/IAMUser",
|
||||
},
|
||||
{
|
||||
name: "Variable not found",
|
||||
pattern: "arn:aws:s3:::bucket/${jwt:missing}/*",
|
||||
context: map[string][]string{},
|
||||
claims: map[string]interface{}{},
|
||||
expected: "arn:aws:s3:::bucket/${jwt:missing}/*",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SubstituteVariables(tt.pattern, tt.context, tt.claims)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyVariablesWithPrincipalType(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy that requires specific principal type
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:*",
|
||||
"Resource": "arn:aws:s3:::bucket/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:principaltype": "IAMUser"
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// Test with IAM User - should allow
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket/file.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/alice",
|
||||
Conditions: map[string][]string{
|
||||
"aws:principaltype": {"IAMUser"},
|
||||
"aws:username": {"alice"},
|
||||
"aws:userid": {"alice"},
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Expected Allow for IAMUser principal, got %v", result)
|
||||
}
|
||||
|
||||
// Test with AssumedRole - should return Indeterminate (condition doesn't match)
|
||||
args.Principal = "arn:aws:sts::123456789012:assumed-role/MyRole/session"
|
||||
args.Conditions["aws:principaltype"] = []string{"AssumedRole"}
|
||||
|
||||
result = engine.EvaluatePolicy("bucket", args)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate for AssumedRole principal, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyVariablesWithJWTClaims(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy using JWT claim in resource
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:*",
|
||||
"Resource": "arn:aws:s3:::bucket/${jwt:preferred_username}/*"
|
||||
}]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// Test with matching JWT claim
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket/alice/file.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/alice",
|
||||
Conditions: map[string][]string{},
|
||||
Claims: map[string]interface{}{
|
||||
"preferred_username": "alice",
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Expected Allow when JWT claim matches resource, got %v", result)
|
||||
}
|
||||
|
||||
// Test with mismatched JWT claim
|
||||
args.Resource = "arn:aws:s3:::bucket/bob/file.txt"
|
||||
|
||||
result = engine.EvaluatePolicy("bucket", args)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate when JWT claim doesn't match resource, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPrincipalVariablesWithAccount(t *testing.T) {
|
||||
principal := "arn:aws:iam::123456789012:user/alice"
|
||||
vars := ExtractPrincipalVariables(principal)
|
||||
|
||||
if account, ok := vars["aws:PrincipalAccount"]; !ok {
|
||||
t.Errorf("Expected aws:PrincipalAccount to be present")
|
||||
} else if len(account) == 0 {
|
||||
t.Errorf("Expected aws:PrincipalAccount to have values")
|
||||
} else if account[0] != "123456789012" {
|
||||
t.Errorf("Expected aws:PrincipalAccount=123456789012, got %v", account[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteVariablesWithLDAP(t *testing.T) {
|
||||
pattern := "arn:aws:s3:::bucket/${ldap:username}/*"
|
||||
context := map[string][]string{}
|
||||
claims := map[string]interface{}{
|
||||
"username": "jdoe",
|
||||
}
|
||||
|
||||
result := SubstituteVariables(pattern, context, claims)
|
||||
expected := "arn:aws:s3:::bucket/jdoe/*"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected %s, got %s", expected, result)
|
||||
}
|
||||
|
||||
// Test ldap:dn
|
||||
pattern = "arn:aws:s3:::bucket/${ldap:dn}/*"
|
||||
claims = map[string]interface{}{
|
||||
"dn": "uid=jdoe,ou=people,dc=example,dc=com",
|
||||
}
|
||||
result = SubstituteVariables(pattern, context, claims)
|
||||
expected = "arn:aws:s3:::bucket/uid=jdoe,ou=people,dc=example,dc=com/*"
|
||||
if result != expected {
|
||||
t.Errorf("Expected %s, got %s", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteVariablesSpecialChars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
context map[string][]string
|
||||
claims map[string]interface{}
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Comparison operators in claims/vars",
|
||||
pattern: "resource/${jwt:scope}",
|
||||
context: map[string][]string{},
|
||||
claims: map[string]interface{}{
|
||||
"scope": "read/write",
|
||||
},
|
||||
expected: "resource/read/write",
|
||||
},
|
||||
{
|
||||
name: "Path traversal attempt (should just substitute text)",
|
||||
pattern: "bucket/${jwt:user}",
|
||||
context: map[string][]string{},
|
||||
claims: map[string]interface{}{
|
||||
"user": "../../../etc/passwd",
|
||||
},
|
||||
expected: "bucket/../../../etc/passwd",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SubstituteVariables(tt.pattern, tt.context, tt.claims)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
100
weed/s3api/policy_engine/engine_isolation_test.go
Normal file
100
weed/s3api/policy_engine/engine_isolation_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsolationPolicy(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
bucketName := "test-isolation"
|
||||
|
||||
policyJSON := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Sid": "AllowOwnFolder",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": "arn:aws:s3:::%s/${aws:username}/*"
|
||||
}, {
|
||||
"Sid": "AllowListOwnPrefix",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::%s",
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"s3:prefix": ["${aws:username}/*", "${aws:username}"]
|
||||
}
|
||||
}
|
||||
}, {
|
||||
"Sid": "DenyOtherFolders",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"NotResource": "arn:aws:s3:::%s/${aws:username}/*"
|
||||
}]
|
||||
}`, bucketName, bucketName, bucketName)
|
||||
|
||||
err := engine.SetBucketPolicy(bucketName, policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// Case 1: Alice accesses her own folder -> should be ALLOWED
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/alice/data.txt", bucketName),
|
||||
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/alice",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
}
|
||||
result := engine.EvaluatePolicy(bucketName, args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Alice should be ALLOWED to her own folder, got %v", result)
|
||||
}
|
||||
|
||||
// Case 2: Alice accesses Bob's folder -> should be DENIED
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/bob/data.txt", bucketName),
|
||||
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/alice",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
}
|
||||
result = engine.EvaluatePolicy(bucketName, args)
|
||||
if result != PolicyResultDeny {
|
||||
t.Errorf("Alice should be DENIED access to Bob's folder, got %v", result)
|
||||
}
|
||||
|
||||
// Case 3: Bob accesses Bob's folder -> should be ALLOWED
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/bob/data.txt", bucketName),
|
||||
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/bob",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"bob"},
|
||||
},
|
||||
}
|
||||
result = engine.EvaluatePolicy(bucketName, args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Bob should be ALLOWED to his own folder, got %v", result)
|
||||
}
|
||||
|
||||
// Case 4: Bob accesses Alice's folder -> should be DENIED
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/alice/data.txt", bucketName),
|
||||
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/bob",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"bob"},
|
||||
},
|
||||
}
|
||||
result = engine.EvaluatePolicy(bucketName, args)
|
||||
if result != PolicyResultDeny {
|
||||
t.Errorf("Bob should be DENIED access to Alice's folder, got %v", result)
|
||||
}
|
||||
}
|
||||
65
weed/s3api/policy_engine/engine_notresource_test.go
Normal file
65
weed/s3api/policy_engine/engine_notresource_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNotResourceWithVariables(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy mirroring the isolation test
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowOwnFolder",
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::bucket/${aws:username}/*"
|
||||
},
|
||||
{
|
||||
"Sid": "DenyOtherFolders",
|
||||
"Effect": "Deny",
|
||||
"Action": "s3:GetObject",
|
||||
"NotResource": "arn:aws:s3:::bucket/${aws:username}/*"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("test-bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// Case 1: Alice accesses her own folder -> should match Allow, but NOT match Deny statement
|
||||
// (because Deny says NotResource is own folder, and she IS accessing her own folder, so NotResource check fails, statement doesn't apply)
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket/alice/data.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/alice",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Alice should be allowed to her own folder, got %v", result)
|
||||
}
|
||||
|
||||
// Case 2: Alice accesses Bob's folder -> should NOT match Allow, and SHOULD match Deny statement
|
||||
// (because Deny says NotResource is own folder, and she is NOT accessing her own folder, so NotResource matches, statement applies)
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket/bob/data.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/alice",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
}
|
||||
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultDeny {
|
||||
t.Errorf("Alice should be denied access to Bob folder, got %v", result)
|
||||
}
|
||||
}
|
||||
77
weed/s3api/policy_engine/engine_paths_test.go
Normal file
77
weed/s3api/policy_engine/engine_paths_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestExtractPrincipalVariablesWithPaths tests ARN parsing with IAM path components
|
||||
func TestExtractPrincipalVariablesWithPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
principal string
|
||||
expected map[string][]string
|
||||
}{
|
||||
{
|
||||
name: "IAM User with path",
|
||||
principal: "arn:aws:iam::123456789012:user/division/team/alice",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"IAMUser"},
|
||||
"aws:username": {"alice"},
|
||||
"aws:userid": {"alice"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IAM Role with path",
|
||||
principal: "arn:aws:iam::123456789012:role/service-role/MyRole",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"IAMRole"},
|
||||
"aws:username": {"MyRole"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Assumed Role with path",
|
||||
principal: "arn:aws:sts::123456789012:assumed-role/service-role/MyRole/session-name",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"AssumedRole"},
|
||||
"aws:username": {"session-name"},
|
||||
"aws:userid": {"session-name"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ExtractPrincipalVariables(tt.principal)
|
||||
|
||||
// Check that all expected keys are present with correct values
|
||||
for key, expectedValues := range tt.expected {
|
||||
actualValues, ok := result[key]
|
||||
if !ok {
|
||||
t.Errorf("Expected key %s not found in result", key)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(actualValues) != len(expectedValues) {
|
||||
t.Errorf("For key %s: expected %d values, got %d", key, len(expectedValues), len(actualValues))
|
||||
continue
|
||||
}
|
||||
|
||||
for i, expectedValue := range expectedValues {
|
||||
if actualValues[i] != expectedValue {
|
||||
t.Errorf("For key %s[%d]: expected %s, got %s", key, i, expectedValue, actualValues[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check that there are no unexpected keys
|
||||
for key := range result {
|
||||
if _, ok := tt.expected[key]; !ok {
|
||||
t.Errorf("Unexpected key %s in result", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1123
weed/s3api/policy_engine/engine_test.go
Normal file
1123
weed/s3api/policy_engine/engine_test.go
Normal file
File diff suppressed because it is too large
Load Diff
152
weed/s3api/policy_engine/engine_variables_test.go
Normal file
152
weed/s3api/policy_engine/engine_variables_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPolicyVariables(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy with variables in Resource and Condition
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowUserHomeDirectory",
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::test-bucket",
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"s3:prefix": ["${aws:username}/*"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "AllowUserObjectAccess",
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": ["arn:aws:s3:::test-bucket/${aws:username}/*"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("test-bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// Case 1: Matching username for resource access
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/johndoe/file.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/johndoe",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"johndoe"},
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Expected Allow for matching username in resource, got %v", result)
|
||||
}
|
||||
|
||||
// Case 2: Mismatched username for resource access
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/janedoe/file.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/johndoe",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"johndoe"},
|
||||
},
|
||||
}
|
||||
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate for mismatched username in resource, got %v", result)
|
||||
}
|
||||
|
||||
// Case 3: ListBucket with matching prefix condition
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:ListBucket",
|
||||
Resource: "arn:aws:s3:::test-bucket",
|
||||
Principal: "arn:aws:iam::123456789012:user/johndoe",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"johndoe"},
|
||||
"s3:prefix": {"johndoe/docs"},
|
||||
},
|
||||
}
|
||||
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Expected Allow for matching prefix condition, got %v", result)
|
||||
}
|
||||
|
||||
// Case 4: ListBucket with mismatched prefix condition
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:ListBucket",
|
||||
Resource: "arn:aws:s3:::test-bucket",
|
||||
Principal: "arn:aws:iam::123456789012:user/johndoe",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"johndoe"},
|
||||
"s3:prefix": {"janedoe/docs"},
|
||||
},
|
||||
}
|
||||
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate for mismatched prefix condition, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluatePolicyForRequestVariables(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy using aws:username
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::test-bucket/${aws:username}/*"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("test-bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// We need to mock the request but the EvaluatePolicyForRequest mostly runs on args extraction
|
||||
// The key thing is that EvaluatePolicyForRequest should populate "aws:username" from principal
|
||||
|
||||
// Since we cannot easily pass a full http.Request that matches everything, we will test the extraction logic
|
||||
// by simulating what EvaluatePolicyForRequest does: calling EvaluatePolicy with populated Conditions
|
||||
|
||||
principal := "arn:aws:iam::123456789012:user/alice"
|
||||
// Should extract "alice"
|
||||
|
||||
// Create args manually as if extracted
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/alice/data.txt",
|
||||
Principal: principal,
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Expected Allow when aws:username is populated, got %v", result)
|
||||
}
|
||||
|
||||
// Now with wrong resource
|
||||
args.Resource = "arn:aws:s3:::test-bucket/bob/data.txt"
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate when resource doesn't match variable, got %v", result)
|
||||
}
|
||||
}
|
||||
462
weed/s3api/policy_engine/examples.go
Normal file
462
weed/s3api/policy_engine/examples.go
Normal file
@@ -0,0 +1,462 @@
|
||||
//go:build ignore
|
||||
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// This file contains examples and documentation for the policy engine
|
||||
|
||||
// ExampleIdentityJSON shows the existing identities.json format (unchanged)
|
||||
var ExampleIdentityJSON = `{
|
||||
"identities": [
|
||||
{
|
||||
"name": "user1",
|
||||
"credentials": [
|
||||
{
|
||||
"accessKey": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
"Read:bucket1/*",
|
||||
"Write:bucket1/*",
|
||||
"Admin:bucket2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "readonly-user",
|
||||
"credentials": [
|
||||
{
|
||||
"accessKey": "AKIAI44QH8DHBEXAMPLE",
|
||||
"secretKey": "je7MtGbClwBF/2Zp9Utk/h3yCo8nvbEXAMPLEKEY"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
"Read:bucket1/*",
|
||||
"List:bucket1"
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// ExampleBucketPolicy shows an AWS S3 bucket policy with conditions
|
||||
var ExampleBucketPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowGetObjectFromSpecificIP",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::my-bucket/*",
|
||||
"Condition": {
|
||||
"IpAddress": {
|
||||
"aws:SourceIp": "192.168.1.0/24"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "AllowPutObjectWithSSL",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:PutObject",
|
||||
"Resource": "arn:aws:s3:::my-bucket/*",
|
||||
"Condition": {
|
||||
"Bool": {
|
||||
"aws:SecureTransport": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "DenyDeleteFromProduction",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": "s3:DeleteObject",
|
||||
"Resource": "arn:aws:s3:::my-bucket/production/*"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// ExampleTimeBasedPolicy shows a policy with time-based conditions
|
||||
var ExampleTimeBasedPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowAccessDuringBusinessHours",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": "arn:aws:s3:::my-bucket/*",
|
||||
"Condition": {
|
||||
"DateGreaterThan": {
|
||||
"aws:RequestTime": "2023-01-01T08:00:00Z"
|
||||
},
|
||||
"DateLessThan": {
|
||||
"aws:RequestTime": "2023-12-31T18:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// ExampleIPRestrictedPolicy shows a policy with IP restrictions
|
||||
var ExampleIPRestrictedPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowFromOfficeNetwork",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:*",
|
||||
"Resource": [
|
||||
"arn:aws:s3:::my-bucket",
|
||||
"arn:aws:s3:::my-bucket/*"
|
||||
],
|
||||
"Condition": {
|
||||
"IpAddress": {
|
||||
"aws:SourceIp": [
|
||||
"203.0.113.0/24",
|
||||
"198.51.100.0/24"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "DenyFromRestrictedIPs",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": "*",
|
||||
"Resource": "*",
|
||||
"Condition": {
|
||||
"IpAddress": {
|
||||
"aws:SourceIp": [
|
||||
"192.0.2.0/24"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// ExamplePublicReadPolicy shows a policy for public read access
|
||||
var ExamplePublicReadPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "PublicReadGetObject",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::my-public-bucket/*"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// ExampleCORSPolicy shows a policy with CORS-related conditions
|
||||
var ExampleCORSPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowCrossOriginRequests",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": "arn:aws:s3:::my-bucket/*",
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"aws:Referer": [
|
||||
"https://example.com/*",
|
||||
"https://*.example.com/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// ExampleUserAgentPolicy shows a policy with user agent restrictions
|
||||
var ExampleUserAgentPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowSpecificUserAgents",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::my-bucket/*",
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"aws:UserAgent": [
|
||||
"MyApp/*",
|
||||
"curl/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// ExamplePrefixBasedPolicy shows a policy with prefix-based access
|
||||
var ExamplePrefixBasedPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowUserFolderAccess",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
|
||||
"Resource": "arn:aws:s3:::my-bucket/${aws:username}/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:prefix": "${aws:username}/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// ExampleMultiStatementPolicy shows a complex policy with multiple statements
|
||||
var ExampleMultiStatementPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowListBucket",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::my-bucket",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:prefix": "public/"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "AllowGetPublicObjects",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::my-bucket/public/*"
|
||||
},
|
||||
{
|
||||
"Sid": "AllowAuthenticatedUpload",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:PutObject",
|
||||
"Resource": "arn:aws:s3:::my-bucket/uploads/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:x-amz-acl": "private"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "DenyInsecureConnections",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": "s3:*",
|
||||
"Resource": [
|
||||
"arn:aws:s3:::my-bucket",
|
||||
"arn:aws:s3:::my-bucket/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Bool": {
|
||||
"aws:SecureTransport": "false"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// GetAllExamples returns all example policies
|
||||
func GetAllExamples() map[string]string {
|
||||
return map[string]string{
|
||||
"basic-bucket-policy": ExampleBucketPolicy,
|
||||
"time-based-policy": ExampleTimeBasedPolicy,
|
||||
"ip-restricted-policy": ExampleIPRestrictedPolicy,
|
||||
"public-read-policy": ExamplePublicReadPolicy,
|
||||
"cors-policy": ExampleCORSPolicy,
|
||||
"user-agent-policy": ExampleUserAgentPolicy,
|
||||
"prefix-based-policy": ExamplePrefixBasedPolicy,
|
||||
"multi-statement-policy": ExampleMultiStatementPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateExamplePolicies validates all example policies
|
||||
func ValidateExamplePolicies() error {
|
||||
examples := GetAllExamples()
|
||||
|
||||
for name, policyJSON := range examples {
|
||||
_, err := ParsePolicy(policyJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid example policy %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetExamplePolicy returns a specific example policy
|
||||
func GetExamplePolicy(name string) (string, error) {
|
||||
examples := GetAllExamples()
|
||||
|
||||
policy, exists := examples[name]
|
||||
if !exists {
|
||||
return "", fmt.Errorf("example policy %s not found", name)
|
||||
}
|
||||
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// CreateExamplePolicyDocument creates a PolicyDocument from an example
|
||||
func CreateExamplePolicyDocument(name string) (*PolicyDocument, error) {
|
||||
policyJSON, err := GetExamplePolicy(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ParsePolicy(policyJSON)
|
||||
}
|
||||
|
||||
// PrintExamplePolicyPretty prints an example policy in pretty format
|
||||
func PrintExamplePolicyPretty(name string) error {
|
||||
policyJSON, err := GetExamplePolicy(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var policy interface{}
|
||||
if err := json.Unmarshal([]byte(policyJSON), &policy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prettyJSON, err := json.MarshalIndent(policy, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Example Policy: %s\n", name)
|
||||
fmt.Printf("================\n")
|
||||
fmt.Println(string(prettyJSON))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExampleUsage demonstrates how to use the policy engine
|
||||
func ExampleUsage() {
|
||||
// Create a new policy engine
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Set a bucket policy
|
||||
policyJSON := ExampleBucketPolicy
|
||||
err := engine.SetBucketPolicy("my-bucket", policyJSON)
|
||||
if err != nil {
|
||||
fmt.Printf("Error setting bucket policy: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Evaluate a policy
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::my-bucket/test-object",
|
||||
Principal: "*",
|
||||
Conditions: map[string][]string{
|
||||
"aws:SourceIp": {"192.168.1.100"},
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("my-bucket", args)
|
||||
|
||||
switch result {
|
||||
case PolicyResultAllow:
|
||||
fmt.Println("Access allowed")
|
||||
case PolicyResultDeny:
|
||||
fmt.Println("Access denied")
|
||||
case PolicyResultIndeterminate:
|
||||
fmt.Println("Access indeterminate")
|
||||
}
|
||||
}
|
||||
|
||||
// ExampleLegacyIntegration demonstrates backward compatibility
|
||||
func ExampleLegacyIntegration() {
|
||||
// Legacy identity actions
|
||||
legacyActions := []string{
|
||||
"Read:bucket1/*",
|
||||
"Write:bucket1/uploads/*",
|
||||
"Admin:bucket2",
|
||||
}
|
||||
|
||||
// Convert to policy
|
||||
policy, err := ConvertIdentityToPolicy(legacyActions)
|
||||
if err != nil {
|
||||
fmt.Printf("Error converting identity to policy: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Create policy-backed IAM
|
||||
policyIAM := NewPolicyBackedIAM()
|
||||
|
||||
// Set the converted policy
|
||||
policyJSON, _ := json.MarshalIndent(policy, "", " ")
|
||||
err = policyIAM.SetBucketPolicy("bucket1", string(policyJSON))
|
||||
if err != nil {
|
||||
fmt.Printf("Error setting bucket policy: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Legacy identity successfully converted to AWS S3 policy")
|
||||
}
|
||||
|
||||
// ExampleConditions demonstrates various condition types
|
||||
func ExampleConditions() {
|
||||
examples := map[string]string{
|
||||
"StringEquals": `"StringEquals": {"s3:prefix": "documents/"}`,
|
||||
"StringLike": `"StringLike": {"aws:UserAgent": "MyApp/*"}`,
|
||||
"NumericEquals": `"NumericEquals": {"s3:max-keys": "10"}`,
|
||||
"NumericLessThan": `"NumericLessThan": {"s3:max-keys": "1000"}`,
|
||||
"DateGreaterThan": `"DateGreaterThan": {"aws:RequestTime": "2023-01-01T00:00:00Z"}`,
|
||||
"DateLessThan": `"DateLessThan": {"aws:RequestTime": "2023-12-31T23:59:59Z"}`,
|
||||
"IpAddress": `"IpAddress": {"aws:SourceIp": "192.168.1.0/24"}`,
|
||||
"NotIpAddress": `"NotIpAddress": {"aws:SourceIp": "10.0.0.0/8"}`,
|
||||
"Bool": `"Bool": {"aws:SecureTransport": "true"}`,
|
||||
"Null": `"Null": {"s3:x-amz-server-side-encryption": "false"}`,
|
||||
}
|
||||
|
||||
fmt.Println("Supported Condition Operators:")
|
||||
fmt.Println("==============================")
|
||||
|
||||
for operator, example := range examples {
|
||||
fmt.Printf("%s: %s\n", operator, example)
|
||||
}
|
||||
}
|
||||
|
||||
// ExampleMigrationStrategy demonstrates migration from legacy to policy-based system
|
||||
func ExampleMigrationStrategy() {
|
||||
fmt.Println("Migration Strategy:")
|
||||
fmt.Println("==================")
|
||||
fmt.Println("1. Keep existing identities.json unchanged")
|
||||
fmt.Println("2. Legacy actions are automatically converted to AWS policies internally")
|
||||
fmt.Println("3. Add bucket policies for advanced features:")
|
||||
fmt.Println(" - IP restrictions")
|
||||
fmt.Println(" - Time-based access")
|
||||
fmt.Println(" - SSL-only access")
|
||||
fmt.Println(" - User agent restrictions")
|
||||
fmt.Println("4. Policy evaluation precedence:")
|
||||
fmt.Println(" - Explicit Deny (highest priority)")
|
||||
fmt.Println(" - Explicit Allow")
|
||||
fmt.Println(" - Default Deny (lowest priority)")
|
||||
}
|
||||
|
||||
// PrintAllExamples prints all example policies
|
||||
func PrintAllExamples() {
|
||||
examples := GetAllExamples()
|
||||
|
||||
for name := range examples {
|
||||
fmt.Printf("\n")
|
||||
PrintExamplePolicyPretty(name)
|
||||
fmt.Printf("\n")
|
||||
}
|
||||
}
|
||||
255
weed/s3api/policy_engine/principal_test.go
Normal file
255
weed/s3api/policy_engine/principal_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPolicyPrincipalUnmarshal covers every Principal shape AWS documents, plus
|
||||
// the bare string/array forms SeaweedFS accepts for backward compatibility.
|
||||
func TestPolicyPrincipalUnmarshal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"bare wildcard", `"*"`, []string{"*"}},
|
||||
{"bare arn", `"arn:aws:iam::123456789012:user/alice"`, []string{"arn:aws:iam::123456789012:user/alice"}},
|
||||
{"bare array", `["arn:aws:iam::123:root","arn:aws:iam::456:root"]`, []string{"arn:aws:iam::123:root", "arn:aws:iam::456:root"}},
|
||||
{"AWS single", `{"AWS":"arn:aws:iam::123456789012:root"}`, []string{"arn:aws:iam::123456789012:root"}},
|
||||
{"AWS array", `{"AWS":["arn:aws:iam::123:root","999999999999"]}`, []string{"arn:aws:iam::123:root", "999999999999"}},
|
||||
{"AWS wildcard", `{"AWS":"*"}`, []string{"*"}},
|
||||
{"AWS array wildcard (public read)", `{"AWS":["*"]}`, []string{"*"}},
|
||||
{"service", `{"Service":"s3.amazonaws.com"}`, []string{"s3.amazonaws.com"}},
|
||||
{"canonical user", `{"CanonicalUser":"79a59df900b949e55d96a1e698fbace"}`, []string{"79a59df900b949e55d96a1e698fbace"}},
|
||||
// Object keys are flattened in sorted-key order: AWS < CanonicalUser.
|
||||
{"mixed keys", `{"CanonicalUser":"79a59","AWS":"arn:aws:iam::123:root"}`, []string{"arn:aws:iam::123:root", "79a59"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var p PolicyPrincipal
|
||||
if err := json.Unmarshal([]byte(c.in), &p); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", c.in, err)
|
||||
}
|
||||
if got := p.Strings(); !slices.Equal(got, c.want) {
|
||||
t.Errorf("Strings() = %v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyPrincipalUnmarshalInvalid(t *testing.T) {
|
||||
// Includes object-form abuses: unknown key, empty array, empty string -- all
|
||||
// of which must error rather than compile to zero matchers (which would let
|
||||
// the match-all fallback silently make an Allow statement public).
|
||||
for _, in := range []string{`123`, `{}`, `{"AWS":123}`, `{"Foo":"bar"}`, `{"AWS":[]}`, `{"AWS":""}`} {
|
||||
var p PolicyPrincipal
|
||||
if err := json.Unmarshal([]byte(in), &p); err == nil {
|
||||
t.Errorf("expected error unmarshaling %s, got values %v", in, p.Strings())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotPrincipalDynamicCompiledPath guards the bug where a NotPrincipal made
|
||||
// only of policy variables (no static matchers) was treated as absent in the
|
||||
// compiled evaluator, bypassing the exclusion. aws:username is pinned via the
|
||||
// condition context so the substituted value is independent of the principal.
|
||||
func TestNotPrincipalDynamicCompiledPath(t *testing.T) {
|
||||
policyJSON := `{"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Principal":"*","Action":"s3:*","Resource":"arn:aws:s3:::b/*"},
|
||||
{"Effect":"Deny","NotPrincipal":{"AWS":"${aws:username}"},"Action":"s3:*","Resource":"arn:aws:s3:::b/*"}
|
||||
]}`
|
||||
doc, err := ParsePolicy(policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePolicy: %v", err)
|
||||
}
|
||||
compiled, err := CompilePolicy(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("CompilePolicy: %v", err)
|
||||
}
|
||||
conds := map[string][]string{"aws:username": {"arn:aws:iam::111:user/alice"}}
|
||||
allowed := func(p string) bool {
|
||||
allow, _ := compiled.EvaluatePolicy(&PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject", Resource: "arn:aws:s3:::b/x", Principal: p, Conditions: conds,
|
||||
})
|
||||
return allow
|
||||
}
|
||||
// alice matches the substituted NotPrincipal -> deny excluded -> allowed.
|
||||
if !allowed("arn:aws:iam::111:user/alice") {
|
||||
t.Errorf("alice should be allowed (matched dynamic NotPrincipal)")
|
||||
}
|
||||
// bob does not match -> deny applies.
|
||||
if allowed("arn:aws:iam::111:user/bob") {
|
||||
t.Errorf("bob should be denied (dynamic NotPrincipal did not match)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicyPrincipalRoundTrip guards idempotency for IaC tools (Terraform,
|
||||
// Ansible): GetBucketPolicy must return the same shape PutBucketPolicy received,
|
||||
// otherwise those tools see perpetual drift (cf. NotResource normalization bug).
|
||||
func TestPolicyPrincipalRoundTrip(t *testing.T) {
|
||||
inputs := []string{
|
||||
`"*"`,
|
||||
`{"AWS":"arn:aws:iam::123456789012:root"}`,
|
||||
`{"AWS":["arn:aws:iam::123456789012:root","arn:aws:iam::555555555555:root"]}`,
|
||||
`{"AWS":["*"]}`,
|
||||
`["arn:aws:iam::123:root","arn:aws:iam::456:root"]`,
|
||||
}
|
||||
for _, in := range inputs {
|
||||
var p PolicyPrincipal
|
||||
if err := json.Unmarshal([]byte(in), &p); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", in, err)
|
||||
}
|
||||
out, err := json.Marshal(&p)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !jsonSemanticEqual(t, string(out), in) {
|
||||
t.Errorf("round-trip mismatch: got %s, want %s", out, in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBucketPolicyAWSObjectPrincipalEndToEnd parses, compiles and evaluates a
|
||||
// public-read policy written with the AWS object form {"AWS":["*"]} — the
|
||||
// AWS-documented shape that previously failed to parse.
|
||||
func TestBucketPolicyAWSObjectPrincipalEndToEnd(t *testing.T) {
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Sid": "AllowPublicRead",
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": ["*"]},
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::my-bucket/*"]
|
||||
}]
|
||||
}`
|
||||
doc, err := ParsePolicy(policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePolicy: %v", err)
|
||||
}
|
||||
compiled, err := CompilePolicy(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("CompilePolicy: %v", err)
|
||||
}
|
||||
|
||||
for _, principal := range []string{"arn:aws:iam::123456789012:user/bob", "*"} {
|
||||
allow, effect := compiled.EvaluatePolicy(&PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::my-bucket/object.txt",
|
||||
Principal: principal,
|
||||
})
|
||||
if !allow || effect != PolicyEffectAllow {
|
||||
t.Errorf("principal %q: allow=%v effect=%v, want allow", principal, allow, effect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBucketPolicySpecificAWSPrincipal verifies a named-principal object form
|
||||
// only matches the named principal.
|
||||
func TestBucketPolicySpecificAWSPrincipal(t *testing.T) {
|
||||
policyJSON := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow",` +
|
||||
`"Principal":{"AWS":"arn:aws:iam::123456789012:user/alice"},` +
|
||||
`"Action":"s3:*","Resource":"arn:aws:s3:::b/*"}]}`
|
||||
doc, err := ParsePolicy(policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePolicy: %v", err)
|
||||
}
|
||||
compiled, err := CompilePolicy(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("CompilePolicy: %v", err)
|
||||
}
|
||||
args := func(p string) *PolicyEvaluationArgs {
|
||||
return &PolicyEvaluationArgs{Action: "s3:GetObject", Resource: "arn:aws:s3:::b/x", Principal: p}
|
||||
}
|
||||
if allow, _ := compiled.EvaluatePolicy(args("arn:aws:iam::123456789012:user/alice")); !allow {
|
||||
t.Errorf("alice should be allowed")
|
||||
}
|
||||
if allow, _ := compiled.EvaluatePolicy(args("arn:aws:iam::123456789012:user/bob")); allow {
|
||||
t.Errorf("bob should NOT be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBucketPolicyNotPrincipalDeny exercises the common "deny everyone except X"
|
||||
// pattern through the real bucket-policy evaluator (PolicyEngine). A Deny with
|
||||
// NotPrincipal applies to every principal NOT named, so the named principal is
|
||||
// spared and everyone else is denied.
|
||||
func TestBucketPolicyNotPrincipalDeny(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{"Sid":"AllowAll","Effect":"Allow","Principal":"*","Action":"s3:*","Resource":"arn:aws:s3:::b/*"},
|
||||
{"Sid":"DenyExceptAlice","Effect":"Deny","NotPrincipal":{"AWS":"arn:aws:iam::123456789012:user/alice"},"Action":"s3:*","Resource":"arn:aws:s3:::b/*"}
|
||||
]
|
||||
}`
|
||||
if err := engine.SetBucketPolicy("b", policyJSON); err != nil {
|
||||
t.Fatalf("SetBucketPolicy: %v", err)
|
||||
}
|
||||
eval := func(p string) PolicyEvaluationResult {
|
||||
return engine.EvaluatePolicy("b", &PolicyEvaluationArgs{Action: "s3:GetObject", Resource: "arn:aws:s3:::b/x", Principal: p})
|
||||
}
|
||||
// alice is excluded from the NotPrincipal deny, so the Allow wins.
|
||||
if got := eval("arn:aws:iam::123456789012:user/alice"); got != PolicyResultAllow {
|
||||
t.Errorf("alice: got %v, want Allow", got)
|
||||
}
|
||||
// bob is not named in NotPrincipal, so the Deny applies.
|
||||
if got := eval("arn:aws:iam::123456789012:user/bob"); got != PolicyResultDeny {
|
||||
t.Errorf("bob: got %v, want Deny", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBucketPolicyNotPrincipalCompiledPath covers the matcher-based evaluator
|
||||
// (CompiledPolicy). An Allow-all paired with a Deny/NotPrincipal makes the
|
||||
// "excluded vs denied" difference observable (a lone Deny would yield implicit
|
||||
// deny for both).
|
||||
func TestBucketPolicyNotPrincipalCompiledPath(t *testing.T) {
|
||||
policyJSON := `{"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","Principal":"*","Action":"s3:*","Resource":"arn:aws:s3:::b/*"},
|
||||
{"Effect":"Deny","NotPrincipal":{"AWS":["arn:aws:iam::123456789012:user/alice"]},"Action":"s3:*","Resource":"arn:aws:s3:::b/*"}
|
||||
]}`
|
||||
doc, err := ParsePolicy(policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePolicy: %v", err)
|
||||
}
|
||||
compiled, err := CompilePolicy(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("CompilePolicy: %v", err)
|
||||
}
|
||||
args := func(p string) *PolicyEvaluationArgs {
|
||||
return &PolicyEvaluationArgs{Action: "s3:GetObject", Resource: "arn:aws:s3:::b/x", Principal: p}
|
||||
}
|
||||
// alice is named in NotPrincipal -> deny excluded -> allowed.
|
||||
if allow, _ := compiled.EvaluatePolicy(args("arn:aws:iam::123456789012:user/alice")); !allow {
|
||||
t.Errorf("alice should be allowed (excluded from NotPrincipal deny)")
|
||||
}
|
||||
// bob is not named -> deny applies.
|
||||
if allow, effect := compiled.EvaluatePolicy(args("arn:aws:iam::123456789012:user/bob")); allow || effect != PolicyEffectDeny {
|
||||
t.Errorf("bob should be denied, got allow=%v effect=%v", allow, effect)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicyBothPrincipalAndNotPrincipalRejected verifies AWS's rule that a
|
||||
// single statement may not contain both Principal and NotPrincipal.
|
||||
func TestPolicyBothPrincipalAndNotPrincipalRejected(t *testing.T) {
|
||||
policyJSON := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny",` +
|
||||
`"Principal":"*","NotPrincipal":{"AWS":"arn:aws:iam::1:user/a"},` +
|
||||
`"Action":"s3:*","Resource":"arn:aws:s3:::b/*"}]}`
|
||||
if _, err := ParsePolicy(policyJSON); err == nil {
|
||||
t.Errorf("expected error when both Principal and NotPrincipal are set")
|
||||
}
|
||||
}
|
||||
|
||||
func jsonSemanticEqual(t *testing.T, a, b string) bool {
|
||||
t.Helper()
|
||||
var av, bv interface{}
|
||||
if err := json.Unmarshal([]byte(a), &av); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", a, err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(b), &bv); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", b, err)
|
||||
}
|
||||
return reflect.DeepEqual(av, bv)
|
||||
}
|
||||
249
weed/s3api/policy_engine/sse_condition_test.go
Normal file
249
weed/s3api/policy_engine/sse_condition_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// requiresSSEPolicy is a bucket policy that denies PutObject when the
|
||||
// x-amz-server-side-encryption header is absent (Null == true).
|
||||
const requiresSSEPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "DenyUnencryptedUploads",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": "s3:PutObject",
|
||||
"Resource": "arn:aws:s3:::test-bucket/*",
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"s3:x-amz-server-side-encryption": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// requiresAES256Policy denies PutObject unless AES256 is explicitly requested.
|
||||
const requiresAES256Policy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowAES256Only",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:PutObject",
|
||||
"Resource": "arn:aws:s3:::test-bucket/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:x-amz-server-side-encryption": "AES256"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// requiresKMSPolicy allows PutObject only when aws:kms encryption is requested.
|
||||
const requiresKMSPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowKMSOnly",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:PutObject",
|
||||
"Resource": "arn:aws:s3:::test-bucket/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:x-amz-server-side-encryption": "aws:kms"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// multipartPolicy denies PutObject when SSE is absent but should NOT block UploadPart.
|
||||
const multipartPolicy = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "DenyUnencryptedUploads",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:PutObject", "s3:UploadPart", "s3:UploadPartCopy"],
|
||||
"Resource": "arn:aws:s3:::test-bucket/*",
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"s3:x-amz-server-side-encryption": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func newEngineWithPolicy(t *testing.T, policy string) *PolicyEngine {
|
||||
t.Helper()
|
||||
engine := NewPolicyEngine()
|
||||
if err := engine.SetBucketPolicy("test-bucket", policy); err != nil {
|
||||
t.Fatalf("SetBucketPolicy: %v", err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
|
||||
func evalArgs(action string, conditions map[string][]string) *PolicyEvaluationArgs {
|
||||
return &PolicyEvaluationArgs{
|
||||
Action: action,
|
||||
Resource: "arn:aws:s3:::test-bucket/object.txt",
|
||||
Principal: "*",
|
||||
Conditions: conditions,
|
||||
}
|
||||
}
|
||||
|
||||
func evalArgsWithSSE(action, inheritedSSE string) *PolicyEvaluationArgs {
|
||||
return &PolicyEvaluationArgs{
|
||||
Action: action,
|
||||
Resource: "arn:aws:s3:::test-bucket/object.txt",
|
||||
Principal: "*",
|
||||
Conditions: map[string][]string{},
|
||||
InheritedSSEAlgorithm: inheritedSSE,
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSEStringEqualsPresent – StringEquals with AES256 header present should Allow.
|
||||
func TestSSEStringEqualsPresent(t *testing.T) {
|
||||
engine := newEngineWithPolicy(t, requiresAES256Policy)
|
||||
|
||||
conditions := map[string][]string{
|
||||
"s3:x-amz-server-side-encryption": {"AES256"},
|
||||
}
|
||||
result := engine.EvaluatePolicy("test-bucket", evalArgs("s3:PutObject", conditions))
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("expected Allow, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSEStringEqualsWrongValue – StringEquals with wrong SSE value should not Allow.
|
||||
func TestSSEStringEqualsWrongValue(t *testing.T) {
|
||||
engine := newEngineWithPolicy(t, requiresAES256Policy)
|
||||
|
||||
conditions := map[string][]string{
|
||||
"s3:x-amz-server-side-encryption": {"aws:kms"},
|
||||
}
|
||||
result := engine.EvaluatePolicy("test-bucket", evalArgs("s3:PutObject", conditions))
|
||||
if result == PolicyResultAllow {
|
||||
t.Errorf("expected non-Allow, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSENullConditionAbsent – Null("true") matches when header is absent → Deny.
|
||||
func TestSSENullConditionAbsent(t *testing.T) {
|
||||
engine := newEngineWithPolicy(t, requiresSSEPolicy)
|
||||
|
||||
// No SSE header → condition "Null == true" matches → Deny statement fires
|
||||
result := engine.EvaluatePolicy("test-bucket", evalArgs("s3:PutObject", map[string][]string{}))
|
||||
if result != PolicyResultDeny {
|
||||
t.Errorf("expected Deny (no SSE header), got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSENullConditionPresent – Null("true") does NOT match when header is present → not Deny.
|
||||
func TestSSENullConditionPresent(t *testing.T) {
|
||||
engine := newEngineWithPolicy(t, requiresSSEPolicy)
|
||||
|
||||
conditions := map[string][]string{
|
||||
"s3:x-amz-server-side-encryption": {"AES256"},
|
||||
}
|
||||
result := engine.EvaluatePolicy("test-bucket", evalArgs("s3:PutObject", conditions))
|
||||
// Deny condition not matched; no explicit Allow → Indeterminate
|
||||
if result == PolicyResultDeny {
|
||||
t.Errorf("expected non-Deny when SSE header present, got Deny")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSECaseInsensitiveNormalizationAES256 drives the AES256 normalisation
|
||||
// through EvaluatePolicyForRequest so that a regression in the production
|
||||
// code path would be caught. The request carries the header in lowercase
|
||||
// ("aes256"); after normalisation it must match the policy's "AES256" value.
|
||||
func TestSSECaseInsensitiveNormalizationAES256(t *testing.T) {
|
||||
engine := newEngineWithPolicy(t, requiresAES256Policy)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut, "/", nil)
|
||||
req.RemoteAddr = "1.2.3.4:1234"
|
||||
req.Header.Set("X-Amz-Server-Side-Encryption", "aes256") // lowercase
|
||||
|
||||
result := engine.EvaluatePolicyForRequest("test-bucket", "object.txt", "PutObject", "*", req)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("expected Allow after AES256 case normalisation, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSECaseInsensitiveNormalizationKMS drives the aws:kms branch of the
|
||||
// normalisation through the production code path. The request carries
|
||||
// "AWS:KMS" (mixed case); after normalisation it must match "aws:kms".
|
||||
func TestSSECaseInsensitiveNormalizationKMS(t *testing.T) {
|
||||
engine := newEngineWithPolicy(t, requiresKMSPolicy)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut, "/", nil)
|
||||
req.RemoteAddr = "1.2.3.4:1234"
|
||||
req.Header.Set("X-Amz-Server-Side-Encryption", "AWS:KMS") // mixed case
|
||||
|
||||
result := engine.EvaluatePolicyForRequest("test-bucket", "object.txt", "PutObject", "*", req)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("expected Allow after aws:kms case normalisation, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSEMultipartAES256Exempt – UploadPart with AES256 inherited from
|
||||
// CreateMultipartUpload is not blocked by the Null("true") deny condition.
|
||||
func TestSSEMultipartAES256Exempt(t *testing.T) {
|
||||
engine := newEngineWithPolicy(t, multipartPolicy)
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", evalArgsWithSSE("s3:UploadPart", "AES256"))
|
||||
if result == PolicyResultDeny {
|
||||
t.Errorf("UploadPart with inherited AES256 should not be Deny, got Deny")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSEMultipartKMSExempt – UploadPart with aws:kms inherited from
|
||||
// CreateMultipartUpload is not blocked by the Null("true") deny condition.
|
||||
func TestSSEMultipartKMSExempt(t *testing.T) {
|
||||
engine := newEngineWithPolicy(t, multipartPolicy)
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", evalArgsWithSSE("s3:UploadPart", "aws:kms"))
|
||||
if result == PolicyResultDeny {
|
||||
t.Errorf("UploadPart with inherited aws:kms should not be Deny, got Deny")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSEMultipartNoSSEDenied – UploadPart for an upload that had no SSE
|
||||
// must still be denied by the Null("true") deny condition.
|
||||
func TestSSEMultipartNoSSEDenied(t *testing.T) {
|
||||
engine := newEngineWithPolicy(t, multipartPolicy)
|
||||
|
||||
// inheritedSSE="" means CreateMultipartUpload was sent without SSE
|
||||
result := engine.EvaluatePolicy("test-bucket", evalArgsWithSSE("s3:UploadPart", ""))
|
||||
if result != PolicyResultDeny {
|
||||
t.Errorf("UploadPart with no inherited SSE should be Deny, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSEUploadPartCopyKMSExempt – UploadPartCopy with aws:kms is also exempt.
|
||||
func TestSSEUploadPartCopyKMSExempt(t *testing.T) {
|
||||
engine := newEngineWithPolicy(t, multipartPolicy)
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", evalArgsWithSSE("s3:UploadPartCopy", "aws:kms"))
|
||||
if result == PolicyResultDeny {
|
||||
t.Errorf("UploadPartCopy with inherited aws:kms should not be Deny, got Deny")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSEPutObjectStillBlockedWithoutHeader – regular PutObject still denied without SSE.
|
||||
func TestSSEPutObjectStillBlockedWithoutHeader(t *testing.T) {
|
||||
engine := newEngineWithPolicy(t, multipartPolicy)
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", evalArgs("s3:PutObject", map[string][]string{}))
|
||||
if result != PolicyResultDeny {
|
||||
t.Errorf("PutObject without SSE should be Deny, got %v", result)
|
||||
}
|
||||
}
|
||||
122
weed/s3api/policy_engine/trusted_proxies.go
Normal file
122
weed/s3api/policy_engine/trusted_proxies.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
)
|
||||
|
||||
type TrustedProxies struct {
|
||||
ips map[string]struct{}
|
||||
cidrs map[string]*net.IPNet
|
||||
}
|
||||
|
||||
func NewTrustedProxies(whiteList []string) *TrustedProxies {
|
||||
tp := &TrustedProxies{
|
||||
ips: make(map[string]struct{}),
|
||||
cidrs: make(map[string]*net.IPNet),
|
||||
}
|
||||
for _, entry := range whiteList {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(entry, "/") {
|
||||
_, cidrnet, err := net.ParseCIDR(entry)
|
||||
if err != nil {
|
||||
glog.Errorf("Parse CIDR %s in s3 trusted_proxies failed: %v", entry, err)
|
||||
continue
|
||||
}
|
||||
tp.cidrs[entry] = cidrnet
|
||||
} else {
|
||||
ip := net.ParseIP(entry)
|
||||
if ip == nil {
|
||||
glog.Errorf("Parse IP %s in s3 trusted_proxies failed", entry)
|
||||
continue
|
||||
}
|
||||
tp.ips[ip.String()] = struct{}{}
|
||||
}
|
||||
}
|
||||
return tp
|
||||
}
|
||||
|
||||
func (tp *TrustedProxies) IsTrusted(ipStr string) bool {
|
||||
if tp == nil {
|
||||
return false
|
||||
}
|
||||
if _, ok := tp.ips[ipStr]; ok {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, cidrnet := range tp.cidrs {
|
||||
if cidrnet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ExtractSourceIP returns the client IP for aws:SourceIp condition evaluation.
|
||||
// The direct TCP peer is used unless it is in the trusted proxy allowlist, in
|
||||
// which case X-Forwarded-For (right-to-left, skipping trusted hops) then
|
||||
// X-Real-Ip are honored.
|
||||
func (tp *TrustedProxies) ExtractSourceIP(r *http.Request) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
remoteAddr := strings.TrimSpace(r.RemoteAddr)
|
||||
if remoteAddr == "" {
|
||||
return ""
|
||||
}
|
||||
if remoteAddr == "@" {
|
||||
return remoteAddr
|
||||
}
|
||||
host := remoteAddr
|
||||
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
host = h
|
||||
}
|
||||
remoteIP := net.ParseIP(host)
|
||||
if remoteIP == nil {
|
||||
return ""
|
||||
}
|
||||
if tp.IsTrusted(host) {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
entries := strings.Split(xff, ",")
|
||||
malformed := false
|
||||
for i := len(entries) - 1; i >= 0; i-- {
|
||||
candidate := strings.TrimSpace(entries[i])
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(candidate)
|
||||
if ip == nil {
|
||||
malformed = true
|
||||
break
|
||||
}
|
||||
if tp.IsTrusted(ip.String()) {
|
||||
continue
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
if !malformed {
|
||||
for _, candidate := range entries {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if ip := net.ParseIP(candidate); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if xRealIP := strings.TrimSpace(r.Header.Get("X-Real-Ip")); xRealIP != "" {
|
||||
if ip := net.ParseIP(xRealIP); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return remoteIP.String()
|
||||
}
|
||||
128
weed/s3api/policy_engine/trusted_proxies_test.go
Normal file
128
weed/s3api/policy_engine/trusted_proxies_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newReq(remoteAddr string, xff, xRealIP string) *http.Request {
|
||||
r := &http.Request{RemoteAddr: remoteAddr, Header: http.Header{}}
|
||||
if xff != "" {
|
||||
r.Header.Set("X-Forwarded-For", xff)
|
||||
}
|
||||
if xRealIP != "" {
|
||||
r.Header.Set("X-Real-Ip", xRealIP)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestTrustedProxies_IsTrusted(t *testing.T) {
|
||||
tp := NewTrustedProxies([]string{"10.0.0.5", "192.168.0.0/16"})
|
||||
if !tp.IsTrusted("10.0.0.5") {
|
||||
t.Error("bare IP should be trusted")
|
||||
}
|
||||
if !tp.IsTrusted("192.168.1.100") {
|
||||
t.Error("IP in CIDR should be trusted")
|
||||
}
|
||||
if tp.IsTrusted("8.8.8.8") {
|
||||
t.Error("public IP should not be trusted")
|
||||
}
|
||||
if tp.IsTrusted("not-an-ip") {
|
||||
t.Error("invalid IP should not be trusted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxies_CanonicalizesBareIPv6(t *testing.T) {
|
||||
tp := NewTrustedProxies([]string{"2001:0db8::1"})
|
||||
if !tp.IsTrusted("2001:db8::1") {
|
||||
t.Error("canonical IPv6 should match non-canonical allowlist entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxies_InvalidBareIPSkipped(t *testing.T) {
|
||||
tp := NewTrustedProxies([]string{"not-an-ip", "10.0.0.5"})
|
||||
if tp.IsTrusted("not-an-ip") {
|
||||
t.Error("invalid entry should not be stored")
|
||||
}
|
||||
if !tp.IsTrusted("10.0.0.5") {
|
||||
t.Error("valid entry after invalid one should still load")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxies_NilNotTrusted(t *testing.T) {
|
||||
var tp *TrustedProxies
|
||||
if tp.IsTrusted("127.0.0.1") {
|
||||
t.Error("nil TrustedProxies should not trust any IP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxies_ExtractSourceIP_DirectPeerWhenUntrusted(t *testing.T) {
|
||||
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
|
||||
r := newReq("203.0.113.5:1234", "8.8.8.8", "1.1.1.1")
|
||||
if got := tp.ExtractSourceIP(r); got != "203.0.113.5" {
|
||||
t.Errorf("untrusted peer: want 203.0.113.5, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxies_ExtractSourceIP_XForwardedFor(t *testing.T) {
|
||||
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
|
||||
r := newReq("10.0.0.1:1234", "8.8.8.8, 10.0.0.2", "")
|
||||
if got := tp.ExtractSourceIP(r); got != "8.8.8.8" {
|
||||
t.Errorf("trusted proxy: want 8.8.8.8, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxies_ExtractSourceIP_AllTrustedReturnsLeftmost(t *testing.T) {
|
||||
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
|
||||
r := newReq("10.0.0.1:1234", "10.0.0.5, 10.0.0.6", "")
|
||||
if got := tp.ExtractSourceIP(r); got != "10.0.0.5" {
|
||||
t.Errorf("all-trusted chain: want leftmost 10.0.0.5, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxies_ExtractSourceIP_MalformedXFFFallsBackToPeer(t *testing.T) {
|
||||
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
|
||||
r := newReq("10.0.0.1:1234", "8.8.8.8, garbage", "")
|
||||
if got := tp.ExtractSourceIP(r); got != "10.0.0.1" {
|
||||
t.Errorf("malformed XFF: want direct peer 10.0.0.1, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxies_ExtractSourceIP_XRealIP(t *testing.T) {
|
||||
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
|
||||
r := newReq("10.0.0.1:1234", "", "8.8.8.8")
|
||||
if got := tp.ExtractSourceIP(r); got != "8.8.8.8" {
|
||||
t.Errorf("X-Real-Ip: want 8.8.8.8, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxies_ExtractSourceIP_XForwardedForPreferredOverXRealIP(t *testing.T) {
|
||||
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
|
||||
r := newReq("10.0.0.1:1234", "8.8.8.8", "1.1.1.1")
|
||||
if got := tp.ExtractSourceIP(r); got != "8.8.8.8" {
|
||||
t.Errorf("XFF should win over X-Real-Ip: want 8.8.8.8, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxies_ExtractSourceIP_NoTrustedProxiesUsesPeer(t *testing.T) {
|
||||
tp := NewTrustedProxies(nil)
|
||||
r := newReq("10.0.0.1:1234", "8.8.8.8", "1.1.1.1")
|
||||
if got := tp.ExtractSourceIP(r); got != "10.0.0.1" {
|
||||
t.Errorf("empty allowlist: want 10.0.0.1, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxies_ExtractSourceIP_NilRequest(t *testing.T) {
|
||||
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
|
||||
if got := tp.ExtractSourceIP(nil); got != "" {
|
||||
t.Errorf("nil request: want empty, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxies_ExtractSourceIP_UnixSocket(t *testing.T) {
|
||||
tp := NewTrustedProxies([]string{"10.0.0.0/24"})
|
||||
r := newReq("@", "", "")
|
||||
if got := tp.ExtractSourceIP(r); got != "@" {
|
||||
t.Errorf("unix socket: want @, got %s", got)
|
||||
}
|
||||
}
|
||||
832
weed/s3api/policy_engine/types.go
Normal file
832
weed/s3api/policy_engine/types.go
Normal file
@@ -0,0 +1,832 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
s3const "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
)
|
||||
|
||||
// Policy Engine Types
|
||||
//
|
||||
// This package provides enhanced AWS S3-compatible policy types with improved type safety.
|
||||
//
|
||||
// MIGRATION COMPLETE:
|
||||
// This is now the unified PolicyDocument type used throughout the SeaweedFS codebase.
|
||||
// The previous duplicate PolicyDocument types in iamapi and credential packages have
|
||||
// been migrated to use these enhanced types, providing:
|
||||
// - Principal specifications
|
||||
// - Complex conditions (IP, time, string patterns, etc.)
|
||||
// - Flexible string/array types with proper JSON marshaling
|
||||
// - Policy compilation for performance
|
||||
//
|
||||
// All policy operations now use this single, consistent type definition.
|
||||
|
||||
// Constants for policy validation
|
||||
const (
|
||||
// PolicyVersion2012_10_17 is the standard AWS policy version
|
||||
PolicyVersion2012_10_17 = "2012-10-17"
|
||||
)
|
||||
|
||||
var (
|
||||
// PolicyVariableRegex detects AWS IAM policy variables like ${aws:username}
|
||||
PolicyVariableRegex = regexp.MustCompile(`\$\{([^}]+)\}`)
|
||||
|
||||
// multipartActionSet contains all S3 multipart upload actions
|
||||
// These are treated as equivalent to s3:PutObject for authorization purposes
|
||||
multipartActionSet = map[string]bool{
|
||||
s3const.S3_ACTION_CREATE_MULTIPART: true,
|
||||
s3const.S3_ACTION_UPLOAD_PART: true,
|
||||
s3const.S3_ACTION_UPLOAD_PART_COPY: true,
|
||||
s3const.S3_ACTION_COMPLETE_MULTIPART: true,
|
||||
s3const.S3_ACTION_ABORT_MULTIPART: true,
|
||||
s3const.S3_ACTION_LIST_PARTS: true,
|
||||
s3const.S3_ACTION_LIST_MULTIPART_UPLOADS: true,
|
||||
}
|
||||
|
||||
// lowerMultipartActionSet keys the same actions for case-insensitive lookup.
|
||||
lowerMultipartActionSet = func() map[string]bool {
|
||||
lowered := make(map[string]bool, len(multipartActionSet))
|
||||
for action := range multipartActionSet {
|
||||
lowered[strings.ToLower(action)] = true
|
||||
}
|
||||
return lowered
|
||||
}()
|
||||
)
|
||||
|
||||
// StringOrStringSlice represents a value that can be either a string or []string
|
||||
type StringOrStringSlice struct {
|
||||
values []string
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler for StringOrStringSlice
|
||||
func (s *StringOrStringSlice) UnmarshalJSON(data []byte) error {
|
||||
// Try unmarshaling as string first
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err == nil {
|
||||
s.values = []string{str}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try unmarshaling as []string
|
||||
var strs []string
|
||||
if err := json.Unmarshal(data, &strs); err == nil {
|
||||
s.values = strs
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("value must be string or []string")
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler for StringOrStringSlice
|
||||
func (s StringOrStringSlice) MarshalJSON() ([]byte, error) {
|
||||
if len(s.values) == 1 {
|
||||
return json.Marshal(s.values[0])
|
||||
}
|
||||
return json.Marshal(s.values)
|
||||
}
|
||||
|
||||
// Strings returns the slice of strings. Nil-safe for pointer receivers.
|
||||
func (s *StringOrStringSlice) Strings() []string {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.values
|
||||
}
|
||||
|
||||
// NewStringOrStringSlice creates a new StringOrStringSlice from strings
|
||||
func NewStringOrStringSlice(values ...string) StringOrStringSlice {
|
||||
return StringOrStringSlice{values: values}
|
||||
}
|
||||
|
||||
// NewStringOrStringSlicePtr creates a new *StringOrStringSlice from strings
|
||||
func NewStringOrStringSlicePtr(values ...string) *StringOrStringSlice {
|
||||
return &StringOrStringSlice{values: values}
|
||||
}
|
||||
|
||||
// CloneStringOrStringSlice returns a copy with its own backing slice.
|
||||
func CloneStringOrStringSlice(value StringOrStringSlice) StringOrStringSlice {
|
||||
return StringOrStringSlice{values: append([]string(nil), value.values...)}
|
||||
}
|
||||
|
||||
// allowedPrincipalKeys is the set of principal-type keys AWS allows inside the
|
||||
// Principal/NotPrincipal object form.
|
||||
var allowedPrincipalKeys = map[string]struct{}{
|
||||
"AWS": {},
|
||||
"Service": {},
|
||||
"Federated": {},
|
||||
"CanonicalUser": {},
|
||||
}
|
||||
|
||||
// PolicyPrincipal represents the Principal element of a policy statement.
|
||||
//
|
||||
// AWS accepts three shapes (see the IAM "Principal" element reference):
|
||||
//
|
||||
// "Principal": "*" // all principals (anonymous)
|
||||
// "Principal": { "AWS": "arn:aws:iam::123:user/x" } // single, keyed by type
|
||||
// "Principal": { "AWS": ["arn:...", "999999999999"] } // array of values
|
||||
//
|
||||
// The object may key on "AWS", "Service", "Federated" or "CanonicalUser", and
|
||||
// each value is itself a string or an array of strings. For backward
|
||||
// compatibility SeaweedFS also accepts a bare string or array of strings.
|
||||
//
|
||||
// Authorization matches the request principal ARN against the flat set of
|
||||
// principal values, so every keyed value is flattened into one list. The
|
||||
// original JSON is preserved so GetBucketPolicy returns the exact shape the
|
||||
// caller submitted; this keeps PutBucketPolicy/GetBucketPolicy idempotent for
|
||||
// infrastructure-as-code tools (Terraform, Ansible) that diff the returned
|
||||
// policy against the submitted one.
|
||||
type PolicyPrincipal struct {
|
||||
values []string
|
||||
raw json.RawMessage
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler for PolicyPrincipal.
|
||||
func (p *PolicyPrincipal) UnmarshalJSON(data []byte) error {
|
||||
// Preserve the original encoding for a faithful round-trip on marshal.
|
||||
p.raw = append(json.RawMessage(nil), data...)
|
||||
|
||||
// Bare string, e.g. "*"
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err == nil {
|
||||
p.values = []string{str}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bare array of strings (SeaweedFS extension; not standard AWS).
|
||||
var strs []string
|
||||
if err := json.Unmarshal(data, &strs); err == nil {
|
||||
p.values = strs
|
||||
return nil
|
||||
}
|
||||
|
||||
// AWS object form: {"AWS": <string|[]string>, "Service": ..., "Federated": ...,
|
||||
// "CanonicalUser": ...}. Each value reuses StringOrStringSlice so it accepts a
|
||||
// single string or an array. All keyed values are flattened for matching.
|
||||
var obj map[string]StringOrStringSlice
|
||||
if err := json.Unmarshal(data, &obj); err == nil && len(obj) > 0 {
|
||||
// Sort keys so the flattened order is deterministic.
|
||||
keys := make([]string, 0, len(obj))
|
||||
for k := range obj {
|
||||
if _, ok := allowedPrincipalKeys[k]; !ok {
|
||||
return fmt.Errorf("unsupported Principal type %q (expected AWS, Service, Federated or CanonicalUser)", k)
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
values := make([]string, 0, len(obj))
|
||||
for _, k := range keys {
|
||||
v := obj[k]
|
||||
items := v.Strings()
|
||||
if len(items) == 0 {
|
||||
return fmt.Errorf("Principal %q must list at least one value", k)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item == "" {
|
||||
return fmt.Errorf("Principal %q must not contain empty values", k)
|
||||
}
|
||||
}
|
||||
values = append(values, items...)
|
||||
}
|
||||
p.values = values
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf(`Principal must be a string, an array of strings, or an object such as {"AWS": ["arn:..."]}`)
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler for PolicyPrincipal.
|
||||
func (p PolicyPrincipal) MarshalJSON() ([]byte, error) {
|
||||
// Echo the original JSON when we parsed it, so the shape is preserved.
|
||||
if len(p.raw) > 0 {
|
||||
return p.raw, nil
|
||||
}
|
||||
// Programmatically-constructed principals marshal as a bare string/array.
|
||||
if len(p.values) == 1 {
|
||||
return json.Marshal(p.values[0])
|
||||
}
|
||||
return json.Marshal(p.values)
|
||||
}
|
||||
|
||||
// Strings returns the flattened principal values. Nil-safe for pointer receivers.
|
||||
func (p *PolicyPrincipal) Strings() []string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p.values
|
||||
}
|
||||
|
||||
// NewPolicyPrincipalPtr builds a *PolicyPrincipal from flat values (no object wrapping).
|
||||
func NewPolicyPrincipalPtr(values ...string) *PolicyPrincipal {
|
||||
return &PolicyPrincipal{values: values}
|
||||
}
|
||||
|
||||
// ClonePolicyPrincipal deep-copies a *PolicyPrincipal (nil-safe).
|
||||
func ClonePolicyPrincipal(p *PolicyPrincipal) *PolicyPrincipal {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return &PolicyPrincipal{
|
||||
values: append([]string(nil), p.values...),
|
||||
raw: append(json.RawMessage(nil), p.raw...),
|
||||
}
|
||||
}
|
||||
|
||||
// PolicyConditions represents policy conditions with proper typing
|
||||
type PolicyConditions map[string]map[string]StringOrStringSlice
|
||||
|
||||
// PolicyDocument represents an AWS S3 bucket policy document
|
||||
type PolicyDocument struct {
|
||||
Version string `json:"Version"`
|
||||
Statement []PolicyStatement `json:"Statement"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler for PolicyDocument
|
||||
func (p *PolicyDocument) UnmarshalJSON(data []byte) error {
|
||||
type Alias PolicyDocument
|
||||
aux := &struct {
|
||||
Statement json.RawMessage `json:"Statement"`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(p),
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Try unmarshaling as []PolicyStatement first
|
||||
var statements []PolicyStatement
|
||||
if err := json.Unmarshal(aux.Statement, &statements); err == nil {
|
||||
p.Statement = statements
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try unmarshaling as single PolicyStatement
|
||||
var statement PolicyStatement
|
||||
if err := json.Unmarshal(aux.Statement, &statement); err == nil {
|
||||
p.Statement = []PolicyStatement{statement}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("Statement must be an array or a single object")
|
||||
}
|
||||
|
||||
// PolicyStatement represents a single policy statement
|
||||
type PolicyStatement struct {
|
||||
Sid string `json:"Sid,omitempty"`
|
||||
Effect PolicyEffect `json:"Effect"`
|
||||
Principal *PolicyPrincipal `json:"Principal,omitempty"`
|
||||
NotPrincipal *PolicyPrincipal `json:"NotPrincipal,omitempty"`
|
||||
Action StringOrStringSlice `json:"Action"`
|
||||
Resource *StringOrStringSlice `json:"Resource,omitempty"`
|
||||
NotResource *StringOrStringSlice `json:"NotResource,omitempty"`
|
||||
Condition PolicyConditions `json:"Condition,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyEffect represents Allow or Deny
|
||||
type PolicyEffect string
|
||||
|
||||
const (
|
||||
PolicyEffectAllow PolicyEffect = "Allow"
|
||||
PolicyEffectDeny PolicyEffect = "Deny"
|
||||
)
|
||||
|
||||
// PolicyEvaluationArgs contains the arguments for policy evaluation
|
||||
type PolicyEvaluationArgs struct {
|
||||
Action string
|
||||
Resource string
|
||||
Principal string
|
||||
Conditions map[string][]string
|
||||
// ObjectEntry is the object's metadata from entry.Extended.
|
||||
// Used for evaluating conditions like s3:ExistingObjectTag/<tag-key>.
|
||||
// Tags are stored with s3_constants.AmzObjectTaggingPrefix (X-Amz-Tagging-) prefix.
|
||||
// Can be nil for bucket-level operations or when object doesn't exist.
|
||||
ObjectEntry map[string][]byte
|
||||
// Claims are JWT claims for jwt:* policy variables (can be nil)
|
||||
Claims map[string]interface{}
|
||||
// InheritedSSEAlgorithm is the canonical SSE algorithm ("AES256" or "aws:kms")
|
||||
// inherited from the CreateMultipartUpload request for UploadPart and
|
||||
// UploadPartCopy actions. The empty string means no SSE was used.
|
||||
InheritedSSEAlgorithm string
|
||||
}
|
||||
|
||||
// PolicyCache for caching compiled policies
|
||||
type PolicyCache struct {
|
||||
policies map[string]*CompiledPolicy
|
||||
}
|
||||
|
||||
// CompiledPolicy represents a policy that has been compiled for efficient evaluation
|
||||
type CompiledPolicy struct {
|
||||
Document *PolicyDocument
|
||||
Statements []*CompiledStatement
|
||||
}
|
||||
|
||||
// CompiledStatement represents a compiled policy statement
|
||||
type CompiledStatement struct {
|
||||
Statement *PolicyStatement
|
||||
ActionMatchers []*wildcard.WildcardMatcher
|
||||
ResourceMatchers []*wildcard.WildcardMatcher
|
||||
PrincipalMatchers []*wildcard.WildcardMatcher
|
||||
// Keep regex patterns for backward compatibility
|
||||
ActionPatterns []*regexp.Regexp
|
||||
ResourcePatterns []*regexp.Regexp
|
||||
PrincipalPatterns []*regexp.Regexp
|
||||
|
||||
// dynamic patterns that require variable substitution before matching
|
||||
DynamicActionPatterns []string
|
||||
DynamicResourcePatterns []string
|
||||
DynamicPrincipalPatterns []string
|
||||
|
||||
// NotPrincipal patterns (principal should NOT match these)
|
||||
NotPrincipalMatchers []*wildcard.WildcardMatcher
|
||||
NotPrincipalPatterns []*regexp.Regexp
|
||||
DynamicNotPrincipalPatterns []string
|
||||
|
||||
// NotResource patterns (resource should NOT match these)
|
||||
NotResourcePatterns []*regexp.Regexp
|
||||
NotResourceMatchers []*wildcard.WildcardMatcher
|
||||
DynamicNotResourcePatterns []string
|
||||
}
|
||||
|
||||
// NewPolicyCache creates a new policy cache
|
||||
func NewPolicyCache() *PolicyCache {
|
||||
return &PolicyCache{
|
||||
policies: make(map[string]*CompiledPolicy),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePolicy validates a policy document
|
||||
func ValidatePolicy(policyDoc *PolicyDocument) error {
|
||||
if policyDoc.Version != PolicyVersion2012_10_17 {
|
||||
return fmt.Errorf("unsupported policy version: %s", policyDoc.Version)
|
||||
}
|
||||
|
||||
if len(policyDoc.Statement) == 0 {
|
||||
return fmt.Errorf("policy must contain at least one statement")
|
||||
}
|
||||
|
||||
for i := range policyDoc.Statement {
|
||||
if err := validateStatement(&policyDoc.Statement[i]); err != nil {
|
||||
return fmt.Errorf("invalid statement %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateStatement validates a single policy statement
|
||||
func validateStatement(stmt *PolicyStatement) error {
|
||||
if stmt.Effect != PolicyEffectAllow && stmt.Effect != PolicyEffectDeny {
|
||||
return fmt.Errorf("invalid effect: %s", stmt.Effect)
|
||||
}
|
||||
|
||||
if len(stmt.Action.Strings()) == 0 {
|
||||
return fmt.Errorf("action is required")
|
||||
}
|
||||
|
||||
if len(stmt.Resource.Strings()) == 0 && len(stmt.NotResource.Strings()) == 0 {
|
||||
return fmt.Errorf("statement must specify Resource or NotResource")
|
||||
}
|
||||
|
||||
// AWS does not allow both Principal and NotPrincipal in the same statement.
|
||||
if stmt.Principal != nil && stmt.NotPrincipal != nil {
|
||||
return fmt.Errorf("statement cannot specify both Principal and NotPrincipal")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParsePolicy parses a policy JSON string
|
||||
func ParsePolicy(policyJSON string) (*PolicyDocument, error) {
|
||||
var policy PolicyDocument
|
||||
if err := json.Unmarshal([]byte(policyJSON), &policy); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse policy JSON: %w", err)
|
||||
}
|
||||
|
||||
if err := ValidatePolicy(&policy); err != nil {
|
||||
return nil, fmt.Errorf("invalid policy: %w", err)
|
||||
}
|
||||
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
// CompilePolicy compiles a policy for efficient evaluation
|
||||
func CompilePolicy(policy *PolicyDocument) (*CompiledPolicy, error) {
|
||||
compiled := &CompiledPolicy{
|
||||
Document: policy,
|
||||
Statements: make([]*CompiledStatement, len(policy.Statement)),
|
||||
}
|
||||
|
||||
for i := range policy.Statement {
|
||||
stmt := &policy.Statement[i]
|
||||
compiledStmt, err := compileStatement(stmt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile statement %d: %v", i, err)
|
||||
}
|
||||
compiled.Statements[i] = compiledStmt
|
||||
}
|
||||
|
||||
return compiled, nil
|
||||
}
|
||||
|
||||
// compileStatement compiles a single policy statement
|
||||
func compileStatement(stmt *PolicyStatement) (*CompiledStatement, error) {
|
||||
resStrings := slices.Clone(stmt.Resource.Strings())
|
||||
notResStrings := slices.Clone(stmt.NotResource.Strings())
|
||||
compiled := &CompiledStatement{
|
||||
Statement: &PolicyStatement{
|
||||
Sid: stmt.Sid,
|
||||
Effect: stmt.Effect,
|
||||
Action: stmt.Action,
|
||||
},
|
||||
}
|
||||
|
||||
// Deep clone Principal / NotPrincipal if present
|
||||
if stmt.Principal != nil {
|
||||
compiled.Statement.Principal = ClonePolicyPrincipal(stmt.Principal)
|
||||
}
|
||||
if stmt.NotPrincipal != nil {
|
||||
compiled.Statement.NotPrincipal = ClonePolicyPrincipal(stmt.NotPrincipal)
|
||||
}
|
||||
|
||||
// Deep clone Resource/NotResource into the internal statement as well for completeness
|
||||
if stmt.Resource != nil {
|
||||
resourceClone := *stmt.Resource
|
||||
resourceClone.values = slices.Clone(stmt.Resource.values)
|
||||
compiled.Statement.Resource = &resourceClone
|
||||
}
|
||||
if stmt.NotResource != nil {
|
||||
notResourceClone := *stmt.NotResource
|
||||
notResourceClone.values = slices.Clone(stmt.NotResource.values)
|
||||
compiled.Statement.NotResource = ¬ResourceClone
|
||||
}
|
||||
compiled.Statement.Action.values = slices.Clone(stmt.Action.values)
|
||||
|
||||
// Deep clone Condition map
|
||||
if stmt.Condition != nil {
|
||||
compiled.Statement.Condition = make(PolicyConditions)
|
||||
for k, v := range stmt.Condition {
|
||||
innerMap := make(map[string]StringOrStringSlice)
|
||||
for ik, iv := range v {
|
||||
innerMap[ik] = StringOrStringSlice{values: slices.Clone(iv.values)}
|
||||
}
|
||||
compiled.Statement.Condition[k] = innerMap
|
||||
}
|
||||
}
|
||||
|
||||
// Compile action patterns and matchers
|
||||
for _, action := range stmt.Action.Strings() {
|
||||
if action == "" {
|
||||
continue
|
||||
}
|
||||
// Check for dynamic variables
|
||||
if PolicyVariableRegex.MatchString(action) {
|
||||
compiled.DynamicActionPatterns = append(compiled.DynamicActionPatterns, action)
|
||||
continue
|
||||
}
|
||||
|
||||
pattern, err := compilePattern(action)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile action pattern %s: %v", action, err)
|
||||
}
|
||||
compiled.ActionPatterns = append(compiled.ActionPatterns, pattern)
|
||||
|
||||
matcher, err := wildcard.NewWildcardMatcher(action)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create action matcher %s: %v", action, err)
|
||||
}
|
||||
compiled.ActionMatchers = append(compiled.ActionMatchers, matcher)
|
||||
}
|
||||
|
||||
// Compile resource patterns and matchers
|
||||
for _, resource := range resStrings {
|
||||
if resource == "" {
|
||||
continue
|
||||
}
|
||||
// Check for dynamic variables
|
||||
if PolicyVariableRegex.MatchString(resource) {
|
||||
compiled.DynamicResourcePatterns = append(compiled.DynamicResourcePatterns, resource)
|
||||
continue
|
||||
}
|
||||
|
||||
pattern, err := compilePattern(resource)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile resource pattern %s: %v", resource, err)
|
||||
}
|
||||
compiled.ResourcePatterns = append(compiled.ResourcePatterns, pattern)
|
||||
|
||||
matcher, err := wildcard.NewWildcardMatcher(resource)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create resource matcher %s: %v", resource, err)
|
||||
}
|
||||
compiled.ResourceMatchers = append(compiled.ResourceMatchers, matcher)
|
||||
}
|
||||
|
||||
// Compile principal patterns and matchers if present
|
||||
if stmt.Principal != nil && len(stmt.Principal.Strings()) > 0 {
|
||||
for _, principal := range stmt.Principal.Strings() {
|
||||
if principal == "" {
|
||||
continue
|
||||
}
|
||||
// Check for dynamic variables
|
||||
if PolicyVariableRegex.MatchString(principal) {
|
||||
compiled.DynamicPrincipalPatterns = append(compiled.DynamicPrincipalPatterns, principal)
|
||||
continue
|
||||
}
|
||||
|
||||
pattern, err := compilePattern(principal)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile principal pattern %s: %v", principal, err)
|
||||
}
|
||||
compiled.PrincipalPatterns = append(compiled.PrincipalPatterns, pattern)
|
||||
|
||||
matcher, err := wildcard.NewWildcardMatcher(principal)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create principal matcher %s: %v", principal, err)
|
||||
}
|
||||
compiled.PrincipalMatchers = append(compiled.PrincipalMatchers, matcher)
|
||||
}
|
||||
}
|
||||
|
||||
// Compile NotPrincipal patterns and matchers (principal should NOT match these)
|
||||
if stmt.NotPrincipal != nil && len(stmt.NotPrincipal.Strings()) > 0 {
|
||||
for _, notPrincipal := range stmt.NotPrincipal.Strings() {
|
||||
if notPrincipal == "" {
|
||||
continue
|
||||
}
|
||||
// Check for dynamic variables
|
||||
if PolicyVariableRegex.MatchString(notPrincipal) {
|
||||
compiled.DynamicNotPrincipalPatterns = append(compiled.DynamicNotPrincipalPatterns, notPrincipal)
|
||||
continue
|
||||
}
|
||||
|
||||
pattern, err := compilePattern(notPrincipal)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile NotPrincipal pattern %s: %v", notPrincipal, err)
|
||||
}
|
||||
compiled.NotPrincipalPatterns = append(compiled.NotPrincipalPatterns, pattern)
|
||||
|
||||
matcher, err := wildcard.NewWildcardMatcher(notPrincipal)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create NotPrincipal matcher %s: %v", notPrincipal, err)
|
||||
}
|
||||
compiled.NotPrincipalMatchers = append(compiled.NotPrincipalMatchers, matcher)
|
||||
}
|
||||
}
|
||||
|
||||
// Compile NotResource patterns (resource should NOT match these)
|
||||
if len(notResStrings) > 0 {
|
||||
for _, notResource := range notResStrings {
|
||||
if notResource == "" {
|
||||
continue
|
||||
}
|
||||
// Check for dynamic variables
|
||||
if PolicyVariableRegex.MatchString(notResource) {
|
||||
compiled.DynamicNotResourcePatterns = append(compiled.DynamicNotResourcePatterns, notResource)
|
||||
continue
|
||||
}
|
||||
|
||||
pattern, err := compilePattern(notResource)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile NotResource pattern %s: %v", notResource, err)
|
||||
}
|
||||
compiled.NotResourcePatterns = append(compiled.NotResourcePatterns, pattern)
|
||||
|
||||
matcher, err := wildcard.NewWildcardMatcher(notResource)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create NotResource matcher %s: %v", notResource, err)
|
||||
}
|
||||
compiled.NotResourceMatchers = append(compiled.NotResourceMatchers, matcher)
|
||||
|
||||
// Debug log
|
||||
// fmt.Printf("Compiled NotResource: %s\n", notResource)
|
||||
}
|
||||
}
|
||||
|
||||
return compiled, nil
|
||||
}
|
||||
|
||||
// compilePattern compiles a wildcard pattern to regex
|
||||
func compilePattern(pattern string) (*regexp.Regexp, error) {
|
||||
return wildcard.CompileWildcardPattern(pattern)
|
||||
}
|
||||
|
||||
// normalizeToStringSlice converts various types to string slice - kept for backward compatibility
|
||||
func normalizeToStringSlice(value interface{}) []string {
|
||||
result, err := normalizeToStringSliceWithError(value)
|
||||
if err != nil {
|
||||
glog.Warningf("unexpected type for policy value: %T, error: %v", value, err)
|
||||
return []string{fmt.Sprintf("%v", value)}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// normalizeToStringSliceWithError converts various types to string slice with proper error handling
|
||||
func normalizeToStringSliceWithError(value interface{}) ([]string, error) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return []string{v}, nil
|
||||
case []string:
|
||||
return v, nil
|
||||
case []interface{}:
|
||||
result := make([]string, len(v))
|
||||
for i, item := range v {
|
||||
result[i] = fmt.Sprintf("%v", item)
|
||||
}
|
||||
return result, nil
|
||||
case StringOrStringSlice:
|
||||
return v.Strings(), nil
|
||||
case *StringOrStringSlice:
|
||||
return v.Strings(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected type for policy value: %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
// GetBucketFromResource extracts bucket name from resource ARN
|
||||
func GetBucketFromResource(resource string) string {
|
||||
// Handle ARN format: arn:aws:s3:::bucket-name/object-path
|
||||
if strings.HasPrefix(resource, "arn:aws:s3:::") {
|
||||
parts := strings.SplitN(resource[13:], "/", 2)
|
||||
return parts[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MatchesAction checks if an action matches any of the compiled action matchers.
|
||||
// It also implicitly grants multipart upload actions if s3:PutObject is allowed,
|
||||
// since multipart upload is an implementation detail of putting objects.
|
||||
func (cs *CompiledStatement) MatchesAction(action string) bool {
|
||||
var matchedAction, hasPutObjectPermission bool
|
||||
|
||||
// Scan all matchers to check for both direct action match and s3:PutObject grant
|
||||
for _, matcher := range cs.ActionMatchers {
|
||||
if matcher.Match(action) {
|
||||
matchedAction = true
|
||||
}
|
||||
if !hasPutObjectPermission && matcher.Match("s3:PutObject") {
|
||||
hasPutObjectPermission = true
|
||||
}
|
||||
}
|
||||
|
||||
// Return true if action matched directly or if multipart action with PutObject permission
|
||||
if matchedAction {
|
||||
return true
|
||||
}
|
||||
|
||||
// Multipart upload operations are part of s3:PutObject permission
|
||||
// If s3:PutObject is allowed, implicitly allow multipart operations
|
||||
if hasPutObjectPermission && multipartActionSet[action] {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// statementMayAllowAction reports whether a statement's actions can match the
|
||||
// action, including the multipart operations that ride on s3:PutObject. It is
|
||||
// looser than either evaluator on purpose: the IAM authorizer matches action
|
||||
// names case-insensitively and a policy variable resolves per request, so a
|
||||
// classifier that reads them strictly would miss a grant that is really there.
|
||||
func statementMayAllowAction(actions []string, action string) bool {
|
||||
for _, pattern := range actions {
|
||||
if actionPatternMayMatch(pattern, action) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if !lowerMultipartActionSet[strings.ToLower(action)] {
|
||||
return false
|
||||
}
|
||||
for _, pattern := range actions {
|
||||
if actionPatternMayMatch(pattern, s3const.S3_ACTION_PUT_OBJECT) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func actionPatternMayMatch(pattern, action string) bool {
|
||||
if PolicyVariableRegex.MatchString(pattern) {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(pattern, action) {
|
||||
return true
|
||||
}
|
||||
return wildcard.MatchesWildcard(strings.ToLower(pattern), strings.ToLower(action))
|
||||
}
|
||||
|
||||
// MatchesResource checks if a resource matches any of the compiled resource matchers
|
||||
func (cs *CompiledStatement) MatchesResource(resource string) bool {
|
||||
for _, matcher := range cs.ResourceMatchers {
|
||||
if matcher.Match(resource) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MatchesPrincipal checks if a principal matches any of the compiled principal matchers
|
||||
func (cs *CompiledStatement) MatchesPrincipal(principal string) bool {
|
||||
// If no principals specified, match all
|
||||
if len(cs.PrincipalMatchers) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, matcher := range cs.PrincipalMatchers {
|
||||
if matcher.Match(principal) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesPrincipalSet reports whether the request principal matches any static
|
||||
// matcher or dynamic (policy-variable) pattern in the given set.
|
||||
func (cs *CompiledStatement) matchesPrincipalSet(args *PolicyEvaluationArgs, matchers []*wildcard.WildcardMatcher, dynamic []string) bool {
|
||||
for _, matcher := range matchers {
|
||||
if matcher.Match(args.Principal) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, pattern := range dynamic {
|
||||
substituted := SubstituteVariables(pattern, args.Conditions, args.Claims)
|
||||
if FastMatchesWildcard(substituted, args.Principal) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EvaluateStatement evaluates a compiled statement against the given arguments
|
||||
func (cs *CompiledStatement) EvaluateStatement(args *PolicyEvaluationArgs) bool {
|
||||
// Check if action matches
|
||||
if !cs.MatchesAction(args.Action) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if resource matches
|
||||
if !cs.MatchesResource(args.Resource) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Principal / NotPrincipal (mutually exclusive per AWS): NotPrincipal makes
|
||||
// the statement apply to everyone EXCEPT the named principals; a plain
|
||||
// Principal requires a match. Both static matchers and dynamic
|
||||
// (policy-variable) patterns are honored.
|
||||
if len(cs.NotPrincipalMatchers) > 0 || len(cs.DynamicNotPrincipalPatterns) > 0 {
|
||||
if cs.matchesPrincipalSet(args, cs.NotPrincipalMatchers, cs.DynamicNotPrincipalPatterns) {
|
||||
return false
|
||||
}
|
||||
} else if len(cs.PrincipalMatchers) > 0 || len(cs.DynamicPrincipalPatterns) > 0 {
|
||||
if !cs.matchesPrincipalSet(args, cs.PrincipalMatchers, cs.DynamicPrincipalPatterns) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// EvaluatePolicy evaluates a compiled policy against the given arguments
|
||||
func (cp *CompiledPolicy) EvaluatePolicy(args *PolicyEvaluationArgs) (bool, PolicyEffect) {
|
||||
var explicitAllow, explicitDeny bool
|
||||
|
||||
// Evaluate each statement
|
||||
for _, stmt := range cp.Statements {
|
||||
if stmt.EvaluateStatement(args) {
|
||||
if stmt.Statement.Effect == PolicyEffectAllow {
|
||||
explicitAllow = true
|
||||
} else if stmt.Statement.Effect == PolicyEffectDeny {
|
||||
explicitDeny = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AWS policy evaluation logic: explicit deny overrides allow
|
||||
if explicitDeny {
|
||||
return false, PolicyEffectDeny
|
||||
}
|
||||
if explicitAllow {
|
||||
return true, PolicyEffectAllow
|
||||
}
|
||||
|
||||
// No matching statements - implicit deny
|
||||
return false, PolicyEffectDeny
|
||||
}
|
||||
|
||||
// FastMatchesWildcard uses cached WildcardMatcher for performance
|
||||
func FastMatchesWildcard(pattern, str string) bool {
|
||||
matcher, err := wildcard.GetCachedWildcardMatcher(pattern)
|
||||
if err != nil {
|
||||
glog.Errorf("Error getting cached WildcardMatcher for pattern %s: %v", pattern, err)
|
||||
// Fall back to the original implementation
|
||||
return wildcard.MatchesWildcard(pattern, str)
|
||||
}
|
||||
return matcher.Match(str)
|
||||
}
|
||||
17
weed/s3api/policy_engine/types_test.go
Normal file
17
weed/s3api/policy_engine/types_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCloneStringOrStringSliceCopiesBackingSlice(t *testing.T) {
|
||||
original := NewStringOrStringSlice("s3:GetObject", "s3:PutObject")
|
||||
|
||||
cloned := CloneStringOrStringSlice(original)
|
||||
cloned.values[0] = "s3:DeleteObject"
|
||||
|
||||
assert.Equal(t, []string{"s3:GetObject", "s3:PutObject"}, original.Strings())
|
||||
assert.Equal(t, []string{"s3:DeleteObject", "s3:PutObject"}, cloned.Strings())
|
||||
}
|
||||
Reference in New Issue
Block a user