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:
72
weed/iam/integration/account_scope_test.go
Normal file
72
weed/iam/integration/account_scope_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetProviderByIssuerAndAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
|
||||
// Two providers for the same issuer, scoped to different accounts.
|
||||
must := func(rec *OIDCProviderRecord) {
|
||||
if err := store.StoreProvider(ctx, "", rec); err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
}
|
||||
must(&OIDCProviderRecord{
|
||||
AccountID: "111",
|
||||
ARN: "arn:aws:iam::111:oidc-provider/idp.example",
|
||||
URL: "https://idp.example",
|
||||
ClientIDs: []string{"a"},
|
||||
})
|
||||
must(&OIDCProviderRecord{
|
||||
AccountID: "222",
|
||||
ARN: "arn:aws:iam::222:oidc-provider/idp.example",
|
||||
URL: "https://idp.example",
|
||||
ClientIDs: []string{"b"},
|
||||
})
|
||||
|
||||
t.Run("matching account returns scoped record", func(t *testing.T) {
|
||||
rec, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://idp.example", "111")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if rec.AccountID != "111" {
|
||||
t.Fatalf("unexpected record: %+v", rec)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown account is rejected", func(t *testing.T) {
|
||||
if _, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://idp.example", "999"); err == nil {
|
||||
t.Fatal("expected error for cross-account use")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("global provider satisfies any account", func(t *testing.T) {
|
||||
must(&OIDCProviderRecord{
|
||||
AccountID: "",
|
||||
ARN: "arn:aws:iam:::oidc-provider/global.example",
|
||||
URL: "https://global.example",
|
||||
ClientIDs: []string{"x"},
|
||||
})
|
||||
rec, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://global.example", "anything")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if rec.AccountID != "" {
|
||||
t.Fatalf("expected global record, got AccountID=%q", rec.AccountID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty caller account accepts any record", func(t *testing.T) {
|
||||
rec, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://idp.example", "")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if rec == nil {
|
||||
t.Fatal("expected a record")
|
||||
}
|
||||
})
|
||||
}
|
||||
239
weed/iam/integration/advanced_policy_test.go
Normal file
239
weed/iam/integration/advanced_policy_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPolicyVariableSubstitution tests dynamic policy variables like ${oidc:sub} in Resource fields
|
||||
func TestPolicyVariableSubstitution(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a role with a policy that uses ${oidc:sub} variable
|
||||
// This allows users to access only their own folder
|
||||
err := iamManager.CreateRole(ctx, "", "DynamicUserRole", &RoleDefinition{
|
||||
RoleName: "DynamicUserRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"DynamicUserPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create the policy with variable substitution
|
||||
userPolicy := &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject", "s3:PutObject"},
|
||||
Resource: []string{
|
||||
"arn:aws:s3:::mybucket/${oidc:sub}/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Store the policy (in a real system this would be in the policy store)
|
||||
err = iamManager.policyEngine.AddPolicy("", "DynamicUserPolicy", userPolicy)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create JWT for user "alice"
|
||||
aliceJWT := createTestJWT(t, "https://test-issuer.com", "alice", "test-signing-key")
|
||||
|
||||
// Assume role as "alice"
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/DynamicUserRole",
|
||||
WebIdentityToken: aliceJWT,
|
||||
RoleSessionName: "alice-session",
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
// Test that the policy engine correctly substitutes ${oidc:sub} with "alice"
|
||||
evalCtx := &policy.EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/DynamicUserRole/alice-session",
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::mybucket/alice/file.txt",
|
||||
RequestContext: map[string]interface{}{
|
||||
"oidc:sub": "alice",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := iamManager.policyEngine.Evaluate(ctx, "", evalCtx, []string{"DynamicUserPolicy"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, policy.EffectAllow, result.Effect, "Alice should be allowed to access her own folder")
|
||||
|
||||
// Test that alice cannot access bob's folder
|
||||
evalCtx.Resource = "arn:aws:s3:::mybucket/bob/file.txt"
|
||||
result, err = iamManager.policyEngine.Evaluate(ctx, "", evalCtx, []string{"DynamicUserPolicy"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, policy.EffectDeny, result.Effect, "Alice should NOT be allowed to access Bob's folder")
|
||||
}
|
||||
|
||||
// TestConditionWithNumericComparison tests numeric conditions like DurationSeconds
|
||||
func TestConditionWithNumericComparison(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create role with trust policy enforcing DurationSeconds <= 3600
|
||||
err := iamManager.CreateRole(ctx, "", "LimitedDurationRole", &RoleDefinition{
|
||||
RoleName: "LimitedDurationRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"NumericLessThanEquals": {
|
||||
"sts:DurationSeconds": 3600, // Max 1 hour
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
validJWT := createTestJWT(t, "https://test-issuer.com", "user", "test-signing-key")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
duration int64
|
||||
shouldAllow bool
|
||||
}{
|
||||
{
|
||||
name: "duration within limit",
|
||||
duration: 1800, // 30 mins
|
||||
shouldAllow: true,
|
||||
},
|
||||
{
|
||||
name: "duration at limit",
|
||||
duration: 3600, // 1 hour
|
||||
shouldAllow: true,
|
||||
},
|
||||
{
|
||||
name: "duration exceeding limit",
|
||||
duration: 7200, // 2 hours
|
||||
shouldAllow: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/LimitedDurationRole",
|
||||
WebIdentityToken: validJWT,
|
||||
RoleSessionName: "test-session",
|
||||
DurationSeconds: &tt.duration,
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, req)
|
||||
|
||||
if tt.shouldAllow {
|
||||
assert.NoError(t, err, "Expected role assumption to succeed for duration %d", tt.duration)
|
||||
assert.NotNil(t, response)
|
||||
} else {
|
||||
assert.Error(t, err, "Expected role assumption to fail for duration %d", tt.duration)
|
||||
assert.Nil(t, response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultipleConditionOperators tests policies with multiple condition operators
|
||||
func TestMultipleConditionOperators(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a policy with multiple conditions
|
||||
complexPolicy := &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{
|
||||
"arn:aws:s3:::secure-bucket/*",
|
||||
},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"StringEquals": {
|
||||
"oidc:aud": "my-app-id",
|
||||
},
|
||||
"StringLike": {
|
||||
"oidc:sub": "user-*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := iamManager.policyEngine.AddPolicy("", "ComplexConditionPolicy", complexPolicy)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
aud string
|
||||
sub string
|
||||
expectedEffect policy.Effect
|
||||
}{
|
||||
{
|
||||
name: "all conditions match",
|
||||
aud: "my-app-id",
|
||||
sub: "user-alice",
|
||||
expectedEffect: policy.EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "aud mismatch",
|
||||
aud: "wrong-app-id",
|
||||
sub: "user-alice",
|
||||
expectedEffect: policy.EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "sub pattern mismatch",
|
||||
aud: "my-app-id",
|
||||
sub: "admin-alice",
|
||||
expectedEffect: policy.EffectDeny,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
evalCtx := &policy.EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/TestRole/session",
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::secure-bucket/file.txt",
|
||||
RequestContext: map[string]interface{}{
|
||||
"oidc:aud": tt.aud,
|
||||
"oidc:sub": tt.sub,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := iamManager.policyEngine.Evaluate(ctx, "", evalCtx, []string{"ComplexConditionPolicy"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedEffect, result.Effect)
|
||||
})
|
||||
}
|
||||
}
|
||||
153
weed/iam/integration/cached_role_store_generic.go
Normal file
153
weed/iam/integration/cached_role_store_generic.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/util"
|
||||
)
|
||||
|
||||
// RoleStoreAdapter adapts RoleStore interface to CacheableStore[*RoleDefinition]
|
||||
type RoleStoreAdapter struct {
|
||||
store RoleStore
|
||||
}
|
||||
|
||||
// NewRoleStoreAdapter creates a new adapter for RoleStore
|
||||
func NewRoleStoreAdapter(store RoleStore) *RoleStoreAdapter {
|
||||
return &RoleStoreAdapter{store: store}
|
||||
}
|
||||
|
||||
// Get implements CacheableStore interface
|
||||
func (a *RoleStoreAdapter) Get(ctx context.Context, filerAddress string, key string) (*RoleDefinition, error) {
|
||||
return a.store.GetRole(ctx, filerAddress, key)
|
||||
}
|
||||
|
||||
// Store implements CacheableStore interface
|
||||
func (a *RoleStoreAdapter) Store(ctx context.Context, filerAddress string, key string, value *RoleDefinition) error {
|
||||
return a.store.StoreRole(ctx, filerAddress, key, value)
|
||||
}
|
||||
|
||||
// Delete implements CacheableStore interface
|
||||
func (a *RoleStoreAdapter) Delete(ctx context.Context, filerAddress string, key string) error {
|
||||
return a.store.DeleteRole(ctx, filerAddress, key)
|
||||
}
|
||||
|
||||
// List implements CacheableStore interface
|
||||
func (a *RoleStoreAdapter) List(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
return a.store.ListRoles(ctx, filerAddress)
|
||||
}
|
||||
|
||||
// GenericCachedRoleStore implements RoleStore using the generic cache
|
||||
type GenericCachedRoleStore struct {
|
||||
*util.CachedStore[*RoleDefinition]
|
||||
adapter *RoleStoreAdapter
|
||||
}
|
||||
|
||||
// NewGenericCachedRoleStore creates a new cached role store using generics
|
||||
func NewGenericCachedRoleStore(config map[string]interface{}, filerAddressProvider func() string) (*GenericCachedRoleStore, error) {
|
||||
// Create underlying filer store
|
||||
filerStore, err := NewFilerRoleStore(config, filerAddressProvider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse cache configuration with defaults
|
||||
cacheTTL := 5 * time.Minute
|
||||
listTTL := 1 * time.Minute
|
||||
maxCacheSize := int64(1000)
|
||||
|
||||
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 := NewRoleStoreAdapter(filerStore)
|
||||
cachedStore := util.NewCachedStore(
|
||||
adapter,
|
||||
genericCopyRoleDefinition, // Copy function
|
||||
util.CachedStoreConfig{
|
||||
TTL: cacheTTL,
|
||||
ListTTL: listTTL,
|
||||
MaxCacheSize: maxCacheSize,
|
||||
},
|
||||
)
|
||||
|
||||
glog.V(2).Infof("Initialized GenericCachedRoleStore with TTL %v, List TTL %v, Max Cache Size %d",
|
||||
cacheTTL, listTTL, maxCacheSize)
|
||||
|
||||
return &GenericCachedRoleStore{
|
||||
CachedStore: cachedStore,
|
||||
adapter: adapter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StoreRole implements RoleStore interface
|
||||
func (c *GenericCachedRoleStore) StoreRole(ctx context.Context, filerAddress string, roleName string, role *RoleDefinition) error {
|
||||
return c.Store(ctx, filerAddress, roleName, role)
|
||||
}
|
||||
|
||||
// GetRole implements RoleStore interface
|
||||
func (c *GenericCachedRoleStore) GetRole(ctx context.Context, filerAddress string, roleName string) (*RoleDefinition, error) {
|
||||
return c.Get(ctx, filerAddress, roleName)
|
||||
}
|
||||
|
||||
// ListRoles implements RoleStore interface
|
||||
func (c *GenericCachedRoleStore) ListRoles(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
return c.List(ctx, filerAddress)
|
||||
}
|
||||
|
||||
// DeleteRole implements RoleStore interface
|
||||
func (c *GenericCachedRoleStore) DeleteRole(ctx context.Context, filerAddress string, roleName string) error {
|
||||
return c.Delete(ctx, filerAddress, roleName)
|
||||
}
|
||||
|
||||
// genericCopyRoleDefinition creates a deep copy of a RoleDefinition for the generic cache
|
||||
func genericCopyRoleDefinition(role *RoleDefinition) *RoleDefinition {
|
||||
if role == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &RoleDefinition{
|
||||
RoleName: role.RoleName,
|
||||
RoleArn: role.RoleArn,
|
||||
Description: role.Description,
|
||||
}
|
||||
|
||||
// Deep copy trust policy if it exists
|
||||
if role.TrustPolicy != nil {
|
||||
trustPolicyData, err := json.Marshal(role.TrustPolicy)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to marshal trust policy for deep copy: %v", err)
|
||||
return nil
|
||||
}
|
||||
var trustPolicyCopy policy.PolicyDocument
|
||||
if err := json.Unmarshal(trustPolicyData, &trustPolicyCopy); err != nil {
|
||||
glog.Errorf("Failed to unmarshal trust policy for deep copy: %v", err)
|
||||
return nil
|
||||
}
|
||||
result.TrustPolicy = &trustPolicyCopy
|
||||
}
|
||||
|
||||
// Deep copy attached policies slice
|
||||
if role.AttachedPolicies != nil {
|
||||
result.AttachedPolicies = make([]string, len(role.AttachedPolicies))
|
||||
copy(result.AttachedPolicies, role.AttachedPolicies)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
912
weed/iam/integration/iam_integration_test.go
Normal file
912
weed/iam/integration/iam_integration_test.go
Normal file
@@ -0,0 +1,912 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/ldap"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/oidc"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestFullOIDCWorkflow tests the complete OIDC → STS → Policy workflow
|
||||
func TestFullOIDCWorkflow(t *testing.T) {
|
||||
// Set up integrated IAM system
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
|
||||
// Create JWT tokens for testing with the correct issuer
|
||||
validJWTToken := createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
|
||||
invalidJWTToken := createTestJWT(t, "https://invalid-issuer.com", "test-user", "wrong-key")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
roleArn string
|
||||
sessionName string
|
||||
webToken string
|
||||
expectedAllow bool
|
||||
testAction string
|
||||
testResource string
|
||||
}{
|
||||
{
|
||||
name: "successful role assumption with policy validation",
|
||||
roleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
sessionName: "oidc-session",
|
||||
webToken: validJWTToken,
|
||||
expectedAllow: true,
|
||||
testAction: "s3:GetObject",
|
||||
testResource: "arn:aws:s3:::test-bucket/file.txt",
|
||||
},
|
||||
{
|
||||
name: "role assumption denied by trust policy",
|
||||
roleArn: "arn:aws:iam::role/RestrictedRole",
|
||||
sessionName: "oidc-session",
|
||||
webToken: validJWTToken,
|
||||
expectedAllow: false,
|
||||
},
|
||||
{
|
||||
name: "invalid token rejected",
|
||||
roleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
sessionName: "oidc-session",
|
||||
webToken: invalidJWTToken,
|
||||
expectedAllow: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Step 1: Attempt role assumption
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: tt.roleArn,
|
||||
WebIdentityToken: tt.webToken,
|
||||
RoleSessionName: tt.sessionName,
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
|
||||
if !tt.expectedAllow {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
return
|
||||
}
|
||||
|
||||
// Should succeed if expectedAllow is true
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
require.NotNil(t, response.Credentials)
|
||||
|
||||
// Step 2: Test policy enforcement with assumed credentials
|
||||
if tt.testAction != "" && tt.testResource != "" {
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: tt.testAction,
|
||||
Resource: tt.testResource,
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed, "Action should be allowed by role policy")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFullLDAPWorkflow tests the complete LDAP → STS → Policy workflow
|
||||
func TestFullLDAPWorkflow(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
roleArn string
|
||||
sessionName string
|
||||
username string
|
||||
password string
|
||||
expectedAllow bool
|
||||
testAction string
|
||||
testResource string
|
||||
}{
|
||||
{
|
||||
name: "successful LDAP role assumption",
|
||||
roleArn: "arn:aws:iam::role/LDAPUserRole",
|
||||
sessionName: "ldap-session",
|
||||
username: "testuser",
|
||||
password: "testpass",
|
||||
expectedAllow: true,
|
||||
testAction: "filer:CreateEntry",
|
||||
testResource: "arn:aws:filer::path/user-docs/*",
|
||||
},
|
||||
{
|
||||
name: "invalid LDAP credentials",
|
||||
roleArn: "arn:aws:iam::role/LDAPUserRole",
|
||||
sessionName: "ldap-session",
|
||||
username: "testuser",
|
||||
password: "wrongpass",
|
||||
expectedAllow: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Step 1: Attempt role assumption with LDAP credentials
|
||||
assumeRequest := &sts.AssumeRoleWithCredentialsRequest{
|
||||
RoleArn: tt.roleArn,
|
||||
Username: tt.username,
|
||||
Password: tt.password,
|
||||
RoleSessionName: tt.sessionName,
|
||||
ProviderName: "test-ldap",
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithCredentials(ctx, assumeRequest)
|
||||
|
||||
if !tt.expectedAllow {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
// Step 2: Test policy enforcement
|
||||
if tt.testAction != "" && tt.testResource != "" {
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: tt.testAction,
|
||||
Resource: tt.testResource,
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicyEnforcement tests policy evaluation for various scenarios
|
||||
func TestPolicyEnforcement(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
|
||||
// Create a valid JWT token for testing
|
||||
validJWTToken := createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
|
||||
|
||||
// Create a session for testing
|
||||
ctx := context.Background()
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
WebIdentityToken: validJWTToken,
|
||||
RoleSessionName: "policy-test-session",
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
sessionToken := response.Credentials.SessionToken
|
||||
principal := response.AssumedRoleUser.Arn
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
resource string
|
||||
shouldAllow bool
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "allow read access",
|
||||
action: "s3:GetObject",
|
||||
resource: "arn:aws:s3:::test-bucket/file.txt",
|
||||
shouldAllow: true,
|
||||
reason: "S3ReadOnlyRole should allow GetObject",
|
||||
},
|
||||
{
|
||||
name: "allow list bucket",
|
||||
action: "s3:ListBucket",
|
||||
resource: "arn:aws:s3:::test-bucket",
|
||||
shouldAllow: true,
|
||||
reason: "S3ReadOnlyRole should allow ListBucket",
|
||||
},
|
||||
{
|
||||
name: "deny write access",
|
||||
action: "s3:PutObject",
|
||||
resource: "arn:aws:s3:::test-bucket/newfile.txt",
|
||||
shouldAllow: false,
|
||||
reason: "S3ReadOnlyRole should deny write operations",
|
||||
},
|
||||
{
|
||||
name: "deny delete access",
|
||||
action: "s3:DeleteObject",
|
||||
resource: "arn:aws:s3:::test-bucket/file.txt",
|
||||
shouldAllow: false,
|
||||
reason: "S3ReadOnlyRole should deny delete operations",
|
||||
},
|
||||
{
|
||||
name: "deny filer access",
|
||||
action: "filer:CreateEntry",
|
||||
resource: "arn:aws:filer::path/test",
|
||||
shouldAllow: false,
|
||||
reason: "S3ReadOnlyRole should not allow filer operations",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: principal,
|
||||
Action: tt.action,
|
||||
Resource: tt.resource,
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.shouldAllow, allowed, tt.reason)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionPolicyBoundary verifies that inline session policies restrict permissions.
|
||||
func TestSessionPolicyBoundary(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
stsService := iamManager.GetSTSService()
|
||||
require.NotNil(t, stsService)
|
||||
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::test-bucket/allowed/*"]}]}`
|
||||
|
||||
sessionId, err := sts.GenerateSessionId()
|
||||
require.NoError(t, err)
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
principal := "arn:aws:sts::000000000000:assumed-role/S3ReadOnlyRole/policy-session"
|
||||
|
||||
claims := sts.NewSTSSessionClaims(sessionId, stsService.Config.Issuer, expiresAt).
|
||||
WithSessionName("policy-session").
|
||||
WithRoleInfo("arn:aws:iam::role/S3ReadOnlyRole", principal, principal).
|
||||
WithSessionPolicy(sessionPolicy)
|
||||
|
||||
sessionToken, err := stsService.GetTokenGenerator().GenerateJWTWithClaims(claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: principal,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/allowed/file.txt",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed, "Session policy should allow GetObject within allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: principal,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/other/file.txt",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny GetObject outside allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: principal,
|
||||
Action: "s3:ListBucket",
|
||||
Resource: "arn:aws:s3:::test-bucket",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny ListBucket when not explicitly allowed")
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithWebIdentitySessionPolicy verifies Policy downscoping is applied to web identity sessions.
|
||||
func TestAssumeRoleWithWebIdentitySessionPolicy(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
validJWTToken := createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::test-bucket/allowed/*"]}]}`
|
||||
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
WebIdentityToken: validJWTToken,
|
||||
RoleSessionName: "policy-web-identity",
|
||||
Policy: &sessionPolicy,
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/allowed/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed, "Session policy should allow GetObject within allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/other/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny GetObject outside allowed prefix")
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithCredentialsSessionPolicy verifies Policy downscoping is applied to credentials sessions.
|
||||
func TestAssumeRoleWithCredentialsSessionPolicy(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["filer:CreateEntry"],"Resource":["arn:aws:filer::path/user-docs/allowed/*"]}]}`
|
||||
assumeRequest := &sts.AssumeRoleWithCredentialsRequest{
|
||||
RoleArn: "arn:aws:iam::role/LDAPUserRole",
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
RoleSessionName: "policy-ldap",
|
||||
ProviderName: "test-ldap",
|
||||
Policy: &sessionPolicy,
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithCredentials(ctx, assumeRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "filer:CreateEntry",
|
||||
Resource: "arn:aws:filer::path/user-docs/allowed/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed, "Session policy should allow CreateEntry within allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "filer:CreateEntry",
|
||||
Resource: "arn:aws:filer::path/user-docs/other/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny CreateEntry outside allowed prefix")
|
||||
}
|
||||
|
||||
// TestSessionExpiration tests session expiration and cleanup
|
||||
func TestSessionExpiration(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a valid JWT token for testing
|
||||
validJWTToken := createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
|
||||
|
||||
// Create a short-lived session
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
WebIdentityToken: validJWTToken,
|
||||
RoleSessionName: "expiration-test",
|
||||
DurationSeconds: int64Ptr(900), // 15 minutes
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
sessionToken := response.Credentials.SessionToken
|
||||
|
||||
// Verify session is initially valid
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/file.txt",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed)
|
||||
|
||||
// Verify the expiration time is set correctly
|
||||
assert.True(t, response.Credentials.Expiration.After(time.Now()))
|
||||
assert.True(t, response.Credentials.Expiration.Before(time.Now().Add(16*time.Minute)))
|
||||
|
||||
// Test session expiration behavior in stateless JWT system
|
||||
// In a stateless system, manual expiration is not supported
|
||||
err = iamManager.ExpireSessionForTesting(ctx, sessionToken)
|
||||
require.Error(t, err, "Manual session expiration should not be supported in stateless system")
|
||||
assert.Contains(t, err.Error(), "manual session expiration not supported")
|
||||
|
||||
// Verify session is still valid (since it hasn't naturally expired)
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/file.txt",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err, "Session should still be valid in stateless system")
|
||||
assert.True(t, allowed, "Access should still be allowed since token hasn't naturally expired")
|
||||
}
|
||||
|
||||
// TestSessionOutlivesShortLivedToken verifies the session lifetime is not
|
||||
// clamped to the web identity token's exp: IdPs like GitLab issue ~2-minute
|
||||
// id_tokens, and the session should still run the configured duration.
|
||||
func TestSessionOutlivesShortLivedToken(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
shortLivedJWT := createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key",
|
||||
time.Now().Add(3*time.Minute))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
durationSeconds *int64
|
||||
wantDuration time.Duration
|
||||
}{
|
||||
{"default duration ignores token exp", nil, time.Hour},
|
||||
{"explicit DurationSeconds ignores token exp", int64Ptr(3600), time.Hour},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
WebIdentityToken: shortLivedJWT,
|
||||
RoleSessionName: "short-lived-token-test",
|
||||
DurationSeconds: tc.durationSeconds,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.WithinDuration(t, time.Now().Add(tc.wantDuration), response.Credentials.Expiration, time.Minute)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrustPolicyValidation tests role trust policy validation
|
||||
func TestTrustPolicyValidation(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
roleArn string
|
||||
provider string
|
||||
userID string
|
||||
shouldAllow bool
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "OIDC user allowed by trust policy",
|
||||
roleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
provider: "oidc",
|
||||
userID: "test-user-id",
|
||||
shouldAllow: true,
|
||||
reason: "Trust policy should allow OIDC users",
|
||||
},
|
||||
{
|
||||
name: "LDAP user allowed by different role",
|
||||
roleArn: "arn:aws:iam::role/LDAPUserRole",
|
||||
provider: "ldap",
|
||||
userID: "testuser",
|
||||
shouldAllow: true,
|
||||
reason: "Trust policy should allow LDAP users for LDAP role",
|
||||
},
|
||||
{
|
||||
name: "Wrong provider for role",
|
||||
roleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
provider: "ldap",
|
||||
userID: "testuser",
|
||||
shouldAllow: false,
|
||||
reason: "S3ReadOnlyRole trust policy should reject LDAP users",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// This would test trust policy evaluation
|
||||
// For now, we'll implement this as part of the IAM manager
|
||||
result := iamManager.ValidateTrustPolicy(ctx, tt.roleArn, tt.provider, tt.userID)
|
||||
assert.Equal(t, tt.shouldAllow, result, tt.reason)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrustPolicyWildcardPrincipal tests wildcard principal handling in trust policies
|
||||
func TestTrustPolicyWildcardPrincipal(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a role with wildcard federated principal
|
||||
err := iamManager.CreateRole(ctx, "", "WildcardFederatedRole", &RoleDefinition{
|
||||
RoleName: "WildcardFederatedRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "*", // Wildcard should allow any federated provider
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a role with wildcard in array
|
||||
err = iamManager.CreateRole(ctx, "", "WildcardArrayRole", &RoleDefinition{
|
||||
RoleName: "WildcardArrayRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": []string{"specific-provider", "*"}, // Array with wildcard
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a role with plain wildcard principal (regression test)
|
||||
err = iamManager.CreateRole(ctx, "", "PlainWildcardRole", &RoleDefinition{
|
||||
RoleName: "PlainWildcardRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: "*", // Plain wildcard
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// NEW: Create a role with specific federated principal (for negative testing)
|
||||
err = iamManager.CreateRole(ctx, "", "SpecificFederatedRole", &RoleDefinition{
|
||||
RoleName: "SpecificFederatedRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// NEW: Create a role with principal as []interface{} (simulating JSON unmarshaling)
|
||||
err = iamManager.CreateRole(ctx, "", "InterfaceArrayRole", &RoleDefinition{
|
||||
RoleName: "InterfaceArrayRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": []interface{}{"specific-provider", "test-oidc"},
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create JWT token for testing
|
||||
validJWTToken := createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
roleArn string
|
||||
token string
|
||||
shouldAllow bool
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "Wildcard federated principal allows any provider",
|
||||
roleArn: "arn:aws:iam::role/WildcardFederatedRole",
|
||||
token: validJWTToken,
|
||||
shouldAllow: true,
|
||||
reason: "Wildcard federated principal should allow any provider",
|
||||
},
|
||||
{
|
||||
name: "Wildcard in array allows any provider",
|
||||
roleArn: "arn:aws:iam::role/WildcardArrayRole",
|
||||
token: validJWTToken,
|
||||
shouldAllow: true,
|
||||
reason: "Wildcard in principal array should allow any provider",
|
||||
},
|
||||
{
|
||||
name: "Plain wildcard allows any provider (regression)",
|
||||
roleArn: "arn:aws:iam::role/PlainWildcardRole",
|
||||
token: validJWTToken,
|
||||
shouldAllow: true,
|
||||
reason: "Plain wildcard principal should still work",
|
||||
},
|
||||
{
|
||||
name: "Non-wildcard federated principal requires matching provider",
|
||||
roleArn: "arn:aws:iam::role/SpecificFederatedRole",
|
||||
token: createTestJWT(t, "https://different-issuer.com", "test-user", "test-signing-key"),
|
||||
shouldAllow: false,
|
||||
reason: "Non-wildcard principal should still require matching provider",
|
||||
},
|
||||
{
|
||||
name: "Interface array principal works correctly",
|
||||
roleArn: "arn:aws:iam::role/InterfaceArrayRole",
|
||||
token: validJWTToken,
|
||||
shouldAllow: true,
|
||||
reason: "Principal as []interface{} should be handled correctly",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: tt.roleArn,
|
||||
WebIdentityToken: tt.token,
|
||||
RoleSessionName: "wildcard-test-session",
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
|
||||
if tt.shouldAllow {
|
||||
require.NoError(t, err, tt.reason)
|
||||
require.NotNil(t, response)
|
||||
require.NotNil(t, response.Credentials)
|
||||
} else {
|
||||
assert.Error(t, err, tt.reason)
|
||||
assert.Nil(t, response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOIDCClaimsTrustPolicy tests that OIDC claims are correctly mapped to trust policy context
|
||||
func TestOIDCClaimsTrustPolicy(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a role that requires a specific OIDC role claim
|
||||
err := iamManager.CreateRole(ctx, "", "OIDCRoleClaimRole", &RoleDefinition{
|
||||
RoleName: "OIDCRoleClaimRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"StringLike": {
|
||||
"oidc:roles": "Dev.SeaweedFS.*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Helper: Create a JWT with the specified roles claim
|
||||
createTokenWithRoles := func(roles []string) string {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"iss": "https://test-issuer.com",
|
||||
"sub": "test-user-123",
|
||||
"aud": "test-client-id",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"roles": roles,
|
||||
})
|
||||
signedToken, err := token.SignedString([]byte("test-signing-key-32-characters-long"))
|
||||
require.NoError(t, err)
|
||||
return signedToken
|
||||
}
|
||||
|
||||
// Create JWT tokens using the helper
|
||||
validToken := createTokenWithRoles([]string{"Dev.SeaweedFS.Admin", "Dev.SeaweedFS.Audit"})
|
||||
invalidToken := createTokenWithRoles([]string{"Other.Role"})
|
||||
|
||||
// Test case 1: Valid roles -> Should succeed
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/OIDCRoleClaimRole",
|
||||
WebIdentityToken: validToken,
|
||||
RoleSessionName: "oidc-claims-test",
|
||||
}
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
require.NoError(t, err, "Should allow role assumption when oidc:roles claim matches")
|
||||
require.NotNil(t, response)
|
||||
|
||||
// Test case 2: Invalid roles -> Should fail
|
||||
badRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/OIDCRoleClaimRole",
|
||||
WebIdentityToken: invalidToken,
|
||||
RoleSessionName: "oidc-claims-fail-test",
|
||||
}
|
||||
response, err = iamManager.AssumeRoleWithWebIdentity(ctx, badRequest)
|
||||
assert.Error(t, err, "Should deny role assumption when oidc:roles claim does not match")
|
||||
assert.Nil(t, response)
|
||||
}
|
||||
|
||||
// Helper functions and test setup
|
||||
|
||||
// createTestJWT creates a test JWT token with the specified issuer, subject and signing key
|
||||
func createTestJWT(t *testing.T, issuer, subject, signingKey string, exp ...time.Time) string {
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
if len(exp) > 0 {
|
||||
expiresAt = exp[0]
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"iss": issuer,
|
||||
"sub": subject,
|
||||
"aud": "test-client-id",
|
||||
"exp": expiresAt.Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
// Add claims that trust policy validation expects
|
||||
"idp": "test-oidc", // Identity provider claim for trust policy matching
|
||||
})
|
||||
|
||||
tokenString, err := token.SignedString([]byte(signingKey))
|
||||
require.NoError(t, err)
|
||||
return tokenString
|
||||
}
|
||||
|
||||
func setupIntegratedIAMSystem(t *testing.T) *IAMManager {
|
||||
// Create IAM manager with all components
|
||||
manager := NewIAMManager()
|
||||
|
||||
// Configure and initialize
|
||||
config := &IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: time.Hour * 12},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory", // Use memory for unit tests
|
||||
},
|
||||
Roles: &RoleStoreConfig{
|
||||
StoreType: "memory", // Use memory for unit tests
|
||||
},
|
||||
}
|
||||
|
||||
err := manager.Initialize(config, func() string {
|
||||
return "localhost:8888" // Mock filer address for testing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set up test providers
|
||||
setupTestProviders(t, manager)
|
||||
|
||||
// Set up test policies and roles
|
||||
setupTestPoliciesAndRoles(t, manager)
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
func setupTestProviders(t *testing.T, manager *IAMManager) {
|
||||
// Set up OIDC provider
|
||||
oidcProvider := oidc.NewMockOIDCProvider("test-oidc")
|
||||
oidcConfig := &oidc.OIDCConfig{
|
||||
Issuer: "https://test-issuer.com",
|
||||
ClientID: "test-client-id",
|
||||
}
|
||||
err := oidcProvider.Initialize(oidcConfig)
|
||||
require.NoError(t, err)
|
||||
oidcProvider.SetupDefaultTestData()
|
||||
|
||||
// Set up LDAP mock provider (no config needed for mock)
|
||||
ldapProvider := ldap.NewMockLDAPProvider("test-ldap")
|
||||
err = ldapProvider.Initialize(nil) // Mock doesn't need real config
|
||||
require.NoError(t, err)
|
||||
ldapProvider.SetupDefaultTestData()
|
||||
|
||||
// Register providers
|
||||
err = manager.RegisterIdentityProvider(oidcProvider)
|
||||
require.NoError(t, err)
|
||||
err = manager.RegisterIdentityProvider(ldapProvider)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func setupTestPoliciesAndRoles(t *testing.T, manager *IAMManager) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create S3 read-only policy
|
||||
s3ReadPolicy := &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Sid: "S3ReadAccess",
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject", "s3:ListBucket"},
|
||||
Resource: []string{
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := manager.CreatePolicy(ctx, "", "S3ReadOnlyPolicy", s3ReadPolicy)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create LDAP user policy
|
||||
ldapUserPolicy := &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Sid: "FilerAccess",
|
||||
Effect: "Allow",
|
||||
Action: []string{"filer:*"},
|
||||
Resource: []string{
|
||||
"arn:aws:filer::path/user-docs/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = manager.CreatePolicy(ctx, "", "LDAPUserPolicy", ldapUserPolicy)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create roles with trust policies
|
||||
err = manager.CreateRole(ctx, "", "S3ReadOnlyRole", &RoleDefinition{
|
||||
RoleName: "S3ReadOnlyRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = manager.CreateRole(ctx, "", "LDAPUserRole", &RoleDefinition{
|
||||
RoleName: "LDAPUserRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-ldap",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithCredentials"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"LDAPUserPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func int64Ptr(v int64) *int64 {
|
||||
return &v
|
||||
}
|
||||
1656
weed/iam/integration/iam_manager.go
Normal file
1656
weed/iam/integration/iam_manager.go
Normal file
File diff suppressed because it is too large
Load Diff
145
weed/iam/integration/iam_manager_no_policy_test.go
Normal file
145
weed/iam/integration/iam_manager_no_policy_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeUserStore struct {
|
||||
users map[string]*iam_pb.Identity
|
||||
}
|
||||
|
||||
func (f *fakeUserStore) GetUser(_ context.Context, name string) (*iam_pb.Identity, error) {
|
||||
if u, ok := f.users[name]; ok {
|
||||
return u, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestIsActionAllowed_RegisteredUserWithoutPoliciesIsDenied is a regression test
|
||||
// for a security issue where a user created via the S3 IAM API with no policies
|
||||
// attached was granted access to every S3 action. The default-allow path was
|
||||
// intended for unmanaged zero-config startup, not for explicitly registered IAM
|
||||
// users with an empty policy set.
|
||||
func TestIsActionAllowed_RegisteredUserWithoutPoliciesIsDenied(t *testing.T) {
|
||||
manager := NewIAMManager()
|
||||
require.NoError(t, manager.Initialize(&IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{
|
||||
// Reproduce the zero-config setup: with no IAM config file, the
|
||||
// loader picks DefaultEffect=Allow for in-memory mode.
|
||||
DefaultEffect: string(policy.EffectAllow),
|
||||
StoreType: sts.StoreTypeMemory,
|
||||
},
|
||||
Roles: &RoleStoreConfig{StoreType: sts.StoreTypeMemory},
|
||||
}, func() string { return "localhost:8888" }))
|
||||
|
||||
manager.SetUserStore(&fakeUserStore{users: map[string]*iam_pb.Identity{
|
||||
"newuser": {Name: "newuser"}, // no PolicyNames, no Actions
|
||||
}})
|
||||
|
||||
allowed, err := manager.IsActionAllowed(context.Background(), &ActionRequest{
|
||||
Principal: "arn:aws:iam::seaweedfs:user/newuser",
|
||||
Action: "s3:DeleteBucket",
|
||||
Resource: "arn:aws:s3:::any-bucket",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "registered user with no policies must not get default-allow access")
|
||||
}
|
||||
|
||||
// TestIsActionAllowed_RegisteredRoleWithoutPoliciesIsDenied is the role-side
|
||||
// counterpart of the registered-user regression: a managed role that exists
|
||||
// but has zero AttachedPolicies must not inherit full access from the
|
||||
// zero-config DefaultEffect=Allow fallback. Coderabbit flagged this as an
|
||||
// uncovered branch of the same fix on PR #9317.
|
||||
func TestIsActionAllowed_RegisteredRoleWithoutPoliciesIsDenied(t *testing.T) {
|
||||
manager := NewIAMManager()
|
||||
require.NoError(t, manager.Initialize(&IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{
|
||||
DefaultEffect: string(policy.EffectAllow),
|
||||
StoreType: sts.StoreTypeMemory,
|
||||
},
|
||||
Roles: &RoleStoreConfig{StoreType: sts.StoreTypeMemory},
|
||||
}, func() string { return "localhost:8888" }))
|
||||
|
||||
ctx := context.Background()
|
||||
require.NoError(t, manager.CreateRole(ctx, "", "EmptyRole", &RoleDefinition{
|
||||
RoleName: "EmptyRole",
|
||||
RoleArn: "arn:aws:iam::seaweedfs:role/EmptyRole",
|
||||
AttachedPolicies: nil, // explicit: managed role with no policies attached
|
||||
}))
|
||||
|
||||
allowed, err := manager.IsActionAllowed(ctx, &ActionRequest{
|
||||
// Use the assumed-role principal form the STS path produces. The
|
||||
// IAM manager extracts role name via ExtractRoleNameFromPrincipal,
|
||||
// finds the role definition, and must deny because AttachedPolicies
|
||||
// is empty even though DefaultEffect is Allow.
|
||||
Principal: "arn:aws:sts::seaweedfs:assumed-role/EmptyRole/some-session",
|
||||
Action: "s3:DeleteBucket",
|
||||
Resource: "arn:aws:s3:::any-bucket",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "registered role with no attached policies must not get default-allow access")
|
||||
}
|
||||
|
||||
// TestIsActionAllowed_RegisteredUserWithNonMatchingPolicyIsDenied covers the
|
||||
// adjacent case: the user has policies attached, but none of the policy
|
||||
// statements match the requested action/resource. The DefaultEffect=Allow
|
||||
// fallback must not silently grant access when an attached policy simply
|
||||
// does not say anything about this action.
|
||||
func TestIsActionAllowed_RegisteredUserWithNonMatchingPolicyIsDenied(t *testing.T) {
|
||||
manager := NewIAMManager()
|
||||
require.NoError(t, manager.Initialize(&IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{
|
||||
DefaultEffect: string(policy.EffectAllow),
|
||||
StoreType: sts.StoreTypeMemory,
|
||||
},
|
||||
Roles: &RoleStoreConfig{StoreType: sts.StoreTypeMemory},
|
||||
}, func() string { return "localhost:8888" }))
|
||||
|
||||
ctx := context.Background()
|
||||
require.NoError(t, manager.CreatePolicy(ctx, "", "ReadOnlyOneBucket", &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{{
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"arn:aws:s3:::reports/*"},
|
||||
}},
|
||||
}))
|
||||
|
||||
manager.SetUserStore(&fakeUserStore{users: map[string]*iam_pb.Identity{
|
||||
"alice": {Name: "alice", PolicyNames: []string{"ReadOnlyOneBucket"}},
|
||||
}})
|
||||
|
||||
allowed, err := manager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: "arn:aws:iam::seaweedfs:user/alice",
|
||||
Action: "s3:DeleteBucket",
|
||||
Resource: "arn:aws:s3:::other-bucket",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "action outside the user's attached policy must be denied")
|
||||
}
|
||||
50
weed/iam/integration/iam_manager_trust.go
Normal file
50
weed/iam/integration/iam_manager_trust.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
|
||||
)
|
||||
|
||||
// ValidateTrustPolicyForPrincipal validates if a principal is allowed to assume a role
|
||||
func (m *IAMManager) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
|
||||
if !m.initialized {
|
||||
return fmt.Errorf("IAM manager not initialized")
|
||||
}
|
||||
|
||||
// Extract role name from ARN
|
||||
roleName := utils.ExtractRoleNameFromArn(roleArn)
|
||||
|
||||
// Get role definition
|
||||
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get role %s: %w", roleName, err)
|
||||
}
|
||||
|
||||
if roleDef.TrustPolicy == nil {
|
||||
return fmt.Errorf("role has no trust policy")
|
||||
}
|
||||
|
||||
// Create evaluation context with RequestContext populated so that
|
||||
// principal matching works for specific (non-wildcard) principals.
|
||||
// Without this, evaluatePrincipalValue cannot look up "aws:PrincipalArn"
|
||||
// and always returns false for non-wildcard trust policy principals.
|
||||
evalCtx := &policy.EvaluationContext{
|
||||
Principal: principalArn,
|
||||
Action: "sts:AssumeRole",
|
||||
Resource: roleArn,
|
||||
RequestContext: map[string]interface{}{
|
||||
"principal": principalArn,
|
||||
"aws:PrincipalArn": principalArn,
|
||||
},
|
||||
}
|
||||
|
||||
// Evaluate the trust policy
|
||||
if !m.evaluateTrustPolicy(roleDef.TrustPolicy, evalCtx) {
|
||||
return fmt.Errorf("trust policy denies access to principal: %s", principalArn)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
173
weed/iam/integration/oidc_provider_audit.go
Normal file
173
weed/iam/integration/oidc_provider_audit.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// OIDCProviderAuditEventType enumerates the lifecycle events emitted when an
|
||||
// IAM-managed OIDC provider record is mutated. Use-events (every successful
|
||||
// token validation) are intentionally not emitted by the lifecycle path:
|
||||
// they're too hot to stream into a filer-backed sink without spikes. A
|
||||
// future PR can add a sampled-use sink behind an explicit opt-in flag.
|
||||
type OIDCProviderAuditEventType string
|
||||
|
||||
const (
|
||||
OIDCAuditEventCreated OIDCProviderAuditEventType = "Create"
|
||||
OIDCAuditEventDeleted OIDCProviderAuditEventType = "Delete"
|
||||
OIDCAuditEventClientIDAdded OIDCProviderAuditEventType = "AddClientID"
|
||||
OIDCAuditEventClientIDRemoved OIDCProviderAuditEventType = "RemoveClientID"
|
||||
OIDCAuditEventThumbprintsSet OIDCProviderAuditEventType = "UpdateThumbprints"
|
||||
OIDCAuditEventTagsAdded OIDCProviderAuditEventType = "Tag"
|
||||
OIDCAuditEventTagsRemoved OIDCProviderAuditEventType = "Untag"
|
||||
)
|
||||
|
||||
// OIDCProviderAuditEvent is the payload emitted for each lifecycle event.
|
||||
type OIDCProviderAuditEvent struct {
|
||||
Type OIDCProviderAuditEventType `json:"type"`
|
||||
ARN string `json:"arn"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Detail map[string]string `json:"detail,omitempty"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
}
|
||||
|
||||
// OIDCProviderAuditSink consumes lifecycle events. Implementations must be
|
||||
// safe for concurrent use; emit happens with the IAM manager's mutation lock
|
||||
// held, so a slow sink slows the IAM API.
|
||||
type OIDCProviderAuditSink interface {
|
||||
Emit(ctx context.Context, event *OIDCProviderAuditEvent) error
|
||||
}
|
||||
|
||||
// GlogAuditSink writes one structured log line per event. Always-on default
|
||||
// when an explicit sink isn't configured — events still appear in stdout/the
|
||||
// log aggregator, just not in a queryable record.
|
||||
type GlogAuditSink struct{}
|
||||
|
||||
func (GlogAuditSink) Emit(_ context.Context, event *OIDCProviderAuditEvent) error {
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
glog.V(0).Infof("oidc-audit: %s arn=%s err-marshal=%v", event.Type, event.ARN, err)
|
||||
return nil
|
||||
}
|
||||
glog.V(0).Infof("oidc-audit: %s", string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemoryAuditSink keeps events in process memory for tests and short-lived
|
||||
// inspection. Not durable; drop entries by reading from Events() and
|
||||
// discarding.
|
||||
type MemoryAuditSink struct {
|
||||
mu sync.Mutex
|
||||
events []*OIDCProviderAuditEvent
|
||||
}
|
||||
|
||||
func NewMemoryAuditSink() *MemoryAuditSink {
|
||||
return &MemoryAuditSink{}
|
||||
}
|
||||
|
||||
func (m *MemoryAuditSink) Emit(_ context.Context, event *OIDCProviderAuditEvent) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cp := *event
|
||||
m.events = append(m.events, &cp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Events returns a copy of the captured event log so tests can assert.
|
||||
func (m *MemoryAuditSink) Events() []*OIDCProviderAuditEvent {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]*OIDCProviderAuditEvent, len(m.events))
|
||||
copy(out, m.events)
|
||||
return out
|
||||
}
|
||||
|
||||
// FilerAuditSink appends event records as separate files under a filer
|
||||
// directory. Each event is its own file so concurrent writers don't conflict;
|
||||
// the filename is `<unixnano>-<type>-<arnhash>.json`. For higher volumes,
|
||||
// switch to an append-only journal — the contract is just `Emit`.
|
||||
type FilerAuditSink struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
basePath string
|
||||
filerAddressProvider func() string
|
||||
}
|
||||
|
||||
// NewFilerAuditSink returns a filer-backed sink. Default basePath
|
||||
// `/etc/iam/audit/oidc-providers` keeps audit records out of the active
|
||||
// IAM data directories.
|
||||
func NewFilerAuditSink(config map[string]interface{}, filerAddressProvider func() string) *FilerAuditSink {
|
||||
sink := &FilerAuditSink{
|
||||
basePath: "/etc/iam/audit/oidc-providers",
|
||||
filerAddressProvider: filerAddressProvider,
|
||||
}
|
||||
if config != nil {
|
||||
if bp, ok := config["basePath"].(string); ok && bp != "" {
|
||||
sink.basePath = strings.TrimSuffix(bp, "/")
|
||||
}
|
||||
}
|
||||
return sink
|
||||
}
|
||||
|
||||
func (f *FilerAuditSink) resolveFilerAddress() string {
|
||||
if f.filerAddressProvider != nil {
|
||||
return f.filerAddressProvider()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *FilerAuditSink) Emit(ctx context.Context, event *OIDCProviderAuditEvent) error {
|
||||
addr := f.resolveFilerAddress()
|
||||
if addr == "" {
|
||||
return fmt.Errorf("filer address not available")
|
||||
}
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
if event.OccurredAt.IsZero() {
|
||||
event.OccurredAt = time.Now().UTC()
|
||||
}
|
||||
data, err := json.MarshalIndent(event, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal audit event: %v", err)
|
||||
}
|
||||
// Filename: <unixnano>-<type>-<arnhash>.json. Two events at the same
|
||||
// nano against the same ARN+type are vanishingly rare, but two events
|
||||
// at the same nano against different ARNs are perfectly plausible
|
||||
// (audit-via-batch script). The ARN hash gives us an effectively-
|
||||
// collision-free name without leaking the ARN into the filer path.
|
||||
arnSum := sha1.Sum([]byte(event.ARN))
|
||||
name := fmt.Sprintf("%d-%s-%s.json", event.OccurredAt.UnixNano(), event.Type, hex.EncodeToString(arnSum[:8]))
|
||||
occurredAt := event.OccurredAt.Unix()
|
||||
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(addr), f.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: name,
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
// Reflect the event's occurrence time so file metadata
|
||||
// matches the audit record itself, not the filer write.
|
||||
Mtime: occurredAt,
|
||||
Crtime: occurredAt,
|
||||
FileMode: uint32(0o600),
|
||||
},
|
||||
Content: data,
|
||||
},
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
105
weed/iam/integration/oidc_provider_audit_test.go
Normal file
105
weed/iam/integration/oidc_provider_audit_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
)
|
||||
|
||||
func newAuditableManager(t *testing.T) (*IAMManager, *MemoryAuditSink) {
|
||||
t.Helper()
|
||||
mgr := NewIAMManager()
|
||||
sink := NewMemoryAuditSink()
|
||||
cfg := &IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
AccountId: "111122223333",
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
|
||||
Roles: &RoleStoreConfig{StoreType: "memory"},
|
||||
}
|
||||
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
mgr.SetOIDCProviderAuditSink(sink)
|
||||
return mgr, sink
|
||||
}
|
||||
|
||||
func TestOIDCAuditLifecycleEventsAreEmitted(t *testing.T) {
|
||||
mgr, sink := newAuditableManager(t)
|
||||
ctx := context.Background()
|
||||
|
||||
rec := &OIDCProviderRecord{
|
||||
AccountID: "111122223333",
|
||||
ARN: "arn:aws:iam::111122223333:oidc-provider/idp.example",
|
||||
URL: "https://idp.example",
|
||||
ClientIDs: []string{"x"},
|
||||
}
|
||||
if err := mgr.CreateOIDCProvider(ctx, rec); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if err := mgr.AddClientIDToOIDCProvider(ctx, rec.ARN, "y"); err != nil {
|
||||
t.Fatalf("AddClientID: %v", err)
|
||||
}
|
||||
if err := mgr.RemoveClientIDFromOIDCProvider(ctx, rec.ARN, "y"); err != nil {
|
||||
t.Fatalf("RemoveClientID: %v", err)
|
||||
}
|
||||
if err := mgr.UpdateOIDCProviderThumbprints(ctx, rec.ARN, []string{"0000000000000000000000000000000000000000"}); err != nil {
|
||||
t.Fatalf("UpdateThumbprints: %v", err)
|
||||
}
|
||||
if err := mgr.TagOIDCProvider(ctx, rec.ARN, map[string]string{"team": "infra"}); err != nil {
|
||||
t.Fatalf("Tag: %v", err)
|
||||
}
|
||||
if err := mgr.UntagOIDCProvider(ctx, rec.ARN, []string{"team"}); err != nil {
|
||||
t.Fatalf("Untag: %v", err)
|
||||
}
|
||||
if err := mgr.DeleteOIDCProvider(ctx, rec.ARN); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
|
||||
want := []OIDCProviderAuditEventType{
|
||||
OIDCAuditEventCreated,
|
||||
OIDCAuditEventClientIDAdded,
|
||||
OIDCAuditEventClientIDRemoved,
|
||||
OIDCAuditEventThumbprintsSet,
|
||||
OIDCAuditEventTagsAdded,
|
||||
OIDCAuditEventTagsRemoved,
|
||||
OIDCAuditEventDeleted,
|
||||
}
|
||||
events := sink.Events()
|
||||
if len(events) != len(want) {
|
||||
t.Fatalf("expected %d events, got %d (%+v)", len(want), len(events), events)
|
||||
}
|
||||
for i, e := range events {
|
||||
if e.Type != want[i] {
|
||||
t.Errorf("event %d: type=%s want=%s", i, e.Type, want[i])
|
||||
}
|
||||
if e.ARN != rec.ARN {
|
||||
t.Errorf("event %d: ARN=%s want=%s", i, e.ARN, rec.ARN)
|
||||
}
|
||||
if e.OccurredAt.IsZero() {
|
||||
t.Errorf("event %d has zero OccurredAt", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCAuditDefaultsToGlogSink(t *testing.T) {
|
||||
// No SetOIDCProviderAuditSink call — should default to glog and not panic.
|
||||
mgr, _ := newAuditableManager(t)
|
||||
mgr.SetOIDCProviderAuditSink(nil)
|
||||
rec := &OIDCProviderRecord{
|
||||
AccountID: "111122223333",
|
||||
ARN: "arn:aws:iam::111122223333:oidc-provider/idp.example",
|
||||
URL: "https://idp.example",
|
||||
ClientIDs: []string{"x"},
|
||||
}
|
||||
if err := mgr.CreateOIDCProvider(context.Background(), rec); err != nil {
|
||||
t.Fatalf("Create with default sink: %v", err)
|
||||
}
|
||||
}
|
||||
178
weed/iam/integration/oidc_provider_bootstrap_test.go
Normal file
178
weed/iam/integration/oidc_provider_bootstrap_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
)
|
||||
|
||||
func TestStaticConfigSeedsProviderStore(t *testing.T) {
|
||||
mgr := NewIAMManager()
|
||||
cfg := &IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
AccountId: "111122223333",
|
||||
Providers: []*sts.ProviderConfig{
|
||||
{
|
||||
Name: "google",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: true,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://accounts.google.com",
|
||||
"clientId": "1234.apps.googleusercontent.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "github-actions",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: true,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://token.actions.githubusercontent.com",
|
||||
"clientId": "sts.amazonaws.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "disabled-google",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: false,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://accounts.disabled.example",
|
||||
"clientId": "x",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
|
||||
Roles: &RoleStoreConfig{StoreType: "memory"},
|
||||
}
|
||||
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
|
||||
store := mgr.GetOIDCProviderStore()
|
||||
if store == nil {
|
||||
t.Fatal("expected store to be initialized")
|
||||
}
|
||||
|
||||
got, err := mgr.ListOIDCProviders(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListOIDCProviders: %v", err)
|
||||
}
|
||||
// Disabled providers must not be mirrored.
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 providers, got %d (%v)", len(got), got)
|
||||
}
|
||||
|
||||
byArn := map[string]*OIDCProviderRecord{}
|
||||
for _, r := range got {
|
||||
byArn[r.ARN] = r
|
||||
}
|
||||
|
||||
googleARN := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
|
||||
g, ok := byArn[googleARN]
|
||||
if !ok {
|
||||
t.Fatalf("missing google ARN; have %v", byArn)
|
||||
}
|
||||
if len(g.ClientIDs) != 1 || g.ClientIDs[0] != "1234.apps.googleusercontent.com" {
|
||||
t.Fatalf("google clientIds wrong: %v", g.ClientIDs)
|
||||
}
|
||||
|
||||
ghARN := "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
|
||||
gh, ok := byArn[ghARN]
|
||||
if !ok {
|
||||
t.Fatalf("missing github-actions ARN; have %v", byArn)
|
||||
}
|
||||
if len(gh.ClientIDs) != 1 || gh.ClientIDs[0] != "sts.amazonaws.com" {
|
||||
t.Fatalf("github clientIds wrong: %v", gh.ClientIDs)
|
||||
}
|
||||
|
||||
// Spot-check the IAM read path returns the same record.
|
||||
rec, err := mgr.GetOIDCProvider(context.Background(), googleARN)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOIDCProvider: %v", err)
|
||||
}
|
||||
if rec.URL != "https://accounts.google.com" {
|
||||
t.Fatalf("URL mismatch: %s", rec.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticConfigMirrorsThumbprintsAndAdvancedFields(t *testing.T) {
|
||||
// Static config sets thumbprints, AllowedPrincipalTagKeys, and PolicyClaim;
|
||||
// the mirror into the IAM-managed store must carry all three. Without it,
|
||||
// the next RefreshOIDCProvidersFromStore rebuilds a runtime provider that
|
||||
// drops thumbprint pinning and silently disables claim-based policies and
|
||||
// principal-tag passthrough — and because IAM-managed entries take
|
||||
// precedence over the static-config map, the bootstrap provider gets
|
||||
// shadowed by this weaker rebuild.
|
||||
mgr := NewIAMManager()
|
||||
cfg := &IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
AccountId: "111122223333",
|
||||
Providers: []*sts.ProviderConfig{
|
||||
{
|
||||
Name: "github-actions",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: true,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://token.actions.githubusercontent.com",
|
||||
"clientId": "sts.amazonaws.com",
|
||||
"thumbprints": []interface{}{"6938fd4d98bab03faadb97b34396831e3780aea1"},
|
||||
"allowedPrincipalTagKeys": []interface{}{"team", "env"},
|
||||
"policyClaim": "policies",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
|
||||
Roles: &RoleStoreConfig{StoreType: "memory"},
|
||||
}
|
||||
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
|
||||
rec, err := mgr.GetOIDCProvider(context.Background(), "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOIDCProvider: %v", err)
|
||||
}
|
||||
if got, want := rec.Thumbprints, []string{"6938fd4d98bab03faadb97b34396831e3780aea1"}; !equalStrings(got, want) {
|
||||
t.Fatalf("thumbprints mismatch: got %v want %v", got, want)
|
||||
}
|
||||
if got, want := rec.AllowedPrincipalTagKeys, []string{"team", "env"}; !equalStrings(got, want) {
|
||||
t.Fatalf("allowedPrincipalTagKeys mismatch: got %v want %v", got, want)
|
||||
}
|
||||
if rec.PolicyClaim != "policies" {
|
||||
t.Fatalf("policyClaim mismatch: got %q want %q", rec.PolicyClaim, "policies")
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestStoreNotConfiguredReturnsClearError(t *testing.T) {
|
||||
mgr := NewIAMManager()
|
||||
if _, err := mgr.GetOIDCProvider(context.Background(), "arn:..."); err == nil {
|
||||
t.Fatal("expected error when store not configured")
|
||||
}
|
||||
if _, err := mgr.ListOIDCProviders(context.Background()); err == nil {
|
||||
t.Fatal("expected error when store not configured")
|
||||
}
|
||||
}
|
||||
473
weed/iam/integration/oidc_provider_store.go
Normal file
473
weed/iam/integration/oidc_provider_store.go
Normal file
@@ -0,0 +1,473 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the IAM manager and OIDCProviderStore. Callers
|
||||
// in the s3api layer use errors.Is rather than substring-matching the
|
||||
// formatted message, so the message can be edited freely without changing
|
||||
// the IAM error code surfaced to the API caller.
|
||||
var (
|
||||
ErrOIDCProviderNotFound = errors.New("OIDC provider not found")
|
||||
ErrOIDCProviderAlreadyExists = errors.New("OIDC provider already exists")
|
||||
)
|
||||
|
||||
// OIDCProviderRecord is the persisted, IAM-managed view of an OIDC identity
|
||||
// provider. It is the source of truth consulted at AssumeRoleWithWebIdentity
|
||||
// time. Static configuration entries are loaded into this same store at
|
||||
// startup so the resolution path is uniform.
|
||||
type OIDCProviderRecord struct {
|
||||
// AccountID scopes the record. Empty means "global" — usable from any
|
||||
// account. Non-empty records are only resolvable when the assuming
|
||||
// caller's RoleArn lives in the same account. Phase 2a leaves this
|
||||
// field unenforced; Phase 3c lights up the cross-account check.
|
||||
AccountID string `json:"accountId,omitempty"`
|
||||
|
||||
// ARN is the canonical IAM identifier for the provider:
|
||||
// arn:aws:iam::<account>:oidc-provider/<host>/<path>.
|
||||
ARN string `json:"arn"`
|
||||
|
||||
// URL is the issuer URL (no trailing slash), e.g.
|
||||
// https://token.actions.githubusercontent.com.
|
||||
URL string `json:"url"`
|
||||
|
||||
// ClientIDs are the audiences AWS calls "client IDs". A token's `aud` or
|
||||
// `azp` must match one of these for the token to be accepted.
|
||||
ClientIDs []string `json:"clientIds,omitempty"`
|
||||
|
||||
// Thumbprints are SHA-1 hex digests of the IDP's TLS certificate, matching
|
||||
// the AWS-compatible thumbprint algorithm. Used at JWKS-fetch time when
|
||||
// non-empty; an empty list means "trust the system root store".
|
||||
Thumbprints []string `json:"thumbprints,omitempty"`
|
||||
|
||||
// AllowedPrincipalTagKeys is the per-provider ABAC allowlist for the
|
||||
// `https://aws.amazon.com/tags/principal_tags` claim namespace. Empty
|
||||
// means no tags are surfaced from this provider.
|
||||
AllowedPrincipalTagKeys []string `json:"allowedPrincipalTagKeys,omitempty"`
|
||||
|
||||
// PolicyClaim, when non-empty, enables claim-based policy mode for this
|
||||
// provider: tokens whose RoleArn matches the configured sentinel will
|
||||
// derive their effective policies from this JWT claim.
|
||||
PolicyClaim string `json:"policyClaim,omitempty"`
|
||||
|
||||
// Tags is the AWS-style tag set attached to the IAM resource itself
|
||||
// (audit/inventory metadata, not propagated into sessions).
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// OIDCProviderStore stores OIDCProviderRecord entries. Implementations are
|
||||
// expected to be safe for concurrent use.
|
||||
type OIDCProviderStore interface {
|
||||
StoreProvider(ctx context.Context, filerAddress string, record *OIDCProviderRecord) error
|
||||
GetProviderByARN(ctx context.Context, filerAddress string, arn string) (*OIDCProviderRecord, error)
|
||||
GetProviderByIssuer(ctx context.Context, filerAddress string, issuer string) (*OIDCProviderRecord, error)
|
||||
GetProviderByIssuerAndAccount(ctx context.Context, filerAddress string, issuer, accountID string) (*OIDCProviderRecord, error)
|
||||
ListProviders(ctx context.Context, filerAddress string) ([]*OIDCProviderRecord, error)
|
||||
DeleteProvider(ctx context.Context, filerAddress string, arn string) error
|
||||
}
|
||||
|
||||
// MemoryOIDCProviderStore is a process-local store, suitable for tests and
|
||||
// single-node deployments. It also acts as the in-memory cache hydrated from
|
||||
// static config at boot.
|
||||
type MemoryOIDCProviderStore struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]*OIDCProviderRecord // keyed by ARN
|
||||
}
|
||||
|
||||
// NewMemoryOIDCProviderStore creates an empty in-memory store.
|
||||
func NewMemoryOIDCProviderStore() *MemoryOIDCProviderStore {
|
||||
return &MemoryOIDCProviderStore{providers: make(map[string]*OIDCProviderRecord)}
|
||||
}
|
||||
|
||||
// StoreProvider replaces any existing record with the same ARN.
|
||||
func (m *MemoryOIDCProviderStore) StoreProvider(ctx context.Context, _ string, record *OIDCProviderRecord) error {
|
||||
if record == nil {
|
||||
return fmt.Errorf("record cannot be nil")
|
||||
}
|
||||
if record.ARN == "" {
|
||||
return fmt.Errorf("record.ARN is required")
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.providers[record.ARN] = copyOIDCProviderRecord(record)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProviderByARN returns the record with the given ARN, or an error if absent.
|
||||
func (m *MemoryOIDCProviderStore) GetProviderByARN(ctx context.Context, _ string, arn string) (*OIDCProviderRecord, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
rec, ok := m.providers[arn]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", ErrOIDCProviderNotFound, arn)
|
||||
}
|
||||
return copyOIDCProviderRecord(rec), nil
|
||||
}
|
||||
|
||||
// GetProviderByIssuer scans for the first record whose URL matches `issuer`.
|
||||
// Comparison strips trailing slashes and is case-insensitive on host.
|
||||
func (m *MemoryOIDCProviderStore) GetProviderByIssuer(ctx context.Context, _ string, issuer string) (*OIDCProviderRecord, error) {
|
||||
want := normalizeIssuer(issuer)
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, rec := range m.providers {
|
||||
if normalizeIssuer(rec.URL) == want {
|
||||
return copyOIDCProviderRecord(rec), nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no OIDC provider registered for issuer: %s", issuer)
|
||||
}
|
||||
|
||||
// GetProviderByIssuerAndAccount returns the record matching `issuer` whose
|
||||
// AccountID is empty (global) or equals `accountID`. AWS-style account
|
||||
// scoping: cross-account references must go through a shared/global
|
||||
// provider, which is why an empty AccountID is treated as a wildcard.
|
||||
//
|
||||
// `accountID` may itself be empty when the caller has no specific account
|
||||
// context (e.g. claim-based mode); in that case any registered provider
|
||||
// matching the issuer is acceptable.
|
||||
func (m *MemoryOIDCProviderStore) GetProviderByIssuerAndAccount(ctx context.Context, _ string, issuer, accountID string) (*OIDCProviderRecord, error) {
|
||||
want := normalizeIssuer(issuer)
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, rec := range m.providers {
|
||||
if normalizeIssuer(rec.URL) != want {
|
||||
continue
|
||||
}
|
||||
if rec.AccountID == "" || accountID == "" || rec.AccountID == accountID {
|
||||
return copyOIDCProviderRecord(rec), nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no OIDC provider registered for issuer %s in account %s", issuer, accountID)
|
||||
}
|
||||
|
||||
// ListProviders returns every record sorted by ARN for stable output.
|
||||
func (m *MemoryOIDCProviderStore) ListProviders(ctx context.Context, _ string) ([]*OIDCProviderRecord, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]*OIDCProviderRecord, 0, len(m.providers))
|
||||
for _, rec := range m.providers {
|
||||
out = append(out, copyOIDCProviderRecord(rec))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ARN < out[j].ARN })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteProvider removes the record. Idempotent — deleting a missing ARN is success.
|
||||
func (m *MemoryOIDCProviderStore) DeleteProvider(ctx context.Context, _ string, arn string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.providers, arn)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilerOIDCProviderStore persists records as JSON files in a filer directory,
|
||||
// mirroring FilerRoleStore.
|
||||
type FilerOIDCProviderStore struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
basePath string
|
||||
filerAddressProvider func() string
|
||||
}
|
||||
|
||||
// NewFilerOIDCProviderStore returns a filer-backed store. Default path
|
||||
// `/etc/iam/oidc-providers` aligns with the existing roles directory.
|
||||
func NewFilerOIDCProviderStore(config map[string]interface{}, filerAddressProvider func() string) *FilerOIDCProviderStore {
|
||||
store := &FilerOIDCProviderStore{
|
||||
basePath: "/etc/iam/oidc-providers",
|
||||
filerAddressProvider: filerAddressProvider,
|
||||
}
|
||||
if config != nil {
|
||||
if bp, ok := config["basePath"].(string); ok && bp != "" {
|
||||
store.basePath = strings.TrimSuffix(bp, "/")
|
||||
}
|
||||
}
|
||||
glog.V(2).Infof("Initialized FilerOIDCProviderStore with basePath %s", store.basePath)
|
||||
return store
|
||||
}
|
||||
|
||||
func (f *FilerOIDCProviderStore) resolveFilerAddress(filerAddress string) string {
|
||||
if filerAddress != "" {
|
||||
return filerAddress
|
||||
}
|
||||
if f.filerAddressProvider != nil {
|
||||
return f.filerAddressProvider()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *FilerOIDCProviderStore) fileName(arn string) string {
|
||||
// Hash the ARN to a fixed-width filename so we can store any character set
|
||||
// (including the URL fragments AWS lets through) without filer-path issues.
|
||||
h := sha1.Sum([]byte(arn))
|
||||
return hex.EncodeToString(h[:]) + ".json"
|
||||
}
|
||||
|
||||
// StoreProvider persists `record` as JSON.
|
||||
func (f *FilerOIDCProviderStore) StoreProvider(ctx context.Context, filerAddress string, record *OIDCProviderRecord) error {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required")
|
||||
}
|
||||
if record == nil || record.ARN == "" {
|
||||
return fmt.Errorf("record with ARN is required")
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal OIDC provider: %v", err)
|
||||
}
|
||||
|
||||
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: f.fileName(record.ARN),
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0o600),
|
||||
},
|
||||
Content: data,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("store OIDC provider %s: %v", record.ARN, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetProviderByARN looks up the record file by its ARN-derived filename.
|
||||
func (f *FilerOIDCProviderStore) GetProviderByARN(ctx context.Context, filerAddress string, arn string) (*OIDCProviderRecord, error) {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required")
|
||||
}
|
||||
|
||||
var data []byte
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: f.fileName(arn),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrOIDCProviderNotFound, err)
|
||||
}
|
||||
if resp.Entry == nil {
|
||||
return fmt.Errorf("OIDC provider not found: %s", arn)
|
||||
}
|
||||
data = resp.Entry.Content
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rec OIDCProviderRecord
|
||||
if err := json.Unmarshal(data, &rec); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal OIDC provider: %v", err)
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// GetProviderByIssuer linearly scans the directory. Phase 2b adds an issuer
|
||||
// index when write throughput grows; for read-only this is good enough.
|
||||
func (f *FilerOIDCProviderStore) GetProviderByIssuer(ctx context.Context, filerAddress string, issuer string) (*OIDCProviderRecord, error) {
|
||||
records, err := f.ListProviders(ctx, filerAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want := normalizeIssuer(issuer)
|
||||
for _, rec := range records {
|
||||
if normalizeIssuer(rec.URL) == want {
|
||||
return rec, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no OIDC provider registered for issuer: %s", issuer)
|
||||
}
|
||||
|
||||
// GetProviderByIssuerAndAccount filters by AccountID on top of the issuer scan.
|
||||
// See the MemoryOIDCProviderStore equivalent for semantics.
|
||||
func (f *FilerOIDCProviderStore) GetProviderByIssuerAndAccount(ctx context.Context, filerAddress string, issuer, accountID string) (*OIDCProviderRecord, error) {
|
||||
records, err := f.ListProviders(ctx, filerAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want := normalizeIssuer(issuer)
|
||||
for _, rec := range records {
|
||||
if normalizeIssuer(rec.URL) != want {
|
||||
continue
|
||||
}
|
||||
if rec.AccountID == "" || accountID == "" || rec.AccountID == accountID {
|
||||
return rec, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no OIDC provider registered for issuer %s in account %s", issuer, accountID)
|
||||
}
|
||||
|
||||
// ListProviders enumerates every record in the directory.
|
||||
func (f *FilerOIDCProviderStore) ListProviders(ctx context.Context, filerAddress string) ([]*OIDCProviderRecord, error) {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required")
|
||||
}
|
||||
|
||||
var out []*OIDCProviderRecord
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Stream-paginate via StartFromFileName so deployments with more
|
||||
// than 1000 providers don't get a silently truncated list.
|
||||
const pageSize = 1000
|
||||
startFrom := ""
|
||||
for {
|
||||
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
|
||||
Directory: f.basePath,
|
||||
Limit: pageSize,
|
||||
StartFromFileName: startFrom,
|
||||
InclusiveStartFrom: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list OIDC providers: %v", err)
|
||||
}
|
||||
lastName := ""
|
||||
pageCount := 0
|
||||
for {
|
||||
resp, recvErr := stream.Recv()
|
||||
if recvErr != nil {
|
||||
if errors.Is(recvErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("recv OIDC provider entry: %w", recvErr)
|
||||
}
|
||||
if resp.Entry == nil || resp.Entry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
lastName = resp.Entry.Name
|
||||
pageCount++
|
||||
if !strings.HasSuffix(resp.Entry.Name, ".json") {
|
||||
continue
|
||||
}
|
||||
var rec OIDCProviderRecord
|
||||
if err := json.Unmarshal(resp.Entry.Content, &rec); err != nil {
|
||||
glog.Warningf("skipping malformed OIDC provider record %s: %v", resp.Entry.Name, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, &rec)
|
||||
}
|
||||
if pageCount < pageSize {
|
||||
break
|
||||
}
|
||||
startFrom = lastName
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ARN < out[j].ARN })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteProvider removes the record. Missing ARN is treated as success.
|
||||
func (f *FilerOIDCProviderStore) DeleteProvider(ctx context.Context, filerAddress string, arn string) error {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required")
|
||||
}
|
||||
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: f.fileName(arn),
|
||||
IsDeleteData: true,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("delete OIDC provider %s: %v", arn, err)
|
||||
}
|
||||
if resp.Error != "" && !strings.Contains(resp.Error, "not found") {
|
||||
return fmt.Errorf("delete OIDC provider %s: %s", arn, resp.Error)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (f *FilerOIDCProviderStore) withFilerClient(filerAddress string, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), f.grpcDialOption, fn)
|
||||
}
|
||||
|
||||
// DeriveOIDCProviderARN turns an issuer URL into the canonical IAM ARN AWS uses.
|
||||
// Empty `accountID` produces a global-style ARN.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// https://accounts.google.com -> arn:aws:iam::<acct>:oidc-provider/accounts.google.com
|
||||
// https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED ->
|
||||
// arn:aws:iam::<acct>:oidc-provider/oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED
|
||||
func DeriveOIDCProviderARN(accountID, issuerURL string) (string, error) {
|
||||
if issuerURL == "" {
|
||||
return "", fmt.Errorf("issuer URL is required")
|
||||
}
|
||||
u, err := url.Parse(issuerURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", fmt.Errorf("invalid issuer URL: %s", issuerURL)
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
resource := host + strings.TrimSuffix(u.Path, "/")
|
||||
return fmt.Sprintf("arn:aws:iam::%s:oidc-provider/%s", accountID, resource), nil
|
||||
}
|
||||
|
||||
// normalizeIssuer compares-friendly form: lowercased host, no trailing slash.
|
||||
func normalizeIssuer(issuer string) string {
|
||||
u, err := url.Parse(issuer)
|
||||
if err != nil || u.Host == "" {
|
||||
return strings.TrimSuffix(strings.ToLower(issuer), "/")
|
||||
}
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
u.Path = strings.TrimSuffix(u.Path, "/")
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func copyOIDCProviderRecord(rec *OIDCProviderRecord) *OIDCProviderRecord {
|
||||
if rec == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *rec
|
||||
if rec.ClientIDs != nil {
|
||||
cp.ClientIDs = append([]string(nil), rec.ClientIDs...)
|
||||
}
|
||||
if rec.Thumbprints != nil {
|
||||
cp.Thumbprints = append([]string(nil), rec.Thumbprints...)
|
||||
}
|
||||
if rec.AllowedPrincipalTagKeys != nil {
|
||||
cp.AllowedPrincipalTagKeys = append([]string(nil), rec.AllowedPrincipalTagKeys...)
|
||||
}
|
||||
if rec.Tags != nil {
|
||||
cp.Tags = make(map[string]string, len(rec.Tags))
|
||||
for k, v := range rec.Tags {
|
||||
cp.Tags[k] = v
|
||||
}
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
199
weed/iam/integration/oidc_provider_store_test.go
Normal file
199
weed/iam/integration/oidc_provider_store_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newRecord(arn, url string) *OIDCProviderRecord {
|
||||
now := time.Now()
|
||||
return &OIDCProviderRecord{
|
||||
ARN: arn,
|
||||
URL: url,
|
||||
ClientIDs: []string{"sts.amazonaws.com"},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreCRUD(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
|
||||
rec := newRecord(
|
||||
"arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com",
|
||||
"https://token.actions.githubusercontent.com",
|
||||
)
|
||||
|
||||
// Store + Get round-trip preserves the record.
|
||||
if err := store.StoreProvider(ctx, "", rec); err != nil {
|
||||
t.Fatalf("StoreProvider: %v", err)
|
||||
}
|
||||
got, err := store.GetProviderByARN(ctx, "", rec.ARN)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProviderByARN: %v", err)
|
||||
}
|
||||
if got.URL != rec.URL {
|
||||
t.Fatalf("URL mismatch: got=%s want=%s", got.URL, rec.URL)
|
||||
}
|
||||
|
||||
// Mutate the returned copy and verify the store wasn't affected.
|
||||
got.ClientIDs[0] = "tampered"
|
||||
again, _ := store.GetProviderByARN(ctx, "", rec.ARN)
|
||||
if again.ClientIDs[0] == "tampered" {
|
||||
t.Fatal("store handed out a shared slice; mutations leaked back")
|
||||
}
|
||||
|
||||
// List returns the entry.
|
||||
all, err := store.ListProviders(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListProviders: %v", err)
|
||||
}
|
||||
if len(all) != 1 || all[0].ARN != rec.ARN {
|
||||
t.Fatalf("ListProviders unexpected result: %+v", all)
|
||||
}
|
||||
|
||||
// Delete -> Get returns not found.
|
||||
if err := store.DeleteProvider(ctx, "", rec.ARN); err != nil {
|
||||
t.Fatalf("DeleteProvider: %v", err)
|
||||
}
|
||||
if _, err := store.GetProviderByARN(ctx, "", rec.ARN); err == nil {
|
||||
t.Fatal("expected not-found after delete")
|
||||
}
|
||||
// Delete is idempotent.
|
||||
if err := store.DeleteProvider(ctx, "", rec.ARN); err != nil {
|
||||
t.Fatalf("idempotent delete should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreGetByIssuerNormalizesHost(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
|
||||
rec := newRecord(
|
||||
"arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com",
|
||||
"https://Token.Actions.GithubUserContent.com/", // mixed case + trailing slash
|
||||
)
|
||||
if err := store.StoreProvider(ctx, "", rec); err != nil {
|
||||
t.Fatalf("StoreProvider: %v", err)
|
||||
}
|
||||
|
||||
cases := []string{
|
||||
"https://token.actions.githubusercontent.com",
|
||||
"https://token.actions.githubusercontent.com/",
|
||||
"https://TOKEN.actions.GITHUBUSERCONTENT.com",
|
||||
}
|
||||
for _, want := range cases {
|
||||
got, err := store.GetProviderByIssuer(ctx, "", want)
|
||||
if err != nil {
|
||||
t.Errorf("issuer %q: GetProviderByIssuer: %v", want, err)
|
||||
continue
|
||||
}
|
||||
if got.ARN != rec.ARN {
|
||||
t.Errorf("issuer %q: ARN mismatch: got=%s", want, got.ARN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreGetByIssuerMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
_, err := store.GetProviderByIssuer(ctx, "", "https://other.example/")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unregistered issuer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveOIDCProviderARN(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
accountID string
|
||||
issuer string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "google",
|
||||
accountID: "111122223333",
|
||||
issuer: "https://accounts.google.com",
|
||||
want: "arn:aws:iam::111122223333:oidc-provider/accounts.google.com",
|
||||
},
|
||||
{
|
||||
name: "EKS with path",
|
||||
accountID: "999999999999",
|
||||
issuer: "https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED",
|
||||
want: "arn:aws:iam::999999999999:oidc-provider/oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED",
|
||||
},
|
||||
{
|
||||
name: "uppercase host normalized",
|
||||
accountID: "111122223333",
|
||||
issuer: "https://Accounts.Google.com",
|
||||
want: "arn:aws:iam::111122223333:oidc-provider/accounts.google.com",
|
||||
},
|
||||
{
|
||||
name: "trailing slash trimmed",
|
||||
accountID: "111122223333",
|
||||
issuer: "https://accounts.google.com/",
|
||||
want: "arn:aws:iam::111122223333:oidc-provider/accounts.google.com",
|
||||
},
|
||||
{
|
||||
name: "empty account allowed",
|
||||
accountID: "",
|
||||
issuer: "https://accounts.google.com",
|
||||
want: "arn:aws:iam:::oidc-provider/accounts.google.com",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := DeriveOIDCProviderARN(tc.accountID, tc.issuer)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got=%s want=%s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveOIDCProviderARNRejectsBadInput(t *testing.T) {
|
||||
cases := []string{"", "not a url", "http://"}
|
||||
for _, in := range cases {
|
||||
if _, err := DeriveOIDCProviderARN("123", in); err == nil {
|
||||
t.Errorf("expected error for input %q", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsEmptyARN(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
rec := newRecord("", "https://issuer/")
|
||||
if err := store.StoreProvider(ctx, "", rec); err == nil {
|
||||
t.Fatal("expected error storing record with empty ARN")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsNil(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
if err := store.StoreProvider(ctx, "", nil); err == nil {
|
||||
t.Fatal("expected error storing nil record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIssuerRejectsEmpty(t *testing.T) {
|
||||
if got := normalizeIssuer(""); got != "" {
|
||||
t.Fatalf("empty issuer should normalize to empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIssuerHandlesNonURL(t *testing.T) {
|
||||
// Defense in depth: even when issuer is junk, normalize doesn't panic and
|
||||
// at least lowercases the input.
|
||||
got := normalizeIssuer("Some Random String/")
|
||||
if !strings.Contains(got, "some random") {
|
||||
t.Fatalf("normalize should lowercase non-URL input, got %q", got)
|
||||
}
|
||||
}
|
||||
34
weed/iam/integration/role_max_session_test.go
Normal file
34
weed/iam/integration/role_max_session_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package integration
|
||||
|
||||
import "testing"
|
||||
|
||||
func intPtr(v int64) *int64 { return &v }
|
||||
|
||||
func TestCapDurationByRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
requested *int64
|
||||
roleMax int64
|
||||
want *int64
|
||||
}{
|
||||
{"no cap, no request", nil, 0, nil},
|
||||
{"no cap, with request", intPtr(7200), 0, intPtr(7200)},
|
||||
{"cap only, no request -> nil so STS default applies", nil, 3600, nil},
|
||||
{"request below cap -> request", intPtr(1800), 3600, intPtr(1800)},
|
||||
{"request equal cap -> request", intPtr(3600), 3600, intPtr(3600)},
|
||||
{"request above cap -> cap", intPtr(43200), 3600, intPtr(3600)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := capDurationByRole(tc.requested, tc.roleMax)
|
||||
switch {
|
||||
case got == nil && tc.want == nil:
|
||||
return
|
||||
case got == nil || tc.want == nil:
|
||||
t.Fatalf("nilness mismatch: got=%v want=%v", got, tc.want)
|
||||
case *got != *tc.want:
|
||||
t.Fatalf("got=%d want=%d", *got, *tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
390
weed/iam/integration/role_store.go
Normal file
390
weed/iam/integration/role_store.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/karlseguin/ccache/v2"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// RoleStore defines the interface for storing IAM role definitions
|
||||
type RoleStore interface {
|
||||
// StoreRole stores a role definition (filerAddress ignored for memory stores)
|
||||
StoreRole(ctx context.Context, filerAddress string, roleName string, role *RoleDefinition) error
|
||||
|
||||
// GetRole retrieves a role definition (filerAddress ignored for memory stores)
|
||||
GetRole(ctx context.Context, filerAddress string, roleName string) (*RoleDefinition, error)
|
||||
|
||||
// ListRoles lists all role names (filerAddress ignored for memory stores)
|
||||
ListRoles(ctx context.Context, filerAddress string) ([]string, error)
|
||||
|
||||
// DeleteRole deletes a role definition (filerAddress ignored for memory stores)
|
||||
DeleteRole(ctx context.Context, filerAddress string, roleName string) error
|
||||
}
|
||||
|
||||
// MemoryRoleStore implements RoleStore using in-memory storage
|
||||
type MemoryRoleStore struct {
|
||||
roles map[string]*RoleDefinition
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMemoryRoleStore creates a new memory-based role store
|
||||
func NewMemoryRoleStore() *MemoryRoleStore {
|
||||
return &MemoryRoleStore{
|
||||
roles: make(map[string]*RoleDefinition),
|
||||
}
|
||||
}
|
||||
|
||||
// StoreRole stores a role definition in memory (filerAddress ignored for memory store)
|
||||
func (m *MemoryRoleStore) StoreRole(ctx context.Context, filerAddress string, roleName string, role *RoleDefinition) error {
|
||||
if roleName == "" {
|
||||
return fmt.Errorf("role name cannot be empty")
|
||||
}
|
||||
if role == nil {
|
||||
return fmt.Errorf("role cannot be nil")
|
||||
}
|
||||
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
// Deep copy the role to prevent external modifications
|
||||
m.roles[roleName] = copyRoleDefinition(role)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRole retrieves a role definition from memory (filerAddress ignored for memory store)
|
||||
func (m *MemoryRoleStore) GetRole(ctx context.Context, filerAddress string, roleName string) (*RoleDefinition, error) {
|
||||
if roleName == "" {
|
||||
return nil, fmt.Errorf("role name cannot be empty")
|
||||
}
|
||||
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
role, exists := m.roles[roleName]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("role not found: %s", roleName)
|
||||
}
|
||||
|
||||
// Return a copy to prevent external modifications
|
||||
return copyRoleDefinition(role), nil
|
||||
}
|
||||
|
||||
// ListRoles lists all role names in memory (filerAddress ignored for memory store)
|
||||
func (m *MemoryRoleStore) ListRoles(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(m.roles))
|
||||
for name := range m.roles {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// DeleteRole deletes a role definition from memory (filerAddress ignored for memory store)
|
||||
func (m *MemoryRoleStore) DeleteRole(ctx context.Context, filerAddress string, roleName string) error {
|
||||
if roleName == "" {
|
||||
return fmt.Errorf("role name cannot be empty")
|
||||
}
|
||||
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
delete(m.roles, roleName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyRoleDefinition creates a deep copy of a role definition
|
||||
func copyRoleDefinition(original *RoleDefinition) *RoleDefinition {
|
||||
if original == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
copied := &RoleDefinition{
|
||||
RoleName: original.RoleName,
|
||||
RoleArn: original.RoleArn,
|
||||
Description: original.Description,
|
||||
}
|
||||
|
||||
// Deep copy trust policy if it exists
|
||||
if original.TrustPolicy != nil {
|
||||
// Use JSON marshaling for deep copy of the complex policy structure
|
||||
trustPolicyData, _ := json.Marshal(original.TrustPolicy)
|
||||
var trustPolicyCopy policy.PolicyDocument
|
||||
json.Unmarshal(trustPolicyData, &trustPolicyCopy)
|
||||
copied.TrustPolicy = &trustPolicyCopy
|
||||
}
|
||||
|
||||
// Copy attached policies slice
|
||||
if original.AttachedPolicies != nil {
|
||||
copied.AttachedPolicies = make([]string, len(original.AttachedPolicies))
|
||||
copy(copied.AttachedPolicies, original.AttachedPolicies)
|
||||
}
|
||||
|
||||
return copied
|
||||
}
|
||||
|
||||
// FilerRoleStore implements RoleStore using SeaweedFS filer
|
||||
type FilerRoleStore struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
basePath string
|
||||
filerAddressProvider func() string
|
||||
}
|
||||
|
||||
// NewFilerRoleStore creates a new filer-based role store
|
||||
func NewFilerRoleStore(config map[string]interface{}, filerAddressProvider func() string) (*FilerRoleStore, error) {
|
||||
store := &FilerRoleStore{
|
||||
basePath: "/etc/iam/roles", // Default path for role 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 FilerRoleStore with basePath %s", store.basePath)
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// StoreRole stores a role definition in filer
|
||||
func (f *FilerRoleStore) StoreRole(ctx context.Context, filerAddress string, roleName string, role *RoleDefinition) error {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && f.filerAddressProvider != nil {
|
||||
filerAddress = f.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required for FilerRoleStore")
|
||||
}
|
||||
if roleName == "" {
|
||||
return fmt.Errorf("role name cannot be empty")
|
||||
}
|
||||
if role == nil {
|
||||
return fmt.Errorf("role cannot be nil")
|
||||
}
|
||||
|
||||
// Serialize role to JSON
|
||||
roleData, err := json.MarshalIndent(role, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize role: %v", err)
|
||||
}
|
||||
|
||||
rolePath := f.getRolePath(roleName)
|
||||
|
||||
// Store in filer
|
||||
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
request := &filer_pb.CreateEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: f.getRoleFileName(roleName),
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0600), // Read/write for owner only
|
||||
Uid: uint32(0),
|
||||
Gid: uint32(0),
|
||||
},
|
||||
Content: roleData,
|
||||
},
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Storing role %s at %s", roleName, rolePath)
|
||||
_, err := client.CreateEntry(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store role %s: %v", roleName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetRole retrieves a role definition from filer
|
||||
func (f *FilerRoleStore) GetRole(ctx context.Context, filerAddress string, roleName string) (*RoleDefinition, error) {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && f.filerAddressProvider != nil {
|
||||
filerAddress = f.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required for FilerRoleStore")
|
||||
}
|
||||
if roleName == "" {
|
||||
return nil, fmt.Errorf("role name cannot be empty")
|
||||
}
|
||||
|
||||
var roleData []byte
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
request := &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: f.getRoleFileName(roleName),
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Looking up role %s", roleName)
|
||||
response, err := client.LookupDirectoryEntry(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("role not found: %v", err)
|
||||
}
|
||||
|
||||
if response.Entry == nil {
|
||||
return fmt.Errorf("role not found")
|
||||
}
|
||||
|
||||
roleData = response.Entry.Content
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Deserialize role from JSON
|
||||
var role RoleDefinition
|
||||
if err := json.Unmarshal(roleData, &role); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize role: %v", err)
|
||||
}
|
||||
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
// ListRoles lists all role names in filer
|
||||
func (f *FilerRoleStore) ListRoles(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && f.filerAddressProvider != nil {
|
||||
filerAddress = f.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required for FilerRoleStore")
|
||||
}
|
||||
|
||||
var roleNames []string
|
||||
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
request := &filer_pb.ListEntriesRequest{
|
||||
Directory: f.basePath,
|
||||
Prefix: "",
|
||||
StartFromFileName: "",
|
||||
InclusiveStartFrom: false,
|
||||
Limit: 1000, // Process in batches of 1000
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Listing roles in %s", f.basePath)
|
||||
stream, err := client.ListEntries(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list roles: %v", err)
|
||||
}
|
||||
|
||||
for {
|
||||
resp, err := stream.Recv()
|
||||
if err != nil {
|
||||
break // End of stream or error
|
||||
}
|
||||
|
||||
if resp.Entry == nil || resp.Entry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract role name from filename
|
||||
filename := resp.Entry.Name
|
||||
if strings.HasSuffix(filename, ".json") {
|
||||
roleName := strings.TrimSuffix(filename, ".json")
|
||||
roleNames = append(roleNames, roleName)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return roleNames, nil
|
||||
}
|
||||
|
||||
// DeleteRole deletes a role definition from filer
|
||||
func (f *FilerRoleStore) DeleteRole(ctx context.Context, filerAddress string, roleName string) error {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && f.filerAddressProvider != nil {
|
||||
filerAddress = f.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required for FilerRoleStore")
|
||||
}
|
||||
if roleName == "" {
|
||||
return fmt.Errorf("role name cannot be empty")
|
||||
}
|
||||
|
||||
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
request := &filer_pb.DeleteEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: f.getRoleFileName(roleName),
|
||||
IsDeleteData: true,
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Deleting role %s", roleName)
|
||||
resp, err := client.DeleteEntry(ctx, request)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return nil // Idempotent: deletion of non-existent role is successful
|
||||
}
|
||||
return fmt.Errorf("failed to delete role %s: %v", roleName, err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
if strings.Contains(resp.Error, "not found") {
|
||||
return nil // Idempotent: deletion of non-existent role is successful
|
||||
}
|
||||
return fmt.Errorf("failed to delete role %s: %s", roleName, resp.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Helper methods for FilerRoleStore
|
||||
|
||||
func (f *FilerRoleStore) getRoleFileName(roleName string) string {
|
||||
return roleName + ".json"
|
||||
}
|
||||
|
||||
func (f *FilerRoleStore) getRolePath(roleName string) string {
|
||||
return f.basePath + "/" + f.getRoleFileName(roleName)
|
||||
}
|
||||
|
||||
func (f *FilerRoleStore) withFilerClient(filerAddress string, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required for FilerRoleStore")
|
||||
}
|
||||
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), f.grpcDialOption, fn)
|
||||
}
|
||||
|
||||
// CachedFilerRoleStore implements RoleStore with TTL caching on top of FilerRoleStore
|
||||
type CachedFilerRoleStore struct {
|
||||
filerStore *FilerRoleStore
|
||||
cache *ccache.Cache
|
||||
listCache *ccache.Cache
|
||||
ttl time.Duration
|
||||
listTTL time.Duration
|
||||
}
|
||||
|
||||
// CachedFilerRoleStoreConfig holds configuration for the cached role store
|
||||
type CachedFilerRoleStoreConfig struct {
|
||||
BasePath string `json:"basePath,omitempty"`
|
||||
TTL string `json:"ttl,omitempty"` // e.g., "5m", "1h"
|
||||
ListTTL string `json:"listTtl,omitempty"` // e.g., "1m", "30s"
|
||||
MaxCacheSize int `json:"maxCacheSize,omitempty"` // Maximum number of cached roles
|
||||
}
|
||||
127
weed/iam/integration/role_store_test.go
Normal file
127
weed/iam/integration/role_store_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMemoryRoleStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryRoleStore()
|
||||
|
||||
// Test storing a role
|
||||
roleDef := &RoleDefinition{
|
||||
RoleName: "TestRole",
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
Description: "Test role for unit testing",
|
||||
AttachedPolicies: []string{"TestPolicy"},
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-provider",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := store.StoreRole(ctx, "", "TestRole", roleDef)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test retrieving the role
|
||||
retrievedRole, err := store.GetRole(ctx, "", "TestRole")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "TestRole", retrievedRole.RoleName)
|
||||
assert.Equal(t, "arn:aws:iam::role/TestRole", retrievedRole.RoleArn)
|
||||
assert.Equal(t, "Test role for unit testing", retrievedRole.Description)
|
||||
assert.Equal(t, []string{"TestPolicy"}, retrievedRole.AttachedPolicies)
|
||||
|
||||
// Test listing roles
|
||||
roles, err := store.ListRoles(ctx, "")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, roles, "TestRole")
|
||||
|
||||
// Test deleting the role
|
||||
err = store.DeleteRole(ctx, "", "TestRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify role is deleted
|
||||
_, err = store.GetRole(ctx, "", "TestRole")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRoleStoreConfiguration(t *testing.T) {
|
||||
// Test memory role store creation
|
||||
memoryStore, err := NewMemoryRoleStore(), error(nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, memoryStore)
|
||||
|
||||
// Test filer role store creation without filerAddress in config
|
||||
filerStore2, err := NewFilerRoleStore(map[string]interface{}{
|
||||
// filerAddress not required in config
|
||||
"basePath": "/test/roles",
|
||||
}, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, filerStore2)
|
||||
|
||||
// Test filer role store creation with valid config
|
||||
filerStore, err := NewFilerRoleStore(map[string]interface{}{
|
||||
"filerAddress": "localhost:8888",
|
||||
"basePath": "/test/roles",
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, filerStore)
|
||||
}
|
||||
|
||||
func TestDistributedIAMManagerWithRoleStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create IAM manager with role store configuration
|
||||
config := &IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Duration(3600) * time.Second},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: time.Duration(43200) * time.Second},
|
||||
Issuer: "test-issuer",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory",
|
||||
},
|
||||
Roles: &RoleStoreConfig{
|
||||
StoreType: "memory",
|
||||
},
|
||||
}
|
||||
|
||||
iamManager := NewIAMManager()
|
||||
err := iamManager.Initialize(config, func() string {
|
||||
return "localhost:8888" // Mock filer address for testing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test creating a role
|
||||
roleDef := &RoleDefinition{
|
||||
RoleName: "DistributedTestRole",
|
||||
RoleArn: "arn:aws:iam::role/DistributedTestRole",
|
||||
Description: "Test role for distributed IAM",
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
}
|
||||
|
||||
err = iamManager.CreateRole(ctx, "", "DistributedTestRole", roleDef)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test that role is accessible through the IAM manager
|
||||
// Note: We can't directly test GetRole as it's not exposed,
|
||||
// but we can test through IsActionAllowed which internally uses the role store
|
||||
assert.True(t, iamManager.initialized)
|
||||
}
|
||||
253
weed/iam/integration/session_revocation.go
Normal file
253
weed/iam/integration/session_revocation.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// RevocationEntry is one row in the revocation list. ExpiresAt is set to the
|
||||
// session's natural expiration so the store can garbage-collect entries that
|
||||
// can no longer be reused.
|
||||
type RevocationEntry struct {
|
||||
JTI string `json:"jti"`
|
||||
RevokedAt time.Time `json:"revokedAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// SessionRevocationStore is the per-deployment blocklist of revoked sessions.
|
||||
// Implementations must be safe for concurrent use; the IsRevoked path is hot
|
||||
// (checked on every signed request) and must not block on slow IO.
|
||||
type SessionRevocationStore interface {
|
||||
Revoke(ctx context.Context, filerAddress string, entry *RevocationEntry) error
|
||||
IsRevoked(ctx context.Context, filerAddress string, jti string) (bool, error)
|
||||
Purge(ctx context.Context, filerAddress string, before time.Time) (int, error)
|
||||
}
|
||||
|
||||
// MemorySessionRevocationStore keeps the blocklist in process memory. Suitable
|
||||
// for single-node deployments and tests; for HA, swap in the filer-backed
|
||||
// implementation so revocations propagate across `weed` instances.
|
||||
type MemorySessionRevocationStore struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*RevocationEntry
|
||||
}
|
||||
|
||||
// NewMemorySessionRevocationStore returns an empty in-memory blocklist.
|
||||
func NewMemorySessionRevocationStore() *MemorySessionRevocationStore {
|
||||
return &MemorySessionRevocationStore{entries: make(map[string]*RevocationEntry)}
|
||||
}
|
||||
|
||||
func (m *MemorySessionRevocationStore) Revoke(ctx context.Context, _ string, entry *RevocationEntry) error {
|
||||
if entry == nil || entry.JTI == "" {
|
||||
return fmt.Errorf("entry with JTI is required")
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cp := *entry
|
||||
if cp.RevokedAt.IsZero() {
|
||||
cp.RevokedAt = time.Now().UTC()
|
||||
}
|
||||
m.entries[entry.JTI] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemorySessionRevocationStore) IsRevoked(ctx context.Context, _ string, jti string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
_, ok := m.entries[jti]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (m *MemorySessionRevocationStore) Purge(ctx context.Context, _ string, before time.Time) (int, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
count := 0
|
||||
for k, v := range m.entries {
|
||||
if !v.ExpiresAt.IsZero() && v.ExpiresAt.Before(before) {
|
||||
delete(m.entries, k)
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// FilerSessionRevocationStore persists entries under a filer directory.
|
||||
// Files are named after the JTI so IsRevoked is a single LookupDirectoryEntry
|
||||
// call — O(1) on a name-indexed filer. Purge enumerates the directory; that
|
||||
// cost is operator-controlled (cron-driven) so the hot path stays cheap.
|
||||
type FilerSessionRevocationStore struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
basePath string
|
||||
filerAddressProvider func() string
|
||||
}
|
||||
|
||||
// NewFilerSessionRevocationStore returns a filer-backed blocklist store.
|
||||
// Default basePath `/etc/iam/revoked-sessions` aligns with the other IAM
|
||||
// directories and is safe to back up alongside roles + providers.
|
||||
func NewFilerSessionRevocationStore(config map[string]interface{}, filerAddressProvider func() string) *FilerSessionRevocationStore {
|
||||
store := &FilerSessionRevocationStore{
|
||||
basePath: "/etc/iam/revoked-sessions",
|
||||
filerAddressProvider: filerAddressProvider,
|
||||
}
|
||||
if config != nil {
|
||||
if bp, ok := config["basePath"].(string); ok && bp != "" {
|
||||
store.basePath = strings.TrimSuffix(bp, "/")
|
||||
}
|
||||
}
|
||||
glog.V(2).Infof("Initialized FilerSessionRevocationStore with basePath %s", store.basePath)
|
||||
return store
|
||||
}
|
||||
|
||||
func (f *FilerSessionRevocationStore) resolveFilerAddress(filerAddress string) string {
|
||||
if filerAddress != "" {
|
||||
return filerAddress
|
||||
}
|
||||
if f.filerAddressProvider != nil {
|
||||
return f.filerAddressProvider()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fileName hashes the JTI before using it as a filename. RevokeSession is
|
||||
// an exported API that accepts an arbitrary string; even though every
|
||||
// SeaweedFS-issued session id is a safe random token, an external caller
|
||||
// could pass "../../etc/passwd" or similar. Hashing produces a fixed-width
|
||||
// hex name that's both filesystem-safe and stable, so the lookup-on-revoke
|
||||
// path still finds the right entry.
|
||||
func (f *FilerSessionRevocationStore) fileName(jti string) string {
|
||||
sum := sha1.Sum([]byte(jti))
|
||||
return hex.EncodeToString(sum[:]) + ".json"
|
||||
}
|
||||
|
||||
func (f *FilerSessionRevocationStore) Revoke(ctx context.Context, filerAddress string, entry *RevocationEntry) error {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required")
|
||||
}
|
||||
if entry == nil || entry.JTI == "" {
|
||||
return fmt.Errorf("entry with JTI is required")
|
||||
}
|
||||
if entry.RevokedAt.IsZero() {
|
||||
entry.RevokedAt = time.Now().UTC()
|
||||
}
|
||||
data, err := json.MarshalIndent(entry, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal revocation entry: %v", err)
|
||||
}
|
||||
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: f.fileName(entry.JTI),
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0o600),
|
||||
},
|
||||
Content: data,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke %s: %v", entry.JTI, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (f *FilerSessionRevocationStore) IsRevoked(ctx context.Context, filerAddress string, jti string) (bool, error) {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return false, fmt.Errorf("filer address is required")
|
||||
}
|
||||
revoked := false
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: f.fileName(jti),
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "no such") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
revoked = resp.Entry != nil
|
||||
return nil
|
||||
})
|
||||
return revoked, err
|
||||
}
|
||||
|
||||
func (f *FilerSessionRevocationStore) Purge(ctx context.Context, filerAddress string, before time.Time) (int, error) {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return 0, fmt.Errorf("filer address is required")
|
||||
}
|
||||
count := 0
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Stream-paginate the directory: ListEntriesRequest has no built-in
|
||||
// "page until done" semantics, so we use StartFromFileName to walk
|
||||
// the directory in chunks and avoid the previous hardcoded 10k cap.
|
||||
const pageSize = 1000
|
||||
startFrom := ""
|
||||
for {
|
||||
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
|
||||
Directory: f.basePath,
|
||||
Limit: pageSize,
|
||||
StartFromFileName: startFrom,
|
||||
InclusiveStartFrom: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list revocation entries: %w", err)
|
||||
}
|
||||
lastName := ""
|
||||
pageCount := 0
|
||||
for {
|
||||
resp, recvErr := stream.Recv()
|
||||
if recvErr != nil {
|
||||
if errors.Is(recvErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("recv revocation entry: %w", recvErr)
|
||||
}
|
||||
if resp.Entry == nil || resp.Entry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
lastName = resp.Entry.Name
|
||||
pageCount++
|
||||
var entry RevocationEntry
|
||||
if err := json.Unmarshal(resp.Entry.Content, &entry); err != nil {
|
||||
continue
|
||||
}
|
||||
if entry.ExpiresAt.IsZero() || entry.ExpiresAt.After(before) {
|
||||
continue
|
||||
}
|
||||
if err := filer_pb.DoRemove(ctx, client, f.basePath, resp.Entry.Name, true, false, false, false, nil); err == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if pageCount < pageSize {
|
||||
return nil
|
||||
}
|
||||
startFrom = lastName
|
||||
}
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (f *FilerSessionRevocationStore) withFilerClient(filerAddress string, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), f.grpcDialOption, fn)
|
||||
}
|
||||
94
weed/iam/integration/session_revocation_test.go
Normal file
94
weed/iam/integration/session_revocation_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMemoryRevocationStoreCRUD(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemorySessionRevocationStore()
|
||||
|
||||
if revoked, _ := store.IsRevoked(ctx, "", "abc"); revoked {
|
||||
t.Fatal("empty store should report not revoked")
|
||||
}
|
||||
|
||||
if err := store.Revoke(ctx, "", &RevocationEntry{JTI: "abc", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if revoked, _ := store.IsRevoked(ctx, "", "abc"); !revoked {
|
||||
t.Fatal("expected revoked")
|
||||
}
|
||||
// Different JTIs are independent.
|
||||
if revoked, _ := store.IsRevoked(ctx, "", "xyz"); revoked {
|
||||
t.Fatal("xyz should not be revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRevocationStoreRejectsBadInput(t *testing.T) {
|
||||
store := NewMemorySessionRevocationStore()
|
||||
if err := store.Revoke(context.Background(), "", nil); err == nil {
|
||||
t.Fatal("expected error for nil entry")
|
||||
}
|
||||
if err := store.Revoke(context.Background(), "", &RevocationEntry{}); err == nil {
|
||||
t.Fatal("expected error for empty JTI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRevocationStorePurge(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemorySessionRevocationStore()
|
||||
now := time.Now()
|
||||
|
||||
must := func(jti string, expiresAt time.Time) {
|
||||
if err := store.Revoke(ctx, "", &RevocationEntry{JTI: jti, ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatalf("revoke %s: %v", jti, err)
|
||||
}
|
||||
}
|
||||
must("expired-1", now.Add(-2*time.Hour))
|
||||
must("expired-2", now.Add(-time.Minute))
|
||||
must("future-1", now.Add(time.Hour))
|
||||
|
||||
count, err := store.Purge(ctx, "", now)
|
||||
if err != nil {
|
||||
t.Fatalf("purge: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 purged, got %d", count)
|
||||
}
|
||||
if revoked, _ := store.IsRevoked(ctx, "", "expired-1"); revoked {
|
||||
t.Fatal("expired-1 should be gone")
|
||||
}
|
||||
if revoked, _ := store.IsRevoked(ctx, "", "future-1"); !revoked {
|
||||
t.Fatal("future-1 should still be revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMManagerRevocationDefaultIsNoop(t *testing.T) {
|
||||
mgr := NewIAMManager()
|
||||
// Without SetSessionRevocationStore, revocation is a no-op.
|
||||
if got, err := mgr.IsSessionRevoked(context.Background(), "anything"); err != nil || got {
|
||||
t.Fatalf("expected (false, nil); got (%v, %v)", got, err)
|
||||
}
|
||||
if err := mgr.RevokeSession(context.Background(), "abc", time.Now().Add(time.Hour), "test"); err == nil {
|
||||
t.Fatal("expected error when no store configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMManagerRevocationFlow(t *testing.T) {
|
||||
mgr := NewIAMManager()
|
||||
mgr.SetSessionRevocationStore(NewMemorySessionRevocationStore())
|
||||
|
||||
jti := "session-1"
|
||||
if err := mgr.RevokeSession(context.Background(), jti, time.Now().Add(time.Hour), "logout"); err != nil {
|
||||
t.Fatalf("RevokeSession: %v", err)
|
||||
}
|
||||
revoked, err := mgr.IsSessionRevoked(context.Background(), jti)
|
||||
if err != nil {
|
||||
t.Fatalf("IsSessionRevoked: %v", err)
|
||||
}
|
||||
if !revoked {
|
||||
t.Fatal("expected revoked")
|
||||
}
|
||||
}
|
||||
248
weed/iam/integration/trust_policy_principal_test.go
Normal file
248
weed/iam/integration/trust_policy_principal_test.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestTrustPolicyAWSUserPrincipal tests that trust policies with specific AWS user
|
||||
// principals work correctly with ValidateTrustPolicyForPrincipal.
|
||||
// This is a regression test for the case where setting a specific user principal like:
|
||||
//
|
||||
// "Principal": {"AWS": "arn:aws:iam::000000000000:user/backend"}
|
||||
//
|
||||
// would fail with "trust policy denies access to principal" even though the
|
||||
// caller's principal ARN matched.
|
||||
func TestTrustPolicyAWSUserPrincipal(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
accountID = "000000000000"
|
||||
backendUser = "backend"
|
||||
backendArn = "arn:aws:iam::" + accountID + ":user/" + backendUser
|
||||
otherUser = "other"
|
||||
otherArn = "arn:aws:iam::" + accountID + ":user/" + otherUser
|
||||
clientRoleN = "ClientRole"
|
||||
clientRoleA = "arn:aws:iam::role/" + clientRoleN
|
||||
)
|
||||
|
||||
// Create role with trust policy restricted to a specific AWS user principal
|
||||
// (the exact scenario from discussion #8588)
|
||||
err := iamManager.CreateRole(ctx, "", clientRoleN, &RoleDefinition{
|
||||
RoleName: clientRoleN,
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": backendArn,
|
||||
},
|
||||
Action: []string{"sts:AssumeRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
principalArn string
|
||||
expectErr bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "matching user principal should be allowed",
|
||||
principalArn: backendArn,
|
||||
expectErr: false,
|
||||
description: "Backend user ARN matches the trust policy principal exactly",
|
||||
},
|
||||
{
|
||||
name: "non-matching user principal should be denied",
|
||||
principalArn: otherArn,
|
||||
expectErr: true,
|
||||
description: "Other user ARN does not match the trust policy principal",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := iamManager.ValidateTrustPolicyForPrincipal(ctx, clientRoleA, tt.principalArn)
|
||||
if tt.expectErr {
|
||||
assert.Error(t, err, tt.description)
|
||||
assert.Contains(t, err.Error(), "trust policy denies access")
|
||||
} else {
|
||||
assert.NoError(t, err, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrustPolicyAWSWildcardPrincipal tests that wildcard AWS principals
|
||||
// continue to work correctly (this already works, serves as a regression guard).
|
||||
func TestTrustPolicyAWSWildcardPrincipal(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const roleName = "WildcardAWSRole"
|
||||
const roleArn = "arn:aws:iam::role/" + roleName
|
||||
|
||||
err := iamManager.CreateRole(ctx, "", roleName, &RoleDefinition{
|
||||
RoleName: roleName,
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": "*",
|
||||
},
|
||||
Action: []string{"sts:AssumeRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Any user should be able to assume a role with wildcard AWS principal
|
||||
err = iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn, "arn:aws:iam::000000000000:user/anyuser")
|
||||
assert.NoError(t, err, "Wildcard AWS principal should allow any user")
|
||||
}
|
||||
|
||||
// TestTrustPolicyAWSUserArrayPrincipal tests trust policies with an array of
|
||||
// AWS user principals.
|
||||
func TestTrustPolicyAWSUserArrayPrincipal(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
accountID = "000000000000"
|
||||
roleName = "MultiUserRole"
|
||||
roleArn = "arn:aws:iam::role/" + roleName
|
||||
user1Arn = "arn:aws:iam::" + accountID + ":user/user1"
|
||||
user2Arn = "arn:aws:iam::" + accountID + ":user/user2"
|
||||
user3Arn = "arn:aws:iam::" + accountID + ":user/user3"
|
||||
)
|
||||
|
||||
// Create role with multiple allowed user principals
|
||||
err := iamManager.CreateRole(ctx, "", roleName, &RoleDefinition{
|
||||
RoleName: roleName,
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": []interface{}{user1Arn, user2Arn},
|
||||
},
|
||||
Action: []string{"sts:AssumeRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
principalArn string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "first listed user is allowed",
|
||||
principalArn: user1Arn,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "second listed user is allowed",
|
||||
principalArn: user2Arn,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "unlisted user is denied",
|
||||
principalArn: user3Arn,
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn, tt.principalArn)
|
||||
if tt.expectErr {
|
||||
if assert.Error(t, err) {
|
||||
assert.Contains(t, err.Error(), "trust policy denies access")
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrustPolicyPlainStringPrincipal tests trust policies with a plain string
|
||||
// principal (not wrapped in {"AWS": ...}).
|
||||
func TestTrustPolicyPlainStringPrincipal(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
roleName = "PlainStringPrincipalRole"
|
||||
roleArn = "arn:aws:iam::role/" + roleName
|
||||
userArn = "arn:aws:iam::000000000000:user/backend"
|
||||
)
|
||||
|
||||
// Plain string wildcard - should work
|
||||
err := iamManager.CreateRole(ctx, "", roleName, &RoleDefinition{
|
||||
RoleName: roleName,
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: "*",
|
||||
Action: []string{"sts:AssumeRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn, userArn)
|
||||
assert.NoError(t, err, "Plain wildcard principal should allow any user")
|
||||
|
||||
// Plain string with specific ARN
|
||||
const roleName2 = "PlainStringSpecificRole"
|
||||
const roleArn2 = "arn:aws:iam::role/" + roleName2
|
||||
|
||||
err = iamManager.CreateRole(ctx, "", roleName2, &RoleDefinition{
|
||||
RoleName: roleName2,
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: userArn,
|
||||
Action: []string{"sts:AssumeRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn2, userArn)
|
||||
assert.NoError(t, err, "Matching plain string principal should allow user")
|
||||
|
||||
err = iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn2, "arn:aws:iam::000000000000:user/other")
|
||||
if assert.Error(t, err, "Non-matching plain string principal should deny user") {
|
||||
assert.Contains(t, err.Error(), "trust policy denies access")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user