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:
295
weed/iam/policy/aws_iam_compliance_test.go
Normal file
295
weed/iam/policy/aws_iam_compliance_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAWSIAMMatch(t *testing.T) {
|
||||
evalCtx := &EvaluationContext{
|
||||
RequestContext: map[string]interface{}{
|
||||
"aws:username": "testuser",
|
||||
"saml:username": "john.doe",
|
||||
"oidc:sub": "user123",
|
||||
"aws:userid": "AIDACKCEVSQ6C2EXAMPLE",
|
||||
"aws:principaltype": "User",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
value string
|
||||
evalCtx *EvaluationContext
|
||||
expected bool
|
||||
}{
|
||||
// Case insensitivity tests
|
||||
{
|
||||
name: "case insensitive exact match",
|
||||
pattern: "S3:GetObject",
|
||||
value: "s3:getobject",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive wildcard match",
|
||||
pattern: "S3:Get*",
|
||||
value: "s3:getobject",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
// Policy variable expansion tests
|
||||
{
|
||||
name: "AWS username variable expansion",
|
||||
pattern: "arn:aws:s3:::mybucket/${aws:username}/*",
|
||||
value: "arn:aws:s3:::mybucket/testuser/document.pdf",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "SAML username variable expansion",
|
||||
pattern: "home/${saml:username}/*",
|
||||
value: "home/john.doe/private.txt",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "OIDC subject variable expansion",
|
||||
pattern: "users/${oidc:sub}/data",
|
||||
value: "users/user123/data",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
// Mixed case and variable tests
|
||||
{
|
||||
name: "case insensitive with variable",
|
||||
pattern: "S3:GetObject/${aws:username}/*",
|
||||
value: "s3:getobject/testuser/file.txt",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
// Universal wildcard
|
||||
{
|
||||
name: "universal wildcard",
|
||||
pattern: "*",
|
||||
value: "anything",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
// Question mark wildcard
|
||||
{
|
||||
name: "question mark wildcard",
|
||||
pattern: "file?.txt",
|
||||
value: "file1.txt",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
// No match cases
|
||||
{
|
||||
name: "no match different pattern",
|
||||
pattern: "s3:PutObject",
|
||||
value: "s3:GetObject",
|
||||
evalCtx: evalCtx,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "variable not expanded due to missing context",
|
||||
pattern: "users/${aws:username}/data",
|
||||
value: "users/${aws:username}/data",
|
||||
evalCtx: nil,
|
||||
expected: true, // Should match literally when no context
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := awsIAMMatch(tt.pattern, tt.value, tt.evalCtx)
|
||||
assert.Equal(t, tt.expected, result, "AWS IAM match result should match expected")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesActionsMultipartExpansion(t *testing.T) {
|
||||
engine := &PolicyEngine{initialized: true}
|
||||
evalCtx := &EvaluationContext{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actions []string
|
||||
requestedAction string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "PutObject directly matches PutObject",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:PutObject",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PutObject implicitly allows CreateMultipartUpload",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:CreateMultipartUpload",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PutObject implicitly allows UploadPart",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:UploadPart",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PutObject implicitly allows CompleteMultipartUpload",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:CompleteMultipartUpload",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PutObject implicitly allows AbortMultipartUpload",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:AbortMultipartUpload",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PutObject implicitly allows ListMultipartUploadParts",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:ListMultipartUploadParts",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PutObject implicitly allows ListBucketMultipartUploads",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:ListBucketMultipartUploads",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PutObject does not allow GetObject",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:GetObject",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "GetObject does not allow CreateMultipartUpload",
|
||||
actions: []string{"s3:GetObject"},
|
||||
requestedAction: "s3:CreateMultipartUpload",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard s3:Put* implicitly allows multipart via PutObject match",
|
||||
actions: []string{"s3:Put*"},
|
||||
requestedAction: "s3:CreateMultipartUpload",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "case-insensitive multipart action lookup",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "S3:CREATEMULTIPARTUPLOAD",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := engine.matchesActions(tt.actions, tt.requestedAction, evalCtx)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPolicyVariables(t *testing.T) {
|
||||
evalCtx := &EvaluationContext{
|
||||
RequestContext: map[string]interface{}{
|
||||
"aws:username": "alice",
|
||||
"saml:username": "alice.smith",
|
||||
"oidc:sub": "sub123",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
evalCtx *EvaluationContext
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "expand aws username",
|
||||
pattern: "home/${aws:username}/documents/*",
|
||||
evalCtx: evalCtx,
|
||||
expected: "home/alice/documents/*",
|
||||
},
|
||||
{
|
||||
name: "expand multiple variables",
|
||||
pattern: "${aws:username}/${oidc:sub}/data",
|
||||
evalCtx: evalCtx,
|
||||
expected: "alice/sub123/data",
|
||||
},
|
||||
{
|
||||
name: "no variables to expand",
|
||||
pattern: "static/path/file.txt",
|
||||
evalCtx: evalCtx,
|
||||
expected: "static/path/file.txt",
|
||||
},
|
||||
{
|
||||
name: "nil context",
|
||||
pattern: "home/${aws:username}/file",
|
||||
evalCtx: nil,
|
||||
expected: "home/${aws:username}/file",
|
||||
},
|
||||
{
|
||||
name: "missing variable in context",
|
||||
pattern: "home/${aws:nonexistent}/file",
|
||||
evalCtx: evalCtx,
|
||||
expected: "home/${aws:nonexistent}/file", // Should remain unchanged
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := expandPolicyVariables(tt.pattern, tt.evalCtx)
|
||||
assert.Equal(t, tt.expected, result, "Policy variable expansion should match expected")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSWildcardMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
value string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "case insensitive asterisk",
|
||||
pattern: "S3:Get*",
|
||||
value: "s3:getobject",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive question mark",
|
||||
pattern: "file?.TXT",
|
||||
value: "file1.txt",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "mixed wildcards",
|
||||
pattern: "S3:*Object?",
|
||||
value: "s3:getobjects",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
pattern: "s3:Put*",
|
||||
value: "s3:GetObject",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := wildcard.MatchesWildcard(strings.ToLower(tt.pattern), strings.ToLower(tt.value))
|
||||
assert.Equal(t, tt.expected, result, "AWS wildcard match should match expected")
|
||||
})
|
||||
}
|
||||
}
|
||||
139
weed/iam/policy/cached_policy_store_generic.go
Normal file
139
weed/iam/policy/cached_policy_store_generic.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/util"
|
||||
)
|
||||
|
||||
// PolicyStoreAdapter adapts PolicyStore interface to CacheableStore[*PolicyDocument]
|
||||
type PolicyStoreAdapter struct {
|
||||
store PolicyStore
|
||||
}
|
||||
|
||||
// NewPolicyStoreAdapter creates a new adapter for PolicyStore
|
||||
func NewPolicyStoreAdapter(store PolicyStore) *PolicyStoreAdapter {
|
||||
return &PolicyStoreAdapter{store: store}
|
||||
}
|
||||
|
||||
// Get implements CacheableStore interface
|
||||
func (a *PolicyStoreAdapter) Get(ctx context.Context, filerAddress string, key string) (*PolicyDocument, error) {
|
||||
return a.store.GetPolicy(ctx, filerAddress, key)
|
||||
}
|
||||
|
||||
// Store implements CacheableStore interface
|
||||
func (a *PolicyStoreAdapter) Store(ctx context.Context, filerAddress string, key string, value *PolicyDocument) error {
|
||||
return a.store.StorePolicy(ctx, filerAddress, key, value)
|
||||
}
|
||||
|
||||
// Delete implements CacheableStore interface
|
||||
func (a *PolicyStoreAdapter) Delete(ctx context.Context, filerAddress string, key string) error {
|
||||
return a.store.DeletePolicy(ctx, filerAddress, key)
|
||||
}
|
||||
|
||||
// List implements CacheableStore interface
|
||||
func (a *PolicyStoreAdapter) List(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
return a.store.ListPolicies(ctx, filerAddress)
|
||||
}
|
||||
|
||||
// GenericCachedPolicyStore implements PolicyStore using the generic cache
|
||||
type GenericCachedPolicyStore struct {
|
||||
*util.CachedStore[*PolicyDocument]
|
||||
adapter *PolicyStoreAdapter
|
||||
}
|
||||
|
||||
// NewGenericCachedPolicyStore creates a new cached policy store using generics
|
||||
func NewGenericCachedPolicyStore(config map[string]interface{}, filerAddressProvider func() string) (*GenericCachedPolicyStore, error) {
|
||||
// Create underlying filer store
|
||||
filerStore, err := NewFilerPolicyStore(config, filerAddressProvider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse cache configuration with defaults
|
||||
cacheTTL := 5 * time.Minute
|
||||
listTTL := 1 * time.Minute
|
||||
maxCacheSize := int64(500)
|
||||
|
||||
if config != nil {
|
||||
if ttlStr, ok := config["ttl"].(string); ok && ttlStr != "" {
|
||||
if parsed, err := time.ParseDuration(ttlStr); err == nil {
|
||||
cacheTTL = parsed
|
||||
}
|
||||
}
|
||||
if listTTLStr, ok := config["listTtl"].(string); ok && listTTLStr != "" {
|
||||
if parsed, err := time.ParseDuration(listTTLStr); err == nil {
|
||||
listTTL = parsed
|
||||
}
|
||||
}
|
||||
if maxSize, ok := config["maxCacheSize"].(int); ok && maxSize > 0 {
|
||||
maxCacheSize = int64(maxSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Create adapter and generic cached store
|
||||
adapter := NewPolicyStoreAdapter(filerStore)
|
||||
cachedStore := util.NewCachedStore(
|
||||
adapter,
|
||||
genericCopyPolicyDocument, // Copy function
|
||||
util.CachedStoreConfig{
|
||||
TTL: cacheTTL,
|
||||
ListTTL: listTTL,
|
||||
MaxCacheSize: maxCacheSize,
|
||||
},
|
||||
)
|
||||
|
||||
glog.V(2).Infof("Initialized GenericCachedPolicyStore with TTL %v, List TTL %v, Max Cache Size %d",
|
||||
cacheTTL, listTTL, maxCacheSize)
|
||||
|
||||
return &GenericCachedPolicyStore{
|
||||
CachedStore: cachedStore,
|
||||
adapter: adapter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StorePolicy implements PolicyStore interface
|
||||
func (c *GenericCachedPolicyStore) StorePolicy(ctx context.Context, filerAddress string, name string, policy *PolicyDocument) error {
|
||||
return c.Store(ctx, filerAddress, name, policy)
|
||||
}
|
||||
|
||||
// GetPolicy implements PolicyStore interface
|
||||
func (c *GenericCachedPolicyStore) GetPolicy(ctx context.Context, filerAddress string, name string) (*PolicyDocument, error) {
|
||||
return c.Get(ctx, filerAddress, name)
|
||||
}
|
||||
|
||||
// ListPolicies implements PolicyStore interface
|
||||
func (c *GenericCachedPolicyStore) ListPolicies(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
return c.List(ctx, filerAddress)
|
||||
}
|
||||
|
||||
// DeletePolicy implements PolicyStore interface
|
||||
func (c *GenericCachedPolicyStore) DeletePolicy(ctx context.Context, filerAddress string, name string) error {
|
||||
return c.Delete(ctx, filerAddress, name)
|
||||
}
|
||||
|
||||
// genericCopyPolicyDocument creates a deep copy of a PolicyDocument for the generic cache
|
||||
func genericCopyPolicyDocument(policy *PolicyDocument) *PolicyDocument {
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Perform a deep copy to ensure cache isolation
|
||||
// Using JSON marshaling is a safe way to achieve this
|
||||
policyData, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to marshal policy document for deep copy: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var copied PolicyDocument
|
||||
if err := json.Unmarshal(policyData, &copied); err != nil {
|
||||
glog.Errorf("Failed to unmarshal policy document for deep copy: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &copied
|
||||
}
|
||||
216
weed/iam/policy/jwt_claim_variables_test.go
Normal file
216
weed/iam/policy/jwt_claim_variables_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestStringifyClaimValue covers the scalar types that can appear in
|
||||
// RequestContext so that both JSON-decoded (float64, json.Number) and typed
|
||||
// (int8..int64, uint..uint64, bool) values substitute correctly.
|
||||
func TestStringifyClaimValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value interface{}
|
||||
want string
|
||||
wantOk bool
|
||||
}{
|
||||
{name: "string", value: "alice", want: "alice", wantOk: true},
|
||||
{name: "bool true", value: true, want: "true", wantOk: true},
|
||||
{name: "bool false", value: false, want: "false", wantOk: true},
|
||||
{name: "float64 integer", value: float64(301), want: "301", wantOk: true},
|
||||
{name: "float64 fraction", value: 1.5, want: "1.5", wantOk: true},
|
||||
{name: "float32", value: float32(2.25), want: "2.25", wantOk: true},
|
||||
{name: "int", value: int(42), want: "42", wantOk: true},
|
||||
{name: "int8", value: int8(-5), want: "-5", wantOk: true},
|
||||
{name: "int16", value: int16(32000), want: "32000", wantOk: true},
|
||||
{name: "int32", value: int32(-123456), want: "-123456", wantOk: true},
|
||||
{name: "int64", value: int64(9999999999), want: "9999999999", wantOk: true},
|
||||
{name: "uint", value: uint(7), want: "7", wantOk: true},
|
||||
{name: "uint8", value: uint8(255), want: "255", wantOk: true},
|
||||
{name: "uint16", value: uint16(65535), want: "65535", wantOk: true},
|
||||
{name: "uint32", value: uint32(4000000000), want: "4000000000", wantOk: true},
|
||||
{name: "uint64", value: uint64(18000000000000000000), want: "18000000000000000000", wantOk: true},
|
||||
{name: "json.Number int", value: json.Number("301"), want: "301", wantOk: true},
|
||||
{name: "json.Number float", value: json.Number("1.5"), want: "1.5", wantOk: true},
|
||||
{name: "nil", value: nil, want: "", wantOk: false},
|
||||
{name: "slice unsupported", value: []string{"a", "b"}, want: "", wantOk: false},
|
||||
{name: "map unsupported", value: map[string]string{"a": "b"}, want: "", wantOk: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := stringifyClaimValue(tt.value)
|
||||
assert.Equal(t, tt.wantOk, ok)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandPolicyVariablesDynamicJWTClaims verifies that arbitrary JWT claims
|
||||
// (not only the hardcoded jwt:sub/jwt:iss/jwt:aud/jwt:preferred_username)
|
||||
// can be substituted in policy patterns when present in the request context.
|
||||
// Reproduces the scenario in https://github.com/seaweedfs/seaweedfs/issues/9214
|
||||
// where ${jwt:project_path} was left unsubstituted.
|
||||
func TestExpandPolicyVariablesDynamicJWTClaims(t *testing.T) {
|
||||
evalCtx := &EvaluationContext{
|
||||
RequestContext: map[string]interface{}{
|
||||
"jwt:project_path": "some/namespace/project",
|
||||
"jwt:namespace_path": "some/namespace",
|
||||
"jwt:user_login": "mat",
|
||||
// JSON-decoded numeric claim
|
||||
"jwt:project_id": float64(301),
|
||||
// Dynamic SAML/OIDC claims
|
||||
"saml:department": "engineering",
|
||||
"oidc:tenant": "acme",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "dynamic jwt claim substituted in resource arn",
|
||||
pattern: "arn:aws:s3:::softs/${jwt:project_path}/*",
|
||||
expected: "arn:aws:s3:::softs/some/namespace/project/*",
|
||||
},
|
||||
{
|
||||
name: "multiple dynamic jwt claims in one pattern",
|
||||
pattern: "${jwt:namespace_path}/${jwt:user_login}/data",
|
||||
expected: "some/namespace/mat/data",
|
||||
},
|
||||
{
|
||||
name: "dynamic numeric jwt claim is stringified",
|
||||
pattern: "projects/${jwt:project_id}/uploads",
|
||||
expected: "projects/301/uploads",
|
||||
},
|
||||
{
|
||||
name: "dynamic saml claim substituted",
|
||||
pattern: "dept/${saml:department}/*",
|
||||
expected: "dept/engineering/*",
|
||||
},
|
||||
{
|
||||
name: "dynamic oidc claim substituted",
|
||||
pattern: "tenants/${oidc:tenant}/data",
|
||||
expected: "tenants/acme/data",
|
||||
},
|
||||
{
|
||||
name: "missing jwt claim leaves placeholder intact",
|
||||
pattern: "softs/${jwt:missing_claim}/*",
|
||||
expected: "softs/${jwt:missing_claim}/*",
|
||||
},
|
||||
{
|
||||
name: "non-identity prefix is not substituted",
|
||||
pattern: "foo/${custom:project_path}/*",
|
||||
expected: "foo/${custom:project_path}/*",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := expandPolicyVariables(tt.pattern, evalCtx)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGitLabProjectUploadPolicy reproduces the GitLab OIDC policy from issue
|
||||
// #9214 end-to-end against the policy engine, proving that jwt:project_path
|
||||
// (a non-hardcoded JWT claim) is now substituted in Resource patterns so that
|
||||
// each project only gets access under its own prefix.
|
||||
func TestGitLabProjectUploadPolicy(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
require.NoError(t, engine.Initialize(&PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory",
|
||||
}))
|
||||
|
||||
policyDoc := &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Sid: "AllowPutInOwnFolder",
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:PutObject"},
|
||||
Resource: []string{"arn:aws:s3:::softs/${jwt:project_path}/*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, engine.AddPolicy("", "GitlabProjectUploadPolicy", policyDoc))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
mat := map[string]interface{}{
|
||||
"jwt:project_path": "some/namespace/project",
|
||||
}
|
||||
other := map[string]interface{}{
|
||||
"jwt:project_path": "other/namespace/project",
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
action string
|
||||
resource string
|
||||
reqCtx map[string]interface{}
|
||||
want Effect
|
||||
}{
|
||||
{
|
||||
name: "user writes into own project folder - allowed",
|
||||
action: "s3:PutObject",
|
||||
resource: "arn:aws:s3:::softs/some/namespace/project/build.zip",
|
||||
reqCtx: mat,
|
||||
want: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "user tries to write into another project - falls through to default Deny",
|
||||
action: "s3:PutObject",
|
||||
resource: "arn:aws:s3:::softs/other/namespace/project/build.zip",
|
||||
reqCtx: mat,
|
||||
want: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "different user writes into their own project - allowed",
|
||||
action: "s3:PutObject",
|
||||
resource: "arn:aws:s3:::softs/other/namespace/project/build.zip",
|
||||
reqCtx: other,
|
||||
want: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "user tries to GetObject - only PutObject allowed, default Deny",
|
||||
action: "s3:GetObject",
|
||||
resource: "arn:aws:s3:::softs/some/namespace/project/build.zip",
|
||||
reqCtx: mat,
|
||||
want: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "missing project_path claim - placeholder prevents match, default Deny",
|
||||
action: "s3:PutObject",
|
||||
resource: "arn:aws:s3:::softs/some/namespace/project/build.zip",
|
||||
reqCtx: map[string]interface{}{},
|
||||
want: EffectDeny,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "user",
|
||||
Action: tc.action,
|
||||
Resource: tc.resource,
|
||||
RequestContext: tc.reqCtx,
|
||||
}
|
||||
|
||||
result, err := engine.Evaluate(ctx, "", evalCtx, []string{"GitlabProjectUploadPolicy"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, result.Effect,
|
||||
"matching statements: %+v", result.MatchingStatements)
|
||||
})
|
||||
}
|
||||
}
|
||||
2036
weed/iam/policy/policy_engine.go
Normal file
2036
weed/iam/policy/policy_engine.go
Normal file
File diff suppressed because it is too large
Load Diff
386
weed/iam/policy/policy_engine_distributed_test.go
Normal file
386
weed/iam/policy/policy_engine_distributed_test.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDistributedPolicyEngine verifies that multiple PolicyEngine instances with identical configurations
|
||||
// behave consistently across distributed environments
|
||||
func TestDistributedPolicyEngine(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Common configuration for all instances
|
||||
commonConfig := &PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory", // For testing - would be "filer" in production
|
||||
StoreConfig: map[string]interface{}{},
|
||||
}
|
||||
|
||||
// Create multiple PolicyEngine instances simulating distributed deployment
|
||||
instance1 := NewPolicyEngine()
|
||||
instance2 := NewPolicyEngine()
|
||||
instance3 := NewPolicyEngine()
|
||||
|
||||
// Initialize all instances with identical configuration
|
||||
err := instance1.Initialize(commonConfig)
|
||||
require.NoError(t, err, "Instance 1 should initialize successfully")
|
||||
|
||||
err = instance2.Initialize(commonConfig)
|
||||
require.NoError(t, err, "Instance 2 should initialize successfully")
|
||||
|
||||
err = instance3.Initialize(commonConfig)
|
||||
require.NoError(t, err, "Instance 3 should initialize successfully")
|
||||
|
||||
// Test policy consistency across instances
|
||||
t.Run("policy_storage_consistency", func(t *testing.T) {
|
||||
// Define a test policy
|
||||
testPolicy := &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Sid: "AllowS3Read",
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject", "s3:ListBucket"},
|
||||
Resource: []string{"arn:aws:s3:::test-bucket/*", "arn:aws:s3:::test-bucket"},
|
||||
},
|
||||
{
|
||||
Sid: "DenyS3Write",
|
||||
Effect: "Deny",
|
||||
Action: []string{"s3:PutObject", "s3:DeleteObject"},
|
||||
Resource: []string{"arn:aws:s3:::test-bucket/*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Store policy on instance 1
|
||||
err := instance1.AddPolicy("", "TestPolicy", testPolicy)
|
||||
require.NoError(t, err, "Should be able to store policy on instance 1")
|
||||
|
||||
// For memory storage, each instance has separate storage
|
||||
// In production with filer storage, all instances would share the same policies
|
||||
|
||||
// Verify policy exists on instance 1
|
||||
storedPolicy1, err := instance1.store.GetPolicy(ctx, "", "TestPolicy")
|
||||
require.NoError(t, err, "Policy should exist on instance 1")
|
||||
assert.Equal(t, "2012-10-17", storedPolicy1.Version)
|
||||
assert.Len(t, storedPolicy1.Statement, 2)
|
||||
|
||||
// For demonstration: store same policy on other instances
|
||||
err = instance2.AddPolicy("", "TestPolicy", testPolicy)
|
||||
require.NoError(t, err, "Should be able to store policy on instance 2")
|
||||
|
||||
err = instance3.AddPolicy("", "TestPolicy", testPolicy)
|
||||
require.NoError(t, err, "Should be able to store policy on instance 3")
|
||||
})
|
||||
|
||||
// Test policy evaluation consistency
|
||||
t.Run("evaluation_consistency", func(t *testing.T) {
|
||||
// Create evaluation context
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/TestRole/session",
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/file.txt",
|
||||
RequestContext: map[string]interface{}{
|
||||
"sourceIp": "192.168.1.100",
|
||||
},
|
||||
}
|
||||
|
||||
// Evaluate policy on all instances
|
||||
result1, err1 := instance1.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
result2, err2 := instance2.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
result3, err3 := instance3.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
|
||||
require.NoError(t, err1, "Evaluation should succeed on instance 1")
|
||||
require.NoError(t, err2, "Evaluation should succeed on instance 2")
|
||||
require.NoError(t, err3, "Evaluation should succeed on instance 3")
|
||||
|
||||
// All instances should return identical results
|
||||
assert.Equal(t, result1.Effect, result2.Effect, "Instance 1 and 2 should have same effect")
|
||||
assert.Equal(t, result2.Effect, result3.Effect, "Instance 2 and 3 should have same effect")
|
||||
assert.Equal(t, EffectAllow, result1.Effect, "Should allow s3:GetObject")
|
||||
|
||||
// Matching statements should be identical
|
||||
assert.Len(t, result1.MatchingStatements, 1, "Should have one matching statement")
|
||||
assert.Len(t, result2.MatchingStatements, 1, "Should have one matching statement")
|
||||
assert.Len(t, result3.MatchingStatements, 1, "Should have one matching statement")
|
||||
|
||||
assert.Equal(t, "AllowS3Read", result1.MatchingStatements[0].StatementSid)
|
||||
assert.Equal(t, "AllowS3Read", result2.MatchingStatements[0].StatementSid)
|
||||
assert.Equal(t, "AllowS3Read", result3.MatchingStatements[0].StatementSid)
|
||||
})
|
||||
|
||||
// Test explicit deny precedence
|
||||
t.Run("deny_precedence_consistency", func(t *testing.T) {
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/TestRole/session",
|
||||
Action: "s3:PutObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/newfile.txt",
|
||||
}
|
||||
|
||||
// All instances should consistently apply deny precedence
|
||||
result1, err1 := instance1.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
result2, err2 := instance2.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
result3, err3 := instance3.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
|
||||
require.NoError(t, err1)
|
||||
require.NoError(t, err2)
|
||||
require.NoError(t, err3)
|
||||
|
||||
// All should deny due to explicit deny statement
|
||||
assert.Equal(t, EffectDeny, result1.Effect, "Instance 1 should deny write operation")
|
||||
assert.Equal(t, EffectDeny, result2.Effect, "Instance 2 should deny write operation")
|
||||
assert.Equal(t, EffectDeny, result3.Effect, "Instance 3 should deny write operation")
|
||||
|
||||
// Should have matching deny statement
|
||||
assert.Len(t, result1.MatchingStatements, 1)
|
||||
assert.Equal(t, "DenyS3Write", result1.MatchingStatements[0].StatementSid)
|
||||
assert.Equal(t, EffectDeny, result1.MatchingStatements[0].Effect)
|
||||
})
|
||||
|
||||
// Test default effect consistency
|
||||
t.Run("default_effect_consistency", func(t *testing.T) {
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/TestRole/session",
|
||||
Action: "filer:CreateEntry", // Action not covered by any policy
|
||||
Resource: "arn:aws:filer::path/test",
|
||||
}
|
||||
|
||||
result1, err1 := instance1.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
result2, err2 := instance2.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
result3, err3 := instance3.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
|
||||
require.NoError(t, err1)
|
||||
require.NoError(t, err2)
|
||||
require.NoError(t, err3)
|
||||
|
||||
// All should use default effect (Deny)
|
||||
assert.Equal(t, EffectDeny, result1.Effect, "Should use default effect")
|
||||
assert.Equal(t, EffectDeny, result2.Effect, "Should use default effect")
|
||||
assert.Equal(t, EffectDeny, result3.Effect, "Should use default effect")
|
||||
|
||||
// No matching statements
|
||||
assert.Empty(t, result1.MatchingStatements, "Should have no matching statements")
|
||||
assert.Empty(t, result2.MatchingStatements, "Should have no matching statements")
|
||||
assert.Empty(t, result3.MatchingStatements, "Should have no matching statements")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPolicyEngineConfigurationConsistency tests configuration validation for distributed deployments
|
||||
func TestPolicyEngineConfigurationConsistency(t *testing.T) {
|
||||
t.Run("consistent_default_effects_required", func(t *testing.T) {
|
||||
// Different default effects could lead to inconsistent authorization
|
||||
config1 := &PolicyEngineConfig{
|
||||
DefaultEffect: "Allow",
|
||||
StoreType: "memory",
|
||||
}
|
||||
|
||||
config2 := &PolicyEngineConfig{
|
||||
DefaultEffect: "Deny", // Different default!
|
||||
StoreType: "memory",
|
||||
}
|
||||
|
||||
instance1 := NewPolicyEngine()
|
||||
instance2 := NewPolicyEngine()
|
||||
|
||||
err1 := instance1.Initialize(config1)
|
||||
err2 := instance2.Initialize(config2)
|
||||
|
||||
require.NoError(t, err1)
|
||||
require.NoError(t, err2)
|
||||
|
||||
// Test with an action not covered by any policy
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/TestRole/session",
|
||||
Action: "uncovered:action",
|
||||
Resource: "arn:aws:test:::resource",
|
||||
}
|
||||
|
||||
result1, _ := instance1.Evaluate(context.Background(), "", evalCtx, []string{})
|
||||
result2, _ := instance2.Evaluate(context.Background(), "", evalCtx, []string{})
|
||||
|
||||
// Results should be different due to different default effects
|
||||
assert.NotEqual(t, result1.Effect, result2.Effect, "Different default effects should produce different results")
|
||||
assert.Equal(t, EffectAllow, result1.Effect, "Instance 1 should allow by default")
|
||||
assert.Equal(t, EffectDeny, result2.Effect, "Instance 2 should deny by default")
|
||||
})
|
||||
|
||||
t.Run("invalid_configuration_handling", func(t *testing.T) {
|
||||
invalidConfigs := []*PolicyEngineConfig{
|
||||
{
|
||||
DefaultEffect: "Maybe", // Invalid effect
|
||||
StoreType: "memory",
|
||||
},
|
||||
{
|
||||
DefaultEffect: "Allow",
|
||||
StoreType: "nonexistent", // Invalid store type
|
||||
},
|
||||
}
|
||||
|
||||
for i, config := range invalidConfigs {
|
||||
t.Run(fmt.Sprintf("invalid_config_%d", i), func(t *testing.T) {
|
||||
instance := NewPolicyEngine()
|
||||
err := instance.Initialize(config)
|
||||
assert.Error(t, err, "Should reject invalid configuration")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPolicyStoreDistributed tests policy store behavior in distributed scenarios
|
||||
func TestPolicyStoreDistributed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("memory_store_isolation", func(t *testing.T) {
|
||||
// Memory stores are isolated per instance (not suitable for distributed)
|
||||
store1 := NewMemoryPolicyStore()
|
||||
store2 := NewMemoryPolicyStore()
|
||||
|
||||
policy := &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Store policy in store1
|
||||
err := store1.StorePolicy(ctx, "", "TestPolicy", policy)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Policy should exist in store1
|
||||
_, err = store1.GetPolicy(ctx, "", "TestPolicy")
|
||||
assert.NoError(t, err, "Policy should exist in store1")
|
||||
|
||||
// Policy should NOT exist in store2 (different instance)
|
||||
_, err = store2.GetPolicy(ctx, "", "TestPolicy")
|
||||
assert.Error(t, err, "Policy should not exist in store2")
|
||||
assert.Contains(t, err.Error(), "not found", "Should be a not found error")
|
||||
})
|
||||
|
||||
t.Run("policy_loading_error_handling", func(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
config := &PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory",
|
||||
}
|
||||
|
||||
err := engine.Initialize(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/TestRole/session",
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket/key",
|
||||
}
|
||||
|
||||
// Evaluate with non-existent policies
|
||||
result, err := engine.Evaluate(ctx, "", evalCtx, []string{"NonExistentPolicy1", "NonExistentPolicy2"})
|
||||
require.NoError(t, err, "Should not error on missing policies")
|
||||
|
||||
// Should use default effect when no policies can be loaded
|
||||
assert.Equal(t, EffectDeny, result.Effect, "Should use default effect")
|
||||
assert.Empty(t, result.MatchingStatements, "Should have no matching statements")
|
||||
})
|
||||
}
|
||||
|
||||
// TestFilerPolicyStoreConfiguration tests filer policy store configuration for distributed deployments
|
||||
func TestFilerPolicyStoreConfiguration(t *testing.T) {
|
||||
t.Run("filer_store_creation", func(t *testing.T) {
|
||||
// Test with minimal configuration
|
||||
config := map[string]interface{}{
|
||||
"filerAddress": "localhost:8888",
|
||||
}
|
||||
|
||||
store, err := NewFilerPolicyStore(config, nil)
|
||||
require.NoError(t, err, "Should create filer policy store with minimal config")
|
||||
assert.NotNil(t, store)
|
||||
})
|
||||
|
||||
t.Run("filer_store_custom_path", func(t *testing.T) {
|
||||
config := map[string]interface{}{
|
||||
"filerAddress": "prod-filer:8888",
|
||||
"basePath": "/custom/iam/policies",
|
||||
}
|
||||
|
||||
store, err := NewFilerPolicyStore(config, nil)
|
||||
require.NoError(t, err, "Should create filer policy store with custom path")
|
||||
assert.NotNil(t, store)
|
||||
})
|
||||
|
||||
t.Run("filer_store_missing_address", func(t *testing.T) {
|
||||
config := map[string]interface{}{
|
||||
"basePath": "/seaweedfs/iam/policies",
|
||||
}
|
||||
|
||||
store, err := NewFilerPolicyStore(config, nil)
|
||||
assert.NoError(t, err, "Should create filer store without filerAddress in config")
|
||||
assert.NotNil(t, store, "Store should be created successfully")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPolicyEvaluationPerformance tests performance considerations for distributed policy evaluation
|
||||
func TestPolicyEvaluationPerformance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create engine with memory store (for performance baseline)
|
||||
engine := NewPolicyEngine()
|
||||
config := &PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory",
|
||||
}
|
||||
|
||||
err := engine.Initialize(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add multiple policies
|
||||
for i := 0; i < 10; i++ {
|
||||
policy := &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Sid: fmt.Sprintf("Statement%d", i),
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject", "s3:ListBucket"},
|
||||
Resource: []string{fmt.Sprintf("arn:aws:s3:::bucket%d/*", i)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := engine.AddPolicy("", fmt.Sprintf("Policy%d", i), policy)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Test evaluation performance
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/TestRole/session",
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket5/file.txt",
|
||||
}
|
||||
|
||||
policyNames := make([]string, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
policyNames[i] = fmt.Sprintf("Policy%d", i)
|
||||
}
|
||||
|
||||
// Measure evaluation time
|
||||
start := time.Now()
|
||||
for i := 0; i < 100; i++ {
|
||||
_, err := engine.Evaluate(ctx, "", evalCtx, policyNames)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
duration := time.Since(start)
|
||||
|
||||
// Should be reasonably fast (less than 10ms per evaluation on average)
|
||||
avgDuration := duration / 100
|
||||
t.Logf("Average policy evaluation time: %v", avgDuration)
|
||||
assert.Less(t, avgDuration, 10*time.Millisecond, "Policy evaluation should be fast")
|
||||
}
|
||||
531
weed/iam/policy/policy_store.go
Normal file
531
weed/iam/policy/policy_store.go
Normal file
@@ -0,0 +1,531 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/credential"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// MemoryPolicyStore implements PolicyStore using in-memory storage
|
||||
type MemoryPolicyStore struct {
|
||||
policies map[string]*PolicyDocument
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMemoryPolicyStore creates a new memory-based policy store
|
||||
func NewMemoryPolicyStore() *MemoryPolicyStore {
|
||||
return &MemoryPolicyStore{
|
||||
policies: make(map[string]*PolicyDocument),
|
||||
}
|
||||
}
|
||||
|
||||
// StorePolicy stores a policy document in memory (filerAddress ignored for memory store)
|
||||
func (s *MemoryPolicyStore) StorePolicy(ctx context.Context, filerAddress string, name string, policy *PolicyDocument) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("policy name cannot be empty")
|
||||
}
|
||||
|
||||
if policy == nil {
|
||||
return fmt.Errorf("policy cannot be nil")
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
// Deep copy the policy to prevent external modifications
|
||||
s.policies[name] = copyPolicyDocument(policy)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPolicy retrieves a policy document from memory (filerAddress ignored for memory store)
|
||||
func (s *MemoryPolicyStore) GetPolicy(ctx context.Context, filerAddress string, name string) (*PolicyDocument, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("policy name cannot be empty")
|
||||
}
|
||||
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
policy, exists := s.policies[name]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("policy not found: %s", name)
|
||||
}
|
||||
|
||||
// Return a copy to prevent external modifications
|
||||
return copyPolicyDocument(policy), nil
|
||||
}
|
||||
|
||||
// DeletePolicy deletes a policy document from memory (filerAddress ignored for memory store)
|
||||
func (s *MemoryPolicyStore) DeletePolicy(ctx context.Context, filerAddress string, name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("policy name cannot be empty")
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
delete(s.policies, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPolicies lists all policy names in memory (filerAddress ignored for memory store)
|
||||
func (s *MemoryPolicyStore) ListPolicies(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(s.policies))
|
||||
for name := range s.policies {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// copyPolicyDocument creates a deep copy of a policy document
|
||||
func copyPolicyDocument(original *PolicyDocument) *PolicyDocument {
|
||||
if original == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
copied := &PolicyDocument{
|
||||
Version: original.Version,
|
||||
Id: original.Id,
|
||||
}
|
||||
|
||||
// Copy statements
|
||||
copied.Statement = make([]Statement, len(original.Statement))
|
||||
for i, stmt := range original.Statement {
|
||||
copied.Statement[i] = Statement{
|
||||
Sid: stmt.Sid,
|
||||
Effect: stmt.Effect,
|
||||
Principal: stmt.Principal,
|
||||
NotPrincipal: stmt.NotPrincipal,
|
||||
}
|
||||
|
||||
// Copy action slice
|
||||
if stmt.Action != nil {
|
||||
copied.Statement[i].Action = make([]string, len(stmt.Action))
|
||||
copy(copied.Statement[i].Action, stmt.Action)
|
||||
}
|
||||
|
||||
// Copy NotAction slice
|
||||
if stmt.NotAction != nil {
|
||||
copied.Statement[i].NotAction = make([]string, len(stmt.NotAction))
|
||||
copy(copied.Statement[i].NotAction, stmt.NotAction)
|
||||
}
|
||||
|
||||
// Copy resource slice
|
||||
if stmt.Resource != nil {
|
||||
copied.Statement[i].Resource = make([]string, len(stmt.Resource))
|
||||
copy(copied.Statement[i].Resource, stmt.Resource)
|
||||
}
|
||||
|
||||
// Copy NotResource slice
|
||||
if stmt.NotResource != nil {
|
||||
copied.Statement[i].NotResource = make([]string, len(stmt.NotResource))
|
||||
copy(copied.Statement[i].NotResource, stmt.NotResource)
|
||||
}
|
||||
|
||||
// Copy condition map
|
||||
if stmt.Condition != nil {
|
||||
copied.Statement[i].Condition = make(map[string]map[string]interface{})
|
||||
for conditionType, conditionValues := range stmt.Condition {
|
||||
copiedConditionValues := make(map[string]interface{}, len(conditionValues))
|
||||
for conditionKey, conditionValue := range conditionValues {
|
||||
copiedConditionValues[conditionKey] = copyPolicyConditionValue(conditionValue)
|
||||
}
|
||||
copied.Statement[i].Condition[conditionType] = copiedConditionValues
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return copied
|
||||
}
|
||||
|
||||
func copyPolicyConditionValue(value interface{}) interface{} {
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
copied := make([]string, len(v))
|
||||
copy(copied, v)
|
||||
return copied
|
||||
case []interface{}:
|
||||
copied := make([]interface{}, len(v))
|
||||
for i := range v {
|
||||
copied[i] = copyPolicyConditionValue(v[i])
|
||||
}
|
||||
return copied
|
||||
case map[string]interface{}:
|
||||
copied := make(map[string]interface{}, len(v))
|
||||
for key, nestedValue := range v {
|
||||
copied[key] = copyPolicyConditionValue(nestedValue)
|
||||
}
|
||||
return copied
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// FilerPolicyStore implements PolicyStore using SeaweedFS filer
|
||||
type FilerPolicyStore struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
basePath string
|
||||
filerAddressProvider func() string
|
||||
}
|
||||
|
||||
// NewFilerPolicyStore creates a new filer-based policy store
|
||||
func NewFilerPolicyStore(config map[string]interface{}, filerAddressProvider func() string) (*FilerPolicyStore, error) {
|
||||
store := &FilerPolicyStore{
|
||||
basePath: "/etc/iam/policies", // Default path for policy storage - aligned with /etc/ convention
|
||||
filerAddressProvider: filerAddressProvider,
|
||||
}
|
||||
|
||||
// Parse configuration - only basePath and other settings, NOT filerAddress
|
||||
if config != nil {
|
||||
if basePath, ok := config["basePath"].(string); ok && basePath != "" {
|
||||
store.basePath = strings.TrimSuffix(basePath, "/")
|
||||
}
|
||||
}
|
||||
|
||||
glog.V(2).Infof("Initialized FilerPolicyStore with basePath %s", store.basePath)
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// StorePolicy stores a policy document in filer
|
||||
func (s *FilerPolicyStore) StorePolicy(ctx context.Context, filerAddress string, name string, policy *PolicyDocument) error {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && s.filerAddressProvider != nil {
|
||||
filerAddress = s.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required for FilerPolicyStore")
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("policy name cannot be empty")
|
||||
}
|
||||
if policy == nil {
|
||||
return fmt.Errorf("policy cannot be nil")
|
||||
}
|
||||
|
||||
// Serialize policy to JSON
|
||||
policyData, err := json.MarshalIndent(policy, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize policy: %v", err)
|
||||
}
|
||||
|
||||
policyPath := s.getPolicyPath(name)
|
||||
|
||||
// Store in filer
|
||||
return s.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
glog.V(3).Infof("Storing policy %s at %s", name, policyPath)
|
||||
if err := s.savePolicyFile(ctx, client, s.getPolicyFileName(name), policyData); err != nil {
|
||||
return fmt.Errorf("failed to store policy %s: %v", name, err)
|
||||
}
|
||||
if err := s.deleteLegacyPolicyFileIfPresent(ctx, client, name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetPolicy retrieves a policy document from filer
|
||||
func (s *FilerPolicyStore) GetPolicy(ctx context.Context, filerAddress string, name string) (*PolicyDocument, error) {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && s.filerAddressProvider != nil {
|
||||
filerAddress = s.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required for FilerPolicyStore")
|
||||
}
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("policy name cannot be empty")
|
||||
}
|
||||
|
||||
var policyData []byte
|
||||
err := s.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
for _, fileName := range s.getPolicyLookupFileNames(name) {
|
||||
request := &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Name: fileName,
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Looking up policy %s as %s", name, fileName)
|
||||
response, err := client.LookupDirectoryEntry(ctx, request)
|
||||
if err != nil {
|
||||
if isNotFoundPolicyStoreError(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("policy lookup failed: %v", err)
|
||||
}
|
||||
|
||||
if response.Entry == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
policyData = response.Entry.Content
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("policy not found")
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Deserialize policy from JSON
|
||||
var policy PolicyDocument
|
||||
if err := json.Unmarshal(policyData, &policy); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize policy: %v", err)
|
||||
}
|
||||
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
// DeletePolicy deletes a policy document from filer
|
||||
func (s *FilerPolicyStore) DeletePolicy(ctx context.Context, filerAddress string, name string) error {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && s.filerAddressProvider != nil {
|
||||
filerAddress = s.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required for FilerPolicyStore")
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("policy name cannot be empty")
|
||||
}
|
||||
|
||||
return s.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
for _, fileName := range s.getPolicyLookupFileNames(name) {
|
||||
request := &filer_pb.DeleteEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Name: fileName,
|
||||
IsDeleteData: true,
|
||||
IsRecursive: false,
|
||||
IgnoreRecursiveError: false,
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Deleting policy %s as %s", name, fileName)
|
||||
resp, err := client.DeleteEntry(ctx, request)
|
||||
if err != nil {
|
||||
if isNotFoundPolicyStoreError(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("failed to delete policy %s: %v", name, err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("failed to delete policy %s: %s", name, resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ListPolicies lists all policy names in filer
|
||||
func (s *FilerPolicyStore) ListPolicies(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && s.filerAddressProvider != nil {
|
||||
filerAddress = s.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required for FilerPolicyStore")
|
||||
}
|
||||
|
||||
var policyNames []string
|
||||
|
||||
err := s.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
// List all entries in the policy directory
|
||||
request := &filer_pb.ListEntriesRequest{
|
||||
Directory: s.basePath,
|
||||
Prefix: "",
|
||||
StartFromFileName: "",
|
||||
InclusiveStartFrom: false,
|
||||
Limit: 1000, // Process in batches of 1000
|
||||
}
|
||||
|
||||
stream, err := client.ListEntries(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list policies: %v", err)
|
||||
}
|
||||
|
||||
for {
|
||||
resp, err := stream.Recv()
|
||||
if err != nil {
|
||||
break // End of stream or error
|
||||
}
|
||||
|
||||
if resp.Entry == nil || resp.Entry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
|
||||
if policyName, ok := s.policyNameFromFileName(resp.Entry.Name); ok {
|
||||
policyNames = append(policyNames, policyName)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uniquePolicyNames := make([]string, 0, len(policyNames))
|
||||
seen := make(map[string]struct{}, len(policyNames))
|
||||
for _, policyName := range policyNames {
|
||||
if _, found := seen[policyName]; found {
|
||||
continue
|
||||
}
|
||||
seen[policyName] = struct{}{}
|
||||
uniquePolicyNames = append(uniquePolicyNames, policyName)
|
||||
}
|
||||
|
||||
return uniquePolicyNames, nil
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
// withFilerClient executes a function with a filer client
|
||||
func (s *FilerPolicyStore) withFilerClient(filerAddress string, fn func(client filer_pb.SeaweedFilerClient) error) error {
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required for FilerPolicyStore")
|
||||
}
|
||||
|
||||
// Use the pb.WithGrpcFilerClient helper similar to existing SeaweedFS code
|
||||
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), s.grpcDialOption, fn)
|
||||
}
|
||||
|
||||
// getPolicyPath returns the full path for a policy
|
||||
func (s *FilerPolicyStore) getPolicyPath(policyName string) string {
|
||||
return s.basePath + "/" + s.getPolicyFileName(policyName)
|
||||
}
|
||||
|
||||
// getPolicyFileName returns the filename for a policy
|
||||
func (s *FilerPolicyStore) getPolicyFileName(policyName string) string {
|
||||
return s.getCanonicalPolicyFileName(policyName)
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) getLegacyPolicyFileName(policyName string) string {
|
||||
return "policy_" + policyName + ".json"
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) getCanonicalPolicyFileName(policyName string) string {
|
||||
return policyName + ".json"
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) getPolicyLookupFileNames(policyName string) []string {
|
||||
return []string{
|
||||
s.getCanonicalPolicyFileName(policyName),
|
||||
s.getLegacyPolicyFileName(policyName),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) policyNameFromFileName(fileName string) (string, bool) {
|
||||
if !strings.HasSuffix(fileName, ".json") {
|
||||
return "", false
|
||||
}
|
||||
policyName := strings.TrimSuffix(fileName, ".json")
|
||||
if strings.HasPrefix(fileName, "policy_") {
|
||||
policyName = strings.TrimPrefix(policyName, "policy_")
|
||||
}
|
||||
if s.isSupportedPolicyName(policyName) {
|
||||
return policyName, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) isSupportedPolicyName(policyName string) bool {
|
||||
if policyName == "" {
|
||||
return false
|
||||
}
|
||||
// Bucket policies are stored alongside IAM policies but use the internal
|
||||
// "bucket-policy:<bucket>" naming scheme, which is intentionally outside the
|
||||
// public IAM policy-name validator.
|
||||
if strings.HasPrefix(policyName, "bucket-policy:") {
|
||||
return len(policyName) > len("bucket-policy:")
|
||||
}
|
||||
return credential.ValidatePolicyName(policyName) == nil
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) deleteLegacyPolicyFileIfPresent(ctx context.Context, client filer_pb.SeaweedFilerClient, policyName string) error {
|
||||
legacyFileName := s.getLegacyPolicyFileName(policyName)
|
||||
response, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Name: legacyFileName,
|
||||
IsDeleteData: true,
|
||||
IsRecursive: false,
|
||||
IgnoreRecursiveError: false,
|
||||
})
|
||||
if err != nil {
|
||||
if isNotFoundPolicyStoreError(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to delete legacy policy %s: %v", policyName, err)
|
||||
}
|
||||
if response.Error != "" {
|
||||
return fmt.Errorf("failed to delete legacy policy %s: %s", policyName, response.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) savePolicyFile(ctx context.Context, client filer_pb.SeaweedFilerClient, fileName string, content []byte) error {
|
||||
now := time.Now().Unix()
|
||||
entry := &filer_pb.Entry{
|
||||
Name: fileName,
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: now,
|
||||
Crtime: now,
|
||||
FileMode: uint32(0600),
|
||||
Uid: uint32(0),
|
||||
Gid: uint32(0),
|
||||
FileSize: uint64(len(content)),
|
||||
},
|
||||
Content: content,
|
||||
}
|
||||
|
||||
createRequest := &filer_pb.CreateEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Entry: entry,
|
||||
}
|
||||
|
||||
if err := filer_pb.CreateEntry(ctx, client, createRequest); err == nil {
|
||||
return nil
|
||||
} else if !isAlreadyExistsPolicyStoreError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
return filer_pb.UpdateEntry(ctx, client, &filer_pb.UpdateEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Entry: entry,
|
||||
})
|
||||
}
|
||||
|
||||
func isNotFoundPolicyStoreError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return errors.Is(err, filer_pb.ErrNotFound) || status.Code(err) == codes.NotFound
|
||||
}
|
||||
|
||||
func isAlreadyExistsPolicyStoreError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return status.Code(err) == codes.AlreadyExists
|
||||
}
|
||||
314
weed/iam/policy/policy_store_test.go
Normal file
314
weed/iam/policy/policy_store_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type policyStoreTestFilerServer struct {
|
||||
filer_pb.UnimplementedSeaweedFilerServer
|
||||
mu sync.RWMutex
|
||||
entries map[string]*filer_pb.Entry
|
||||
}
|
||||
|
||||
func newPolicyStoreTestFilerServer() *policyStoreTestFilerServer {
|
||||
return &policyStoreTestFilerServer{
|
||||
entries: make(map[string]*filer_pb.Entry),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
entry, found := s.entries[policyStoreTestEntryKey(req.Directory, req.Name)]
|
||||
if !found {
|
||||
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
||||
}
|
||||
|
||||
return &filer_pb.LookupDirectoryEntryResponse{Entry: clonePolicyStoreEntry(entry)}, nil
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) CreateEntry(_ context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key := policyStoreTestEntryKey(req.Directory, req.Entry.Name)
|
||||
if _, found := s.entries[key]; found {
|
||||
return nil, status.Error(codes.AlreadyExists, "entry already exists")
|
||||
}
|
||||
|
||||
s.entries[key] = clonePolicyStoreEntry(req.Entry)
|
||||
return &filer_pb.CreateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) UpdateEntry(_ context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key := policyStoreTestEntryKey(req.Directory, req.Entry.Name)
|
||||
if _, found := s.entries[key]; !found {
|
||||
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
||||
}
|
||||
|
||||
s.entries[key] = clonePolicyStoreEntry(req.Entry)
|
||||
return &filer_pb.UpdateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) ListEntries(req *filer_pb.ListEntriesRequest, stream grpc.ServerStreamingServer[filer_pb.ListEntriesResponse]) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
names := make([]string, 0)
|
||||
for key := range s.entries {
|
||||
dir, name := splitPolicyStoreEntryKey(key)
|
||||
if dir != req.Directory {
|
||||
continue
|
||||
}
|
||||
if req.Prefix != "" && len(name) >= len(req.Prefix) && name[:len(req.Prefix)] != req.Prefix {
|
||||
continue
|
||||
}
|
||||
if req.Prefix != "" && len(name) < len(req.Prefix) {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
if err := stream.Send(&filer_pb.ListEntriesResponse{
|
||||
Entry: clonePolicyStoreEntry(s.entries[policyStoreTestEntryKey(req.Directory, name)]),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key := policyStoreTestEntryKey(req.Directory, req.Name)
|
||||
if _, found := s.entries[key]; !found {
|
||||
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
||||
}
|
||||
|
||||
delete(s.entries, key)
|
||||
return &filer_pb.DeleteEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) putPolicyFile(t *testing.T, dir string, name string, document *PolicyDocument) {
|
||||
t.Helper()
|
||||
|
||||
content, err := json.Marshal(document)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.entries[policyStoreTestEntryKey(dir, name)] = &filer_pb.Entry{
|
||||
Name: name,
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) hasEntry(dir string, name string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, found := s.entries[policyStoreTestEntryKey(dir, name)]
|
||||
return found
|
||||
}
|
||||
|
||||
func newTestFilerPolicyStore(t *testing.T) (*FilerPolicyStore, *policyStoreTestFilerServer) {
|
||||
t.Helper()
|
||||
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
server := newPolicyStoreTestFilerServer()
|
||||
grpcServer := pb.NewGrpcServer()
|
||||
filer_pb.RegisterSeaweedFilerServer(grpcServer, server)
|
||||
go func() {
|
||||
_ = grpcServer.Serve(lis)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
grpcServer.Stop()
|
||||
_ = lis.Close()
|
||||
})
|
||||
|
||||
host, portString, err := net.SplitHostPort(lis.Addr().String())
|
||||
require.NoError(t, err)
|
||||
grpcPort, err := strconv.Atoi(portString)
|
||||
require.NoError(t, err)
|
||||
|
||||
store, err := NewFilerPolicyStore(nil, func() string {
|
||||
return string(pb.NewServerAddress(host, 1, grpcPort))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
store.grpcDialOption = grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
|
||||
return store, server
|
||||
}
|
||||
|
||||
func TestFilerPolicyStoreGetPolicyPrefersCanonicalFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newTestFilerPolicyStore(t)
|
||||
|
||||
server.putPolicyFile(t, store.basePath, "cli-bucket-access-policy.json", testPolicyDocument("s3:ListBucket", "arn:aws:s3:::cli-allowed-bucket"))
|
||||
server.putPolicyFile(t, store.basePath, "policy_cli-bucket-access-policy.json", testPolicyDocument("s3:PutObject", "arn:aws:s3:::cli-forbidden-bucket/*"))
|
||||
|
||||
document, err := store.GetPolicy(ctx, "", "cli-bucket-access-policy")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, document.Statement, 1)
|
||||
assert.Equal(t, "s3:ListBucket", document.Statement[0].Action[0])
|
||||
assert.Equal(t, "arn:aws:s3:::cli-allowed-bucket", document.Statement[0].Resource[0])
|
||||
}
|
||||
|
||||
func TestFilerPolicyStoreListPoliciesIncludesCanonicalAndLegacyFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newTestFilerPolicyStore(t)
|
||||
|
||||
server.putPolicyFile(t, store.basePath, "canonical-only.json", testPolicyDocument("s3:GetObject", "arn:aws:s3:::canonical-only/*"))
|
||||
server.putPolicyFile(t, store.basePath, "policy_legacy-only.json", testPolicyDocument("s3:PutObject", "arn:aws:s3:::legacy-only/*"))
|
||||
server.putPolicyFile(t, store.basePath, "shared.json", testPolicyDocument("s3:DeleteObject", "arn:aws:s3:::shared/*"))
|
||||
server.putPolicyFile(t, store.basePath, "policy_shared.json", testPolicyDocument("s3:ListBucket", "arn:aws:s3:::shared"))
|
||||
server.putPolicyFile(t, store.basePath, "policy_invalid:name.json", testPolicyDocument("s3:GetObject", "arn:aws:s3:::ignored/*"))
|
||||
server.putPolicyFile(t, store.basePath, "bucket-policy:bucket-a.json", testPolicyDocument("s3:ListBucket", "arn:aws:s3:::bucket-a"))
|
||||
|
||||
names, err := store.ListPolicies(ctx, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.ElementsMatch(t, []string{"canonical-only", "legacy-only", "shared", "bucket-policy:bucket-a"}, names)
|
||||
}
|
||||
|
||||
func TestFilerPolicyStoreDeletePolicyRemovesCanonicalAndLegacyFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newTestFilerPolicyStore(t)
|
||||
|
||||
server.putPolicyFile(t, store.basePath, "dual-format.json", testPolicyDocument("s3:GetObject", "arn:aws:s3:::dual-format/*"))
|
||||
server.putPolicyFile(t, store.basePath, "policy_dual-format.json", testPolicyDocument("s3:PutObject", "arn:aws:s3:::dual-format/*"))
|
||||
|
||||
require.NoError(t, store.DeletePolicy(ctx, "", "dual-format"))
|
||||
assert.False(t, server.hasEntry(store.basePath, "dual-format.json"))
|
||||
assert.False(t, server.hasEntry(store.basePath, "policy_dual-format.json"))
|
||||
}
|
||||
|
||||
func TestFilerPolicyStoreStorePolicyWritesCanonicalFileAndRemovesLegacyTwin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newTestFilerPolicyStore(t)
|
||||
|
||||
server.putPolicyFile(t, store.basePath, "policy_dual-format.json", testPolicyDocument("s3:PutObject", "arn:aws:s3:::dual-format/*"))
|
||||
|
||||
require.NoError(t, store.StorePolicy(ctx, "", "dual-format", testPolicyDocument("s3:GetObject", "arn:aws:s3:::dual-format/*")))
|
||||
|
||||
assert.True(t, server.hasEntry(store.basePath, "dual-format.json"))
|
||||
assert.False(t, server.hasEntry(store.basePath, "policy_dual-format.json"))
|
||||
|
||||
document, err := store.GetPolicy(ctx, "", "dual-format")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, document.Statement, 1)
|
||||
assert.Equal(t, "s3:GetObject", document.Statement[0].Action[0])
|
||||
}
|
||||
|
||||
func TestFilerPolicyStoreStorePolicyUpdatesExistingCanonicalFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newTestFilerPolicyStore(t)
|
||||
|
||||
server.putPolicyFile(t, store.basePath, "existing.json", testPolicyDocument("s3:PutObject", "arn:aws:s3:::existing/*"))
|
||||
|
||||
require.NoError(t, store.StorePolicy(ctx, "", "existing", testPolicyDocument("s3:GetObject", "arn:aws:s3:::existing/*")))
|
||||
|
||||
document, err := store.GetPolicy(ctx, "", "existing")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, document.Statement, 1)
|
||||
assert.Equal(t, "s3:GetObject", document.Statement[0].Action[0])
|
||||
assert.Equal(t, "arn:aws:s3:::existing/*", document.Statement[0].Resource[0])
|
||||
}
|
||||
|
||||
func TestCopyPolicyDocumentClonesConditionState(t *testing.T) {
|
||||
original := &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{
|
||||
"arn:aws:s3:::test-bucket/*",
|
||||
},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"StringEquals": {
|
||||
"s3:prefix": []string{"public/", "private/"},
|
||||
},
|
||||
"Null": {
|
||||
"aws:PrincipalArn": "false",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
copied := copyPolicyDocument(original)
|
||||
require.NotNil(t, copied)
|
||||
|
||||
original.Statement[0].Condition["StringEquals"]["s3:prefix"] = []string{"mutated/"}
|
||||
original.Statement[0].Condition["Null"]["aws:PrincipalArn"] = "true"
|
||||
|
||||
assert.Equal(t, []string{"public/", "private/"}, copied.Statement[0].Condition["StringEquals"]["s3:prefix"])
|
||||
assert.Equal(t, "false", copied.Statement[0].Condition["Null"]["aws:PrincipalArn"])
|
||||
}
|
||||
|
||||
func TestIsAlreadyExistsPolicyStoreErrorUsesStatusCode(t *testing.T) {
|
||||
assert.True(t, isAlreadyExistsPolicyStoreError(status.Error(codes.AlreadyExists, "entry already exists")))
|
||||
assert.False(t, isAlreadyExistsPolicyStoreError(fmt.Errorf("entry already exists")))
|
||||
}
|
||||
|
||||
func testPolicyDocument(action string, resource string) *PolicyDocument {
|
||||
return &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: []string{action},
|
||||
Resource: []string{resource},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func clonePolicyStoreEntry(entry *filer_pb.Entry) *filer_pb.Entry {
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
return proto.Clone(entry).(*filer_pb.Entry)
|
||||
}
|
||||
|
||||
func policyStoreTestEntryKey(dir string, name string) string {
|
||||
return dir + "\x00" + name
|
||||
}
|
||||
|
||||
func splitPolicyStoreEntryKey(key string) (string, string) {
|
||||
for i := 0; i < len(key); i++ {
|
||||
if key[i] == '\x00' {
|
||||
return key[:i], key[i+1:]
|
||||
}
|
||||
}
|
||||
return key, ""
|
||||
}
|
||||
191
weed/iam/policy/policy_variable_matching_test.go
Normal file
191
weed/iam/policy/policy_variable_matching_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPolicyVariableMatchingInActionsAndResources tests that Actions and Resources
|
||||
// now support policy variables like ${aws:username} just like string conditions do
|
||||
func TestPolicyVariableMatchingInActionsAndResources(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
config := &PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory",
|
||||
}
|
||||
|
||||
err := engine.Initialize(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
filerAddress := ""
|
||||
|
||||
// Create a policy that uses policy variables in Action and Resource fields
|
||||
policyDoc := &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Sid: "AllowUserSpecificActions",
|
||||
Effect: "Allow",
|
||||
Action: []string{
|
||||
"s3:Get*", // Regular wildcard
|
||||
"s3:${aws:principaltype}*", // Policy variable in action
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:aws:s3:::user-${aws:username}/*", // Policy variable in resource
|
||||
"arn:aws:s3:::shared/${saml:username}/*", // Different policy variable
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = engine.AddPolicy(filerAddress, "user-specific-policy", policyDoc)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
principal string
|
||||
action string
|
||||
resource string
|
||||
requestContext map[string]interface{}
|
||||
expectedEffect Effect
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "policy_variable_in_action_matches",
|
||||
principal: "test-user",
|
||||
action: "s3:AssumedRole", // Should match s3:${aws:principaltype}* when principaltype=AssumedRole
|
||||
resource: "arn:aws:s3:::user-testuser/file.txt",
|
||||
requestContext: map[string]interface{}{
|
||||
"aws:username": "testuser",
|
||||
"aws:principaltype": "AssumedRole",
|
||||
},
|
||||
expectedEffect: EffectAllow,
|
||||
description: "Action with policy variable should match when variable is expanded",
|
||||
},
|
||||
{
|
||||
name: "policy_variable_in_resource_matches",
|
||||
principal: "alice",
|
||||
action: "s3:GetObject",
|
||||
resource: "arn:aws:s3:::user-alice/document.pdf", // Should match user-${aws:username}/*
|
||||
requestContext: map[string]interface{}{
|
||||
"aws:username": "alice",
|
||||
},
|
||||
expectedEffect: EffectAllow,
|
||||
description: "Resource with policy variable should match when variable is expanded",
|
||||
},
|
||||
{
|
||||
name: "saml_username_variable_in_resource",
|
||||
principal: "bob",
|
||||
action: "s3:GetObject",
|
||||
resource: "arn:aws:s3:::shared/bob/data.json", // Should match shared/${saml:username}/*
|
||||
requestContext: map[string]interface{}{
|
||||
"saml:username": "bob",
|
||||
},
|
||||
expectedEffect: EffectAllow,
|
||||
description: "SAML username variable should be expanded in resource patterns",
|
||||
},
|
||||
{
|
||||
name: "policy_variable_no_match_wrong_user",
|
||||
principal: "charlie",
|
||||
action: "s3:GetObject",
|
||||
resource: "arn:aws:s3:::user-alice/file.txt", // charlie trying to access alice's files
|
||||
requestContext: map[string]interface{}{
|
||||
"aws:username": "charlie",
|
||||
},
|
||||
expectedEffect: EffectDeny,
|
||||
description: "Policy variable should prevent access when username doesn't match",
|
||||
},
|
||||
{
|
||||
name: "missing_policy_variable_context",
|
||||
principal: "dave",
|
||||
action: "s3:GetObject",
|
||||
resource: "arn:aws:s3:::user-dave/file.txt",
|
||||
requestContext: map[string]interface{}{
|
||||
// Missing aws:username context
|
||||
},
|
||||
expectedEffect: EffectDeny,
|
||||
description: "Missing policy variable context should result in no match",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: tt.principal,
|
||||
Action: tt.action,
|
||||
Resource: tt.resource,
|
||||
RequestContext: tt.requestContext,
|
||||
}
|
||||
|
||||
result, err := engine.Evaluate(ctx, filerAddress, evalCtx, []string{"user-specific-policy"})
|
||||
require.NoError(t, err, "Policy evaluation should not error")
|
||||
|
||||
assert.Equal(t, tt.expectedEffect, result.Effect,
|
||||
"Test %s: %s. Expected %s but got %s",
|
||||
tt.name, tt.description, tt.expectedEffect, result.Effect)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionResourceConsistencyWithStringConditions verifies that Actions, Resources,
|
||||
// and string conditions all use the same AWS IAM-compliant matching logic
|
||||
func TestActionResourceConsistencyWithStringConditions(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
config := &PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory",
|
||||
}
|
||||
|
||||
err := engine.Initialize(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
filerAddress := ""
|
||||
|
||||
// Policy that uses case-insensitive matching in all three areas
|
||||
policyDoc := &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Sid: "CaseInsensitiveMatching",
|
||||
Effect: "Allow",
|
||||
Action: []string{"S3:GET*"}, // Uppercase action pattern
|
||||
Resource: []string{"arn:aws:s3:::TEST-BUCKET/*"}, // Uppercase resource pattern
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"StringLikeIgnoreCase": {
|
||||
"s3:RequestedRegion": "US-*", // Uppercase condition pattern
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = engine.AddPolicy(filerAddress, "case-insensitive-policy", policyDoc)
|
||||
require.NoError(t, err)
|
||||
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "test-user",
|
||||
Action: "s3:getobject", // lowercase action
|
||||
Resource: "arn:aws:s3:::test-bucket/file.txt", // lowercase resource
|
||||
RequestContext: map[string]interface{}{
|
||||
"s3:RequestedRegion": "us-east-1", // lowercase condition value
|
||||
},
|
||||
}
|
||||
|
||||
result, err := engine.Evaluate(ctx, filerAddress, evalCtx, []string{"case-insensitive-policy"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// All should match due to case-insensitive AWS IAM-compliant matching
|
||||
assert.Equal(t, EffectAllow, result.Effect,
|
||||
"Actions, Resources, and Conditions should all use case-insensitive AWS IAM matching")
|
||||
|
||||
// Verify that matching statements were found
|
||||
require.Len(t, result.MatchingStatements, 1,
|
||||
"Should have exactly one matching statement")
|
||||
assert.Equal(t, "Allow", string(result.MatchingStatements[0].Effect),
|
||||
"Matching statement should have Allow effect")
|
||||
}
|
||||
Reference in New Issue
Block a user