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

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

View File

@@ -0,0 +1,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")
}
})
}

View 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)
})
}
}

View 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
}

View 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
}

File diff suppressed because it is too large Load Diff

View 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")
}

View 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
}

View 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
})
}

View 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)
}
}

View 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")
}
}

View 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
}

View 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)
}
}

View 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)
}
})
}
}

View 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
}

View 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)
}

View 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)
}

View 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")
}
}

View 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")
}
}