snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
This commit is contained in:
41
weed/iam/constants.go
Normal file
41
weed/iam/constants.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package iam
|
||||
|
||||
// Character sets for credential generation
|
||||
const (
|
||||
CharsetUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
Charset = CharsetUpper + "abcdefghijklmnopqrstuvwxyz"
|
||||
)
|
||||
|
||||
// Policy document version
|
||||
const PolicyDocumentVersion = "2012-10-17"
|
||||
|
||||
// DefaultAccountID is the placeholder AWS account id used in generated ARNs,
|
||||
// matching the value used throughout the S3 API.
|
||||
const DefaultAccountID = "000000000000"
|
||||
|
||||
// Error message templates
|
||||
const UserDoesNotExist = "the user with name %s cannot be found."
|
||||
|
||||
// Statement action constants - these map to IAM policy actions
|
||||
const (
|
||||
StatementActionAdmin = "*"
|
||||
StatementActionWrite = "Put*"
|
||||
StatementActionWriteAcp = "PutBucketAcl"
|
||||
StatementActionRead = "Get*"
|
||||
StatementActionReadAcp = "GetBucketAcl"
|
||||
StatementActionList = "List*"
|
||||
StatementActionTagging = "Tagging*"
|
||||
StatementActionDelete = "DeleteBucket*"
|
||||
)
|
||||
|
||||
// Access key lengths
|
||||
const (
|
||||
AccessKeyIdLength = 21
|
||||
SecretAccessKeyLength = 42
|
||||
)
|
||||
|
||||
// Access key status values (AWS IAM compatible)
|
||||
const (
|
||||
AccessKeyStatusActive = "Active"
|
||||
AccessKeyStatusInactive = "Inactive"
|
||||
)
|
||||
201
weed/iam/helpers.go
Normal file
201
weed/iam/helpers.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
// Hash computes a SHA1 hash of the input string.
|
||||
func Hash(s *string) string {
|
||||
h := sha1.New()
|
||||
h.Write([]byte(*s))
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// UserArn builds an AWS-compatible IAM user ARN.
|
||||
func UserArn(userName string) string {
|
||||
return fmt.Sprintf("arn:aws:iam::%s:user/%s", DefaultAccountID, userName)
|
||||
}
|
||||
|
||||
// NewUser builds an iam.User for IAM API responses. The Arn must be a real ARN:
|
||||
// the terraform aws provider (>= 6.41) reads a user back after creating it and
|
||||
// blocks until GetUser returns a value that passes arn.IsARN, so an empty Arn
|
||||
// leaves apply hanging until it times out.
|
||||
func NewUser(userName string) iam.User {
|
||||
arn := UserArn(userName)
|
||||
path := "/"
|
||||
return iam.User{UserName: &userName, Arn: &arn, Path: &path}
|
||||
}
|
||||
|
||||
// GenerateRandomString generates a cryptographically secure random string.
|
||||
// Uses crypto/rand for security-sensitive credential generation.
|
||||
func GenerateRandomString(length int, charset string) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", fmt.Errorf("length must be positive, got %d", length)
|
||||
}
|
||||
if charset == "" {
|
||||
return "", fmt.Errorf("charset must not be empty")
|
||||
}
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random index: %w", err)
|
||||
}
|
||||
b[i] = charset[n.Int64()]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// GenerateSecretAccessKey generates a new secret access key.
|
||||
func GenerateSecretAccessKey() (string, error) {
|
||||
return GenerateRandomString(SecretAccessKeyLength, Charset)
|
||||
}
|
||||
|
||||
// StringSlicesEqual compares two string slices for equality, ignoring order.
|
||||
// This is used instead of reflect.DeepEqual to avoid order-dependent comparisons.
|
||||
func StringSlicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
// Make copies to avoid modifying the originals
|
||||
aCopy := make([]string, len(a))
|
||||
bCopy := make([]string, len(b))
|
||||
copy(aCopy, a)
|
||||
copy(bCopy, b)
|
||||
sort.Strings(aCopy)
|
||||
sort.Strings(bCopy)
|
||||
for i := range aCopy {
|
||||
if aCopy[i] != bCopy[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// fineGrainedActionMap maps S3 IAM action names to internal S3 action constants.
|
||||
// Supports both prefixed (e.g., "s3:DeleteObject") and unprefixed (e.g., "DeleteObject") formats.
|
||||
// Populated in init() to avoid duplication of prefixed/unprefixed variants.
|
||||
var fineGrainedActionMap = map[string]string{
|
||||
// Coarse-grained actions (populated statically)
|
||||
StatementActionAdmin: s3_constants.ACTION_ADMIN,
|
||||
StatementActionWrite: s3_constants.ACTION_WRITE,
|
||||
StatementActionWriteAcp: s3_constants.ACTION_WRITE_ACP,
|
||||
StatementActionRead: s3_constants.ACTION_READ,
|
||||
StatementActionReadAcp: s3_constants.ACTION_READ_ACP,
|
||||
StatementActionList: s3_constants.ACTION_LIST,
|
||||
StatementActionTagging: s3_constants.ACTION_TAGGING,
|
||||
StatementActionDelete: s3_constants.ACTION_DELETE_BUCKET,
|
||||
}
|
||||
|
||||
// baseS3ActionMap defines the base S3 actions that will be populated with both
|
||||
// prefixed (s3:Action) and unprefixed (Action) variants in init().
|
||||
var baseS3ActionMap = map[string]string{
|
||||
// Object operations
|
||||
"DeleteObject": s3_constants.ACTION_WRITE,
|
||||
"PutObject": s3_constants.ACTION_WRITE,
|
||||
"GetObject": s3_constants.ACTION_READ,
|
||||
"DeleteObjectVersion": s3_constants.ACTION_WRITE,
|
||||
"GetObjectVersion": s3_constants.ACTION_READ,
|
||||
// Tagging operations
|
||||
"GetObjectTagging": s3_constants.ACTION_TAGGING,
|
||||
"GetObjectVersionTagging": s3_constants.ACTION_TAGGING,
|
||||
"PutObjectTagging": s3_constants.ACTION_TAGGING,
|
||||
"DeleteObjectTagging": s3_constants.ACTION_TAGGING,
|
||||
"GetBucketTagging": s3_constants.ACTION_TAGGING,
|
||||
"PutBucketTagging": s3_constants.ACTION_TAGGING,
|
||||
"DeleteBucketTagging": s3_constants.ACTION_TAGGING,
|
||||
// ACL operations
|
||||
"PutObjectAcl": s3_constants.ACTION_WRITE_ACP,
|
||||
"GetObjectAcl": s3_constants.ACTION_READ_ACP,
|
||||
"GetObjectVersionAcl": s3_constants.ACTION_READ_ACP,
|
||||
"PutBucketAcl": s3_constants.ACTION_WRITE_ACP,
|
||||
"GetBucketAcl": s3_constants.ACTION_READ_ACP,
|
||||
// Bucket operations
|
||||
"DeleteBucket": s3_constants.ACTION_DELETE_BUCKET,
|
||||
// Bucket policy is permissions management: an explicit Allow in one skips
|
||||
// the IAM check, so it gets its own actions rather than folding into Write
|
||||
// (which any object writer holds) or Admin (which grants everything).
|
||||
"GetBucketPolicy": s3_constants.ACTION_READ,
|
||||
"PutBucketPolicy": s3_constants.ACTION_PUT_BUCKET_POLICY,
|
||||
"DeleteBucketPolicy": s3_constants.ACTION_DELETE_BUCKET_POLICY,
|
||||
"ListBucket": s3_constants.ACTION_LIST,
|
||||
"ListBucketVersions": s3_constants.ACTION_LIST,
|
||||
"ListAllMyBuckets": s3_constants.ACTION_LIST,
|
||||
"GetBucketLocation": s3_constants.ACTION_READ,
|
||||
"GetBucketVersioning": s3_constants.ACTION_READ,
|
||||
"PutBucketVersioning": s3_constants.ACTION_WRITE,
|
||||
"GetBucketCors": s3_constants.ACTION_READ,
|
||||
"PutBucketCors": s3_constants.ACTION_WRITE,
|
||||
"DeleteBucketCors": s3_constants.ACTION_WRITE,
|
||||
"GetBucketNotification": s3_constants.ACTION_READ,
|
||||
"PutBucketNotification": s3_constants.ACTION_WRITE,
|
||||
"GetBucketObjectLockConfiguration": s3_constants.ACTION_READ,
|
||||
"PutBucketObjectLockConfiguration": s3_constants.ACTION_WRITE,
|
||||
// Multipart upload operations
|
||||
"CreateMultipartUpload": s3_constants.ACTION_WRITE,
|
||||
"UploadPart": s3_constants.ACTION_WRITE,
|
||||
"CompleteMultipartUpload": s3_constants.ACTION_WRITE,
|
||||
"AbortMultipartUpload": s3_constants.ACTION_WRITE,
|
||||
"ListMultipartUploads": s3_constants.ACTION_WRITE,
|
||||
"ListParts": s3_constants.ACTION_WRITE,
|
||||
// Retention and legal hold operations
|
||||
"GetObjectRetention": s3_constants.ACTION_READ,
|
||||
"PutObjectRetention": s3_constants.ACTION_WRITE,
|
||||
"GetObjectLegalHold": s3_constants.ACTION_READ,
|
||||
"PutObjectLegalHold": s3_constants.ACTION_WRITE,
|
||||
"BypassGovernanceRetention": s3_constants.ACTION_WRITE,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Populate both prefixed and unprefixed variants for all base S3 actions.
|
||||
// This avoids duplication and makes it easy to add new actions in one place.
|
||||
for action, constant := range baseS3ActionMap {
|
||||
fineGrainedActionMap[action] = constant // unprefixed: "DeleteObject"
|
||||
fineGrainedActionMap["s3:"+action] = constant // prefixed: "s3:DeleteObject"
|
||||
}
|
||||
}
|
||||
|
||||
// MapToStatementAction converts a policy statement action to an S3 action constant.
|
||||
// It handles both coarse-grained statement actions (e.g., "Put*", "Get*") and
|
||||
// fine-grained S3 actions (e.g., "s3:DeleteObject", "s3:PutObject") via exact lookup.
|
||||
func MapToStatementAction(action string) string {
|
||||
if val, ok := fineGrainedActionMap[action]; ok {
|
||||
return val
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MapToIdentitiesAction converts an S3 action constant to a policy statement action.
|
||||
func MapToIdentitiesAction(action string) string {
|
||||
switch action {
|
||||
case s3_constants.ACTION_ADMIN:
|
||||
return StatementActionAdmin
|
||||
case s3_constants.ACTION_WRITE:
|
||||
return StatementActionWrite
|
||||
case s3_constants.ACTION_WRITE_ACP:
|
||||
return StatementActionWriteAcp
|
||||
case s3_constants.ACTION_READ:
|
||||
return StatementActionRead
|
||||
case s3_constants.ACTION_READ_ACP:
|
||||
return StatementActionReadAcp
|
||||
case s3_constants.ACTION_LIST:
|
||||
return StatementActionList
|
||||
case s3_constants.ACTION_TAGGING:
|
||||
return StatementActionTagging
|
||||
case s3_constants.ACTION_DELETE_BUCKET:
|
||||
return StatementActionDelete
|
||||
case s3_constants.ACTION_PUT_BUCKET_POLICY:
|
||||
return "PutBucketPolicy"
|
||||
case s3_constants.ACTION_DELETE_BUCKET_POLICY:
|
||||
return "DeleteBucketPolicy"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
72
weed/iam/integration/account_scope_test.go
Normal file
72
weed/iam/integration/account_scope_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetProviderByIssuerAndAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
|
||||
// Two providers for the same issuer, scoped to different accounts.
|
||||
must := func(rec *OIDCProviderRecord) {
|
||||
if err := store.StoreProvider(ctx, "", rec); err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
}
|
||||
must(&OIDCProviderRecord{
|
||||
AccountID: "111",
|
||||
ARN: "arn:aws:iam::111:oidc-provider/idp.example",
|
||||
URL: "https://idp.example",
|
||||
ClientIDs: []string{"a"},
|
||||
})
|
||||
must(&OIDCProviderRecord{
|
||||
AccountID: "222",
|
||||
ARN: "arn:aws:iam::222:oidc-provider/idp.example",
|
||||
URL: "https://idp.example",
|
||||
ClientIDs: []string{"b"},
|
||||
})
|
||||
|
||||
t.Run("matching account returns scoped record", func(t *testing.T) {
|
||||
rec, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://idp.example", "111")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if rec.AccountID != "111" {
|
||||
t.Fatalf("unexpected record: %+v", rec)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown account is rejected", func(t *testing.T) {
|
||||
if _, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://idp.example", "999"); err == nil {
|
||||
t.Fatal("expected error for cross-account use")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("global provider satisfies any account", func(t *testing.T) {
|
||||
must(&OIDCProviderRecord{
|
||||
AccountID: "",
|
||||
ARN: "arn:aws:iam:::oidc-provider/global.example",
|
||||
URL: "https://global.example",
|
||||
ClientIDs: []string{"x"},
|
||||
})
|
||||
rec, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://global.example", "anything")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if rec.AccountID != "" {
|
||||
t.Fatalf("expected global record, got AccountID=%q", rec.AccountID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty caller account accepts any record", func(t *testing.T) {
|
||||
rec, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://idp.example", "")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if rec == nil {
|
||||
t.Fatal("expected a record")
|
||||
}
|
||||
})
|
||||
}
|
||||
239
weed/iam/integration/advanced_policy_test.go
Normal file
239
weed/iam/integration/advanced_policy_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPolicyVariableSubstitution tests dynamic policy variables like ${oidc:sub} in Resource fields
|
||||
func TestPolicyVariableSubstitution(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a role with a policy that uses ${oidc:sub} variable
|
||||
// This allows users to access only their own folder
|
||||
err := iamManager.CreateRole(ctx, "", "DynamicUserRole", &RoleDefinition{
|
||||
RoleName: "DynamicUserRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"DynamicUserPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create the policy with variable substitution
|
||||
userPolicy := &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject", "s3:PutObject"},
|
||||
Resource: []string{
|
||||
"arn:aws:s3:::mybucket/${oidc:sub}/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Store the policy (in a real system this would be in the policy store)
|
||||
err = iamManager.policyEngine.AddPolicy("", "DynamicUserPolicy", userPolicy)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create JWT for user "alice"
|
||||
aliceJWT := createTestJWT(t, "https://test-issuer.com", "alice", "test-signing-key")
|
||||
|
||||
// Assume role as "alice"
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/DynamicUserRole",
|
||||
WebIdentityToken: aliceJWT,
|
||||
RoleSessionName: "alice-session",
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
// Test that the policy engine correctly substitutes ${oidc:sub} with "alice"
|
||||
evalCtx := &policy.EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/DynamicUserRole/alice-session",
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::mybucket/alice/file.txt",
|
||||
RequestContext: map[string]interface{}{
|
||||
"oidc:sub": "alice",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := iamManager.policyEngine.Evaluate(ctx, "", evalCtx, []string{"DynamicUserPolicy"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, policy.EffectAllow, result.Effect, "Alice should be allowed to access her own folder")
|
||||
|
||||
// Test that alice cannot access bob's folder
|
||||
evalCtx.Resource = "arn:aws:s3:::mybucket/bob/file.txt"
|
||||
result, err = iamManager.policyEngine.Evaluate(ctx, "", evalCtx, []string{"DynamicUserPolicy"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, policy.EffectDeny, result.Effect, "Alice should NOT be allowed to access Bob's folder")
|
||||
}
|
||||
|
||||
// TestConditionWithNumericComparison tests numeric conditions like DurationSeconds
|
||||
func TestConditionWithNumericComparison(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create role with trust policy enforcing DurationSeconds <= 3600
|
||||
err := iamManager.CreateRole(ctx, "", "LimitedDurationRole", &RoleDefinition{
|
||||
RoleName: "LimitedDurationRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"NumericLessThanEquals": {
|
||||
"sts:DurationSeconds": 3600, // Max 1 hour
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
validJWT := createTestJWT(t, "https://test-issuer.com", "user", "test-signing-key")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
duration int64
|
||||
shouldAllow bool
|
||||
}{
|
||||
{
|
||||
name: "duration within limit",
|
||||
duration: 1800, // 30 mins
|
||||
shouldAllow: true,
|
||||
},
|
||||
{
|
||||
name: "duration at limit",
|
||||
duration: 3600, // 1 hour
|
||||
shouldAllow: true,
|
||||
},
|
||||
{
|
||||
name: "duration exceeding limit",
|
||||
duration: 7200, // 2 hours
|
||||
shouldAllow: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/LimitedDurationRole",
|
||||
WebIdentityToken: validJWT,
|
||||
RoleSessionName: "test-session",
|
||||
DurationSeconds: &tt.duration,
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, req)
|
||||
|
||||
if tt.shouldAllow {
|
||||
assert.NoError(t, err, "Expected role assumption to succeed for duration %d", tt.duration)
|
||||
assert.NotNil(t, response)
|
||||
} else {
|
||||
assert.Error(t, err, "Expected role assumption to fail for duration %d", tt.duration)
|
||||
assert.Nil(t, response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultipleConditionOperators tests policies with multiple condition operators
|
||||
func TestMultipleConditionOperators(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a policy with multiple conditions
|
||||
complexPolicy := &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{
|
||||
"arn:aws:s3:::secure-bucket/*",
|
||||
},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"StringEquals": {
|
||||
"oidc:aud": "my-app-id",
|
||||
},
|
||||
"StringLike": {
|
||||
"oidc:sub": "user-*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := iamManager.policyEngine.AddPolicy("", "ComplexConditionPolicy", complexPolicy)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
aud string
|
||||
sub string
|
||||
expectedEffect policy.Effect
|
||||
}{
|
||||
{
|
||||
name: "all conditions match",
|
||||
aud: "my-app-id",
|
||||
sub: "user-alice",
|
||||
expectedEffect: policy.EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "aud mismatch",
|
||||
aud: "wrong-app-id",
|
||||
sub: "user-alice",
|
||||
expectedEffect: policy.EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "sub pattern mismatch",
|
||||
aud: "my-app-id",
|
||||
sub: "admin-alice",
|
||||
expectedEffect: policy.EffectDeny,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
evalCtx := &policy.EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/TestRole/session",
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::secure-bucket/file.txt",
|
||||
RequestContext: map[string]interface{}{
|
||||
"oidc:aud": tt.aud,
|
||||
"oidc:sub": tt.sub,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := iamManager.policyEngine.Evaluate(ctx, "", evalCtx, []string{"ComplexConditionPolicy"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedEffect, result.Effect)
|
||||
})
|
||||
}
|
||||
}
|
||||
153
weed/iam/integration/cached_role_store_generic.go
Normal file
153
weed/iam/integration/cached_role_store_generic.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/util"
|
||||
)
|
||||
|
||||
// RoleStoreAdapter adapts RoleStore interface to CacheableStore[*RoleDefinition]
|
||||
type RoleStoreAdapter struct {
|
||||
store RoleStore
|
||||
}
|
||||
|
||||
// NewRoleStoreAdapter creates a new adapter for RoleStore
|
||||
func NewRoleStoreAdapter(store RoleStore) *RoleStoreAdapter {
|
||||
return &RoleStoreAdapter{store: store}
|
||||
}
|
||||
|
||||
// Get implements CacheableStore interface
|
||||
func (a *RoleStoreAdapter) Get(ctx context.Context, filerAddress string, key string) (*RoleDefinition, error) {
|
||||
return a.store.GetRole(ctx, filerAddress, key)
|
||||
}
|
||||
|
||||
// Store implements CacheableStore interface
|
||||
func (a *RoleStoreAdapter) Store(ctx context.Context, filerAddress string, key string, value *RoleDefinition) error {
|
||||
return a.store.StoreRole(ctx, filerAddress, key, value)
|
||||
}
|
||||
|
||||
// Delete implements CacheableStore interface
|
||||
func (a *RoleStoreAdapter) Delete(ctx context.Context, filerAddress string, key string) error {
|
||||
return a.store.DeleteRole(ctx, filerAddress, key)
|
||||
}
|
||||
|
||||
// List implements CacheableStore interface
|
||||
func (a *RoleStoreAdapter) List(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
return a.store.ListRoles(ctx, filerAddress)
|
||||
}
|
||||
|
||||
// GenericCachedRoleStore implements RoleStore using the generic cache
|
||||
type GenericCachedRoleStore struct {
|
||||
*util.CachedStore[*RoleDefinition]
|
||||
adapter *RoleStoreAdapter
|
||||
}
|
||||
|
||||
// NewGenericCachedRoleStore creates a new cached role store using generics
|
||||
func NewGenericCachedRoleStore(config map[string]interface{}, filerAddressProvider func() string) (*GenericCachedRoleStore, error) {
|
||||
// Create underlying filer store
|
||||
filerStore, err := NewFilerRoleStore(config, filerAddressProvider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse cache configuration with defaults
|
||||
cacheTTL := 5 * time.Minute
|
||||
listTTL := 1 * time.Minute
|
||||
maxCacheSize := int64(1000)
|
||||
|
||||
if config != nil {
|
||||
if ttlStr, ok := config["ttl"].(string); ok && ttlStr != "" {
|
||||
if parsed, err := time.ParseDuration(ttlStr); err == nil {
|
||||
cacheTTL = parsed
|
||||
}
|
||||
}
|
||||
if listTTLStr, ok := config["listTtl"].(string); ok && listTTLStr != "" {
|
||||
if parsed, err := time.ParseDuration(listTTLStr); err == nil {
|
||||
listTTL = parsed
|
||||
}
|
||||
}
|
||||
if maxSize, ok := config["maxCacheSize"].(int); ok && maxSize > 0 {
|
||||
maxCacheSize = int64(maxSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Create adapter and generic cached store
|
||||
adapter := NewRoleStoreAdapter(filerStore)
|
||||
cachedStore := util.NewCachedStore(
|
||||
adapter,
|
||||
genericCopyRoleDefinition, // Copy function
|
||||
util.CachedStoreConfig{
|
||||
TTL: cacheTTL,
|
||||
ListTTL: listTTL,
|
||||
MaxCacheSize: maxCacheSize,
|
||||
},
|
||||
)
|
||||
|
||||
glog.V(2).Infof("Initialized GenericCachedRoleStore with TTL %v, List TTL %v, Max Cache Size %d",
|
||||
cacheTTL, listTTL, maxCacheSize)
|
||||
|
||||
return &GenericCachedRoleStore{
|
||||
CachedStore: cachedStore,
|
||||
adapter: adapter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StoreRole implements RoleStore interface
|
||||
func (c *GenericCachedRoleStore) StoreRole(ctx context.Context, filerAddress string, roleName string, role *RoleDefinition) error {
|
||||
return c.Store(ctx, filerAddress, roleName, role)
|
||||
}
|
||||
|
||||
// GetRole implements RoleStore interface
|
||||
func (c *GenericCachedRoleStore) GetRole(ctx context.Context, filerAddress string, roleName string) (*RoleDefinition, error) {
|
||||
return c.Get(ctx, filerAddress, roleName)
|
||||
}
|
||||
|
||||
// ListRoles implements RoleStore interface
|
||||
func (c *GenericCachedRoleStore) ListRoles(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
return c.List(ctx, filerAddress)
|
||||
}
|
||||
|
||||
// DeleteRole implements RoleStore interface
|
||||
func (c *GenericCachedRoleStore) DeleteRole(ctx context.Context, filerAddress string, roleName string) error {
|
||||
return c.Delete(ctx, filerAddress, roleName)
|
||||
}
|
||||
|
||||
// genericCopyRoleDefinition creates a deep copy of a RoleDefinition for the generic cache
|
||||
func genericCopyRoleDefinition(role *RoleDefinition) *RoleDefinition {
|
||||
if role == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &RoleDefinition{
|
||||
RoleName: role.RoleName,
|
||||
RoleArn: role.RoleArn,
|
||||
Description: role.Description,
|
||||
}
|
||||
|
||||
// Deep copy trust policy if it exists
|
||||
if role.TrustPolicy != nil {
|
||||
trustPolicyData, err := json.Marshal(role.TrustPolicy)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to marshal trust policy for deep copy: %v", err)
|
||||
return nil
|
||||
}
|
||||
var trustPolicyCopy policy.PolicyDocument
|
||||
if err := json.Unmarshal(trustPolicyData, &trustPolicyCopy); err != nil {
|
||||
glog.Errorf("Failed to unmarshal trust policy for deep copy: %v", err)
|
||||
return nil
|
||||
}
|
||||
result.TrustPolicy = &trustPolicyCopy
|
||||
}
|
||||
|
||||
// Deep copy attached policies slice
|
||||
if role.AttachedPolicies != nil {
|
||||
result.AttachedPolicies = make([]string, len(role.AttachedPolicies))
|
||||
copy(result.AttachedPolicies, role.AttachedPolicies)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
912
weed/iam/integration/iam_integration_test.go
Normal file
912
weed/iam/integration/iam_integration_test.go
Normal file
@@ -0,0 +1,912 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/ldap"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/oidc"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestFullOIDCWorkflow tests the complete OIDC → STS → Policy workflow
|
||||
func TestFullOIDCWorkflow(t *testing.T) {
|
||||
// Set up integrated IAM system
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
|
||||
// Create JWT tokens for testing with the correct issuer
|
||||
validJWTToken := createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
|
||||
invalidJWTToken := createTestJWT(t, "https://invalid-issuer.com", "test-user", "wrong-key")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
roleArn string
|
||||
sessionName string
|
||||
webToken string
|
||||
expectedAllow bool
|
||||
testAction string
|
||||
testResource string
|
||||
}{
|
||||
{
|
||||
name: "successful role assumption with policy validation",
|
||||
roleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
sessionName: "oidc-session",
|
||||
webToken: validJWTToken,
|
||||
expectedAllow: true,
|
||||
testAction: "s3:GetObject",
|
||||
testResource: "arn:aws:s3:::test-bucket/file.txt",
|
||||
},
|
||||
{
|
||||
name: "role assumption denied by trust policy",
|
||||
roleArn: "arn:aws:iam::role/RestrictedRole",
|
||||
sessionName: "oidc-session",
|
||||
webToken: validJWTToken,
|
||||
expectedAllow: false,
|
||||
},
|
||||
{
|
||||
name: "invalid token rejected",
|
||||
roleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
sessionName: "oidc-session",
|
||||
webToken: invalidJWTToken,
|
||||
expectedAllow: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Step 1: Attempt role assumption
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: tt.roleArn,
|
||||
WebIdentityToken: tt.webToken,
|
||||
RoleSessionName: tt.sessionName,
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
|
||||
if !tt.expectedAllow {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
return
|
||||
}
|
||||
|
||||
// Should succeed if expectedAllow is true
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
require.NotNil(t, response.Credentials)
|
||||
|
||||
// Step 2: Test policy enforcement with assumed credentials
|
||||
if tt.testAction != "" && tt.testResource != "" {
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: tt.testAction,
|
||||
Resource: tt.testResource,
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed, "Action should be allowed by role policy")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFullLDAPWorkflow tests the complete LDAP → STS → Policy workflow
|
||||
func TestFullLDAPWorkflow(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
roleArn string
|
||||
sessionName string
|
||||
username string
|
||||
password string
|
||||
expectedAllow bool
|
||||
testAction string
|
||||
testResource string
|
||||
}{
|
||||
{
|
||||
name: "successful LDAP role assumption",
|
||||
roleArn: "arn:aws:iam::role/LDAPUserRole",
|
||||
sessionName: "ldap-session",
|
||||
username: "testuser",
|
||||
password: "testpass",
|
||||
expectedAllow: true,
|
||||
testAction: "filer:CreateEntry",
|
||||
testResource: "arn:aws:filer::path/user-docs/*",
|
||||
},
|
||||
{
|
||||
name: "invalid LDAP credentials",
|
||||
roleArn: "arn:aws:iam::role/LDAPUserRole",
|
||||
sessionName: "ldap-session",
|
||||
username: "testuser",
|
||||
password: "wrongpass",
|
||||
expectedAllow: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Step 1: Attempt role assumption with LDAP credentials
|
||||
assumeRequest := &sts.AssumeRoleWithCredentialsRequest{
|
||||
RoleArn: tt.roleArn,
|
||||
Username: tt.username,
|
||||
Password: tt.password,
|
||||
RoleSessionName: tt.sessionName,
|
||||
ProviderName: "test-ldap",
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithCredentials(ctx, assumeRequest)
|
||||
|
||||
if !tt.expectedAllow {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
// Step 2: Test policy enforcement
|
||||
if tt.testAction != "" && tt.testResource != "" {
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: tt.testAction,
|
||||
Resource: tt.testResource,
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicyEnforcement tests policy evaluation for various scenarios
|
||||
func TestPolicyEnforcement(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
|
||||
// Create a valid JWT token for testing
|
||||
validJWTToken := createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
|
||||
|
||||
// Create a session for testing
|
||||
ctx := context.Background()
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
WebIdentityToken: validJWTToken,
|
||||
RoleSessionName: "policy-test-session",
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
sessionToken := response.Credentials.SessionToken
|
||||
principal := response.AssumedRoleUser.Arn
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
resource string
|
||||
shouldAllow bool
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "allow read access",
|
||||
action: "s3:GetObject",
|
||||
resource: "arn:aws:s3:::test-bucket/file.txt",
|
||||
shouldAllow: true,
|
||||
reason: "S3ReadOnlyRole should allow GetObject",
|
||||
},
|
||||
{
|
||||
name: "allow list bucket",
|
||||
action: "s3:ListBucket",
|
||||
resource: "arn:aws:s3:::test-bucket",
|
||||
shouldAllow: true,
|
||||
reason: "S3ReadOnlyRole should allow ListBucket",
|
||||
},
|
||||
{
|
||||
name: "deny write access",
|
||||
action: "s3:PutObject",
|
||||
resource: "arn:aws:s3:::test-bucket/newfile.txt",
|
||||
shouldAllow: false,
|
||||
reason: "S3ReadOnlyRole should deny write operations",
|
||||
},
|
||||
{
|
||||
name: "deny delete access",
|
||||
action: "s3:DeleteObject",
|
||||
resource: "arn:aws:s3:::test-bucket/file.txt",
|
||||
shouldAllow: false,
|
||||
reason: "S3ReadOnlyRole should deny delete operations",
|
||||
},
|
||||
{
|
||||
name: "deny filer access",
|
||||
action: "filer:CreateEntry",
|
||||
resource: "arn:aws:filer::path/test",
|
||||
shouldAllow: false,
|
||||
reason: "S3ReadOnlyRole should not allow filer operations",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: principal,
|
||||
Action: tt.action,
|
||||
Resource: tt.resource,
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.shouldAllow, allowed, tt.reason)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionPolicyBoundary verifies that inline session policies restrict permissions.
|
||||
func TestSessionPolicyBoundary(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
stsService := iamManager.GetSTSService()
|
||||
require.NotNil(t, stsService)
|
||||
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::test-bucket/allowed/*"]}]}`
|
||||
|
||||
sessionId, err := sts.GenerateSessionId()
|
||||
require.NoError(t, err)
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
principal := "arn:aws:sts::000000000000:assumed-role/S3ReadOnlyRole/policy-session"
|
||||
|
||||
claims := sts.NewSTSSessionClaims(sessionId, stsService.Config.Issuer, expiresAt).
|
||||
WithSessionName("policy-session").
|
||||
WithRoleInfo("arn:aws:iam::role/S3ReadOnlyRole", principal, principal).
|
||||
WithSessionPolicy(sessionPolicy)
|
||||
|
||||
sessionToken, err := stsService.GetTokenGenerator().GenerateJWTWithClaims(claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: principal,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/allowed/file.txt",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed, "Session policy should allow GetObject within allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: principal,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/other/file.txt",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny GetObject outside allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: principal,
|
||||
Action: "s3:ListBucket",
|
||||
Resource: "arn:aws:s3:::test-bucket",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny ListBucket when not explicitly allowed")
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithWebIdentitySessionPolicy verifies Policy downscoping is applied to web identity sessions.
|
||||
func TestAssumeRoleWithWebIdentitySessionPolicy(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
validJWTToken := createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::test-bucket/allowed/*"]}]}`
|
||||
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
WebIdentityToken: validJWTToken,
|
||||
RoleSessionName: "policy-web-identity",
|
||||
Policy: &sessionPolicy,
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/allowed/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed, "Session policy should allow GetObject within allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/other/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny GetObject outside allowed prefix")
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithCredentialsSessionPolicy verifies Policy downscoping is applied to credentials sessions.
|
||||
func TestAssumeRoleWithCredentialsSessionPolicy(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["filer:CreateEntry"],"Resource":["arn:aws:filer::path/user-docs/allowed/*"]}]}`
|
||||
assumeRequest := &sts.AssumeRoleWithCredentialsRequest{
|
||||
RoleArn: "arn:aws:iam::role/LDAPUserRole",
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
RoleSessionName: "policy-ldap",
|
||||
ProviderName: "test-ldap",
|
||||
Policy: &sessionPolicy,
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithCredentials(ctx, assumeRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "filer:CreateEntry",
|
||||
Resource: "arn:aws:filer::path/user-docs/allowed/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed, "Session policy should allow CreateEntry within allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "filer:CreateEntry",
|
||||
Resource: "arn:aws:filer::path/user-docs/other/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny CreateEntry outside allowed prefix")
|
||||
}
|
||||
|
||||
// TestSessionExpiration tests session expiration and cleanup
|
||||
func TestSessionExpiration(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a valid JWT token for testing
|
||||
validJWTToken := createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
|
||||
|
||||
// Create a short-lived session
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
WebIdentityToken: validJWTToken,
|
||||
RoleSessionName: "expiration-test",
|
||||
DurationSeconds: int64Ptr(900), // 15 minutes
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
sessionToken := response.Credentials.SessionToken
|
||||
|
||||
// Verify session is initially valid
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/file.txt",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed)
|
||||
|
||||
// Verify the expiration time is set correctly
|
||||
assert.True(t, response.Credentials.Expiration.After(time.Now()))
|
||||
assert.True(t, response.Credentials.Expiration.Before(time.Now().Add(16*time.Minute)))
|
||||
|
||||
// Test session expiration behavior in stateless JWT system
|
||||
// In a stateless system, manual expiration is not supported
|
||||
err = iamManager.ExpireSessionForTesting(ctx, sessionToken)
|
||||
require.Error(t, err, "Manual session expiration should not be supported in stateless system")
|
||||
assert.Contains(t, err.Error(), "manual session expiration not supported")
|
||||
|
||||
// Verify session is still valid (since it hasn't naturally expired)
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/file.txt",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err, "Session should still be valid in stateless system")
|
||||
assert.True(t, allowed, "Access should still be allowed since token hasn't naturally expired")
|
||||
}
|
||||
|
||||
// TestSessionOutlivesShortLivedToken verifies the session lifetime is not
|
||||
// clamped to the web identity token's exp: IdPs like GitLab issue ~2-minute
|
||||
// id_tokens, and the session should still run the configured duration.
|
||||
func TestSessionOutlivesShortLivedToken(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
shortLivedJWT := createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key",
|
||||
time.Now().Add(3*time.Minute))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
durationSeconds *int64
|
||||
wantDuration time.Duration
|
||||
}{
|
||||
{"default duration ignores token exp", nil, time.Hour},
|
||||
{"explicit DurationSeconds ignores token exp", int64Ptr(3600), time.Hour},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
WebIdentityToken: shortLivedJWT,
|
||||
RoleSessionName: "short-lived-token-test",
|
||||
DurationSeconds: tc.durationSeconds,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.WithinDuration(t, time.Now().Add(tc.wantDuration), response.Credentials.Expiration, time.Minute)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrustPolicyValidation tests role trust policy validation
|
||||
func TestTrustPolicyValidation(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
roleArn string
|
||||
provider string
|
||||
userID string
|
||||
shouldAllow bool
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "OIDC user allowed by trust policy",
|
||||
roleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
provider: "oidc",
|
||||
userID: "test-user-id",
|
||||
shouldAllow: true,
|
||||
reason: "Trust policy should allow OIDC users",
|
||||
},
|
||||
{
|
||||
name: "LDAP user allowed by different role",
|
||||
roleArn: "arn:aws:iam::role/LDAPUserRole",
|
||||
provider: "ldap",
|
||||
userID: "testuser",
|
||||
shouldAllow: true,
|
||||
reason: "Trust policy should allow LDAP users for LDAP role",
|
||||
},
|
||||
{
|
||||
name: "Wrong provider for role",
|
||||
roleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
provider: "ldap",
|
||||
userID: "testuser",
|
||||
shouldAllow: false,
|
||||
reason: "S3ReadOnlyRole trust policy should reject LDAP users",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// This would test trust policy evaluation
|
||||
// For now, we'll implement this as part of the IAM manager
|
||||
result := iamManager.ValidateTrustPolicy(ctx, tt.roleArn, tt.provider, tt.userID)
|
||||
assert.Equal(t, tt.shouldAllow, result, tt.reason)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrustPolicyWildcardPrincipal tests wildcard principal handling in trust policies
|
||||
func TestTrustPolicyWildcardPrincipal(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a role with wildcard federated principal
|
||||
err := iamManager.CreateRole(ctx, "", "WildcardFederatedRole", &RoleDefinition{
|
||||
RoleName: "WildcardFederatedRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "*", // Wildcard should allow any federated provider
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a role with wildcard in array
|
||||
err = iamManager.CreateRole(ctx, "", "WildcardArrayRole", &RoleDefinition{
|
||||
RoleName: "WildcardArrayRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": []string{"specific-provider", "*"}, // Array with wildcard
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a role with plain wildcard principal (regression test)
|
||||
err = iamManager.CreateRole(ctx, "", "PlainWildcardRole", &RoleDefinition{
|
||||
RoleName: "PlainWildcardRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: "*", // Plain wildcard
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// NEW: Create a role with specific federated principal (for negative testing)
|
||||
err = iamManager.CreateRole(ctx, "", "SpecificFederatedRole", &RoleDefinition{
|
||||
RoleName: "SpecificFederatedRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// NEW: Create a role with principal as []interface{} (simulating JSON unmarshaling)
|
||||
err = iamManager.CreateRole(ctx, "", "InterfaceArrayRole", &RoleDefinition{
|
||||
RoleName: "InterfaceArrayRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": []interface{}{"specific-provider", "test-oidc"},
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create JWT token for testing
|
||||
validJWTToken := createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
roleArn string
|
||||
token string
|
||||
shouldAllow bool
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "Wildcard federated principal allows any provider",
|
||||
roleArn: "arn:aws:iam::role/WildcardFederatedRole",
|
||||
token: validJWTToken,
|
||||
shouldAllow: true,
|
||||
reason: "Wildcard federated principal should allow any provider",
|
||||
},
|
||||
{
|
||||
name: "Wildcard in array allows any provider",
|
||||
roleArn: "arn:aws:iam::role/WildcardArrayRole",
|
||||
token: validJWTToken,
|
||||
shouldAllow: true,
|
||||
reason: "Wildcard in principal array should allow any provider",
|
||||
},
|
||||
{
|
||||
name: "Plain wildcard allows any provider (regression)",
|
||||
roleArn: "arn:aws:iam::role/PlainWildcardRole",
|
||||
token: validJWTToken,
|
||||
shouldAllow: true,
|
||||
reason: "Plain wildcard principal should still work",
|
||||
},
|
||||
{
|
||||
name: "Non-wildcard federated principal requires matching provider",
|
||||
roleArn: "arn:aws:iam::role/SpecificFederatedRole",
|
||||
token: createTestJWT(t, "https://different-issuer.com", "test-user", "test-signing-key"),
|
||||
shouldAllow: false,
|
||||
reason: "Non-wildcard principal should still require matching provider",
|
||||
},
|
||||
{
|
||||
name: "Interface array principal works correctly",
|
||||
roleArn: "arn:aws:iam::role/InterfaceArrayRole",
|
||||
token: validJWTToken,
|
||||
shouldAllow: true,
|
||||
reason: "Principal as []interface{} should be handled correctly",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: tt.roleArn,
|
||||
WebIdentityToken: tt.token,
|
||||
RoleSessionName: "wildcard-test-session",
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
|
||||
if tt.shouldAllow {
|
||||
require.NoError(t, err, tt.reason)
|
||||
require.NotNil(t, response)
|
||||
require.NotNil(t, response.Credentials)
|
||||
} else {
|
||||
assert.Error(t, err, tt.reason)
|
||||
assert.Nil(t, response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOIDCClaimsTrustPolicy tests that OIDC claims are correctly mapped to trust policy context
|
||||
func TestOIDCClaimsTrustPolicy(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a role that requires a specific OIDC role claim
|
||||
err := iamManager.CreateRole(ctx, "", "OIDCRoleClaimRole", &RoleDefinition{
|
||||
RoleName: "OIDCRoleClaimRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"StringLike": {
|
||||
"oidc:roles": "Dev.SeaweedFS.*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Helper: Create a JWT with the specified roles claim
|
||||
createTokenWithRoles := func(roles []string) string {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"iss": "https://test-issuer.com",
|
||||
"sub": "test-user-123",
|
||||
"aud": "test-client-id",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"roles": roles,
|
||||
})
|
||||
signedToken, err := token.SignedString([]byte("test-signing-key-32-characters-long"))
|
||||
require.NoError(t, err)
|
||||
return signedToken
|
||||
}
|
||||
|
||||
// Create JWT tokens using the helper
|
||||
validToken := createTokenWithRoles([]string{"Dev.SeaweedFS.Admin", "Dev.SeaweedFS.Audit"})
|
||||
invalidToken := createTokenWithRoles([]string{"Other.Role"})
|
||||
|
||||
// Test case 1: Valid roles -> Should succeed
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/OIDCRoleClaimRole",
|
||||
WebIdentityToken: validToken,
|
||||
RoleSessionName: "oidc-claims-test",
|
||||
}
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
require.NoError(t, err, "Should allow role assumption when oidc:roles claim matches")
|
||||
require.NotNil(t, response)
|
||||
|
||||
// Test case 2: Invalid roles -> Should fail
|
||||
badRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/OIDCRoleClaimRole",
|
||||
WebIdentityToken: invalidToken,
|
||||
RoleSessionName: "oidc-claims-fail-test",
|
||||
}
|
||||
response, err = iamManager.AssumeRoleWithWebIdentity(ctx, badRequest)
|
||||
assert.Error(t, err, "Should deny role assumption when oidc:roles claim does not match")
|
||||
assert.Nil(t, response)
|
||||
}
|
||||
|
||||
// Helper functions and test setup
|
||||
|
||||
// createTestJWT creates a test JWT token with the specified issuer, subject and signing key
|
||||
func createTestJWT(t *testing.T, issuer, subject, signingKey string, exp ...time.Time) string {
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
if len(exp) > 0 {
|
||||
expiresAt = exp[0]
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"iss": issuer,
|
||||
"sub": subject,
|
||||
"aud": "test-client-id",
|
||||
"exp": expiresAt.Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
// Add claims that trust policy validation expects
|
||||
"idp": "test-oidc", // Identity provider claim for trust policy matching
|
||||
})
|
||||
|
||||
tokenString, err := token.SignedString([]byte(signingKey))
|
||||
require.NoError(t, err)
|
||||
return tokenString
|
||||
}
|
||||
|
||||
func setupIntegratedIAMSystem(t *testing.T) *IAMManager {
|
||||
// Create IAM manager with all components
|
||||
manager := NewIAMManager()
|
||||
|
||||
// Configure and initialize
|
||||
config := &IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: time.Hour * 12},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory", // Use memory for unit tests
|
||||
},
|
||||
Roles: &RoleStoreConfig{
|
||||
StoreType: "memory", // Use memory for unit tests
|
||||
},
|
||||
}
|
||||
|
||||
err := manager.Initialize(config, func() string {
|
||||
return "localhost:8888" // Mock filer address for testing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set up test providers
|
||||
setupTestProviders(t, manager)
|
||||
|
||||
// Set up test policies and roles
|
||||
setupTestPoliciesAndRoles(t, manager)
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
func setupTestProviders(t *testing.T, manager *IAMManager) {
|
||||
// Set up OIDC provider
|
||||
oidcProvider := oidc.NewMockOIDCProvider("test-oidc")
|
||||
oidcConfig := &oidc.OIDCConfig{
|
||||
Issuer: "https://test-issuer.com",
|
||||
ClientID: "test-client-id",
|
||||
}
|
||||
err := oidcProvider.Initialize(oidcConfig)
|
||||
require.NoError(t, err)
|
||||
oidcProvider.SetupDefaultTestData()
|
||||
|
||||
// Set up LDAP mock provider (no config needed for mock)
|
||||
ldapProvider := ldap.NewMockLDAPProvider("test-ldap")
|
||||
err = ldapProvider.Initialize(nil) // Mock doesn't need real config
|
||||
require.NoError(t, err)
|
||||
ldapProvider.SetupDefaultTestData()
|
||||
|
||||
// Register providers
|
||||
err = manager.RegisterIdentityProvider(oidcProvider)
|
||||
require.NoError(t, err)
|
||||
err = manager.RegisterIdentityProvider(ldapProvider)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func setupTestPoliciesAndRoles(t *testing.T, manager *IAMManager) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create S3 read-only policy
|
||||
s3ReadPolicy := &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Sid: "S3ReadAccess",
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject", "s3:ListBucket"},
|
||||
Resource: []string{
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := manager.CreatePolicy(ctx, "", "S3ReadOnlyPolicy", s3ReadPolicy)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create LDAP user policy
|
||||
ldapUserPolicy := &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Sid: "FilerAccess",
|
||||
Effect: "Allow",
|
||||
Action: []string{"filer:*"},
|
||||
Resource: []string{
|
||||
"arn:aws:filer::path/user-docs/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = manager.CreatePolicy(ctx, "", "LDAPUserPolicy", ldapUserPolicy)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create roles with trust policies
|
||||
err = manager.CreateRole(ctx, "", "S3ReadOnlyRole", &RoleDefinition{
|
||||
RoleName: "S3ReadOnlyRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = manager.CreateRole(ctx, "", "LDAPUserRole", &RoleDefinition{
|
||||
RoleName: "LDAPUserRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-ldap",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithCredentials"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"LDAPUserPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func int64Ptr(v int64) *int64 {
|
||||
return &v
|
||||
}
|
||||
1656
weed/iam/integration/iam_manager.go
Normal file
1656
weed/iam/integration/iam_manager.go
Normal file
File diff suppressed because it is too large
Load Diff
145
weed/iam/integration/iam_manager_no_policy_test.go
Normal file
145
weed/iam/integration/iam_manager_no_policy_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeUserStore struct {
|
||||
users map[string]*iam_pb.Identity
|
||||
}
|
||||
|
||||
func (f *fakeUserStore) GetUser(_ context.Context, name string) (*iam_pb.Identity, error) {
|
||||
if u, ok := f.users[name]; ok {
|
||||
return u, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestIsActionAllowed_RegisteredUserWithoutPoliciesIsDenied is a regression test
|
||||
// for a security issue where a user created via the S3 IAM API with no policies
|
||||
// attached was granted access to every S3 action. The default-allow path was
|
||||
// intended for unmanaged zero-config startup, not for explicitly registered IAM
|
||||
// users with an empty policy set.
|
||||
func TestIsActionAllowed_RegisteredUserWithoutPoliciesIsDenied(t *testing.T) {
|
||||
manager := NewIAMManager()
|
||||
require.NoError(t, manager.Initialize(&IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{
|
||||
// Reproduce the zero-config setup: with no IAM config file, the
|
||||
// loader picks DefaultEffect=Allow for in-memory mode.
|
||||
DefaultEffect: string(policy.EffectAllow),
|
||||
StoreType: sts.StoreTypeMemory,
|
||||
},
|
||||
Roles: &RoleStoreConfig{StoreType: sts.StoreTypeMemory},
|
||||
}, func() string { return "localhost:8888" }))
|
||||
|
||||
manager.SetUserStore(&fakeUserStore{users: map[string]*iam_pb.Identity{
|
||||
"newuser": {Name: "newuser"}, // no PolicyNames, no Actions
|
||||
}})
|
||||
|
||||
allowed, err := manager.IsActionAllowed(context.Background(), &ActionRequest{
|
||||
Principal: "arn:aws:iam::seaweedfs:user/newuser",
|
||||
Action: "s3:DeleteBucket",
|
||||
Resource: "arn:aws:s3:::any-bucket",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "registered user with no policies must not get default-allow access")
|
||||
}
|
||||
|
||||
// TestIsActionAllowed_RegisteredRoleWithoutPoliciesIsDenied is the role-side
|
||||
// counterpart of the registered-user regression: a managed role that exists
|
||||
// but has zero AttachedPolicies must not inherit full access from the
|
||||
// zero-config DefaultEffect=Allow fallback. Coderabbit flagged this as an
|
||||
// uncovered branch of the same fix on PR #9317.
|
||||
func TestIsActionAllowed_RegisteredRoleWithoutPoliciesIsDenied(t *testing.T) {
|
||||
manager := NewIAMManager()
|
||||
require.NoError(t, manager.Initialize(&IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{
|
||||
DefaultEffect: string(policy.EffectAllow),
|
||||
StoreType: sts.StoreTypeMemory,
|
||||
},
|
||||
Roles: &RoleStoreConfig{StoreType: sts.StoreTypeMemory},
|
||||
}, func() string { return "localhost:8888" }))
|
||||
|
||||
ctx := context.Background()
|
||||
require.NoError(t, manager.CreateRole(ctx, "", "EmptyRole", &RoleDefinition{
|
||||
RoleName: "EmptyRole",
|
||||
RoleArn: "arn:aws:iam::seaweedfs:role/EmptyRole",
|
||||
AttachedPolicies: nil, // explicit: managed role with no policies attached
|
||||
}))
|
||||
|
||||
allowed, err := manager.IsActionAllowed(ctx, &ActionRequest{
|
||||
// Use the assumed-role principal form the STS path produces. The
|
||||
// IAM manager extracts role name via ExtractRoleNameFromPrincipal,
|
||||
// finds the role definition, and must deny because AttachedPolicies
|
||||
// is empty even though DefaultEffect is Allow.
|
||||
Principal: "arn:aws:sts::seaweedfs:assumed-role/EmptyRole/some-session",
|
||||
Action: "s3:DeleteBucket",
|
||||
Resource: "arn:aws:s3:::any-bucket",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "registered role with no attached policies must not get default-allow access")
|
||||
}
|
||||
|
||||
// TestIsActionAllowed_RegisteredUserWithNonMatchingPolicyIsDenied covers the
|
||||
// adjacent case: the user has policies attached, but none of the policy
|
||||
// statements match the requested action/resource. The DefaultEffect=Allow
|
||||
// fallback must not silently grant access when an attached policy simply
|
||||
// does not say anything about this action.
|
||||
func TestIsActionAllowed_RegisteredUserWithNonMatchingPolicyIsDenied(t *testing.T) {
|
||||
manager := NewIAMManager()
|
||||
require.NoError(t, manager.Initialize(&IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{
|
||||
DefaultEffect: string(policy.EffectAllow),
|
||||
StoreType: sts.StoreTypeMemory,
|
||||
},
|
||||
Roles: &RoleStoreConfig{StoreType: sts.StoreTypeMemory},
|
||||
}, func() string { return "localhost:8888" }))
|
||||
|
||||
ctx := context.Background()
|
||||
require.NoError(t, manager.CreatePolicy(ctx, "", "ReadOnlyOneBucket", &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{{
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"arn:aws:s3:::reports/*"},
|
||||
}},
|
||||
}))
|
||||
|
||||
manager.SetUserStore(&fakeUserStore{users: map[string]*iam_pb.Identity{
|
||||
"alice": {Name: "alice", PolicyNames: []string{"ReadOnlyOneBucket"}},
|
||||
}})
|
||||
|
||||
allowed, err := manager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: "arn:aws:iam::seaweedfs:user/alice",
|
||||
Action: "s3:DeleteBucket",
|
||||
Resource: "arn:aws:s3:::other-bucket",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "action outside the user's attached policy must be denied")
|
||||
}
|
||||
50
weed/iam/integration/iam_manager_trust.go
Normal file
50
weed/iam/integration/iam_manager_trust.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
|
||||
)
|
||||
|
||||
// ValidateTrustPolicyForPrincipal validates if a principal is allowed to assume a role
|
||||
func (m *IAMManager) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
|
||||
if !m.initialized {
|
||||
return fmt.Errorf("IAM manager not initialized")
|
||||
}
|
||||
|
||||
// Extract role name from ARN
|
||||
roleName := utils.ExtractRoleNameFromArn(roleArn)
|
||||
|
||||
// Get role definition
|
||||
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get role %s: %w", roleName, err)
|
||||
}
|
||||
|
||||
if roleDef.TrustPolicy == nil {
|
||||
return fmt.Errorf("role has no trust policy")
|
||||
}
|
||||
|
||||
// Create evaluation context with RequestContext populated so that
|
||||
// principal matching works for specific (non-wildcard) principals.
|
||||
// Without this, evaluatePrincipalValue cannot look up "aws:PrincipalArn"
|
||||
// and always returns false for non-wildcard trust policy principals.
|
||||
evalCtx := &policy.EvaluationContext{
|
||||
Principal: principalArn,
|
||||
Action: "sts:AssumeRole",
|
||||
Resource: roleArn,
|
||||
RequestContext: map[string]interface{}{
|
||||
"principal": principalArn,
|
||||
"aws:PrincipalArn": principalArn,
|
||||
},
|
||||
}
|
||||
|
||||
// Evaluate the trust policy
|
||||
if !m.evaluateTrustPolicy(roleDef.TrustPolicy, evalCtx) {
|
||||
return fmt.Errorf("trust policy denies access to principal: %s", principalArn)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
173
weed/iam/integration/oidc_provider_audit.go
Normal file
173
weed/iam/integration/oidc_provider_audit.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// OIDCProviderAuditEventType enumerates the lifecycle events emitted when an
|
||||
// IAM-managed OIDC provider record is mutated. Use-events (every successful
|
||||
// token validation) are intentionally not emitted by the lifecycle path:
|
||||
// they're too hot to stream into a filer-backed sink without spikes. A
|
||||
// future PR can add a sampled-use sink behind an explicit opt-in flag.
|
||||
type OIDCProviderAuditEventType string
|
||||
|
||||
const (
|
||||
OIDCAuditEventCreated OIDCProviderAuditEventType = "Create"
|
||||
OIDCAuditEventDeleted OIDCProviderAuditEventType = "Delete"
|
||||
OIDCAuditEventClientIDAdded OIDCProviderAuditEventType = "AddClientID"
|
||||
OIDCAuditEventClientIDRemoved OIDCProviderAuditEventType = "RemoveClientID"
|
||||
OIDCAuditEventThumbprintsSet OIDCProviderAuditEventType = "UpdateThumbprints"
|
||||
OIDCAuditEventTagsAdded OIDCProviderAuditEventType = "Tag"
|
||||
OIDCAuditEventTagsRemoved OIDCProviderAuditEventType = "Untag"
|
||||
)
|
||||
|
||||
// OIDCProviderAuditEvent is the payload emitted for each lifecycle event.
|
||||
type OIDCProviderAuditEvent struct {
|
||||
Type OIDCProviderAuditEventType `json:"type"`
|
||||
ARN string `json:"arn"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Detail map[string]string `json:"detail,omitempty"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
}
|
||||
|
||||
// OIDCProviderAuditSink consumes lifecycle events. Implementations must be
|
||||
// safe for concurrent use; emit happens with the IAM manager's mutation lock
|
||||
// held, so a slow sink slows the IAM API.
|
||||
type OIDCProviderAuditSink interface {
|
||||
Emit(ctx context.Context, event *OIDCProviderAuditEvent) error
|
||||
}
|
||||
|
||||
// GlogAuditSink writes one structured log line per event. Always-on default
|
||||
// when an explicit sink isn't configured — events still appear in stdout/the
|
||||
// log aggregator, just not in a queryable record.
|
||||
type GlogAuditSink struct{}
|
||||
|
||||
func (GlogAuditSink) Emit(_ context.Context, event *OIDCProviderAuditEvent) error {
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
glog.V(0).Infof("oidc-audit: %s arn=%s err-marshal=%v", event.Type, event.ARN, err)
|
||||
return nil
|
||||
}
|
||||
glog.V(0).Infof("oidc-audit: %s", string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemoryAuditSink keeps events in process memory for tests and short-lived
|
||||
// inspection. Not durable; drop entries by reading from Events() and
|
||||
// discarding.
|
||||
type MemoryAuditSink struct {
|
||||
mu sync.Mutex
|
||||
events []*OIDCProviderAuditEvent
|
||||
}
|
||||
|
||||
func NewMemoryAuditSink() *MemoryAuditSink {
|
||||
return &MemoryAuditSink{}
|
||||
}
|
||||
|
||||
func (m *MemoryAuditSink) Emit(_ context.Context, event *OIDCProviderAuditEvent) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cp := *event
|
||||
m.events = append(m.events, &cp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Events returns a copy of the captured event log so tests can assert.
|
||||
func (m *MemoryAuditSink) Events() []*OIDCProviderAuditEvent {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]*OIDCProviderAuditEvent, len(m.events))
|
||||
copy(out, m.events)
|
||||
return out
|
||||
}
|
||||
|
||||
// FilerAuditSink appends event records as separate files under a filer
|
||||
// directory. Each event is its own file so concurrent writers don't conflict;
|
||||
// the filename is `<unixnano>-<type>-<arnhash>.json`. For higher volumes,
|
||||
// switch to an append-only journal — the contract is just `Emit`.
|
||||
type FilerAuditSink struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
basePath string
|
||||
filerAddressProvider func() string
|
||||
}
|
||||
|
||||
// NewFilerAuditSink returns a filer-backed sink. Default basePath
|
||||
// `/etc/iam/audit/oidc-providers` keeps audit records out of the active
|
||||
// IAM data directories.
|
||||
func NewFilerAuditSink(config map[string]interface{}, filerAddressProvider func() string) *FilerAuditSink {
|
||||
sink := &FilerAuditSink{
|
||||
basePath: "/etc/iam/audit/oidc-providers",
|
||||
filerAddressProvider: filerAddressProvider,
|
||||
}
|
||||
if config != nil {
|
||||
if bp, ok := config["basePath"].(string); ok && bp != "" {
|
||||
sink.basePath = strings.TrimSuffix(bp, "/")
|
||||
}
|
||||
}
|
||||
return sink
|
||||
}
|
||||
|
||||
func (f *FilerAuditSink) resolveFilerAddress() string {
|
||||
if f.filerAddressProvider != nil {
|
||||
return f.filerAddressProvider()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *FilerAuditSink) Emit(ctx context.Context, event *OIDCProviderAuditEvent) error {
|
||||
addr := f.resolveFilerAddress()
|
||||
if addr == "" {
|
||||
return fmt.Errorf("filer address not available")
|
||||
}
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
if event.OccurredAt.IsZero() {
|
||||
event.OccurredAt = time.Now().UTC()
|
||||
}
|
||||
data, err := json.MarshalIndent(event, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal audit event: %v", err)
|
||||
}
|
||||
// Filename: <unixnano>-<type>-<arnhash>.json. Two events at the same
|
||||
// nano against the same ARN+type are vanishingly rare, but two events
|
||||
// at the same nano against different ARNs are perfectly plausible
|
||||
// (audit-via-batch script). The ARN hash gives us an effectively-
|
||||
// collision-free name without leaking the ARN into the filer path.
|
||||
arnSum := sha1.Sum([]byte(event.ARN))
|
||||
name := fmt.Sprintf("%d-%s-%s.json", event.OccurredAt.UnixNano(), event.Type, hex.EncodeToString(arnSum[:8]))
|
||||
occurredAt := event.OccurredAt.Unix()
|
||||
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(addr), f.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: name,
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
// Reflect the event's occurrence time so file metadata
|
||||
// matches the audit record itself, not the filer write.
|
||||
Mtime: occurredAt,
|
||||
Crtime: occurredAt,
|
||||
FileMode: uint32(0o600),
|
||||
},
|
||||
Content: data,
|
||||
},
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
105
weed/iam/integration/oidc_provider_audit_test.go
Normal file
105
weed/iam/integration/oidc_provider_audit_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
)
|
||||
|
||||
func newAuditableManager(t *testing.T) (*IAMManager, *MemoryAuditSink) {
|
||||
t.Helper()
|
||||
mgr := NewIAMManager()
|
||||
sink := NewMemoryAuditSink()
|
||||
cfg := &IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
AccountId: "111122223333",
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
|
||||
Roles: &RoleStoreConfig{StoreType: "memory"},
|
||||
}
|
||||
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
mgr.SetOIDCProviderAuditSink(sink)
|
||||
return mgr, sink
|
||||
}
|
||||
|
||||
func TestOIDCAuditLifecycleEventsAreEmitted(t *testing.T) {
|
||||
mgr, sink := newAuditableManager(t)
|
||||
ctx := context.Background()
|
||||
|
||||
rec := &OIDCProviderRecord{
|
||||
AccountID: "111122223333",
|
||||
ARN: "arn:aws:iam::111122223333:oidc-provider/idp.example",
|
||||
URL: "https://idp.example",
|
||||
ClientIDs: []string{"x"},
|
||||
}
|
||||
if err := mgr.CreateOIDCProvider(ctx, rec); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if err := mgr.AddClientIDToOIDCProvider(ctx, rec.ARN, "y"); err != nil {
|
||||
t.Fatalf("AddClientID: %v", err)
|
||||
}
|
||||
if err := mgr.RemoveClientIDFromOIDCProvider(ctx, rec.ARN, "y"); err != nil {
|
||||
t.Fatalf("RemoveClientID: %v", err)
|
||||
}
|
||||
if err := mgr.UpdateOIDCProviderThumbprints(ctx, rec.ARN, []string{"0000000000000000000000000000000000000000"}); err != nil {
|
||||
t.Fatalf("UpdateThumbprints: %v", err)
|
||||
}
|
||||
if err := mgr.TagOIDCProvider(ctx, rec.ARN, map[string]string{"team": "infra"}); err != nil {
|
||||
t.Fatalf("Tag: %v", err)
|
||||
}
|
||||
if err := mgr.UntagOIDCProvider(ctx, rec.ARN, []string{"team"}); err != nil {
|
||||
t.Fatalf("Untag: %v", err)
|
||||
}
|
||||
if err := mgr.DeleteOIDCProvider(ctx, rec.ARN); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
|
||||
want := []OIDCProviderAuditEventType{
|
||||
OIDCAuditEventCreated,
|
||||
OIDCAuditEventClientIDAdded,
|
||||
OIDCAuditEventClientIDRemoved,
|
||||
OIDCAuditEventThumbprintsSet,
|
||||
OIDCAuditEventTagsAdded,
|
||||
OIDCAuditEventTagsRemoved,
|
||||
OIDCAuditEventDeleted,
|
||||
}
|
||||
events := sink.Events()
|
||||
if len(events) != len(want) {
|
||||
t.Fatalf("expected %d events, got %d (%+v)", len(want), len(events), events)
|
||||
}
|
||||
for i, e := range events {
|
||||
if e.Type != want[i] {
|
||||
t.Errorf("event %d: type=%s want=%s", i, e.Type, want[i])
|
||||
}
|
||||
if e.ARN != rec.ARN {
|
||||
t.Errorf("event %d: ARN=%s want=%s", i, e.ARN, rec.ARN)
|
||||
}
|
||||
if e.OccurredAt.IsZero() {
|
||||
t.Errorf("event %d has zero OccurredAt", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCAuditDefaultsToGlogSink(t *testing.T) {
|
||||
// No SetOIDCProviderAuditSink call — should default to glog and not panic.
|
||||
mgr, _ := newAuditableManager(t)
|
||||
mgr.SetOIDCProviderAuditSink(nil)
|
||||
rec := &OIDCProviderRecord{
|
||||
AccountID: "111122223333",
|
||||
ARN: "arn:aws:iam::111122223333:oidc-provider/idp.example",
|
||||
URL: "https://idp.example",
|
||||
ClientIDs: []string{"x"},
|
||||
}
|
||||
if err := mgr.CreateOIDCProvider(context.Background(), rec); err != nil {
|
||||
t.Fatalf("Create with default sink: %v", err)
|
||||
}
|
||||
}
|
||||
178
weed/iam/integration/oidc_provider_bootstrap_test.go
Normal file
178
weed/iam/integration/oidc_provider_bootstrap_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
)
|
||||
|
||||
func TestStaticConfigSeedsProviderStore(t *testing.T) {
|
||||
mgr := NewIAMManager()
|
||||
cfg := &IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
AccountId: "111122223333",
|
||||
Providers: []*sts.ProviderConfig{
|
||||
{
|
||||
Name: "google",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: true,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://accounts.google.com",
|
||||
"clientId": "1234.apps.googleusercontent.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "github-actions",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: true,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://token.actions.githubusercontent.com",
|
||||
"clientId": "sts.amazonaws.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "disabled-google",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: false,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://accounts.disabled.example",
|
||||
"clientId": "x",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
|
||||
Roles: &RoleStoreConfig{StoreType: "memory"},
|
||||
}
|
||||
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
|
||||
store := mgr.GetOIDCProviderStore()
|
||||
if store == nil {
|
||||
t.Fatal("expected store to be initialized")
|
||||
}
|
||||
|
||||
got, err := mgr.ListOIDCProviders(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListOIDCProviders: %v", err)
|
||||
}
|
||||
// Disabled providers must not be mirrored.
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 providers, got %d (%v)", len(got), got)
|
||||
}
|
||||
|
||||
byArn := map[string]*OIDCProviderRecord{}
|
||||
for _, r := range got {
|
||||
byArn[r.ARN] = r
|
||||
}
|
||||
|
||||
googleARN := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
|
||||
g, ok := byArn[googleARN]
|
||||
if !ok {
|
||||
t.Fatalf("missing google ARN; have %v", byArn)
|
||||
}
|
||||
if len(g.ClientIDs) != 1 || g.ClientIDs[0] != "1234.apps.googleusercontent.com" {
|
||||
t.Fatalf("google clientIds wrong: %v", g.ClientIDs)
|
||||
}
|
||||
|
||||
ghARN := "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
|
||||
gh, ok := byArn[ghARN]
|
||||
if !ok {
|
||||
t.Fatalf("missing github-actions ARN; have %v", byArn)
|
||||
}
|
||||
if len(gh.ClientIDs) != 1 || gh.ClientIDs[0] != "sts.amazonaws.com" {
|
||||
t.Fatalf("github clientIds wrong: %v", gh.ClientIDs)
|
||||
}
|
||||
|
||||
// Spot-check the IAM read path returns the same record.
|
||||
rec, err := mgr.GetOIDCProvider(context.Background(), googleARN)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOIDCProvider: %v", err)
|
||||
}
|
||||
if rec.URL != "https://accounts.google.com" {
|
||||
t.Fatalf("URL mismatch: %s", rec.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticConfigMirrorsThumbprintsAndAdvancedFields(t *testing.T) {
|
||||
// Static config sets thumbprints, AllowedPrincipalTagKeys, and PolicyClaim;
|
||||
// the mirror into the IAM-managed store must carry all three. Without it,
|
||||
// the next RefreshOIDCProvidersFromStore rebuilds a runtime provider that
|
||||
// drops thumbprint pinning and silently disables claim-based policies and
|
||||
// principal-tag passthrough — and because IAM-managed entries take
|
||||
// precedence over the static-config map, the bootstrap provider gets
|
||||
// shadowed by this weaker rebuild.
|
||||
mgr := NewIAMManager()
|
||||
cfg := &IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
AccountId: "111122223333",
|
||||
Providers: []*sts.ProviderConfig{
|
||||
{
|
||||
Name: "github-actions",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: true,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://token.actions.githubusercontent.com",
|
||||
"clientId": "sts.amazonaws.com",
|
||||
"thumbprints": []interface{}{"6938fd4d98bab03faadb97b34396831e3780aea1"},
|
||||
"allowedPrincipalTagKeys": []interface{}{"team", "env"},
|
||||
"policyClaim": "policies",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
|
||||
Roles: &RoleStoreConfig{StoreType: "memory"},
|
||||
}
|
||||
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
|
||||
rec, err := mgr.GetOIDCProvider(context.Background(), "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOIDCProvider: %v", err)
|
||||
}
|
||||
if got, want := rec.Thumbprints, []string{"6938fd4d98bab03faadb97b34396831e3780aea1"}; !equalStrings(got, want) {
|
||||
t.Fatalf("thumbprints mismatch: got %v want %v", got, want)
|
||||
}
|
||||
if got, want := rec.AllowedPrincipalTagKeys, []string{"team", "env"}; !equalStrings(got, want) {
|
||||
t.Fatalf("allowedPrincipalTagKeys mismatch: got %v want %v", got, want)
|
||||
}
|
||||
if rec.PolicyClaim != "policies" {
|
||||
t.Fatalf("policyClaim mismatch: got %q want %q", rec.PolicyClaim, "policies")
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestStoreNotConfiguredReturnsClearError(t *testing.T) {
|
||||
mgr := NewIAMManager()
|
||||
if _, err := mgr.GetOIDCProvider(context.Background(), "arn:..."); err == nil {
|
||||
t.Fatal("expected error when store not configured")
|
||||
}
|
||||
if _, err := mgr.ListOIDCProviders(context.Background()); err == nil {
|
||||
t.Fatal("expected error when store not configured")
|
||||
}
|
||||
}
|
||||
473
weed/iam/integration/oidc_provider_store.go
Normal file
473
weed/iam/integration/oidc_provider_store.go
Normal file
@@ -0,0 +1,473 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the IAM manager and OIDCProviderStore. Callers
|
||||
// in the s3api layer use errors.Is rather than substring-matching the
|
||||
// formatted message, so the message can be edited freely without changing
|
||||
// the IAM error code surfaced to the API caller.
|
||||
var (
|
||||
ErrOIDCProviderNotFound = errors.New("OIDC provider not found")
|
||||
ErrOIDCProviderAlreadyExists = errors.New("OIDC provider already exists")
|
||||
)
|
||||
|
||||
// OIDCProviderRecord is the persisted, IAM-managed view of an OIDC identity
|
||||
// provider. It is the source of truth consulted at AssumeRoleWithWebIdentity
|
||||
// time. Static configuration entries are loaded into this same store at
|
||||
// startup so the resolution path is uniform.
|
||||
type OIDCProviderRecord struct {
|
||||
// AccountID scopes the record. Empty means "global" — usable from any
|
||||
// account. Non-empty records are only resolvable when the assuming
|
||||
// caller's RoleArn lives in the same account. Phase 2a leaves this
|
||||
// field unenforced; Phase 3c lights up the cross-account check.
|
||||
AccountID string `json:"accountId,omitempty"`
|
||||
|
||||
// ARN is the canonical IAM identifier for the provider:
|
||||
// arn:aws:iam::<account>:oidc-provider/<host>/<path>.
|
||||
ARN string `json:"arn"`
|
||||
|
||||
// URL is the issuer URL (no trailing slash), e.g.
|
||||
// https://token.actions.githubusercontent.com.
|
||||
URL string `json:"url"`
|
||||
|
||||
// ClientIDs are the audiences AWS calls "client IDs". A token's `aud` or
|
||||
// `azp` must match one of these for the token to be accepted.
|
||||
ClientIDs []string `json:"clientIds,omitempty"`
|
||||
|
||||
// Thumbprints are SHA-1 hex digests of the IDP's TLS certificate, matching
|
||||
// the AWS-compatible thumbprint algorithm. Used at JWKS-fetch time when
|
||||
// non-empty; an empty list means "trust the system root store".
|
||||
Thumbprints []string `json:"thumbprints,omitempty"`
|
||||
|
||||
// AllowedPrincipalTagKeys is the per-provider ABAC allowlist for the
|
||||
// `https://aws.amazon.com/tags/principal_tags` claim namespace. Empty
|
||||
// means no tags are surfaced from this provider.
|
||||
AllowedPrincipalTagKeys []string `json:"allowedPrincipalTagKeys,omitempty"`
|
||||
|
||||
// PolicyClaim, when non-empty, enables claim-based policy mode for this
|
||||
// provider: tokens whose RoleArn matches the configured sentinel will
|
||||
// derive their effective policies from this JWT claim.
|
||||
PolicyClaim string `json:"policyClaim,omitempty"`
|
||||
|
||||
// Tags is the AWS-style tag set attached to the IAM resource itself
|
||||
// (audit/inventory metadata, not propagated into sessions).
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// OIDCProviderStore stores OIDCProviderRecord entries. Implementations are
|
||||
// expected to be safe for concurrent use.
|
||||
type OIDCProviderStore interface {
|
||||
StoreProvider(ctx context.Context, filerAddress string, record *OIDCProviderRecord) error
|
||||
GetProviderByARN(ctx context.Context, filerAddress string, arn string) (*OIDCProviderRecord, error)
|
||||
GetProviderByIssuer(ctx context.Context, filerAddress string, issuer string) (*OIDCProviderRecord, error)
|
||||
GetProviderByIssuerAndAccount(ctx context.Context, filerAddress string, issuer, accountID string) (*OIDCProviderRecord, error)
|
||||
ListProviders(ctx context.Context, filerAddress string) ([]*OIDCProviderRecord, error)
|
||||
DeleteProvider(ctx context.Context, filerAddress string, arn string) error
|
||||
}
|
||||
|
||||
// MemoryOIDCProviderStore is a process-local store, suitable for tests and
|
||||
// single-node deployments. It also acts as the in-memory cache hydrated from
|
||||
// static config at boot.
|
||||
type MemoryOIDCProviderStore struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]*OIDCProviderRecord // keyed by ARN
|
||||
}
|
||||
|
||||
// NewMemoryOIDCProviderStore creates an empty in-memory store.
|
||||
func NewMemoryOIDCProviderStore() *MemoryOIDCProviderStore {
|
||||
return &MemoryOIDCProviderStore{providers: make(map[string]*OIDCProviderRecord)}
|
||||
}
|
||||
|
||||
// StoreProvider replaces any existing record with the same ARN.
|
||||
func (m *MemoryOIDCProviderStore) StoreProvider(ctx context.Context, _ string, record *OIDCProviderRecord) error {
|
||||
if record == nil {
|
||||
return fmt.Errorf("record cannot be nil")
|
||||
}
|
||||
if record.ARN == "" {
|
||||
return fmt.Errorf("record.ARN is required")
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.providers[record.ARN] = copyOIDCProviderRecord(record)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProviderByARN returns the record with the given ARN, or an error if absent.
|
||||
func (m *MemoryOIDCProviderStore) GetProviderByARN(ctx context.Context, _ string, arn string) (*OIDCProviderRecord, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
rec, ok := m.providers[arn]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", ErrOIDCProviderNotFound, arn)
|
||||
}
|
||||
return copyOIDCProviderRecord(rec), nil
|
||||
}
|
||||
|
||||
// GetProviderByIssuer scans for the first record whose URL matches `issuer`.
|
||||
// Comparison strips trailing slashes and is case-insensitive on host.
|
||||
func (m *MemoryOIDCProviderStore) GetProviderByIssuer(ctx context.Context, _ string, issuer string) (*OIDCProviderRecord, error) {
|
||||
want := normalizeIssuer(issuer)
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, rec := range m.providers {
|
||||
if normalizeIssuer(rec.URL) == want {
|
||||
return copyOIDCProviderRecord(rec), nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no OIDC provider registered for issuer: %s", issuer)
|
||||
}
|
||||
|
||||
// GetProviderByIssuerAndAccount returns the record matching `issuer` whose
|
||||
// AccountID is empty (global) or equals `accountID`. AWS-style account
|
||||
// scoping: cross-account references must go through a shared/global
|
||||
// provider, which is why an empty AccountID is treated as a wildcard.
|
||||
//
|
||||
// `accountID` may itself be empty when the caller has no specific account
|
||||
// context (e.g. claim-based mode); in that case any registered provider
|
||||
// matching the issuer is acceptable.
|
||||
func (m *MemoryOIDCProviderStore) GetProviderByIssuerAndAccount(ctx context.Context, _ string, issuer, accountID string) (*OIDCProviderRecord, error) {
|
||||
want := normalizeIssuer(issuer)
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, rec := range m.providers {
|
||||
if normalizeIssuer(rec.URL) != want {
|
||||
continue
|
||||
}
|
||||
if rec.AccountID == "" || accountID == "" || rec.AccountID == accountID {
|
||||
return copyOIDCProviderRecord(rec), nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no OIDC provider registered for issuer %s in account %s", issuer, accountID)
|
||||
}
|
||||
|
||||
// ListProviders returns every record sorted by ARN for stable output.
|
||||
func (m *MemoryOIDCProviderStore) ListProviders(ctx context.Context, _ string) ([]*OIDCProviderRecord, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]*OIDCProviderRecord, 0, len(m.providers))
|
||||
for _, rec := range m.providers {
|
||||
out = append(out, copyOIDCProviderRecord(rec))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ARN < out[j].ARN })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteProvider removes the record. Idempotent — deleting a missing ARN is success.
|
||||
func (m *MemoryOIDCProviderStore) DeleteProvider(ctx context.Context, _ string, arn string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.providers, arn)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilerOIDCProviderStore persists records as JSON files in a filer directory,
|
||||
// mirroring FilerRoleStore.
|
||||
type FilerOIDCProviderStore struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
basePath string
|
||||
filerAddressProvider func() string
|
||||
}
|
||||
|
||||
// NewFilerOIDCProviderStore returns a filer-backed store. Default path
|
||||
// `/etc/iam/oidc-providers` aligns with the existing roles directory.
|
||||
func NewFilerOIDCProviderStore(config map[string]interface{}, filerAddressProvider func() string) *FilerOIDCProviderStore {
|
||||
store := &FilerOIDCProviderStore{
|
||||
basePath: "/etc/iam/oidc-providers",
|
||||
filerAddressProvider: filerAddressProvider,
|
||||
}
|
||||
if config != nil {
|
||||
if bp, ok := config["basePath"].(string); ok && bp != "" {
|
||||
store.basePath = strings.TrimSuffix(bp, "/")
|
||||
}
|
||||
}
|
||||
glog.V(2).Infof("Initialized FilerOIDCProviderStore with basePath %s", store.basePath)
|
||||
return store
|
||||
}
|
||||
|
||||
func (f *FilerOIDCProviderStore) resolveFilerAddress(filerAddress string) string {
|
||||
if filerAddress != "" {
|
||||
return filerAddress
|
||||
}
|
||||
if f.filerAddressProvider != nil {
|
||||
return f.filerAddressProvider()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *FilerOIDCProviderStore) fileName(arn string) string {
|
||||
// Hash the ARN to a fixed-width filename so we can store any character set
|
||||
// (including the URL fragments AWS lets through) without filer-path issues.
|
||||
h := sha1.Sum([]byte(arn))
|
||||
return hex.EncodeToString(h[:]) + ".json"
|
||||
}
|
||||
|
||||
// StoreProvider persists `record` as JSON.
|
||||
func (f *FilerOIDCProviderStore) StoreProvider(ctx context.Context, filerAddress string, record *OIDCProviderRecord) error {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required")
|
||||
}
|
||||
if record == nil || record.ARN == "" {
|
||||
return fmt.Errorf("record with ARN is required")
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal OIDC provider: %v", err)
|
||||
}
|
||||
|
||||
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: f.fileName(record.ARN),
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0o600),
|
||||
},
|
||||
Content: data,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("store OIDC provider %s: %v", record.ARN, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetProviderByARN looks up the record file by its ARN-derived filename.
|
||||
func (f *FilerOIDCProviderStore) GetProviderByARN(ctx context.Context, filerAddress string, arn string) (*OIDCProviderRecord, error) {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required")
|
||||
}
|
||||
|
||||
var data []byte
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: f.fileName(arn),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrOIDCProviderNotFound, err)
|
||||
}
|
||||
if resp.Entry == nil {
|
||||
return fmt.Errorf("OIDC provider not found: %s", arn)
|
||||
}
|
||||
data = resp.Entry.Content
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rec OIDCProviderRecord
|
||||
if err := json.Unmarshal(data, &rec); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal OIDC provider: %v", err)
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// GetProviderByIssuer linearly scans the directory. Phase 2b adds an issuer
|
||||
// index when write throughput grows; for read-only this is good enough.
|
||||
func (f *FilerOIDCProviderStore) GetProviderByIssuer(ctx context.Context, filerAddress string, issuer string) (*OIDCProviderRecord, error) {
|
||||
records, err := f.ListProviders(ctx, filerAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want := normalizeIssuer(issuer)
|
||||
for _, rec := range records {
|
||||
if normalizeIssuer(rec.URL) == want {
|
||||
return rec, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no OIDC provider registered for issuer: %s", issuer)
|
||||
}
|
||||
|
||||
// GetProviderByIssuerAndAccount filters by AccountID on top of the issuer scan.
|
||||
// See the MemoryOIDCProviderStore equivalent for semantics.
|
||||
func (f *FilerOIDCProviderStore) GetProviderByIssuerAndAccount(ctx context.Context, filerAddress string, issuer, accountID string) (*OIDCProviderRecord, error) {
|
||||
records, err := f.ListProviders(ctx, filerAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want := normalizeIssuer(issuer)
|
||||
for _, rec := range records {
|
||||
if normalizeIssuer(rec.URL) != want {
|
||||
continue
|
||||
}
|
||||
if rec.AccountID == "" || accountID == "" || rec.AccountID == accountID {
|
||||
return rec, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no OIDC provider registered for issuer %s in account %s", issuer, accountID)
|
||||
}
|
||||
|
||||
// ListProviders enumerates every record in the directory.
|
||||
func (f *FilerOIDCProviderStore) ListProviders(ctx context.Context, filerAddress string) ([]*OIDCProviderRecord, error) {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required")
|
||||
}
|
||||
|
||||
var out []*OIDCProviderRecord
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Stream-paginate via StartFromFileName so deployments with more
|
||||
// than 1000 providers don't get a silently truncated list.
|
||||
const pageSize = 1000
|
||||
startFrom := ""
|
||||
for {
|
||||
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
|
||||
Directory: f.basePath,
|
||||
Limit: pageSize,
|
||||
StartFromFileName: startFrom,
|
||||
InclusiveStartFrom: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list OIDC providers: %v", err)
|
||||
}
|
||||
lastName := ""
|
||||
pageCount := 0
|
||||
for {
|
||||
resp, recvErr := stream.Recv()
|
||||
if recvErr != nil {
|
||||
if errors.Is(recvErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("recv OIDC provider entry: %w", recvErr)
|
||||
}
|
||||
if resp.Entry == nil || resp.Entry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
lastName = resp.Entry.Name
|
||||
pageCount++
|
||||
if !strings.HasSuffix(resp.Entry.Name, ".json") {
|
||||
continue
|
||||
}
|
||||
var rec OIDCProviderRecord
|
||||
if err := json.Unmarshal(resp.Entry.Content, &rec); err != nil {
|
||||
glog.Warningf("skipping malformed OIDC provider record %s: %v", resp.Entry.Name, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, &rec)
|
||||
}
|
||||
if pageCount < pageSize {
|
||||
break
|
||||
}
|
||||
startFrom = lastName
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ARN < out[j].ARN })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteProvider removes the record. Missing ARN is treated as success.
|
||||
func (f *FilerOIDCProviderStore) DeleteProvider(ctx context.Context, filerAddress string, arn string) error {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required")
|
||||
}
|
||||
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: f.fileName(arn),
|
||||
IsDeleteData: true,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("delete OIDC provider %s: %v", arn, err)
|
||||
}
|
||||
if resp.Error != "" && !strings.Contains(resp.Error, "not found") {
|
||||
return fmt.Errorf("delete OIDC provider %s: %s", arn, resp.Error)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (f *FilerOIDCProviderStore) withFilerClient(filerAddress string, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), f.grpcDialOption, fn)
|
||||
}
|
||||
|
||||
// DeriveOIDCProviderARN turns an issuer URL into the canonical IAM ARN AWS uses.
|
||||
// Empty `accountID` produces a global-style ARN.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// https://accounts.google.com -> arn:aws:iam::<acct>:oidc-provider/accounts.google.com
|
||||
// https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED ->
|
||||
// arn:aws:iam::<acct>:oidc-provider/oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED
|
||||
func DeriveOIDCProviderARN(accountID, issuerURL string) (string, error) {
|
||||
if issuerURL == "" {
|
||||
return "", fmt.Errorf("issuer URL is required")
|
||||
}
|
||||
u, err := url.Parse(issuerURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", fmt.Errorf("invalid issuer URL: %s", issuerURL)
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
resource := host + strings.TrimSuffix(u.Path, "/")
|
||||
return fmt.Sprintf("arn:aws:iam::%s:oidc-provider/%s", accountID, resource), nil
|
||||
}
|
||||
|
||||
// normalizeIssuer compares-friendly form: lowercased host, no trailing slash.
|
||||
func normalizeIssuer(issuer string) string {
|
||||
u, err := url.Parse(issuer)
|
||||
if err != nil || u.Host == "" {
|
||||
return strings.TrimSuffix(strings.ToLower(issuer), "/")
|
||||
}
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
u.Path = strings.TrimSuffix(u.Path, "/")
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func copyOIDCProviderRecord(rec *OIDCProviderRecord) *OIDCProviderRecord {
|
||||
if rec == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *rec
|
||||
if rec.ClientIDs != nil {
|
||||
cp.ClientIDs = append([]string(nil), rec.ClientIDs...)
|
||||
}
|
||||
if rec.Thumbprints != nil {
|
||||
cp.Thumbprints = append([]string(nil), rec.Thumbprints...)
|
||||
}
|
||||
if rec.AllowedPrincipalTagKeys != nil {
|
||||
cp.AllowedPrincipalTagKeys = append([]string(nil), rec.AllowedPrincipalTagKeys...)
|
||||
}
|
||||
if rec.Tags != nil {
|
||||
cp.Tags = make(map[string]string, len(rec.Tags))
|
||||
for k, v := range rec.Tags {
|
||||
cp.Tags[k] = v
|
||||
}
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
199
weed/iam/integration/oidc_provider_store_test.go
Normal file
199
weed/iam/integration/oidc_provider_store_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newRecord(arn, url string) *OIDCProviderRecord {
|
||||
now := time.Now()
|
||||
return &OIDCProviderRecord{
|
||||
ARN: arn,
|
||||
URL: url,
|
||||
ClientIDs: []string{"sts.amazonaws.com"},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreCRUD(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
|
||||
rec := newRecord(
|
||||
"arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com",
|
||||
"https://token.actions.githubusercontent.com",
|
||||
)
|
||||
|
||||
// Store + Get round-trip preserves the record.
|
||||
if err := store.StoreProvider(ctx, "", rec); err != nil {
|
||||
t.Fatalf("StoreProvider: %v", err)
|
||||
}
|
||||
got, err := store.GetProviderByARN(ctx, "", rec.ARN)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProviderByARN: %v", err)
|
||||
}
|
||||
if got.URL != rec.URL {
|
||||
t.Fatalf("URL mismatch: got=%s want=%s", got.URL, rec.URL)
|
||||
}
|
||||
|
||||
// Mutate the returned copy and verify the store wasn't affected.
|
||||
got.ClientIDs[0] = "tampered"
|
||||
again, _ := store.GetProviderByARN(ctx, "", rec.ARN)
|
||||
if again.ClientIDs[0] == "tampered" {
|
||||
t.Fatal("store handed out a shared slice; mutations leaked back")
|
||||
}
|
||||
|
||||
// List returns the entry.
|
||||
all, err := store.ListProviders(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListProviders: %v", err)
|
||||
}
|
||||
if len(all) != 1 || all[0].ARN != rec.ARN {
|
||||
t.Fatalf("ListProviders unexpected result: %+v", all)
|
||||
}
|
||||
|
||||
// Delete -> Get returns not found.
|
||||
if err := store.DeleteProvider(ctx, "", rec.ARN); err != nil {
|
||||
t.Fatalf("DeleteProvider: %v", err)
|
||||
}
|
||||
if _, err := store.GetProviderByARN(ctx, "", rec.ARN); err == nil {
|
||||
t.Fatal("expected not-found after delete")
|
||||
}
|
||||
// Delete is idempotent.
|
||||
if err := store.DeleteProvider(ctx, "", rec.ARN); err != nil {
|
||||
t.Fatalf("idempotent delete should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreGetByIssuerNormalizesHost(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
|
||||
rec := newRecord(
|
||||
"arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com",
|
||||
"https://Token.Actions.GithubUserContent.com/", // mixed case + trailing slash
|
||||
)
|
||||
if err := store.StoreProvider(ctx, "", rec); err != nil {
|
||||
t.Fatalf("StoreProvider: %v", err)
|
||||
}
|
||||
|
||||
cases := []string{
|
||||
"https://token.actions.githubusercontent.com",
|
||||
"https://token.actions.githubusercontent.com/",
|
||||
"https://TOKEN.actions.GITHUBUSERCONTENT.com",
|
||||
}
|
||||
for _, want := range cases {
|
||||
got, err := store.GetProviderByIssuer(ctx, "", want)
|
||||
if err != nil {
|
||||
t.Errorf("issuer %q: GetProviderByIssuer: %v", want, err)
|
||||
continue
|
||||
}
|
||||
if got.ARN != rec.ARN {
|
||||
t.Errorf("issuer %q: ARN mismatch: got=%s", want, got.ARN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreGetByIssuerMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
_, err := store.GetProviderByIssuer(ctx, "", "https://other.example/")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unregistered issuer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveOIDCProviderARN(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
accountID string
|
||||
issuer string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "google",
|
||||
accountID: "111122223333",
|
||||
issuer: "https://accounts.google.com",
|
||||
want: "arn:aws:iam::111122223333:oidc-provider/accounts.google.com",
|
||||
},
|
||||
{
|
||||
name: "EKS with path",
|
||||
accountID: "999999999999",
|
||||
issuer: "https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED",
|
||||
want: "arn:aws:iam::999999999999:oidc-provider/oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED",
|
||||
},
|
||||
{
|
||||
name: "uppercase host normalized",
|
||||
accountID: "111122223333",
|
||||
issuer: "https://Accounts.Google.com",
|
||||
want: "arn:aws:iam::111122223333:oidc-provider/accounts.google.com",
|
||||
},
|
||||
{
|
||||
name: "trailing slash trimmed",
|
||||
accountID: "111122223333",
|
||||
issuer: "https://accounts.google.com/",
|
||||
want: "arn:aws:iam::111122223333:oidc-provider/accounts.google.com",
|
||||
},
|
||||
{
|
||||
name: "empty account allowed",
|
||||
accountID: "",
|
||||
issuer: "https://accounts.google.com",
|
||||
want: "arn:aws:iam:::oidc-provider/accounts.google.com",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := DeriveOIDCProviderARN(tc.accountID, tc.issuer)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got=%s want=%s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveOIDCProviderARNRejectsBadInput(t *testing.T) {
|
||||
cases := []string{"", "not a url", "http://"}
|
||||
for _, in := range cases {
|
||||
if _, err := DeriveOIDCProviderARN("123", in); err == nil {
|
||||
t.Errorf("expected error for input %q", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsEmptyARN(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
rec := newRecord("", "https://issuer/")
|
||||
if err := store.StoreProvider(ctx, "", rec); err == nil {
|
||||
t.Fatal("expected error storing record with empty ARN")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsNil(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
if err := store.StoreProvider(ctx, "", nil); err == nil {
|
||||
t.Fatal("expected error storing nil record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIssuerRejectsEmpty(t *testing.T) {
|
||||
if got := normalizeIssuer(""); got != "" {
|
||||
t.Fatalf("empty issuer should normalize to empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIssuerHandlesNonURL(t *testing.T) {
|
||||
// Defense in depth: even when issuer is junk, normalize doesn't panic and
|
||||
// at least lowercases the input.
|
||||
got := normalizeIssuer("Some Random String/")
|
||||
if !strings.Contains(got, "some random") {
|
||||
t.Fatalf("normalize should lowercase non-URL input, got %q", got)
|
||||
}
|
||||
}
|
||||
34
weed/iam/integration/role_max_session_test.go
Normal file
34
weed/iam/integration/role_max_session_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package integration
|
||||
|
||||
import "testing"
|
||||
|
||||
func intPtr(v int64) *int64 { return &v }
|
||||
|
||||
func TestCapDurationByRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
requested *int64
|
||||
roleMax int64
|
||||
want *int64
|
||||
}{
|
||||
{"no cap, no request", nil, 0, nil},
|
||||
{"no cap, with request", intPtr(7200), 0, intPtr(7200)},
|
||||
{"cap only, no request -> nil so STS default applies", nil, 3600, nil},
|
||||
{"request below cap -> request", intPtr(1800), 3600, intPtr(1800)},
|
||||
{"request equal cap -> request", intPtr(3600), 3600, intPtr(3600)},
|
||||
{"request above cap -> cap", intPtr(43200), 3600, intPtr(3600)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := capDurationByRole(tc.requested, tc.roleMax)
|
||||
switch {
|
||||
case got == nil && tc.want == nil:
|
||||
return
|
||||
case got == nil || tc.want == nil:
|
||||
t.Fatalf("nilness mismatch: got=%v want=%v", got, tc.want)
|
||||
case *got != *tc.want:
|
||||
t.Fatalf("got=%d want=%d", *got, *tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
390
weed/iam/integration/role_store.go
Normal file
390
weed/iam/integration/role_store.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/karlseguin/ccache/v2"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// RoleStore defines the interface for storing IAM role definitions
|
||||
type RoleStore interface {
|
||||
// StoreRole stores a role definition (filerAddress ignored for memory stores)
|
||||
StoreRole(ctx context.Context, filerAddress string, roleName string, role *RoleDefinition) error
|
||||
|
||||
// GetRole retrieves a role definition (filerAddress ignored for memory stores)
|
||||
GetRole(ctx context.Context, filerAddress string, roleName string) (*RoleDefinition, error)
|
||||
|
||||
// ListRoles lists all role names (filerAddress ignored for memory stores)
|
||||
ListRoles(ctx context.Context, filerAddress string) ([]string, error)
|
||||
|
||||
// DeleteRole deletes a role definition (filerAddress ignored for memory stores)
|
||||
DeleteRole(ctx context.Context, filerAddress string, roleName string) error
|
||||
}
|
||||
|
||||
// MemoryRoleStore implements RoleStore using in-memory storage
|
||||
type MemoryRoleStore struct {
|
||||
roles map[string]*RoleDefinition
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMemoryRoleStore creates a new memory-based role store
|
||||
func NewMemoryRoleStore() *MemoryRoleStore {
|
||||
return &MemoryRoleStore{
|
||||
roles: make(map[string]*RoleDefinition),
|
||||
}
|
||||
}
|
||||
|
||||
// StoreRole stores a role definition in memory (filerAddress ignored for memory store)
|
||||
func (m *MemoryRoleStore) StoreRole(ctx context.Context, filerAddress string, roleName string, role *RoleDefinition) error {
|
||||
if roleName == "" {
|
||||
return fmt.Errorf("role name cannot be empty")
|
||||
}
|
||||
if role == nil {
|
||||
return fmt.Errorf("role cannot be nil")
|
||||
}
|
||||
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
// Deep copy the role to prevent external modifications
|
||||
m.roles[roleName] = copyRoleDefinition(role)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRole retrieves a role definition from memory (filerAddress ignored for memory store)
|
||||
func (m *MemoryRoleStore) GetRole(ctx context.Context, filerAddress string, roleName string) (*RoleDefinition, error) {
|
||||
if roleName == "" {
|
||||
return nil, fmt.Errorf("role name cannot be empty")
|
||||
}
|
||||
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
role, exists := m.roles[roleName]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("role not found: %s", roleName)
|
||||
}
|
||||
|
||||
// Return a copy to prevent external modifications
|
||||
return copyRoleDefinition(role), nil
|
||||
}
|
||||
|
||||
// ListRoles lists all role names in memory (filerAddress ignored for memory store)
|
||||
func (m *MemoryRoleStore) ListRoles(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(m.roles))
|
||||
for name := range m.roles {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// DeleteRole deletes a role definition from memory (filerAddress ignored for memory store)
|
||||
func (m *MemoryRoleStore) DeleteRole(ctx context.Context, filerAddress string, roleName string) error {
|
||||
if roleName == "" {
|
||||
return fmt.Errorf("role name cannot be empty")
|
||||
}
|
||||
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
delete(m.roles, roleName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyRoleDefinition creates a deep copy of a role definition
|
||||
func copyRoleDefinition(original *RoleDefinition) *RoleDefinition {
|
||||
if original == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
copied := &RoleDefinition{
|
||||
RoleName: original.RoleName,
|
||||
RoleArn: original.RoleArn,
|
||||
Description: original.Description,
|
||||
}
|
||||
|
||||
// Deep copy trust policy if it exists
|
||||
if original.TrustPolicy != nil {
|
||||
// Use JSON marshaling for deep copy of the complex policy structure
|
||||
trustPolicyData, _ := json.Marshal(original.TrustPolicy)
|
||||
var trustPolicyCopy policy.PolicyDocument
|
||||
json.Unmarshal(trustPolicyData, &trustPolicyCopy)
|
||||
copied.TrustPolicy = &trustPolicyCopy
|
||||
}
|
||||
|
||||
// Copy attached policies slice
|
||||
if original.AttachedPolicies != nil {
|
||||
copied.AttachedPolicies = make([]string, len(original.AttachedPolicies))
|
||||
copy(copied.AttachedPolicies, original.AttachedPolicies)
|
||||
}
|
||||
|
||||
return copied
|
||||
}
|
||||
|
||||
// FilerRoleStore implements RoleStore using SeaweedFS filer
|
||||
type FilerRoleStore struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
basePath string
|
||||
filerAddressProvider func() string
|
||||
}
|
||||
|
||||
// NewFilerRoleStore creates a new filer-based role store
|
||||
func NewFilerRoleStore(config map[string]interface{}, filerAddressProvider func() string) (*FilerRoleStore, error) {
|
||||
store := &FilerRoleStore{
|
||||
basePath: "/etc/iam/roles", // Default path for role storage - aligned with /etc/ convention
|
||||
filerAddressProvider: filerAddressProvider,
|
||||
}
|
||||
|
||||
// Parse configuration - only basePath and other settings, NOT filerAddress
|
||||
if config != nil {
|
||||
if basePath, ok := config["basePath"].(string); ok && basePath != "" {
|
||||
store.basePath = strings.TrimSuffix(basePath, "/")
|
||||
}
|
||||
}
|
||||
|
||||
glog.V(2).Infof("Initialized FilerRoleStore with basePath %s", store.basePath)
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// StoreRole stores a role definition in filer
|
||||
func (f *FilerRoleStore) StoreRole(ctx context.Context, filerAddress string, roleName string, role *RoleDefinition) error {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && f.filerAddressProvider != nil {
|
||||
filerAddress = f.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required for FilerRoleStore")
|
||||
}
|
||||
if roleName == "" {
|
||||
return fmt.Errorf("role name cannot be empty")
|
||||
}
|
||||
if role == nil {
|
||||
return fmt.Errorf("role cannot be nil")
|
||||
}
|
||||
|
||||
// Serialize role to JSON
|
||||
roleData, err := json.MarshalIndent(role, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize role: %v", err)
|
||||
}
|
||||
|
||||
rolePath := f.getRolePath(roleName)
|
||||
|
||||
// Store in filer
|
||||
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
request := &filer_pb.CreateEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: f.getRoleFileName(roleName),
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0600), // Read/write for owner only
|
||||
Uid: uint32(0),
|
||||
Gid: uint32(0),
|
||||
},
|
||||
Content: roleData,
|
||||
},
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Storing role %s at %s", roleName, rolePath)
|
||||
_, err := client.CreateEntry(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store role %s: %v", roleName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetRole retrieves a role definition from filer
|
||||
func (f *FilerRoleStore) GetRole(ctx context.Context, filerAddress string, roleName string) (*RoleDefinition, error) {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && f.filerAddressProvider != nil {
|
||||
filerAddress = f.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required for FilerRoleStore")
|
||||
}
|
||||
if roleName == "" {
|
||||
return nil, fmt.Errorf("role name cannot be empty")
|
||||
}
|
||||
|
||||
var roleData []byte
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
request := &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: f.getRoleFileName(roleName),
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Looking up role %s", roleName)
|
||||
response, err := client.LookupDirectoryEntry(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("role not found: %v", err)
|
||||
}
|
||||
|
||||
if response.Entry == nil {
|
||||
return fmt.Errorf("role not found")
|
||||
}
|
||||
|
||||
roleData = response.Entry.Content
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Deserialize role from JSON
|
||||
var role RoleDefinition
|
||||
if err := json.Unmarshal(roleData, &role); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize role: %v", err)
|
||||
}
|
||||
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
// ListRoles lists all role names in filer
|
||||
func (f *FilerRoleStore) ListRoles(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && f.filerAddressProvider != nil {
|
||||
filerAddress = f.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required for FilerRoleStore")
|
||||
}
|
||||
|
||||
var roleNames []string
|
||||
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
request := &filer_pb.ListEntriesRequest{
|
||||
Directory: f.basePath,
|
||||
Prefix: "",
|
||||
StartFromFileName: "",
|
||||
InclusiveStartFrom: false,
|
||||
Limit: 1000, // Process in batches of 1000
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Listing roles in %s", f.basePath)
|
||||
stream, err := client.ListEntries(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list roles: %v", err)
|
||||
}
|
||||
|
||||
for {
|
||||
resp, err := stream.Recv()
|
||||
if err != nil {
|
||||
break // End of stream or error
|
||||
}
|
||||
|
||||
if resp.Entry == nil || resp.Entry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract role name from filename
|
||||
filename := resp.Entry.Name
|
||||
if strings.HasSuffix(filename, ".json") {
|
||||
roleName := strings.TrimSuffix(filename, ".json")
|
||||
roleNames = append(roleNames, roleName)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return roleNames, nil
|
||||
}
|
||||
|
||||
// DeleteRole deletes a role definition from filer
|
||||
func (f *FilerRoleStore) DeleteRole(ctx context.Context, filerAddress string, roleName string) error {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && f.filerAddressProvider != nil {
|
||||
filerAddress = f.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required for FilerRoleStore")
|
||||
}
|
||||
if roleName == "" {
|
||||
return fmt.Errorf("role name cannot be empty")
|
||||
}
|
||||
|
||||
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
request := &filer_pb.DeleteEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: f.getRoleFileName(roleName),
|
||||
IsDeleteData: true,
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Deleting role %s", roleName)
|
||||
resp, err := client.DeleteEntry(ctx, request)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return nil // Idempotent: deletion of non-existent role is successful
|
||||
}
|
||||
return fmt.Errorf("failed to delete role %s: %v", roleName, err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
if strings.Contains(resp.Error, "not found") {
|
||||
return nil // Idempotent: deletion of non-existent role is successful
|
||||
}
|
||||
return fmt.Errorf("failed to delete role %s: %s", roleName, resp.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Helper methods for FilerRoleStore
|
||||
|
||||
func (f *FilerRoleStore) getRoleFileName(roleName string) string {
|
||||
return roleName + ".json"
|
||||
}
|
||||
|
||||
func (f *FilerRoleStore) getRolePath(roleName string) string {
|
||||
return f.basePath + "/" + f.getRoleFileName(roleName)
|
||||
}
|
||||
|
||||
func (f *FilerRoleStore) withFilerClient(filerAddress string, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required for FilerRoleStore")
|
||||
}
|
||||
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), f.grpcDialOption, fn)
|
||||
}
|
||||
|
||||
// CachedFilerRoleStore implements RoleStore with TTL caching on top of FilerRoleStore
|
||||
type CachedFilerRoleStore struct {
|
||||
filerStore *FilerRoleStore
|
||||
cache *ccache.Cache
|
||||
listCache *ccache.Cache
|
||||
ttl time.Duration
|
||||
listTTL time.Duration
|
||||
}
|
||||
|
||||
// CachedFilerRoleStoreConfig holds configuration for the cached role store
|
||||
type CachedFilerRoleStoreConfig struct {
|
||||
BasePath string `json:"basePath,omitempty"`
|
||||
TTL string `json:"ttl,omitempty"` // e.g., "5m", "1h"
|
||||
ListTTL string `json:"listTtl,omitempty"` // e.g., "1m", "30s"
|
||||
MaxCacheSize int `json:"maxCacheSize,omitempty"` // Maximum number of cached roles
|
||||
}
|
||||
127
weed/iam/integration/role_store_test.go
Normal file
127
weed/iam/integration/role_store_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMemoryRoleStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryRoleStore()
|
||||
|
||||
// Test storing a role
|
||||
roleDef := &RoleDefinition{
|
||||
RoleName: "TestRole",
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
Description: "Test role for unit testing",
|
||||
AttachedPolicies: []string{"TestPolicy"},
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-provider",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := store.StoreRole(ctx, "", "TestRole", roleDef)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test retrieving the role
|
||||
retrievedRole, err := store.GetRole(ctx, "", "TestRole")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "TestRole", retrievedRole.RoleName)
|
||||
assert.Equal(t, "arn:aws:iam::role/TestRole", retrievedRole.RoleArn)
|
||||
assert.Equal(t, "Test role for unit testing", retrievedRole.Description)
|
||||
assert.Equal(t, []string{"TestPolicy"}, retrievedRole.AttachedPolicies)
|
||||
|
||||
// Test listing roles
|
||||
roles, err := store.ListRoles(ctx, "")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, roles, "TestRole")
|
||||
|
||||
// Test deleting the role
|
||||
err = store.DeleteRole(ctx, "", "TestRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify role is deleted
|
||||
_, err = store.GetRole(ctx, "", "TestRole")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRoleStoreConfiguration(t *testing.T) {
|
||||
// Test memory role store creation
|
||||
memoryStore, err := NewMemoryRoleStore(), error(nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, memoryStore)
|
||||
|
||||
// Test filer role store creation without filerAddress in config
|
||||
filerStore2, err := NewFilerRoleStore(map[string]interface{}{
|
||||
// filerAddress not required in config
|
||||
"basePath": "/test/roles",
|
||||
}, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, filerStore2)
|
||||
|
||||
// Test filer role store creation with valid config
|
||||
filerStore, err := NewFilerRoleStore(map[string]interface{}{
|
||||
"filerAddress": "localhost:8888",
|
||||
"basePath": "/test/roles",
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, filerStore)
|
||||
}
|
||||
|
||||
func TestDistributedIAMManagerWithRoleStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create IAM manager with role store configuration
|
||||
config := &IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Duration(3600) * time.Second},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: time.Duration(43200) * time.Second},
|
||||
Issuer: "test-issuer",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory",
|
||||
},
|
||||
Roles: &RoleStoreConfig{
|
||||
StoreType: "memory",
|
||||
},
|
||||
}
|
||||
|
||||
iamManager := NewIAMManager()
|
||||
err := iamManager.Initialize(config, func() string {
|
||||
return "localhost:8888" // Mock filer address for testing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test creating a role
|
||||
roleDef := &RoleDefinition{
|
||||
RoleName: "DistributedTestRole",
|
||||
RoleArn: "arn:aws:iam::role/DistributedTestRole",
|
||||
Description: "Test role for distributed IAM",
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
}
|
||||
|
||||
err = iamManager.CreateRole(ctx, "", "DistributedTestRole", roleDef)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test that role is accessible through the IAM manager
|
||||
// Note: We can't directly test GetRole as it's not exposed,
|
||||
// but we can test through IsActionAllowed which internally uses the role store
|
||||
assert.True(t, iamManager.initialized)
|
||||
}
|
||||
253
weed/iam/integration/session_revocation.go
Normal file
253
weed/iam/integration/session_revocation.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// RevocationEntry is one row in the revocation list. ExpiresAt is set to the
|
||||
// session's natural expiration so the store can garbage-collect entries that
|
||||
// can no longer be reused.
|
||||
type RevocationEntry struct {
|
||||
JTI string `json:"jti"`
|
||||
RevokedAt time.Time `json:"revokedAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// SessionRevocationStore is the per-deployment blocklist of revoked sessions.
|
||||
// Implementations must be safe for concurrent use; the IsRevoked path is hot
|
||||
// (checked on every signed request) and must not block on slow IO.
|
||||
type SessionRevocationStore interface {
|
||||
Revoke(ctx context.Context, filerAddress string, entry *RevocationEntry) error
|
||||
IsRevoked(ctx context.Context, filerAddress string, jti string) (bool, error)
|
||||
Purge(ctx context.Context, filerAddress string, before time.Time) (int, error)
|
||||
}
|
||||
|
||||
// MemorySessionRevocationStore keeps the blocklist in process memory. Suitable
|
||||
// for single-node deployments and tests; for HA, swap in the filer-backed
|
||||
// implementation so revocations propagate across `weed` instances.
|
||||
type MemorySessionRevocationStore struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*RevocationEntry
|
||||
}
|
||||
|
||||
// NewMemorySessionRevocationStore returns an empty in-memory blocklist.
|
||||
func NewMemorySessionRevocationStore() *MemorySessionRevocationStore {
|
||||
return &MemorySessionRevocationStore{entries: make(map[string]*RevocationEntry)}
|
||||
}
|
||||
|
||||
func (m *MemorySessionRevocationStore) Revoke(ctx context.Context, _ string, entry *RevocationEntry) error {
|
||||
if entry == nil || entry.JTI == "" {
|
||||
return fmt.Errorf("entry with JTI is required")
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cp := *entry
|
||||
if cp.RevokedAt.IsZero() {
|
||||
cp.RevokedAt = time.Now().UTC()
|
||||
}
|
||||
m.entries[entry.JTI] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemorySessionRevocationStore) IsRevoked(ctx context.Context, _ string, jti string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
_, ok := m.entries[jti]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (m *MemorySessionRevocationStore) Purge(ctx context.Context, _ string, before time.Time) (int, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
count := 0
|
||||
for k, v := range m.entries {
|
||||
if !v.ExpiresAt.IsZero() && v.ExpiresAt.Before(before) {
|
||||
delete(m.entries, k)
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// FilerSessionRevocationStore persists entries under a filer directory.
|
||||
// Files are named after the JTI so IsRevoked is a single LookupDirectoryEntry
|
||||
// call — O(1) on a name-indexed filer. Purge enumerates the directory; that
|
||||
// cost is operator-controlled (cron-driven) so the hot path stays cheap.
|
||||
type FilerSessionRevocationStore struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
basePath string
|
||||
filerAddressProvider func() string
|
||||
}
|
||||
|
||||
// NewFilerSessionRevocationStore returns a filer-backed blocklist store.
|
||||
// Default basePath `/etc/iam/revoked-sessions` aligns with the other IAM
|
||||
// directories and is safe to back up alongside roles + providers.
|
||||
func NewFilerSessionRevocationStore(config map[string]interface{}, filerAddressProvider func() string) *FilerSessionRevocationStore {
|
||||
store := &FilerSessionRevocationStore{
|
||||
basePath: "/etc/iam/revoked-sessions",
|
||||
filerAddressProvider: filerAddressProvider,
|
||||
}
|
||||
if config != nil {
|
||||
if bp, ok := config["basePath"].(string); ok && bp != "" {
|
||||
store.basePath = strings.TrimSuffix(bp, "/")
|
||||
}
|
||||
}
|
||||
glog.V(2).Infof("Initialized FilerSessionRevocationStore with basePath %s", store.basePath)
|
||||
return store
|
||||
}
|
||||
|
||||
func (f *FilerSessionRevocationStore) resolveFilerAddress(filerAddress string) string {
|
||||
if filerAddress != "" {
|
||||
return filerAddress
|
||||
}
|
||||
if f.filerAddressProvider != nil {
|
||||
return f.filerAddressProvider()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fileName hashes the JTI before using it as a filename. RevokeSession is
|
||||
// an exported API that accepts an arbitrary string; even though every
|
||||
// SeaweedFS-issued session id is a safe random token, an external caller
|
||||
// could pass "../../etc/passwd" or similar. Hashing produces a fixed-width
|
||||
// hex name that's both filesystem-safe and stable, so the lookup-on-revoke
|
||||
// path still finds the right entry.
|
||||
func (f *FilerSessionRevocationStore) fileName(jti string) string {
|
||||
sum := sha1.Sum([]byte(jti))
|
||||
return hex.EncodeToString(sum[:]) + ".json"
|
||||
}
|
||||
|
||||
func (f *FilerSessionRevocationStore) Revoke(ctx context.Context, filerAddress string, entry *RevocationEntry) error {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required")
|
||||
}
|
||||
if entry == nil || entry.JTI == "" {
|
||||
return fmt.Errorf("entry with JTI is required")
|
||||
}
|
||||
if entry.RevokedAt.IsZero() {
|
||||
entry.RevokedAt = time.Now().UTC()
|
||||
}
|
||||
data, err := json.MarshalIndent(entry, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal revocation entry: %v", err)
|
||||
}
|
||||
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: f.fileName(entry.JTI),
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0o600),
|
||||
},
|
||||
Content: data,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke %s: %v", entry.JTI, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (f *FilerSessionRevocationStore) IsRevoked(ctx context.Context, filerAddress string, jti string) (bool, error) {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return false, fmt.Errorf("filer address is required")
|
||||
}
|
||||
revoked := false
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: f.fileName(jti),
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "no such") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
revoked = resp.Entry != nil
|
||||
return nil
|
||||
})
|
||||
return revoked, err
|
||||
}
|
||||
|
||||
func (f *FilerSessionRevocationStore) Purge(ctx context.Context, filerAddress string, before time.Time) (int, error) {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return 0, fmt.Errorf("filer address is required")
|
||||
}
|
||||
count := 0
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Stream-paginate the directory: ListEntriesRequest has no built-in
|
||||
// "page until done" semantics, so we use StartFromFileName to walk
|
||||
// the directory in chunks and avoid the previous hardcoded 10k cap.
|
||||
const pageSize = 1000
|
||||
startFrom := ""
|
||||
for {
|
||||
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
|
||||
Directory: f.basePath,
|
||||
Limit: pageSize,
|
||||
StartFromFileName: startFrom,
|
||||
InclusiveStartFrom: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list revocation entries: %w", err)
|
||||
}
|
||||
lastName := ""
|
||||
pageCount := 0
|
||||
for {
|
||||
resp, recvErr := stream.Recv()
|
||||
if recvErr != nil {
|
||||
if errors.Is(recvErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("recv revocation entry: %w", recvErr)
|
||||
}
|
||||
if resp.Entry == nil || resp.Entry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
lastName = resp.Entry.Name
|
||||
pageCount++
|
||||
var entry RevocationEntry
|
||||
if err := json.Unmarshal(resp.Entry.Content, &entry); err != nil {
|
||||
continue
|
||||
}
|
||||
if entry.ExpiresAt.IsZero() || entry.ExpiresAt.After(before) {
|
||||
continue
|
||||
}
|
||||
if err := filer_pb.DoRemove(ctx, client, f.basePath, resp.Entry.Name, true, false, false, false, nil); err == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if pageCount < pageSize {
|
||||
return nil
|
||||
}
|
||||
startFrom = lastName
|
||||
}
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (f *FilerSessionRevocationStore) withFilerClient(filerAddress string, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), f.grpcDialOption, fn)
|
||||
}
|
||||
94
weed/iam/integration/session_revocation_test.go
Normal file
94
weed/iam/integration/session_revocation_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMemoryRevocationStoreCRUD(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemorySessionRevocationStore()
|
||||
|
||||
if revoked, _ := store.IsRevoked(ctx, "", "abc"); revoked {
|
||||
t.Fatal("empty store should report not revoked")
|
||||
}
|
||||
|
||||
if err := store.Revoke(ctx, "", &RevocationEntry{JTI: "abc", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if revoked, _ := store.IsRevoked(ctx, "", "abc"); !revoked {
|
||||
t.Fatal("expected revoked")
|
||||
}
|
||||
// Different JTIs are independent.
|
||||
if revoked, _ := store.IsRevoked(ctx, "", "xyz"); revoked {
|
||||
t.Fatal("xyz should not be revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRevocationStoreRejectsBadInput(t *testing.T) {
|
||||
store := NewMemorySessionRevocationStore()
|
||||
if err := store.Revoke(context.Background(), "", nil); err == nil {
|
||||
t.Fatal("expected error for nil entry")
|
||||
}
|
||||
if err := store.Revoke(context.Background(), "", &RevocationEntry{}); err == nil {
|
||||
t.Fatal("expected error for empty JTI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRevocationStorePurge(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemorySessionRevocationStore()
|
||||
now := time.Now()
|
||||
|
||||
must := func(jti string, expiresAt time.Time) {
|
||||
if err := store.Revoke(ctx, "", &RevocationEntry{JTI: jti, ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatalf("revoke %s: %v", jti, err)
|
||||
}
|
||||
}
|
||||
must("expired-1", now.Add(-2*time.Hour))
|
||||
must("expired-2", now.Add(-time.Minute))
|
||||
must("future-1", now.Add(time.Hour))
|
||||
|
||||
count, err := store.Purge(ctx, "", now)
|
||||
if err != nil {
|
||||
t.Fatalf("purge: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 purged, got %d", count)
|
||||
}
|
||||
if revoked, _ := store.IsRevoked(ctx, "", "expired-1"); revoked {
|
||||
t.Fatal("expired-1 should be gone")
|
||||
}
|
||||
if revoked, _ := store.IsRevoked(ctx, "", "future-1"); !revoked {
|
||||
t.Fatal("future-1 should still be revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMManagerRevocationDefaultIsNoop(t *testing.T) {
|
||||
mgr := NewIAMManager()
|
||||
// Without SetSessionRevocationStore, revocation is a no-op.
|
||||
if got, err := mgr.IsSessionRevoked(context.Background(), "anything"); err != nil || got {
|
||||
t.Fatalf("expected (false, nil); got (%v, %v)", got, err)
|
||||
}
|
||||
if err := mgr.RevokeSession(context.Background(), "abc", time.Now().Add(time.Hour), "test"); err == nil {
|
||||
t.Fatal("expected error when no store configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMManagerRevocationFlow(t *testing.T) {
|
||||
mgr := NewIAMManager()
|
||||
mgr.SetSessionRevocationStore(NewMemorySessionRevocationStore())
|
||||
|
||||
jti := "session-1"
|
||||
if err := mgr.RevokeSession(context.Background(), jti, time.Now().Add(time.Hour), "logout"); err != nil {
|
||||
t.Fatalf("RevokeSession: %v", err)
|
||||
}
|
||||
revoked, err := mgr.IsSessionRevoked(context.Background(), jti)
|
||||
if err != nil {
|
||||
t.Fatalf("IsSessionRevoked: %v", err)
|
||||
}
|
||||
if !revoked {
|
||||
t.Fatal("expected revoked")
|
||||
}
|
||||
}
|
||||
248
weed/iam/integration/trust_policy_principal_test.go
Normal file
248
weed/iam/integration/trust_policy_principal_test.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestTrustPolicyAWSUserPrincipal tests that trust policies with specific AWS user
|
||||
// principals work correctly with ValidateTrustPolicyForPrincipal.
|
||||
// This is a regression test for the case where setting a specific user principal like:
|
||||
//
|
||||
// "Principal": {"AWS": "arn:aws:iam::000000000000:user/backend"}
|
||||
//
|
||||
// would fail with "trust policy denies access to principal" even though the
|
||||
// caller's principal ARN matched.
|
||||
func TestTrustPolicyAWSUserPrincipal(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
accountID = "000000000000"
|
||||
backendUser = "backend"
|
||||
backendArn = "arn:aws:iam::" + accountID + ":user/" + backendUser
|
||||
otherUser = "other"
|
||||
otherArn = "arn:aws:iam::" + accountID + ":user/" + otherUser
|
||||
clientRoleN = "ClientRole"
|
||||
clientRoleA = "arn:aws:iam::role/" + clientRoleN
|
||||
)
|
||||
|
||||
// Create role with trust policy restricted to a specific AWS user principal
|
||||
// (the exact scenario from discussion #8588)
|
||||
err := iamManager.CreateRole(ctx, "", clientRoleN, &RoleDefinition{
|
||||
RoleName: clientRoleN,
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": backendArn,
|
||||
},
|
||||
Action: []string{"sts:AssumeRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
principalArn string
|
||||
expectErr bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "matching user principal should be allowed",
|
||||
principalArn: backendArn,
|
||||
expectErr: false,
|
||||
description: "Backend user ARN matches the trust policy principal exactly",
|
||||
},
|
||||
{
|
||||
name: "non-matching user principal should be denied",
|
||||
principalArn: otherArn,
|
||||
expectErr: true,
|
||||
description: "Other user ARN does not match the trust policy principal",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := iamManager.ValidateTrustPolicyForPrincipal(ctx, clientRoleA, tt.principalArn)
|
||||
if tt.expectErr {
|
||||
assert.Error(t, err, tt.description)
|
||||
assert.Contains(t, err.Error(), "trust policy denies access")
|
||||
} else {
|
||||
assert.NoError(t, err, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrustPolicyAWSWildcardPrincipal tests that wildcard AWS principals
|
||||
// continue to work correctly (this already works, serves as a regression guard).
|
||||
func TestTrustPolicyAWSWildcardPrincipal(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const roleName = "WildcardAWSRole"
|
||||
const roleArn = "arn:aws:iam::role/" + roleName
|
||||
|
||||
err := iamManager.CreateRole(ctx, "", roleName, &RoleDefinition{
|
||||
RoleName: roleName,
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": "*",
|
||||
},
|
||||
Action: []string{"sts:AssumeRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Any user should be able to assume a role with wildcard AWS principal
|
||||
err = iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn, "arn:aws:iam::000000000000:user/anyuser")
|
||||
assert.NoError(t, err, "Wildcard AWS principal should allow any user")
|
||||
}
|
||||
|
||||
// TestTrustPolicyAWSUserArrayPrincipal tests trust policies with an array of
|
||||
// AWS user principals.
|
||||
func TestTrustPolicyAWSUserArrayPrincipal(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
accountID = "000000000000"
|
||||
roleName = "MultiUserRole"
|
||||
roleArn = "arn:aws:iam::role/" + roleName
|
||||
user1Arn = "arn:aws:iam::" + accountID + ":user/user1"
|
||||
user2Arn = "arn:aws:iam::" + accountID + ":user/user2"
|
||||
user3Arn = "arn:aws:iam::" + accountID + ":user/user3"
|
||||
)
|
||||
|
||||
// Create role with multiple allowed user principals
|
||||
err := iamManager.CreateRole(ctx, "", roleName, &RoleDefinition{
|
||||
RoleName: roleName,
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": []interface{}{user1Arn, user2Arn},
|
||||
},
|
||||
Action: []string{"sts:AssumeRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
principalArn string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "first listed user is allowed",
|
||||
principalArn: user1Arn,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "second listed user is allowed",
|
||||
principalArn: user2Arn,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "unlisted user is denied",
|
||||
principalArn: user3Arn,
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn, tt.principalArn)
|
||||
if tt.expectErr {
|
||||
if assert.Error(t, err) {
|
||||
assert.Contains(t, err.Error(), "trust policy denies access")
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrustPolicyPlainStringPrincipal tests trust policies with a plain string
|
||||
// principal (not wrapped in {"AWS": ...}).
|
||||
func TestTrustPolicyPlainStringPrincipal(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
roleName = "PlainStringPrincipalRole"
|
||||
roleArn = "arn:aws:iam::role/" + roleName
|
||||
userArn = "arn:aws:iam::000000000000:user/backend"
|
||||
)
|
||||
|
||||
// Plain string wildcard - should work
|
||||
err := iamManager.CreateRole(ctx, "", roleName, &RoleDefinition{
|
||||
RoleName: roleName,
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: "*",
|
||||
Action: []string{"sts:AssumeRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn, userArn)
|
||||
assert.NoError(t, err, "Plain wildcard principal should allow any user")
|
||||
|
||||
// Plain string with specific ARN
|
||||
const roleName2 = "PlainStringSpecificRole"
|
||||
const roleArn2 = "arn:aws:iam::role/" + roleName2
|
||||
|
||||
err = iamManager.CreateRole(ctx, "", roleName2, &RoleDefinition{
|
||||
RoleName: roleName2,
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: userArn,
|
||||
Action: []string{"sts:AssumeRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
AttachedPolicies: []string{"S3ReadOnlyPolicy"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn2, userArn)
|
||||
assert.NoError(t, err, "Matching plain string principal should allow user")
|
||||
|
||||
err = iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn2, "arn:aws:iam::000000000000:user/other")
|
||||
if assert.Error(t, err, "Non-matching plain string principal should deny user") {
|
||||
assert.Contains(t, err.Error(), "trust policy denies access")
|
||||
}
|
||||
}
|
||||
571
weed/iam/ldap/ldap_provider.go
Normal file
571
weed/iam/ldap/ldap_provider.go
Normal file
@@ -0,0 +1,571 @@
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
)
|
||||
|
||||
// LDAPConfig holds configuration for LDAP provider
|
||||
type LDAPConfig struct {
|
||||
// Server is the LDAP server URL (ldap:// or ldaps://)
|
||||
Server string `json:"server"`
|
||||
|
||||
// BindDN is the DN used to bind for searches (optional for anonymous bind)
|
||||
BindDN string `json:"bindDN,omitempty"`
|
||||
|
||||
// BindPassword is the password for the bind DN
|
||||
BindPassword string `json:"bindPassword,omitempty"`
|
||||
|
||||
// BaseDN is the base DN for user searches
|
||||
BaseDN string `json:"baseDN"`
|
||||
|
||||
// UserFilter is the filter to find users (use %s for username placeholder)
|
||||
// Example: "(uid=%s)" or "(cn=%s)" or "(&(objectClass=person)(uid=%s))"
|
||||
UserFilter string `json:"userFilter"`
|
||||
|
||||
// GroupFilter is the filter to find user groups (use %s for user DN placeholder)
|
||||
// Example: "(member=%s)" or "(memberUid=%s)"
|
||||
GroupFilter string `json:"groupFilter,omitempty"`
|
||||
|
||||
// GroupBaseDN is the base DN for group searches (defaults to BaseDN)
|
||||
GroupBaseDN string `json:"groupBaseDN,omitempty"`
|
||||
|
||||
// Attributes to retrieve from LDAP
|
||||
Attributes LDAPAttributes `json:"attributes,omitempty"`
|
||||
|
||||
// UseTLS enables StartTLS
|
||||
UseTLS bool `json:"useTLS,omitempty"`
|
||||
|
||||
// InsecureSkipVerify skips TLS certificate verification
|
||||
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"`
|
||||
|
||||
// ConnectionTimeout is the connection timeout
|
||||
ConnectionTimeout time.Duration `json:"connectionTimeout,omitempty"`
|
||||
|
||||
// PoolSize is the number of connections in the pool (default: 10)
|
||||
PoolSize int `json:"poolSize,omitempty"`
|
||||
|
||||
// Audience is the expected audience for tokens (optional)
|
||||
Audience string `json:"audience,omitempty"`
|
||||
}
|
||||
|
||||
// LDAPAttributes maps LDAP attribute names
|
||||
type LDAPAttributes struct {
|
||||
Email string `json:"email,omitempty"` // Default: mail
|
||||
DisplayName string `json:"displayName,omitempty"` // Default: cn
|
||||
Groups string `json:"groups,omitempty"` // Default: memberOf
|
||||
UID string `json:"uid,omitempty"` // Default: uid
|
||||
}
|
||||
|
||||
// connectionPool manages a pool of LDAP connections for reuse
|
||||
type connectionPool struct {
|
||||
conns chan *ldap.Conn
|
||||
mu sync.Mutex
|
||||
size int
|
||||
closed uint32 // atomic flag: 1 if closed, 0 if open
|
||||
}
|
||||
|
||||
// LDAPProvider implements the IdentityProvider interface for LDAP
|
||||
type LDAPProvider struct {
|
||||
name string
|
||||
config *LDAPConfig
|
||||
initialized bool
|
||||
mu sync.RWMutex
|
||||
pool *connectionPool
|
||||
}
|
||||
|
||||
// NewLDAPProvider creates a new LDAP provider
|
||||
func NewLDAPProvider(name string) *LDAPProvider {
|
||||
return &LDAPProvider{
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the provider name
|
||||
func (p *LDAPProvider) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
// Initialize initializes the provider with configuration
|
||||
func (p *LDAPProvider) Initialize(config interface{}) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.initialized {
|
||||
return fmt.Errorf("LDAP provider already initialized")
|
||||
}
|
||||
|
||||
cfg := &LDAPConfig{}
|
||||
|
||||
// Check if input is already the correct struct type
|
||||
if c, ok := config.(*LDAPConfig); ok {
|
||||
cfg = c
|
||||
} else {
|
||||
// Parse from map using mapstructure with weak typing and time duration hook
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
),
|
||||
Result: cfg,
|
||||
TagName: "json",
|
||||
WeaklyTypedInput: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config decoder: %w", err)
|
||||
}
|
||||
|
||||
if err := decoder.Decode(config); err != nil {
|
||||
return fmt.Errorf("failed to decode LDAP configuration: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if cfg.Server == "" {
|
||||
return fmt.Errorf("LDAP server URL is required")
|
||||
}
|
||||
if cfg.BaseDN == "" {
|
||||
return fmt.Errorf("LDAP base DN is required")
|
||||
}
|
||||
if cfg.UserFilter == "" {
|
||||
cfg.UserFilter = "(cn=%s)" // Default filter
|
||||
}
|
||||
|
||||
// Warn if BindDN is configured but BindPassword is empty
|
||||
if cfg.BindDN != "" && cfg.BindPassword == "" {
|
||||
glog.Warningf("LDAP provider '%s' configured with BindDN but no BindPassword", p.name)
|
||||
}
|
||||
|
||||
// Warn if InsecureSkipVerify is enabled
|
||||
if cfg.InsecureSkipVerify {
|
||||
glog.Warningf("LDAP provider '%s' has InsecureSkipVerify enabled. Do not use in production.", p.name)
|
||||
}
|
||||
|
||||
// Set default attributes
|
||||
if cfg.Attributes.Email == "" {
|
||||
cfg.Attributes.Email = "mail"
|
||||
}
|
||||
if cfg.Attributes.DisplayName == "" {
|
||||
cfg.Attributes.DisplayName = "cn"
|
||||
}
|
||||
if cfg.Attributes.Groups == "" {
|
||||
cfg.Attributes.Groups = "memberOf"
|
||||
}
|
||||
if cfg.Attributes.UID == "" {
|
||||
cfg.Attributes.UID = "uid"
|
||||
}
|
||||
if cfg.GroupBaseDN == "" {
|
||||
cfg.GroupBaseDN = cfg.BaseDN
|
||||
}
|
||||
if cfg.ConnectionTimeout == 0 {
|
||||
cfg.ConnectionTimeout = 10 * time.Second
|
||||
}
|
||||
|
||||
p.config = cfg
|
||||
|
||||
// Initialize connection pool (default size: 10 connections)
|
||||
poolSize := 10
|
||||
if cfg.PoolSize > 0 {
|
||||
poolSize = cfg.PoolSize
|
||||
}
|
||||
p.pool = &connectionPool{
|
||||
conns: make(chan *ldap.Conn, poolSize),
|
||||
size: poolSize,
|
||||
}
|
||||
|
||||
p.initialized = true
|
||||
|
||||
glog.V(1).Infof("LDAP provider '%s' initialized: server=%s, baseDN=%s",
|
||||
p.name, cfg.Server, cfg.BaseDN)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getConnection gets a connection from the pool or creates a new one
|
||||
func (p *LDAPProvider) getConnection() (*ldap.Conn, error) {
|
||||
// Try to get a connection from the pool (non-blocking)
|
||||
select {
|
||||
case conn := <-p.pool.conns:
|
||||
// Test if connection is still alive
|
||||
if conn != nil && conn.IsClosing() {
|
||||
conn.Close()
|
||||
// Connection is dead, create a new one
|
||||
return p.createConnection()
|
||||
}
|
||||
return conn, nil
|
||||
default:
|
||||
// Pool is empty, create a new connection
|
||||
return p.createConnection()
|
||||
}
|
||||
}
|
||||
|
||||
// returnConnection returns a connection to the pool
|
||||
func (p *LDAPProvider) returnConnection(conn *ldap.Conn) {
|
||||
if conn == nil || conn.IsClosing() {
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if pool is closed before attempting to send
|
||||
if atomic.LoadUint32(&p.pool.closed) == 1 {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Try to return to pool (non-blocking)
|
||||
select {
|
||||
case p.pool.conns <- conn:
|
||||
// Successfully returned to pool
|
||||
default:
|
||||
// Pool is full, close the connection
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// createConnection establishes a new connection to the LDAP server
|
||||
func (p *LDAPProvider) createConnection() (*ldap.Conn, error) {
|
||||
var conn *ldap.Conn
|
||||
var err error
|
||||
|
||||
// Create dialer with timeout
|
||||
dialer := &net.Dialer{Timeout: p.config.ConnectionTimeout}
|
||||
|
||||
// Parse server URL
|
||||
if strings.HasPrefix(p.config.Server, "ldaps://") {
|
||||
// LDAPS connection
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: p.config.InsecureSkipVerify,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
conn, err = ldap.DialURL(p.config.Server, ldap.DialWithDialer(dialer), ldap.DialWithTLSConfig(tlsConfig))
|
||||
} else {
|
||||
// LDAP connection
|
||||
conn, err = ldap.DialURL(p.config.Server, ldap.DialWithDialer(dialer))
|
||||
if err == nil && p.config.UseTLS {
|
||||
// StartTLS
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: p.config.InsecureSkipVerify,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
if err = conn.StartTLS(tlsConfig); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("failed to start TLS: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to LDAP server: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// Close closes all connections in the pool
|
||||
func (p *LDAPProvider) Close() error {
|
||||
if p.pool == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Atomically mark pool as closed to prevent new connections being returned
|
||||
if !atomic.CompareAndSwapUint32(&p.pool.closed, 0, 1) {
|
||||
// Already closed
|
||||
return nil
|
||||
}
|
||||
|
||||
p.pool.mu.Lock()
|
||||
defer p.pool.mu.Unlock()
|
||||
|
||||
// Now safe to close the channel since closed flag prevents new sends
|
||||
close(p.pool.conns)
|
||||
for conn := range p.pool.conns {
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticate authenticates a user with username:password credentials
|
||||
func (p *LDAPProvider) Authenticate(ctx context.Context, credentials string) (*providers.ExternalIdentity, error) {
|
||||
p.mu.RLock()
|
||||
if !p.initialized {
|
||||
p.mu.RUnlock()
|
||||
return nil, fmt.Errorf("LDAP provider not initialized")
|
||||
}
|
||||
config := p.config
|
||||
p.mu.RUnlock()
|
||||
|
||||
// Parse credentials (username:password format)
|
||||
parts := strings.SplitN(credentials, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid credentials format (expected username:password)")
|
||||
}
|
||||
username, password := parts[0], parts[1]
|
||||
|
||||
if username == "" || password == "" {
|
||||
return nil, fmt.Errorf("username and password are required")
|
||||
}
|
||||
|
||||
// Get connection from pool
|
||||
conn, err := p.getConnection()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Note: defer returnConnection moved to after rebinding to service account
|
||||
|
||||
// First, bind with service account to search for user
|
||||
if config.BindDN != "" {
|
||||
err = conn.Bind(config.BindDN, config.BindPassword)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("LDAP service bind failed: %v", err)
|
||||
conn.Close() // Close on error, don't return to pool
|
||||
return nil, fmt.Errorf("LDAP service bind failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Search for the user
|
||||
userFilter := fmt.Sprintf(config.UserFilter, ldap.EscapeFilter(username))
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
config.BaseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
1, // Size limit
|
||||
int(config.ConnectionTimeout.Seconds()),
|
||||
false,
|
||||
userFilter,
|
||||
[]string{"dn", config.Attributes.Email, config.Attributes.DisplayName, config.Attributes.UID, config.Attributes.Groups},
|
||||
nil,
|
||||
)
|
||||
|
||||
result, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("LDAP user search failed: %v", err)
|
||||
conn.Close() // Close on error
|
||||
return nil, fmt.Errorf("LDAP user search failed: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Entries) == 0 {
|
||||
conn.Close() // Close on error
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
if len(result.Entries) > 1 {
|
||||
conn.Close() // Close on error
|
||||
return nil, fmt.Errorf("multiple users found")
|
||||
}
|
||||
|
||||
userEntry := result.Entries[0]
|
||||
userDN := userEntry.DN
|
||||
|
||||
// Bind as the user to verify password
|
||||
err = conn.Bind(userDN, password)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("LDAP user bind failed for %s: %v", username, err)
|
||||
conn.Close() // Close on error, don't return to pool
|
||||
return nil, fmt.Errorf("authentication failed: invalid credentials")
|
||||
}
|
||||
|
||||
// Rebind to service account before returning connection to pool
|
||||
// This prevents pool corruption from authenticated user binds
|
||||
if config.BindDN != "" {
|
||||
if err = conn.Bind(config.BindDN, config.BindPassword); err != nil {
|
||||
glog.V(2).Infof("LDAP rebind to service account failed: %v", err)
|
||||
conn.Close() // Close on error, don't return to pool
|
||||
return nil, fmt.Errorf("LDAP service account rebind failed after successful user authentication (check bindDN %q and its credentials): %w", config.BindDN, err)
|
||||
}
|
||||
}
|
||||
// Now safe to defer return to pool with clean service account binding
|
||||
defer p.returnConnection(conn)
|
||||
|
||||
// Build identity from LDAP attributes
|
||||
identity := &providers.ExternalIdentity{
|
||||
UserID: username,
|
||||
Email: userEntry.GetAttributeValue(config.Attributes.Email),
|
||||
DisplayName: userEntry.GetAttributeValue(config.Attributes.DisplayName),
|
||||
Groups: userEntry.GetAttributeValues(config.Attributes.Groups),
|
||||
Provider: p.name,
|
||||
Attributes: map[string]string{
|
||||
"dn": userDN,
|
||||
"uid": userEntry.GetAttributeValue(config.Attributes.UID),
|
||||
},
|
||||
}
|
||||
|
||||
// If no groups from memberOf, try group search
|
||||
if len(identity.Groups) == 0 && config.GroupFilter != "" {
|
||||
groups, err := p.searchUserGroups(conn, userDN, config)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("Group search failed for %s: %v", username, err)
|
||||
} else {
|
||||
identity.Groups = groups
|
||||
}
|
||||
}
|
||||
|
||||
glog.V(2).Infof("LDAP authentication successful for user: %s, groups: %v", username, identity.Groups)
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// searchUserGroups searches for groups the user belongs to
|
||||
func (p *LDAPProvider) searchUserGroups(conn *ldap.Conn, userDN string, config *LDAPConfig) ([]string, error) {
|
||||
groupFilter := fmt.Sprintf(config.GroupFilter, ldap.EscapeFilter(userDN))
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
config.GroupBaseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0,
|
||||
int(config.ConnectionTimeout.Seconds()),
|
||||
false,
|
||||
groupFilter,
|
||||
[]string{"cn", "dn"},
|
||||
nil,
|
||||
)
|
||||
|
||||
result, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var groups []string
|
||||
for _, entry := range result.Entries {
|
||||
cn := entry.GetAttributeValue("cn")
|
||||
if cn != "" {
|
||||
groups = append(groups, cn)
|
||||
}
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// GetUserInfo retrieves user information by user ID
|
||||
func (p *LDAPProvider) GetUserInfo(ctx context.Context, userID string) (*providers.ExternalIdentity, error) {
|
||||
p.mu.RLock()
|
||||
if !p.initialized {
|
||||
p.mu.RUnlock()
|
||||
return nil, fmt.Errorf("LDAP provider not initialized")
|
||||
}
|
||||
config := p.config
|
||||
p.mu.RUnlock()
|
||||
|
||||
// Get connection from pool
|
||||
conn, err := p.getConnection()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Note: defer returnConnection moved to after bind
|
||||
|
||||
// Bind with service account
|
||||
if config.BindDN != "" {
|
||||
err = conn.Bind(config.BindDN, config.BindPassword)
|
||||
if err != nil {
|
||||
conn.Close() // Close on bind failure
|
||||
return nil, fmt.Errorf("LDAP service bind failed: %w", err)
|
||||
}
|
||||
}
|
||||
defer p.returnConnection(conn)
|
||||
|
||||
// Search for the user
|
||||
userFilter := fmt.Sprintf(config.UserFilter, ldap.EscapeFilter(userID))
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
config.BaseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
1,
|
||||
int(config.ConnectionTimeout.Seconds()),
|
||||
false,
|
||||
userFilter,
|
||||
[]string{"dn", config.Attributes.Email, config.Attributes.DisplayName, config.Attributes.UID, config.Attributes.Groups},
|
||||
nil,
|
||||
)
|
||||
|
||||
result, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LDAP user search failed: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Entries) == 0 {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
if len(result.Entries) > 1 {
|
||||
return nil, fmt.Errorf("multiple users found")
|
||||
}
|
||||
|
||||
userEntry := result.Entries[0]
|
||||
identity := &providers.ExternalIdentity{
|
||||
UserID: userID,
|
||||
Email: userEntry.GetAttributeValue(config.Attributes.Email),
|
||||
DisplayName: userEntry.GetAttributeValue(config.Attributes.DisplayName),
|
||||
Groups: userEntry.GetAttributeValues(config.Attributes.Groups),
|
||||
Provider: p.name,
|
||||
Attributes: map[string]string{
|
||||
"dn": userEntry.DN,
|
||||
"uid": userEntry.GetAttributeValue(config.Attributes.UID),
|
||||
},
|
||||
}
|
||||
|
||||
// If no groups from memberOf, try group search
|
||||
if len(identity.Groups) == 0 && config.GroupFilter != "" {
|
||||
groups, err := p.searchUserGroups(conn, userEntry.DN, config)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("Group search failed for %s: %v", userID, err)
|
||||
} else {
|
||||
identity.Groups = groups
|
||||
}
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// ValidateToken validates credentials (username:password format) and returns claims
|
||||
func (p *LDAPProvider) ValidateToken(ctx context.Context, token string) (*providers.TokenClaims, error) {
|
||||
identity, err := p.Authenticate(ctx, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
config := p.config
|
||||
p.mu.RUnlock()
|
||||
|
||||
// If audience is configured, validate it (consistent with OIDC approach)
|
||||
audience := p.name
|
||||
if config.Audience != "" {
|
||||
audience = config.Audience
|
||||
}
|
||||
|
||||
// Populate standard TokenClaims fields for interface compliance
|
||||
now := time.Now()
|
||||
ttl := 1 * time.Hour // Default TTL for LDAP tokens
|
||||
|
||||
return &providers.TokenClaims{
|
||||
Subject: identity.UserID,
|
||||
Issuer: p.name,
|
||||
Audience: audience,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
Claims: map[string]interface{}{
|
||||
"email": identity.Email,
|
||||
"name": identity.DisplayName,
|
||||
"groups": identity.Groups,
|
||||
"dn": identity.Attributes["dn"],
|
||||
"provider": p.name,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsInitialized returns whether the provider is initialized
|
||||
func (p *LDAPProvider) IsInitialized() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.initialized
|
||||
}
|
||||
186
weed/iam/ldap/mock_provider.go
Normal file
186
weed/iam/ldap/mock_provider.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
)
|
||||
|
||||
// MockLDAPProvider is a mock implementation for testing
|
||||
// This is a standalone mock that doesn't depend on production LDAP code
|
||||
type MockLDAPProvider struct {
|
||||
name string
|
||||
initialized bool
|
||||
TestUsers map[string]*providers.ExternalIdentity
|
||||
TestCredentials map[string]string // username -> password
|
||||
}
|
||||
|
||||
// NewMockLDAPProvider creates a mock LDAP provider for testing
|
||||
func NewMockLDAPProvider(name string) *MockLDAPProvider {
|
||||
return &MockLDAPProvider{
|
||||
name: name,
|
||||
initialized: true, // Mock is always initialized
|
||||
TestUsers: make(map[string]*providers.ExternalIdentity),
|
||||
TestCredentials: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the provider name
|
||||
func (m *MockLDAPProvider) Name() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
// Initialize initializes the mock provider (no-op for testing)
|
||||
func (m *MockLDAPProvider) Initialize(config interface{}) error {
|
||||
m.initialized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddTestUser adds a test user with credentials
|
||||
func (m *MockLDAPProvider) AddTestUser(username, password string, identity *providers.ExternalIdentity) {
|
||||
m.TestCredentials[username] = password
|
||||
m.TestUsers[username] = identity
|
||||
}
|
||||
|
||||
// Authenticate authenticates using test data
|
||||
func (m *MockLDAPProvider) Authenticate(ctx context.Context, credentials string) (*providers.ExternalIdentity, error) {
|
||||
if !m.initialized {
|
||||
return nil, fmt.Errorf("provider not initialized")
|
||||
}
|
||||
|
||||
if credentials == "" {
|
||||
return nil, fmt.Errorf("credentials cannot be empty")
|
||||
}
|
||||
|
||||
// Parse credentials (username:password format)
|
||||
parts := strings.SplitN(credentials, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid credentials format (expected username:password)")
|
||||
}
|
||||
|
||||
username, password := parts[0], parts[1]
|
||||
|
||||
// Check test credentials
|
||||
expectedPassword, userExists := m.TestCredentials[username]
|
||||
if !userExists {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
if password != expectedPassword {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
// Return test user identity
|
||||
if identity, exists := m.TestUsers[username]; exists {
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("user identity not found")
|
||||
}
|
||||
|
||||
// GetUserInfo returns test user info
|
||||
func (m *MockLDAPProvider) GetUserInfo(ctx context.Context, userID string) (*providers.ExternalIdentity, error) {
|
||||
if !m.initialized {
|
||||
return nil, fmt.Errorf("provider not initialized")
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("user ID cannot be empty")
|
||||
}
|
||||
|
||||
// Check test users
|
||||
if identity, exists := m.TestUsers[userID]; exists {
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// Return default test user if not found
|
||||
return &providers.ExternalIdentity{
|
||||
UserID: userID,
|
||||
Email: userID + "@test-ldap.com",
|
||||
DisplayName: "Test LDAP User " + userID,
|
||||
Groups: []string{"test-group"},
|
||||
Provider: m.name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateToken validates credentials using test data
|
||||
func (m *MockLDAPProvider) ValidateToken(ctx context.Context, token string) (*providers.TokenClaims, error) {
|
||||
if !m.initialized {
|
||||
return nil, fmt.Errorf("provider not initialized")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("token cannot be empty")
|
||||
}
|
||||
|
||||
// Parse credentials (username:password format)
|
||||
parts := strings.SplitN(token, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid token format (expected username:password)")
|
||||
}
|
||||
|
||||
username, password := parts[0], parts[1]
|
||||
|
||||
// Check test credentials
|
||||
expectedPassword, userExists := m.TestCredentials[username]
|
||||
if !userExists {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
if password != expectedPassword {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
// Return test claims
|
||||
identity := m.TestUsers[username]
|
||||
return &providers.TokenClaims{
|
||||
Subject: username,
|
||||
Claims: map[string]interface{}{
|
||||
"ldap_dn": "CN=" + username + ",DC=test,DC=com",
|
||||
"email": identity.Email,
|
||||
"name": identity.DisplayName,
|
||||
"groups": identity.Groups,
|
||||
"provider": m.name,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetupDefaultTestData configures common test data
|
||||
func (m *MockLDAPProvider) SetupDefaultTestData() {
|
||||
// Add default test user
|
||||
m.AddTestUser("testuser", "testpass", &providers.ExternalIdentity{
|
||||
UserID: "testuser",
|
||||
Email: "testuser@ldap-test.com",
|
||||
DisplayName: "Test LDAP User",
|
||||
Groups: []string{"developers", "users"},
|
||||
Provider: m.name,
|
||||
Attributes: map[string]string{
|
||||
"department": "Engineering",
|
||||
"location": "Test City",
|
||||
},
|
||||
})
|
||||
|
||||
// Add admin test user
|
||||
m.AddTestUser("admin", "adminpass", &providers.ExternalIdentity{
|
||||
UserID: "admin",
|
||||
Email: "admin@ldap-test.com",
|
||||
DisplayName: "LDAP Administrator",
|
||||
Groups: []string{"admins", "users"},
|
||||
Provider: m.name,
|
||||
Attributes: map[string]string{
|
||||
"department": "IT",
|
||||
"role": "administrator",
|
||||
},
|
||||
})
|
||||
|
||||
// Add readonly user
|
||||
m.AddTestUser("readonly", "readpass", &providers.ExternalIdentity{
|
||||
UserID: "readonly",
|
||||
Email: "readonly@ldap-test.com",
|
||||
DisplayName: "Read Only User",
|
||||
Groups: []string{"readonly"},
|
||||
Provider: m.name,
|
||||
})
|
||||
}
|
||||
219
weed/iam/oidc/mock_provider.go
Normal file
219
weed/iam/oidc/mock_provider.go
Normal file
@@ -0,0 +1,219 @@
|
||||
// This file contains mock OIDC provider implementations for testing only.
|
||||
// These should NOT be used in production environments.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
)
|
||||
|
||||
// MockOIDCProvider is a mock implementation for testing
|
||||
type MockOIDCProvider struct {
|
||||
*OIDCProvider
|
||||
TestTokens map[string]*providers.TokenClaims
|
||||
TestUsers map[string]*providers.ExternalIdentity
|
||||
}
|
||||
|
||||
// NewMockOIDCProvider creates a mock OIDC provider for testing
|
||||
func NewMockOIDCProvider(name string) *MockOIDCProvider {
|
||||
return &MockOIDCProvider{
|
||||
OIDCProvider: NewOIDCProvider(name),
|
||||
TestTokens: make(map[string]*providers.TokenClaims),
|
||||
TestUsers: make(map[string]*providers.ExternalIdentity),
|
||||
}
|
||||
}
|
||||
|
||||
// AddTestToken adds a test token with expected claims
|
||||
func (m *MockOIDCProvider) AddTestToken(token string, claims *providers.TokenClaims) {
|
||||
m.TestTokens[token] = claims
|
||||
}
|
||||
|
||||
// AddTestUser adds a test user with expected identity
|
||||
func (m *MockOIDCProvider) AddTestUser(userID string, identity *providers.ExternalIdentity) {
|
||||
m.TestUsers[userID] = identity
|
||||
}
|
||||
|
||||
// Authenticate overrides the parent Authenticate method to use mock data
|
||||
func (m *MockOIDCProvider) Authenticate(ctx context.Context, token string) (*providers.ExternalIdentity, error) {
|
||||
if !m.initialized {
|
||||
return nil, fmt.Errorf("provider not initialized")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("token cannot be empty")
|
||||
}
|
||||
|
||||
// Validate token using mock validation
|
||||
claims, err := m.ValidateToken(ctx, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Map claims to external identity
|
||||
email, _ := claims.GetClaimString("email")
|
||||
displayName, _ := claims.GetClaimString("name")
|
||||
groups, _ := claims.GetClaimStringSlice("groups")
|
||||
rawRoles, _ := claims.GetClaimStringSlice("roles")
|
||||
|
||||
identity := &providers.ExternalIdentity{
|
||||
UserID: claims.Subject,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
Groups: groups,
|
||||
Roles: rawRoles,
|
||||
Provider: m.name,
|
||||
}
|
||||
if !claims.ExpiresAt.IsZero() {
|
||||
identity.TokenExpiration = &claims.ExpiresAt
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// ValidateToken validates tokens using test data
|
||||
func (m *MockOIDCProvider) ValidateToken(ctx context.Context, token string) (*providers.TokenClaims, error) {
|
||||
if !m.initialized {
|
||||
return nil, fmt.Errorf("provider not initialized")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("token cannot be empty")
|
||||
}
|
||||
|
||||
// Special test tokens
|
||||
if token == "expired_token" {
|
||||
return nil, fmt.Errorf("token has expired")
|
||||
}
|
||||
if token == "invalid_token" {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// Try to parse as JWT token first
|
||||
if len(token) > 20 && strings.Count(token, ".") >= 2 {
|
||||
parsedToken, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{})
|
||||
if err == nil {
|
||||
if jwtClaims, ok := parsedToken.Claims.(jwt.MapClaims); ok {
|
||||
issuer, _ := jwtClaims["iss"].(string)
|
||||
subject, _ := jwtClaims["sub"].(string)
|
||||
audience, _ := jwtClaims["aud"].(string)
|
||||
|
||||
// Verify the issuer matches our configuration
|
||||
if issuer == m.config.Issuer && subject != "" {
|
||||
// Extract expiration and issued at times
|
||||
var expiresAt, issuedAt time.Time
|
||||
if exp, ok := jwtClaims["exp"].(float64); ok {
|
||||
expiresAt = time.Unix(int64(exp), 0)
|
||||
}
|
||||
if iat, ok := jwtClaims["iat"].(float64); ok {
|
||||
issuedAt = time.Unix(int64(iat), 0)
|
||||
}
|
||||
|
||||
// Carry the token's claims through like the real provider, keeping
|
||||
// the synthesized defaults for tests that rely on them
|
||||
claimsMap := make(map[string]interface{}, len(jwtClaims)+2)
|
||||
for k, v := range jwtClaims {
|
||||
claimsMap[k] = v
|
||||
}
|
||||
if _, ok := claimsMap["email"]; !ok {
|
||||
claimsMap["email"] = subject + "@test-domain.com"
|
||||
}
|
||||
if _, ok := claimsMap["name"]; !ok {
|
||||
claimsMap["name"] = "Test User " + subject
|
||||
}
|
||||
|
||||
return &providers.TokenClaims{
|
||||
Subject: subject,
|
||||
Issuer: issuer,
|
||||
Audience: audience,
|
||||
ExpiresAt: expiresAt,
|
||||
IssuedAt: issuedAt,
|
||||
Claims: claimsMap,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check test tokens
|
||||
if claims, exists := m.TestTokens[token]; exists {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// Default test token for basic testing
|
||||
if token == "valid_test_token" {
|
||||
return &providers.TokenClaims{
|
||||
Subject: "test-user-id",
|
||||
Issuer: m.config.Issuer,
|
||||
Audience: m.config.ClientID,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
IssuedAt: time.Now(),
|
||||
Claims: map[string]interface{}{
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"groups": []string{"developers", "users"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown test token: %s", token)
|
||||
}
|
||||
|
||||
// GetUserInfo returns test user info
|
||||
func (m *MockOIDCProvider) GetUserInfo(ctx context.Context, userID string) (*providers.ExternalIdentity, error) {
|
||||
if !m.initialized {
|
||||
return nil, fmt.Errorf("provider not initialized")
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("user ID cannot be empty")
|
||||
}
|
||||
|
||||
// Check test users
|
||||
if identity, exists := m.TestUsers[userID]; exists {
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// Default test user
|
||||
return &providers.ExternalIdentity{
|
||||
UserID: userID,
|
||||
Email: userID + "@example.com",
|
||||
DisplayName: "Test User " + userID,
|
||||
Provider: m.name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetupDefaultTestData configures common test data
|
||||
func (m *MockOIDCProvider) SetupDefaultTestData() {
|
||||
// Create default token claims
|
||||
defaultClaims := &providers.TokenClaims{
|
||||
Subject: "test-user-123",
|
||||
Issuer: "https://test-issuer.com",
|
||||
Audience: "test-client-id",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
IssuedAt: time.Now(),
|
||||
Claims: map[string]interface{}{
|
||||
"email": "testuser@example.com",
|
||||
"name": "Test User",
|
||||
"groups": []string{"developers"},
|
||||
},
|
||||
}
|
||||
|
||||
// Add multiple token variants for compatibility
|
||||
m.AddTestToken("valid_token", defaultClaims)
|
||||
m.AddTestToken("valid-oidc-token", defaultClaims) // For integration tests
|
||||
m.AddTestToken("valid_test_token", defaultClaims) // For STS tests
|
||||
|
||||
// Add default test users
|
||||
m.AddTestUser("test-user-123", &providers.ExternalIdentity{
|
||||
UserID: "test-user-123",
|
||||
Email: "testuser@example.com",
|
||||
DisplayName: "Test User",
|
||||
Groups: []string{"developers"},
|
||||
Provider: m.name,
|
||||
})
|
||||
}
|
||||
202
weed/iam/oidc/mock_provider_test.go
Normal file
202
weed/iam/oidc/mock_provider_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
//go:build test
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
)
|
||||
|
||||
// MockOIDCProvider is a mock implementation for testing
|
||||
type MockOIDCProvider struct {
|
||||
*OIDCProvider
|
||||
TestTokens map[string]*providers.TokenClaims
|
||||
TestUsers map[string]*providers.ExternalIdentity
|
||||
}
|
||||
|
||||
// NewMockOIDCProvider creates a mock OIDC provider for testing
|
||||
func NewMockOIDCProvider(name string) *MockOIDCProvider {
|
||||
return &MockOIDCProvider{
|
||||
OIDCProvider: NewOIDCProvider(name),
|
||||
TestTokens: make(map[string]*providers.TokenClaims),
|
||||
TestUsers: make(map[string]*providers.ExternalIdentity),
|
||||
}
|
||||
}
|
||||
|
||||
// AddTestToken adds a test token with expected claims
|
||||
func (m *MockOIDCProvider) AddTestToken(token string, claims *providers.TokenClaims) {
|
||||
m.TestTokens[token] = claims
|
||||
}
|
||||
|
||||
// AddTestUser adds a test user with expected identity
|
||||
func (m *MockOIDCProvider) AddTestUser(userID string, identity *providers.ExternalIdentity) {
|
||||
m.TestUsers[userID] = identity
|
||||
}
|
||||
|
||||
// Authenticate overrides the parent Authenticate method to use mock data
|
||||
func (m *MockOIDCProvider) Authenticate(ctx context.Context, token string) (*providers.ExternalIdentity, error) {
|
||||
if !m.initialized {
|
||||
return nil, fmt.Errorf("provider not initialized")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("token cannot be empty")
|
||||
}
|
||||
|
||||
// Validate token using mock validation
|
||||
claims, err := m.ValidateToken(ctx, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Map claims to external identity
|
||||
email, _ := claims.GetClaimString("email")
|
||||
displayName, _ := claims.GetClaimString("name")
|
||||
groups, _ := claims.GetClaimStringSlice("groups")
|
||||
|
||||
return &providers.ExternalIdentity{
|
||||
UserID: claims.Subject,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
Groups: groups,
|
||||
Provider: m.name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateToken validates tokens using test data
|
||||
func (m *MockOIDCProvider) ValidateToken(ctx context.Context, token string) (*providers.TokenClaims, error) {
|
||||
if !m.initialized {
|
||||
return nil, fmt.Errorf("provider not initialized")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("token cannot be empty")
|
||||
}
|
||||
|
||||
// Special test tokens
|
||||
if token == "expired_token" {
|
||||
return nil, fmt.Errorf("token has expired")
|
||||
}
|
||||
if token == "invalid_token" {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// Try to parse as JWT token first
|
||||
if len(token) > 20 && strings.Count(token, ".") >= 2 {
|
||||
parsedToken, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{})
|
||||
if err == nil {
|
||||
if jwtClaims, ok := parsedToken.Claims.(jwt.MapClaims); ok {
|
||||
issuer, _ := jwtClaims["iss"].(string)
|
||||
subject, _ := jwtClaims["sub"].(string)
|
||||
audience, _ := jwtClaims["aud"].(string)
|
||||
|
||||
// Verify the issuer matches our configuration
|
||||
if issuer == m.config.Issuer && subject != "" {
|
||||
// Extract expiration and issued at times
|
||||
var expiresAt, issuedAt time.Time
|
||||
if exp, ok := jwtClaims["exp"].(float64); ok {
|
||||
expiresAt = time.Unix(int64(exp), 0)
|
||||
}
|
||||
if iat, ok := jwtClaims["iat"].(float64); ok {
|
||||
issuedAt = time.Unix(int64(iat), 0)
|
||||
}
|
||||
|
||||
return &providers.TokenClaims{
|
||||
Subject: subject,
|
||||
Issuer: issuer,
|
||||
Audience: audience,
|
||||
ExpiresAt: expiresAt,
|
||||
IssuedAt: issuedAt,
|
||||
Claims: map[string]interface{}{
|
||||
"email": subject + "@test-domain.com",
|
||||
"name": "Test User " + subject,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check test tokens
|
||||
if claims, exists := m.TestTokens[token]; exists {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// Default test token for basic testing
|
||||
if token == "valid_test_token" {
|
||||
return &providers.TokenClaims{
|
||||
Subject: "test-user-id",
|
||||
Issuer: m.config.Issuer,
|
||||
Audience: m.config.ClientID,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
IssuedAt: time.Now(),
|
||||
Claims: map[string]interface{}{
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"groups": []string{"developers", "users"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown test token: %s", token)
|
||||
}
|
||||
|
||||
// GetUserInfo returns test user info
|
||||
func (m *MockOIDCProvider) GetUserInfo(ctx context.Context, userID string) (*providers.ExternalIdentity, error) {
|
||||
if !m.initialized {
|
||||
return nil, fmt.Errorf("provider not initialized")
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("user ID cannot be empty")
|
||||
}
|
||||
|
||||
// Check test users
|
||||
if identity, exists := m.TestUsers[userID]; exists {
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// Default test user
|
||||
return &providers.ExternalIdentity{
|
||||
UserID: userID,
|
||||
Email: userID + "@example.com",
|
||||
DisplayName: "Test User " + userID,
|
||||
Provider: m.name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetupDefaultTestData configures common test data
|
||||
func (m *MockOIDCProvider) SetupDefaultTestData() {
|
||||
// Create default token claims
|
||||
defaultClaims := &providers.TokenClaims{
|
||||
Subject: "test-user-123",
|
||||
Issuer: "https://test-issuer.com",
|
||||
Audience: "test-client-id",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
IssuedAt: time.Now(),
|
||||
Claims: map[string]interface{}{
|
||||
"email": "testuser@example.com",
|
||||
"name": "Test User",
|
||||
"groups": []string{"developers"},
|
||||
},
|
||||
}
|
||||
|
||||
// Add multiple token variants for compatibility
|
||||
m.AddTestToken("valid_token", defaultClaims)
|
||||
m.AddTestToken("valid-oidc-token", defaultClaims) // For integration tests
|
||||
m.AddTestToken("valid_test_token", defaultClaims) // For STS tests
|
||||
|
||||
// Add default test users
|
||||
m.AddTestUser("test-user-123", &providers.ExternalIdentity{
|
||||
UserID: "test-user-123",
|
||||
Email: "testuser@example.com",
|
||||
DisplayName: "Test User",
|
||||
Groups: []string{"developers"},
|
||||
Provider: m.name,
|
||||
})
|
||||
}
|
||||
197
weed/iam/oidc/oidc_discovery_test.go
Normal file
197
weed/iam/oidc/oidc_discovery_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeIDP wraps an httptest.Server and counts how many times each well-known
|
||||
// endpoint is hit. Tests use it to assert discovery vs. fallback behaviour.
|
||||
type fakeIDP struct {
|
||||
server *httptest.Server
|
||||
discoveryHits atomic.Int32
|
||||
jwksHits atomic.Int32
|
||||
customJWKSHits atomic.Int32
|
||||
disableDiscovery bool
|
||||
discoveryStatusCode int
|
||||
discoveryIssuer string
|
||||
omitDiscoveryIssuer bool // when true, the discovery doc omits the "issuer" field entirely
|
||||
customJWKSPathSuffix string // optional suffix that fakeIDP serves at /custom/<suffix>
|
||||
jwks JWKS
|
||||
}
|
||||
|
||||
func newFakeIDP(t *testing.T) *fakeIDP {
|
||||
t.Helper()
|
||||
idp := &fakeIDP{
|
||||
discoveryStatusCode: http.StatusOK,
|
||||
jwks: JWKS{Keys: []JWK{{Kty: "RSA", Kid: "k1", Use: "sig", Alg: "RS256", N: "AQAB", E: "AQAB"}}},
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
idp.discoveryHits.Add(1)
|
||||
if idp.disableDiscovery {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(idp.discoveryStatusCode)
|
||||
issuer := idp.discoveryIssuer
|
||||
if issuer == "" {
|
||||
issuer = idp.server.URL
|
||||
}
|
||||
jwksURI := idp.server.URL + "/discovered/jwks"
|
||||
if idp.customJWKSPathSuffix != "" {
|
||||
jwksURI = idp.server.URL + "/custom/" + idp.customJWKSPathSuffix
|
||||
}
|
||||
body := map[string]string{"jwks_uri": jwksURI}
|
||||
if !idp.omitDiscoveryIssuer {
|
||||
body["issuer"] = issuer
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
})
|
||||
mux.HandleFunc("/discovered/jwks", func(w http.ResponseWriter, r *http.Request) {
|
||||
idp.jwksHits.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(idp.jwks)
|
||||
})
|
||||
mux.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
idp.jwksHits.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(idp.jwks)
|
||||
})
|
||||
mux.HandleFunc("/custom/", func(w http.ResponseWriter, r *http.Request) {
|
||||
idp.customJWKSHits.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(idp.jwks)
|
||||
})
|
||||
idp.server = httptest.NewServer(mux)
|
||||
t.Cleanup(idp.server.Close)
|
||||
return idp
|
||||
}
|
||||
|
||||
func newProviderForIDP(t *testing.T, idp *fakeIDP, jwksURIOverride string) *OIDCProvider {
|
||||
t.Helper()
|
||||
p := NewOIDCProvider("test")
|
||||
cfg := &OIDCConfig{
|
||||
Issuer: idp.server.URL,
|
||||
ClientID: "test-client",
|
||||
JWKSUri: jwksURIOverride,
|
||||
}
|
||||
if err := p.Initialize(cfg); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestDiscoveryHappyPath(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
p := newProviderForIDP(t, idp, "")
|
||||
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS: %v", err)
|
||||
}
|
||||
if got := idp.discoveryHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 discovery hit, got %d", got)
|
||||
}
|
||||
if got := idp.jwksHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 JWKS hit at discovered uri, got %d", got)
|
||||
}
|
||||
|
||||
// A second fetch reuses the cached jwks_uri without re-discovering.
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS second: %v", err)
|
||||
}
|
||||
if got := idp.discoveryHits.Load(); got != 1 {
|
||||
t.Fatalf("discovery should be cached, got %d hits", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryFallback404(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
idp.disableDiscovery = true
|
||||
p := newProviderForIDP(t, idp, "")
|
||||
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS: %v", err)
|
||||
}
|
||||
if got := idp.discoveryHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 discovery probe, got %d", got)
|
||||
}
|
||||
if got := idp.jwksHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 JWKS hit at fallback uri, got %d", got)
|
||||
}
|
||||
|
||||
// Subsequent fetches retry discovery — discoveryFailed resets at the top
|
||||
// of fetchJWKSLocked when no URI was cached, so a transient 5xx at
|
||||
// startup doesn't lock the provider into the fallback path forever.
|
||||
// Retry rate is bounded by the JWKS TTL (one retry per refresh cycle).
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS second: %v", err)
|
||||
}
|
||||
if got := idp.discoveryHits.Load(); got != 2 {
|
||||
t.Fatalf("discovery probe should retry while no URI is cached, got %d hits", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryDisabledByExplicitJWKSUri(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
override := idp.server.URL + "/custom/explicit"
|
||||
idp.customJWKSPathSuffix = "explicit"
|
||||
p := newProviderForIDP(t, idp, override)
|
||||
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS: %v", err)
|
||||
}
|
||||
if got := idp.discoveryHits.Load(); got != 0 {
|
||||
t.Fatalf("explicit JWKSUri should bypass discovery, got %d hits", got)
|
||||
}
|
||||
if got := idp.customJWKSHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 custom JWKS hit, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryRejectsIssuerMismatch(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
idp.discoveryIssuer = "https://attacker.example/"
|
||||
p := newProviderForIDP(t, idp, "")
|
||||
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS should fall back to /.well-known/jwks.json, got error: %v", err)
|
||||
}
|
||||
// Discovery probe was tried once, rejected, then fell through to fallback path.
|
||||
if got := idp.discoveryHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 discovery probe, got %d", got)
|
||||
}
|
||||
if got := idp.jwksHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 fallback JWKS hit, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoveryRejectsMissingIssuer: a discovery document that omits the
|
||||
// issuer field entirely must be treated the same as one that supplies a
|
||||
// mismatched issuer. Otherwise an attacker who can intercept the discovery
|
||||
// response can strip the issuer field and the comparison silently passes,
|
||||
// letting the document point fetchJWKS at any URL it pleases.
|
||||
func TestDiscoveryRejectsMissingIssuer(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
idp.omitDiscoveryIssuer = true
|
||||
p := newProviderForIDP(t, idp, "")
|
||||
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS should fall back to /.well-known/jwks.json on issuer-missing discovery: %v", err)
|
||||
}
|
||||
if got := idp.discoveryHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 discovery probe, got %d", got)
|
||||
}
|
||||
// The discovery document was rejected; the JWKS that ultimately served
|
||||
// us must be the fallback one, not the discovered URI. The fakeIDP
|
||||
// counts both hits under jwksHits since they share a counter; what
|
||||
// matters is that customJWKSHits stayed zero.
|
||||
if got := idp.customJWKSHits.Load(); got != 0 {
|
||||
t.Fatalf("custom JWKS endpoint must not have been used, got %d hits", got)
|
||||
}
|
||||
if got := idp.jwksHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 fallback JWKS hit, got %d", got)
|
||||
}
|
||||
}
|
||||
1194
weed/iam/oidc/oidc_provider.go
Normal file
1194
weed/iam/oidc/oidc_provider.go
Normal file
File diff suppressed because it is too large
Load Diff
662
weed/iam/oidc/oidc_provider_test.go
Normal file
662
weed/iam/oidc/oidc_provider_test.go
Normal file
@@ -0,0 +1,662 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestOIDCProviderInitialization tests OIDC provider initialization
|
||||
func TestOIDCProviderInitialization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *OIDCConfig
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
config: &OIDCConfig{
|
||||
Issuer: "https://accounts.google.com",
|
||||
ClientID: "test-client-id",
|
||||
JWKSUri: "https://www.googleapis.com/oauth2/v3/certs",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing issuer",
|
||||
config: &OIDCConfig{
|
||||
ClientID: "test-client-id",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing client id",
|
||||
config: &OIDCConfig{
|
||||
Issuer: "https://accounts.google.com",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid issuer url",
|
||||
config: &OIDCConfig{
|
||||
Issuer: "not-a-url",
|
||||
ClientID: "test-client-id",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
provider := NewOIDCProvider("test-provider")
|
||||
|
||||
err := provider.Initialize(tt.config)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "test-provider", provider.Name())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOIDCProviderJWTValidation tests JWT token validation
|
||||
func TestOIDCProviderJWTValidation(t *testing.T) {
|
||||
// Set up test server with JWKS endpoint
|
||||
privateKey, publicKey := generateTestKeys(t)
|
||||
|
||||
jwks := map[string]interface{}{
|
||||
"keys": []map[string]interface{}{
|
||||
{
|
||||
"kty": "RSA",
|
||||
"kid": "test-key-id",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"n": encodePublicKey(t, publicKey),
|
||||
"e": "AQAB",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/.well-known/openid_configuration" {
|
||||
config := map[string]interface{}{
|
||||
"issuer": "http://" + r.Host,
|
||||
"jwks_uri": "http://" + r.Host + "/jwks",
|
||||
}
|
||||
json.NewEncoder(w).Encode(config)
|
||||
} else if r.URL.Path == "/jwks" {
|
||||
json.NewEncoder(w).Encode(jwks)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewOIDCProvider("test-oidc")
|
||||
config := &OIDCConfig{
|
||||
Issuer: server.URL,
|
||||
ClientID: "test-client",
|
||||
JWKSUri: server.URL + "/jwks",
|
||||
}
|
||||
|
||||
err := provider.Initialize(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("valid token", func(t *testing.T) {
|
||||
// Create valid JWT token
|
||||
token := createTestJWT(t, privateKey, jwt.MapClaims{
|
||||
"iss": server.URL,
|
||||
"aud": "test-client",
|
||||
"sub": "user123",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"email": "user@example.com",
|
||||
"name": "Test User",
|
||||
})
|
||||
|
||||
claims, err := provider.ValidateToken(context.Background(), token)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, claims)
|
||||
assert.Equal(t, "user123", claims.Subject)
|
||||
assert.Equal(t, server.URL, claims.Issuer)
|
||||
|
||||
email, exists := claims.GetClaimString("email")
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "user@example.com", email)
|
||||
})
|
||||
|
||||
t.Run("valid token with array audience", func(t *testing.T) {
|
||||
// Create valid JWT token with audience as an array (per RFC 7519)
|
||||
token := createTestJWT(t, privateKey, jwt.MapClaims{
|
||||
"iss": server.URL,
|
||||
"aud": []string{"test-client", "another-client"},
|
||||
"sub": "user456",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"email": "user2@example.com",
|
||||
"name": "Test User 2",
|
||||
})
|
||||
|
||||
claims, err := provider.ValidateToken(context.Background(), token)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, claims)
|
||||
assert.Equal(t, "user456", claims.Subject)
|
||||
assert.Equal(t, server.URL, claims.Issuer)
|
||||
|
||||
email, exists := claims.GetClaimString("email")
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "user2@example.com", email)
|
||||
})
|
||||
|
||||
t.Run("expired token", func(t *testing.T) {
|
||||
// Create expired JWT token
|
||||
token := createTestJWT(t, privateKey, jwt.MapClaims{
|
||||
"iss": server.URL,
|
||||
"aud": "test-client",
|
||||
"sub": "user123",
|
||||
"exp": time.Now().Add(-time.Hour).Unix(), // Expired
|
||||
"iat": time.Now().Add(-time.Hour * 2).Unix(),
|
||||
})
|
||||
|
||||
_, err := provider.ValidateToken(context.Background(), token)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "expired")
|
||||
})
|
||||
|
||||
t.Run("invalid signature", func(t *testing.T) {
|
||||
// Create token with wrong key
|
||||
wrongKey, _ := generateTestKeys(t)
|
||||
token := createTestJWT(t, wrongKey, jwt.MapClaims{
|
||||
"iss": server.URL,
|
||||
"aud": "test-client",
|
||||
"sub": "user123",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
|
||||
_, err := provider.ValidateToken(context.Background(), token)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, providers.ErrProviderInvalidToken)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOIDCProviderJWTValidationECDSA(t *testing.T) {
|
||||
privateKey, publicKey := generateTestECKeys(t)
|
||||
x, y := encodeECPublicKey(t, publicKey)
|
||||
|
||||
jwks := map[string]interface{}{
|
||||
"keys": []map[string]interface{}{
|
||||
{
|
||||
"kty": "EC",
|
||||
"kid": "test-ec-key-id",
|
||||
"use": "sig",
|
||||
"alg": "ES256",
|
||||
"crv": "P-256",
|
||||
"x": x,
|
||||
"y": y,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/.well-known/openid_configuration" {
|
||||
config := map[string]interface{}{
|
||||
"issuer": "http://" + r.Host,
|
||||
"jwks_uri": "http://" + r.Host + "/jwks",
|
||||
}
|
||||
json.NewEncoder(w).Encode(config)
|
||||
} else if r.URL.Path == "/jwks" {
|
||||
json.NewEncoder(w).Encode(jwks)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewOIDCProvider("test-oidc-ecdsa")
|
||||
config := &OIDCConfig{
|
||||
Issuer: server.URL,
|
||||
ClientID: "test-client",
|
||||
JWKSUri: server.URL + "/jwks",
|
||||
}
|
||||
|
||||
err := provider.Initialize(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("valid token", func(t *testing.T) {
|
||||
token := createTestECDSAJWT(t, privateKey, jwt.MapClaims{
|
||||
"iss": server.URL,
|
||||
"aud": "test-client",
|
||||
"sub": "user789",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
|
||||
claims, err := provider.ValidateToken(context.Background(), token)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, claims)
|
||||
assert.Equal(t, "user789", claims.Subject)
|
||||
assert.Equal(t, server.URL, claims.Issuer)
|
||||
})
|
||||
|
||||
t.Run("expired token", func(t *testing.T) {
|
||||
token := createTestECDSAJWT(t, privateKey, jwt.MapClaims{
|
||||
"iss": server.URL,
|
||||
"aud": "test-client",
|
||||
"sub": "user789",
|
||||
"exp": time.Now().Add(-time.Hour).Unix(),
|
||||
"iat": time.Now().Add(-time.Hour * 2).Unix(),
|
||||
})
|
||||
|
||||
_, err := provider.ValidateToken(context.Background(), token)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, providers.ErrProviderTokenExpired)
|
||||
})
|
||||
|
||||
t.Run("invalid signature", func(t *testing.T) {
|
||||
wrongKey, _ := generateTestECKeys(t)
|
||||
token := createTestECDSAJWT(t, wrongKey, jwt.MapClaims{
|
||||
"iss": server.URL,
|
||||
"aud": "test-client",
|
||||
"sub": "user789",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
|
||||
_, err := provider.ValidateToken(context.Background(), token)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, providers.ErrProviderInvalidToken)
|
||||
})
|
||||
|
||||
t.Run("invalid issuer", func(t *testing.T) {
|
||||
token := createTestECDSAJWT(t, privateKey, jwt.MapClaims{
|
||||
"iss": "http://wrong-issuer",
|
||||
"aud": "test-client",
|
||||
"sub": "user789",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
|
||||
_, err := provider.ValidateToken(context.Background(), token)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, providers.ErrProviderInvalidIssuer)
|
||||
})
|
||||
|
||||
t.Run("invalid audience", func(t *testing.T) {
|
||||
token := createTestECDSAJWT(t, privateKey, jwt.MapClaims{
|
||||
"iss": server.URL,
|
||||
"aud": "wrong-client",
|
||||
"sub": "user789",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
|
||||
_, err := provider.ValidateToken(context.Background(), token)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, providers.ErrProviderInvalidAudience)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOIDCProviderAuthentication tests authentication flow
|
||||
func TestOIDCProviderAuthentication(t *testing.T) {
|
||||
// Set up test OIDC provider
|
||||
privateKey, publicKey := generateTestKeys(t)
|
||||
|
||||
server := setupOIDCTestServer(t, publicKey)
|
||||
defer server.Close()
|
||||
|
||||
provider := NewOIDCProvider("test-oidc")
|
||||
config := &OIDCConfig{
|
||||
Issuer: server.URL,
|
||||
ClientID: "test-client",
|
||||
JWKSUri: server.URL + "/jwks",
|
||||
RoleMapping: &providers.RoleMapping{
|
||||
Rules: []providers.MappingRule{
|
||||
{
|
||||
Claim: "email",
|
||||
Value: "*@example.com",
|
||||
Role: "arn:aws:iam::role/UserRole",
|
||||
},
|
||||
{
|
||||
Claim: "groups",
|
||||
Value: "admins",
|
||||
Role: "arn:aws:iam::role/AdminRole",
|
||||
},
|
||||
},
|
||||
DefaultRole: "arn:aws:iam::role/GuestRole",
|
||||
},
|
||||
}
|
||||
|
||||
err := provider.Initialize(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("successful authentication", func(t *testing.T) {
|
||||
token := createTestJWT(t, privateKey, jwt.MapClaims{
|
||||
"iss": server.URL,
|
||||
"aud": "test-client",
|
||||
"sub": "user123",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"email": "user@example.com",
|
||||
"name": "Test User",
|
||||
"groups": []string{"users", "developers"},
|
||||
})
|
||||
|
||||
identity, err := provider.Authenticate(context.Background(), token)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, identity)
|
||||
assert.Equal(t, "user123", identity.UserID)
|
||||
assert.Equal(t, "user@example.com", identity.Email)
|
||||
assert.Equal(t, "Test User", identity.DisplayName)
|
||||
assert.Equal(t, "test-oidc", identity.Provider)
|
||||
assert.Contains(t, identity.Groups, "users")
|
||||
assert.Contains(t, identity.Groups, "developers")
|
||||
})
|
||||
|
||||
t.Run("successful authentication with additional attributes", func(t *testing.T) {
|
||||
token := createTestJWT(t, privateKey, jwt.MapClaims{
|
||||
"iss": server.URL,
|
||||
"aud": "test-client",
|
||||
"sub": "user123",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"email": "user@example.com",
|
||||
"name": "Test User",
|
||||
"groups": []string{"users"},
|
||||
"preferred_username": "myusername", // Extra claim
|
||||
"department": "engineering", // Extra claim
|
||||
"custom_number": 42, // Non-string claim
|
||||
"custom_avg": 98.6, // Non-string claim
|
||||
"custom_object": map[string]interface{}{"nested": "value"}, // Nested object claim
|
||||
})
|
||||
|
||||
identity, err := provider.Authenticate(context.Background(), token)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, identity)
|
||||
|
||||
// Check standard fields
|
||||
assert.Equal(t, "user123", identity.UserID)
|
||||
|
||||
// Check attributes
|
||||
val, exists := identity.Attributes["preferred_username"]
|
||||
assert.True(t, exists, "preferred_username should be in attributes")
|
||||
assert.Equal(t, "myusername", val)
|
||||
|
||||
val, exists = identity.Attributes["department"]
|
||||
assert.True(t, exists, "department should be in attributes")
|
||||
assert.Equal(t, "engineering", val)
|
||||
|
||||
// Test non-string claims (should be JSON marshaled)
|
||||
val, exists = identity.Attributes["custom_number"]
|
||||
assert.True(t, exists, "custom_number should be in attributes")
|
||||
assert.Equal(t, "42", val)
|
||||
|
||||
val, exists = identity.Attributes["custom_avg"]
|
||||
assert.True(t, exists, "custom_avg should be in attributes")
|
||||
assert.Contains(t, val, "98.6") // JSON number formatting might vary
|
||||
|
||||
val, exists = identity.Attributes["custom_object"]
|
||||
assert.True(t, exists, "custom_object should be in attributes")
|
||||
assert.Contains(t, val, "\"nested\":\"value\"")
|
||||
|
||||
// Verify structural JWT claims are excluded from attributes
|
||||
excludedClaims := []string{"iss", "aud", "exp", "iat"}
|
||||
for _, claim := range excludedClaims {
|
||||
_, exists := identity.Attributes[claim]
|
||||
assert.False(t, exists, "standard claim %s should not be in attributes", claim)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("authentication with invalid token", func(t *testing.T) {
|
||||
_, err := provider.Authenticate(context.Background(), "invalid-token")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOIDCProviderUserInfo tests user info retrieval
|
||||
func TestOIDCProviderUserInfo(t *testing.T) {
|
||||
// Set up test server with UserInfo endpoint
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/userinfo" {
|
||||
// Check for Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error": "unauthorized"}`))
|
||||
return
|
||||
}
|
||||
|
||||
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Return 401 for explicitly invalid tokens
|
||||
if accessToken == "invalid-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error": "invalid_token"}`))
|
||||
return
|
||||
}
|
||||
|
||||
// Mock user info response
|
||||
userInfo := map[string]interface{}{
|
||||
"sub": "user123",
|
||||
"email": "user@example.com",
|
||||
"name": "Test User",
|
||||
"groups": []string{"users", "developers"},
|
||||
}
|
||||
|
||||
// Customize response based on token
|
||||
if strings.Contains(accessToken, "admin") {
|
||||
userInfo["groups"] = []string{"admins"}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(userInfo)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewOIDCProvider("test-oidc")
|
||||
config := &OIDCConfig{
|
||||
Issuer: server.URL,
|
||||
ClientID: "test-client",
|
||||
UserInfoUri: server.URL + "/userinfo",
|
||||
}
|
||||
|
||||
err := provider.Initialize(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("get user info with access token", func(t *testing.T) {
|
||||
// Test using access token (real UserInfo endpoint call)
|
||||
identity, err := provider.GetUserInfoWithToken(context.Background(), "valid-access-token")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, identity)
|
||||
assert.Equal(t, "user123", identity.UserID)
|
||||
assert.Equal(t, "user@example.com", identity.Email)
|
||||
assert.Equal(t, "Test User", identity.DisplayName)
|
||||
assert.Contains(t, identity.Groups, "users")
|
||||
assert.Contains(t, identity.Groups, "developers")
|
||||
assert.Equal(t, "test-oidc", identity.Provider)
|
||||
})
|
||||
|
||||
t.Run("get admin user info", func(t *testing.T) {
|
||||
// Test admin token response
|
||||
identity, err := provider.GetUserInfoWithToken(context.Background(), "admin-access-token")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, identity)
|
||||
assert.Equal(t, "user123", identity.UserID)
|
||||
assert.Contains(t, identity.Groups, "admins")
|
||||
})
|
||||
|
||||
t.Run("get user info without token", func(t *testing.T) {
|
||||
// Test without access token (should fail)
|
||||
_, err := provider.GetUserInfoWithToken(context.Background(), "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "access token cannot be empty")
|
||||
})
|
||||
|
||||
t.Run("get user info with invalid token", func(t *testing.T) {
|
||||
// Test with invalid access token (should get 401)
|
||||
_, err := provider.GetUserInfoWithToken(context.Background(), "invalid-token")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "UserInfo endpoint returned status 401")
|
||||
})
|
||||
|
||||
t.Run("get user info with custom claims mapping", func(t *testing.T) {
|
||||
// Create provider with custom claims mapping
|
||||
customProvider := NewOIDCProvider("test-custom-oidc")
|
||||
customConfig := &OIDCConfig{
|
||||
Issuer: server.URL,
|
||||
ClientID: "test-client",
|
||||
UserInfoUri: server.URL + "/userinfo",
|
||||
ClaimsMapping: map[string]string{
|
||||
"customEmail": "email",
|
||||
"customName": "name",
|
||||
},
|
||||
}
|
||||
|
||||
err := customProvider.Initialize(customConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
identity, err := customProvider.GetUserInfoWithToken(context.Background(), "valid-access-token")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, identity)
|
||||
|
||||
// Standard claims should still work
|
||||
assert.Equal(t, "user123", identity.UserID)
|
||||
assert.Equal(t, "user@example.com", identity.Email)
|
||||
assert.Equal(t, "Test User", identity.DisplayName)
|
||||
})
|
||||
|
||||
t.Run("get user info with empty id", func(t *testing.T) {
|
||||
_, err := provider.GetUserInfo(context.Background(), "")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions for testing
|
||||
|
||||
func generateTestKeys(t *testing.T) (*rsa.PrivateKey, *rsa.PublicKey) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
return privateKey, &privateKey.PublicKey
|
||||
}
|
||||
|
||||
func generateTestECKeys(t *testing.T) (*ecdsa.PrivateKey, *ecdsa.PublicKey) {
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
return privateKey, &privateKey.PublicKey
|
||||
}
|
||||
|
||||
func createTestJWT(t *testing.T, privateKey *rsa.PrivateKey, claims jwt.MapClaims) string {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
token.Header["kid"] = "test-key-id"
|
||||
|
||||
tokenString, err := token.SignedString(privateKey)
|
||||
require.NoError(t, err)
|
||||
return tokenString
|
||||
}
|
||||
|
||||
func createTestECDSAJWT(t *testing.T, privateKey *ecdsa.PrivateKey, claims jwt.MapClaims) string {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
token.Header["kid"] = "test-ec-key-id"
|
||||
|
||||
tokenString, err := token.SignedString(privateKey)
|
||||
require.NoError(t, err)
|
||||
return tokenString
|
||||
}
|
||||
|
||||
func encodePublicKey(t *testing.T, publicKey *rsa.PublicKey) string {
|
||||
// Properly encode the RSA modulus (N) as base64url
|
||||
return base64.RawURLEncoding.EncodeToString(publicKey.N.Bytes())
|
||||
}
|
||||
|
||||
func encodeECPublicKey(t *testing.T, publicKey *ecdsa.PublicKey) (string, string) {
|
||||
// RFC 7518 §6.2.1.2 requires EC coordinates to be zero-padded to the full field size
|
||||
curveParams := publicKey.Curve.Params()
|
||||
size := (curveParams.BitSize + 7) / 8
|
||||
xBytes := publicKey.X.Bytes()
|
||||
yBytes := publicKey.Y.Bytes()
|
||||
xPadded := make([]byte, size)
|
||||
yPadded := make([]byte, size)
|
||||
// Right-align the coordinate bytes and leave leading zeros for padding
|
||||
copy(xPadded[size-len(xBytes):], xBytes)
|
||||
copy(yPadded[size-len(yBytes):], yBytes)
|
||||
return base64.RawURLEncoding.EncodeToString(xPadded),
|
||||
base64.RawURLEncoding.EncodeToString(yPadded)
|
||||
}
|
||||
|
||||
func setupOIDCTestServer(t *testing.T, publicKey *rsa.PublicKey) *httptest.Server {
|
||||
jwks := map[string]interface{}{
|
||||
"keys": []map[string]interface{}{
|
||||
{
|
||||
"kty": "RSA",
|
||||
"kid": "test-key-id",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"n": encodePublicKey(t, publicKey),
|
||||
"e": "AQAB",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/.well-known/openid_configuration":
|
||||
config := map[string]interface{}{
|
||||
"issuer": "http://" + r.Host,
|
||||
"jwks_uri": "http://" + r.Host + "/jwks",
|
||||
"userinfo_endpoint": "http://" + r.Host + "/userinfo",
|
||||
}
|
||||
json.NewEncoder(w).Encode(config)
|
||||
case "/jwks":
|
||||
json.NewEncoder(w).Encode(jwks)
|
||||
case "/userinfo":
|
||||
// Mock UserInfo endpoint
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error": "unauthorized"}`))
|
||||
return
|
||||
}
|
||||
|
||||
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Return 401 for explicitly invalid tokens
|
||||
if accessToken == "invalid-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error": "invalid_token"}`))
|
||||
return
|
||||
}
|
||||
|
||||
// Mock user info response based on access token
|
||||
userInfo := map[string]interface{}{
|
||||
"sub": "user123",
|
||||
"email": "user@example.com",
|
||||
"name": "Test User",
|
||||
"groups": []string{"users", "developers"},
|
||||
}
|
||||
|
||||
// Customize response based on token
|
||||
if strings.Contains(accessToken, "admin") {
|
||||
userInfo["groups"] = []string{"admins"}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(userInfo)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
57
weed/iam/oidc/policy_claim_test.go
Normal file
57
weed/iam/oidc/policy_claim_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractClaimPoliciesString(t *testing.T) {
|
||||
got := extractClaimPolicies(map[string]interface{}{"policy": "readonly"}, "policy")
|
||||
if !reflect.DeepEqual(got, []string{"readonly"}) {
|
||||
t.Fatalf("got=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimPoliciesCommaSeparated(t *testing.T) {
|
||||
got := extractClaimPolicies(map[string]interface{}{"policy": "readonly, billing , "}, "policy")
|
||||
if !reflect.DeepEqual(got, []string{"readonly", "billing"}) {
|
||||
t.Fatalf("got=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimPoliciesArray(t *testing.T) {
|
||||
got := extractClaimPolicies(map[string]interface{}{
|
||||
"policy": []interface{}{"readonly", "billing", 42, ""}, // non-string + empty filtered
|
||||
}, "policy")
|
||||
if !reflect.DeepEqual(got, []string{"readonly", "billing"}) {
|
||||
t.Fatalf("got=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimPoliciesMissing(t *testing.T) {
|
||||
if got := extractClaimPolicies(map[string]interface{}{}, "policy"); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimPoliciesEmptyClaimName(t *testing.T) {
|
||||
// Provider not in claim-mode -> never read the claim, even if present.
|
||||
if got := extractClaimPolicies(map[string]interface{}{"policy": "readonly"}, ""); got != nil {
|
||||
t.Fatalf("empty claim name should return nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimPoliciesUnsupportedShape(t *testing.T) {
|
||||
// Numeric / object / bool values should be ignored, not panic.
|
||||
cases := []interface{}{
|
||||
42,
|
||||
3.14,
|
||||
map[string]interface{}{"x": "y"},
|
||||
true,
|
||||
}
|
||||
for _, v := range cases {
|
||||
if got := extractClaimPolicies(map[string]interface{}{"policy": v}, "policy"); got != nil {
|
||||
t.Fatalf("value %v: expected nil, got %v", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
83
weed/iam/oidc/principal_tags_test.go
Normal file
83
weed/iam/oidc/principal_tags_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractPrincipalTagsObjectShape(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
PrincipalTagsClaim: map[string]interface{}{
|
||||
"team": "infra",
|
||||
"env": "prod",
|
||||
"empty": "",
|
||||
// non-string values are dropped
|
||||
"count": 42,
|
||||
},
|
||||
}
|
||||
got := extractPrincipalTags(claims)
|
||||
want := map[string]string{"team": "infra", "env": "prod", "empty": ""}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPrincipalTagsArrayValueTakesFirst(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
PrincipalTagsClaim: map[string]interface{}{
|
||||
"team": []interface{}{"infra", "ignored"},
|
||||
},
|
||||
}
|
||||
got := extractPrincipalTags(claims)
|
||||
if got["team"] != "infra" {
|
||||
t.Fatalf("expected first array element, got %q", got["team"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPrincipalTagsAbsent(t *testing.T) {
|
||||
if got := extractPrincipalTags(map[string]interface{}{}); got != nil {
|
||||
t.Fatalf("expected nil for missing claim, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPrincipalTagsWrongShape(t *testing.T) {
|
||||
claims := map[string]interface{}{PrincipalTagsClaim: "not-an-object"}
|
||||
if got := extractPrincipalTags(claims); got != nil {
|
||||
t.Fatalf("expected nil for non-object claim, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPrincipalTagsAllowlist(t *testing.T) {
|
||||
in := map[string]string{"team": "infra", "env": "prod", "secret": "shh"}
|
||||
|
||||
t.Run("empty allowlist denies all", func(t *testing.T) {
|
||||
if got := filterPrincipalTags(in, nil); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial allowlist", func(t *testing.T) {
|
||||
got := filterPrincipalTags(in, []string{"team", "env"})
|
||||
want := map[string]string{"team": "infra", "env": "prod"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got=%v want=%v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowlist with no matches", func(t *testing.T) {
|
||||
if got := filterPrincipalTags(in, []string{"unknown"}); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case-insensitive on key", func(t *testing.T) {
|
||||
// AWS session tag keys are case-insensitive. An IDP whose claim
|
||||
// uses different casing than the operator's allowlist string
|
||||
// must still match.
|
||||
got := filterPrincipalTags(map[string]string{"Team": "infra"}, []string{"TEAM"})
|
||||
want := map[string]string{"Team": "infra"} // original casing preserved on output
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got=%v want=%v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
153
weed/iam/oidc/thumbprint_test.go
Normal file
153
weed/iam/oidc/thumbprint_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// thumbprintFromTLS returns the SHA-1 hex of the first peer certificate the
|
||||
// given TLS server presents. It mirrors the pinning algorithm used by the
|
||||
// OIDC provider so the test is hashing the cert that will actually be
|
||||
// verified at runtime.
|
||||
func thumbprintFromTLS(t *testing.T, server *httptest.Server) string {
|
||||
t.Helper()
|
||||
if server.TLS == nil || len(server.Certificate().Raw) == 0 {
|
||||
t.Fatal("server has no TLS certificate")
|
||||
}
|
||||
sum := sha1.Sum(server.Certificate().Raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// newTLSIDP starts a TLS-only test IDP that always serves a static JWKS.
|
||||
// httptest TLS servers use a self-signed certificate by default, which means
|
||||
// callers who want a successful handshake must either trust that cert or
|
||||
// use TLSInsecureSkipVerify.
|
||||
func newTLSIDP(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(JWKS{Keys: []JWK{{Kty: "RSA", Kid: "k1", Use: "sig", Alg: "RS256", N: "AQAB", E: "AQAB"}}})
|
||||
})
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r) // force fallback path
|
||||
})
|
||||
server := httptest.NewTLSServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
// pinnedProvider configures an OIDC provider that pins TLS to the given
|
||||
// thumbprint allowlist and trusts only the test server's self-signed cert.
|
||||
func pinnedProvider(t *testing.T, server *httptest.Server, thumbprints []string) *OIDCProvider {
|
||||
t.Helper()
|
||||
p := NewOIDCProvider("thumbprint-test")
|
||||
cfg := &OIDCConfig{
|
||||
Issuer: server.URL,
|
||||
ClientID: "anything",
|
||||
Thumbprints: thumbprints,
|
||||
TLSInsecureSkipVerify: true, // we're doing the trust decision via thumbprint
|
||||
}
|
||||
if err := p.Initialize(cfg); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
// Replace the inner transport's RootCAs with the server's cert so the chain
|
||||
// builder also accepts it; this exercises the verified-chain path.
|
||||
if transport, ok := p.httpClient.Transport.(*http.Transport); ok && transport.TLSClientConfig != nil {
|
||||
transport.TLSClientConfig.InsecureSkipVerify = false
|
||||
transport.TLSClientConfig.RootCAs = server.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestThumbprintMatchAccepted(t *testing.T) {
|
||||
server := newTLSIDP(t)
|
||||
tp := thumbprintFromTLS(t, server)
|
||||
p := pinnedProvider(t, server, []string{tp})
|
||||
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS with matching thumbprint should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThumbprintMismatchRejected(t *testing.T) {
|
||||
server := newTLSIDP(t)
|
||||
// Flip the last byte so the digest can't match anything legitimate.
|
||||
bad := "0000000000000000000000000000000000000000"
|
||||
p := pinnedProvider(t, server, []string{bad})
|
||||
|
||||
err := p.fetchJWKS(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("fetchJWKS with mismatched thumbprint should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThumbprintAllowlistAcceptsMixedCase(t *testing.T) {
|
||||
server := newTLSIDP(t)
|
||||
tp := thumbprintFromTLS(t, server)
|
||||
// Mix uppercase + whitespace to ensure normalization is applied.
|
||||
p := pinnedProvider(t, server, []string{" " + uppercase(tp) + " "})
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("normalized thumbprint should match: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// uppercase returns s with letters uppercased — cheap helper avoiding a
|
||||
// dependency on strings.ToUpper at the package level for clarity.
|
||||
func uppercase(s string) string {
|
||||
out := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'a' && c <= 'z' {
|
||||
c -= 32
|
||||
}
|
||||
out[i] = c
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// guard against a future refactor that drops VerifyConnection by checking
|
||||
// that omitting Thumbprints leaves the connection unverified-by-pin.
|
||||
func TestThumbprintEmptyAllowlistSkipsCheck(t *testing.T) {
|
||||
server := newTLSIDP(t)
|
||||
p := pinnedProvider(t, server, nil)
|
||||
transport, ok := p.httpClient.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatal("expected *http.Transport")
|
||||
}
|
||||
if transport.TLSClientConfig.VerifyConnection != nil {
|
||||
t.Fatal("VerifyConnection must be nil when no thumbprints are configured")
|
||||
}
|
||||
// And the full TLS handshake still succeeds normally.
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS with no thumbprints: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the underlying tls.Config still rejects truly invalid handshakes
|
||||
// when InsecureSkipVerify is on but no thumbprint allowlist is configured —
|
||||
// in that case we explicitly want to *trust* the connection (skip-verify),
|
||||
// just as the existing test code did.
|
||||
func TestThumbprintSkipVerifyHonoured(t *testing.T) {
|
||||
server := newTLSIDP(t)
|
||||
p := NewOIDCProvider("skip")
|
||||
if err := p.Initialize(&OIDCConfig{
|
||||
Issuer: server.URL,
|
||||
ClientID: "x",
|
||||
TLSInsecureSkipVerify: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
transport := p.httpClient.Transport.(*http.Transport)
|
||||
if !transport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("InsecureSkipVerify should be set")
|
||||
}
|
||||
if got := transport.TLSClientConfig.MinVersion; got != tls.VersionTLS12 {
|
||||
t.Fatalf("MinVersion = %v want TLS12", got)
|
||||
}
|
||||
}
|
||||
295
weed/iam/policy/aws_iam_compliance_test.go
Normal file
295
weed/iam/policy/aws_iam_compliance_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAWSIAMMatch(t *testing.T) {
|
||||
evalCtx := &EvaluationContext{
|
||||
RequestContext: map[string]interface{}{
|
||||
"aws:username": "testuser",
|
||||
"saml:username": "john.doe",
|
||||
"oidc:sub": "user123",
|
||||
"aws:userid": "AIDACKCEVSQ6C2EXAMPLE",
|
||||
"aws:principaltype": "User",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
value string
|
||||
evalCtx *EvaluationContext
|
||||
expected bool
|
||||
}{
|
||||
// Case insensitivity tests
|
||||
{
|
||||
name: "case insensitive exact match",
|
||||
pattern: "S3:GetObject",
|
||||
value: "s3:getobject",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive wildcard match",
|
||||
pattern: "S3:Get*",
|
||||
value: "s3:getobject",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
// Policy variable expansion tests
|
||||
{
|
||||
name: "AWS username variable expansion",
|
||||
pattern: "arn:aws:s3:::mybucket/${aws:username}/*",
|
||||
value: "arn:aws:s3:::mybucket/testuser/document.pdf",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "SAML username variable expansion",
|
||||
pattern: "home/${saml:username}/*",
|
||||
value: "home/john.doe/private.txt",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "OIDC subject variable expansion",
|
||||
pattern: "users/${oidc:sub}/data",
|
||||
value: "users/user123/data",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
// Mixed case and variable tests
|
||||
{
|
||||
name: "case insensitive with variable",
|
||||
pattern: "S3:GetObject/${aws:username}/*",
|
||||
value: "s3:getobject/testuser/file.txt",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
// Universal wildcard
|
||||
{
|
||||
name: "universal wildcard",
|
||||
pattern: "*",
|
||||
value: "anything",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
// Question mark wildcard
|
||||
{
|
||||
name: "question mark wildcard",
|
||||
pattern: "file?.txt",
|
||||
value: "file1.txt",
|
||||
evalCtx: evalCtx,
|
||||
expected: true,
|
||||
},
|
||||
// No match cases
|
||||
{
|
||||
name: "no match different pattern",
|
||||
pattern: "s3:PutObject",
|
||||
value: "s3:GetObject",
|
||||
evalCtx: evalCtx,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "variable not expanded due to missing context",
|
||||
pattern: "users/${aws:username}/data",
|
||||
value: "users/${aws:username}/data",
|
||||
evalCtx: nil,
|
||||
expected: true, // Should match literally when no context
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := awsIAMMatch(tt.pattern, tt.value, tt.evalCtx)
|
||||
assert.Equal(t, tt.expected, result, "AWS IAM match result should match expected")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesActionsMultipartExpansion(t *testing.T) {
|
||||
engine := &PolicyEngine{initialized: true}
|
||||
evalCtx := &EvaluationContext{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actions []string
|
||||
requestedAction string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "PutObject directly matches PutObject",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:PutObject",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PutObject implicitly allows CreateMultipartUpload",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:CreateMultipartUpload",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PutObject implicitly allows UploadPart",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:UploadPart",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PutObject implicitly allows CompleteMultipartUpload",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:CompleteMultipartUpload",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PutObject implicitly allows AbortMultipartUpload",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:AbortMultipartUpload",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PutObject implicitly allows ListMultipartUploadParts",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:ListMultipartUploadParts",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PutObject implicitly allows ListBucketMultipartUploads",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:ListBucketMultipartUploads",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "PutObject does not allow GetObject",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "s3:GetObject",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "GetObject does not allow CreateMultipartUpload",
|
||||
actions: []string{"s3:GetObject"},
|
||||
requestedAction: "s3:CreateMultipartUpload",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard s3:Put* implicitly allows multipart via PutObject match",
|
||||
actions: []string{"s3:Put*"},
|
||||
requestedAction: "s3:CreateMultipartUpload",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "case-insensitive multipart action lookup",
|
||||
actions: []string{"s3:PutObject"},
|
||||
requestedAction: "S3:CREATEMULTIPARTUPLOAD",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := engine.matchesActions(tt.actions, tt.requestedAction, evalCtx)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPolicyVariables(t *testing.T) {
|
||||
evalCtx := &EvaluationContext{
|
||||
RequestContext: map[string]interface{}{
|
||||
"aws:username": "alice",
|
||||
"saml:username": "alice.smith",
|
||||
"oidc:sub": "sub123",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
evalCtx *EvaluationContext
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "expand aws username",
|
||||
pattern: "home/${aws:username}/documents/*",
|
||||
evalCtx: evalCtx,
|
||||
expected: "home/alice/documents/*",
|
||||
},
|
||||
{
|
||||
name: "expand multiple variables",
|
||||
pattern: "${aws:username}/${oidc:sub}/data",
|
||||
evalCtx: evalCtx,
|
||||
expected: "alice/sub123/data",
|
||||
},
|
||||
{
|
||||
name: "no variables to expand",
|
||||
pattern: "static/path/file.txt",
|
||||
evalCtx: evalCtx,
|
||||
expected: "static/path/file.txt",
|
||||
},
|
||||
{
|
||||
name: "nil context",
|
||||
pattern: "home/${aws:username}/file",
|
||||
evalCtx: nil,
|
||||
expected: "home/${aws:username}/file",
|
||||
},
|
||||
{
|
||||
name: "missing variable in context",
|
||||
pattern: "home/${aws:nonexistent}/file",
|
||||
evalCtx: evalCtx,
|
||||
expected: "home/${aws:nonexistent}/file", // Should remain unchanged
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := expandPolicyVariables(tt.pattern, tt.evalCtx)
|
||||
assert.Equal(t, tt.expected, result, "Policy variable expansion should match expected")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSWildcardMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
value string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "case insensitive asterisk",
|
||||
pattern: "S3:Get*",
|
||||
value: "s3:getobject",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive question mark",
|
||||
pattern: "file?.TXT",
|
||||
value: "file1.txt",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "mixed wildcards",
|
||||
pattern: "S3:*Object?",
|
||||
value: "s3:getobjects",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
pattern: "s3:Put*",
|
||||
value: "s3:GetObject",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := wildcard.MatchesWildcard(strings.ToLower(tt.pattern), strings.ToLower(tt.value))
|
||||
assert.Equal(t, tt.expected, result, "AWS wildcard match should match expected")
|
||||
})
|
||||
}
|
||||
}
|
||||
139
weed/iam/policy/cached_policy_store_generic.go
Normal file
139
weed/iam/policy/cached_policy_store_generic.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/util"
|
||||
)
|
||||
|
||||
// PolicyStoreAdapter adapts PolicyStore interface to CacheableStore[*PolicyDocument]
|
||||
type PolicyStoreAdapter struct {
|
||||
store PolicyStore
|
||||
}
|
||||
|
||||
// NewPolicyStoreAdapter creates a new adapter for PolicyStore
|
||||
func NewPolicyStoreAdapter(store PolicyStore) *PolicyStoreAdapter {
|
||||
return &PolicyStoreAdapter{store: store}
|
||||
}
|
||||
|
||||
// Get implements CacheableStore interface
|
||||
func (a *PolicyStoreAdapter) Get(ctx context.Context, filerAddress string, key string) (*PolicyDocument, error) {
|
||||
return a.store.GetPolicy(ctx, filerAddress, key)
|
||||
}
|
||||
|
||||
// Store implements CacheableStore interface
|
||||
func (a *PolicyStoreAdapter) Store(ctx context.Context, filerAddress string, key string, value *PolicyDocument) error {
|
||||
return a.store.StorePolicy(ctx, filerAddress, key, value)
|
||||
}
|
||||
|
||||
// Delete implements CacheableStore interface
|
||||
func (a *PolicyStoreAdapter) Delete(ctx context.Context, filerAddress string, key string) error {
|
||||
return a.store.DeletePolicy(ctx, filerAddress, key)
|
||||
}
|
||||
|
||||
// List implements CacheableStore interface
|
||||
func (a *PolicyStoreAdapter) List(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
return a.store.ListPolicies(ctx, filerAddress)
|
||||
}
|
||||
|
||||
// GenericCachedPolicyStore implements PolicyStore using the generic cache
|
||||
type GenericCachedPolicyStore struct {
|
||||
*util.CachedStore[*PolicyDocument]
|
||||
adapter *PolicyStoreAdapter
|
||||
}
|
||||
|
||||
// NewGenericCachedPolicyStore creates a new cached policy store using generics
|
||||
func NewGenericCachedPolicyStore(config map[string]interface{}, filerAddressProvider func() string) (*GenericCachedPolicyStore, error) {
|
||||
// Create underlying filer store
|
||||
filerStore, err := NewFilerPolicyStore(config, filerAddressProvider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse cache configuration with defaults
|
||||
cacheTTL := 5 * time.Minute
|
||||
listTTL := 1 * time.Minute
|
||||
maxCacheSize := int64(500)
|
||||
|
||||
if config != nil {
|
||||
if ttlStr, ok := config["ttl"].(string); ok && ttlStr != "" {
|
||||
if parsed, err := time.ParseDuration(ttlStr); err == nil {
|
||||
cacheTTL = parsed
|
||||
}
|
||||
}
|
||||
if listTTLStr, ok := config["listTtl"].(string); ok && listTTLStr != "" {
|
||||
if parsed, err := time.ParseDuration(listTTLStr); err == nil {
|
||||
listTTL = parsed
|
||||
}
|
||||
}
|
||||
if maxSize, ok := config["maxCacheSize"].(int); ok && maxSize > 0 {
|
||||
maxCacheSize = int64(maxSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Create adapter and generic cached store
|
||||
adapter := NewPolicyStoreAdapter(filerStore)
|
||||
cachedStore := util.NewCachedStore(
|
||||
adapter,
|
||||
genericCopyPolicyDocument, // Copy function
|
||||
util.CachedStoreConfig{
|
||||
TTL: cacheTTL,
|
||||
ListTTL: listTTL,
|
||||
MaxCacheSize: maxCacheSize,
|
||||
},
|
||||
)
|
||||
|
||||
glog.V(2).Infof("Initialized GenericCachedPolicyStore with TTL %v, List TTL %v, Max Cache Size %d",
|
||||
cacheTTL, listTTL, maxCacheSize)
|
||||
|
||||
return &GenericCachedPolicyStore{
|
||||
CachedStore: cachedStore,
|
||||
adapter: adapter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StorePolicy implements PolicyStore interface
|
||||
func (c *GenericCachedPolicyStore) StorePolicy(ctx context.Context, filerAddress string, name string, policy *PolicyDocument) error {
|
||||
return c.Store(ctx, filerAddress, name, policy)
|
||||
}
|
||||
|
||||
// GetPolicy implements PolicyStore interface
|
||||
func (c *GenericCachedPolicyStore) GetPolicy(ctx context.Context, filerAddress string, name string) (*PolicyDocument, error) {
|
||||
return c.Get(ctx, filerAddress, name)
|
||||
}
|
||||
|
||||
// ListPolicies implements PolicyStore interface
|
||||
func (c *GenericCachedPolicyStore) ListPolicies(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
return c.List(ctx, filerAddress)
|
||||
}
|
||||
|
||||
// DeletePolicy implements PolicyStore interface
|
||||
func (c *GenericCachedPolicyStore) DeletePolicy(ctx context.Context, filerAddress string, name string) error {
|
||||
return c.Delete(ctx, filerAddress, name)
|
||||
}
|
||||
|
||||
// genericCopyPolicyDocument creates a deep copy of a PolicyDocument for the generic cache
|
||||
func genericCopyPolicyDocument(policy *PolicyDocument) *PolicyDocument {
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Perform a deep copy to ensure cache isolation
|
||||
// Using JSON marshaling is a safe way to achieve this
|
||||
policyData, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to marshal policy document for deep copy: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var copied PolicyDocument
|
||||
if err := json.Unmarshal(policyData, &copied); err != nil {
|
||||
glog.Errorf("Failed to unmarshal policy document for deep copy: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &copied
|
||||
}
|
||||
216
weed/iam/policy/jwt_claim_variables_test.go
Normal file
216
weed/iam/policy/jwt_claim_variables_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestStringifyClaimValue covers the scalar types that can appear in
|
||||
// RequestContext so that both JSON-decoded (float64, json.Number) and typed
|
||||
// (int8..int64, uint..uint64, bool) values substitute correctly.
|
||||
func TestStringifyClaimValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value interface{}
|
||||
want string
|
||||
wantOk bool
|
||||
}{
|
||||
{name: "string", value: "alice", want: "alice", wantOk: true},
|
||||
{name: "bool true", value: true, want: "true", wantOk: true},
|
||||
{name: "bool false", value: false, want: "false", wantOk: true},
|
||||
{name: "float64 integer", value: float64(301), want: "301", wantOk: true},
|
||||
{name: "float64 fraction", value: 1.5, want: "1.5", wantOk: true},
|
||||
{name: "float32", value: float32(2.25), want: "2.25", wantOk: true},
|
||||
{name: "int", value: int(42), want: "42", wantOk: true},
|
||||
{name: "int8", value: int8(-5), want: "-5", wantOk: true},
|
||||
{name: "int16", value: int16(32000), want: "32000", wantOk: true},
|
||||
{name: "int32", value: int32(-123456), want: "-123456", wantOk: true},
|
||||
{name: "int64", value: int64(9999999999), want: "9999999999", wantOk: true},
|
||||
{name: "uint", value: uint(7), want: "7", wantOk: true},
|
||||
{name: "uint8", value: uint8(255), want: "255", wantOk: true},
|
||||
{name: "uint16", value: uint16(65535), want: "65535", wantOk: true},
|
||||
{name: "uint32", value: uint32(4000000000), want: "4000000000", wantOk: true},
|
||||
{name: "uint64", value: uint64(18000000000000000000), want: "18000000000000000000", wantOk: true},
|
||||
{name: "json.Number int", value: json.Number("301"), want: "301", wantOk: true},
|
||||
{name: "json.Number float", value: json.Number("1.5"), want: "1.5", wantOk: true},
|
||||
{name: "nil", value: nil, want: "", wantOk: false},
|
||||
{name: "slice unsupported", value: []string{"a", "b"}, want: "", wantOk: false},
|
||||
{name: "map unsupported", value: map[string]string{"a": "b"}, want: "", wantOk: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := stringifyClaimValue(tt.value)
|
||||
assert.Equal(t, tt.wantOk, ok)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandPolicyVariablesDynamicJWTClaims verifies that arbitrary JWT claims
|
||||
// (not only the hardcoded jwt:sub/jwt:iss/jwt:aud/jwt:preferred_username)
|
||||
// can be substituted in policy patterns when present in the request context.
|
||||
// Reproduces the scenario in https://github.com/seaweedfs/seaweedfs/issues/9214
|
||||
// where ${jwt:project_path} was left unsubstituted.
|
||||
func TestExpandPolicyVariablesDynamicJWTClaims(t *testing.T) {
|
||||
evalCtx := &EvaluationContext{
|
||||
RequestContext: map[string]interface{}{
|
||||
"jwt:project_path": "some/namespace/project",
|
||||
"jwt:namespace_path": "some/namespace",
|
||||
"jwt:user_login": "mat",
|
||||
// JSON-decoded numeric claim
|
||||
"jwt:project_id": float64(301),
|
||||
// Dynamic SAML/OIDC claims
|
||||
"saml:department": "engineering",
|
||||
"oidc:tenant": "acme",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "dynamic jwt claim substituted in resource arn",
|
||||
pattern: "arn:aws:s3:::softs/${jwt:project_path}/*",
|
||||
expected: "arn:aws:s3:::softs/some/namespace/project/*",
|
||||
},
|
||||
{
|
||||
name: "multiple dynamic jwt claims in one pattern",
|
||||
pattern: "${jwt:namespace_path}/${jwt:user_login}/data",
|
||||
expected: "some/namespace/mat/data",
|
||||
},
|
||||
{
|
||||
name: "dynamic numeric jwt claim is stringified",
|
||||
pattern: "projects/${jwt:project_id}/uploads",
|
||||
expected: "projects/301/uploads",
|
||||
},
|
||||
{
|
||||
name: "dynamic saml claim substituted",
|
||||
pattern: "dept/${saml:department}/*",
|
||||
expected: "dept/engineering/*",
|
||||
},
|
||||
{
|
||||
name: "dynamic oidc claim substituted",
|
||||
pattern: "tenants/${oidc:tenant}/data",
|
||||
expected: "tenants/acme/data",
|
||||
},
|
||||
{
|
||||
name: "missing jwt claim leaves placeholder intact",
|
||||
pattern: "softs/${jwt:missing_claim}/*",
|
||||
expected: "softs/${jwt:missing_claim}/*",
|
||||
},
|
||||
{
|
||||
name: "non-identity prefix is not substituted",
|
||||
pattern: "foo/${custom:project_path}/*",
|
||||
expected: "foo/${custom:project_path}/*",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := expandPolicyVariables(tt.pattern, evalCtx)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGitLabProjectUploadPolicy reproduces the GitLab OIDC policy from issue
|
||||
// #9214 end-to-end against the policy engine, proving that jwt:project_path
|
||||
// (a non-hardcoded JWT claim) is now substituted in Resource patterns so that
|
||||
// each project only gets access under its own prefix.
|
||||
func TestGitLabProjectUploadPolicy(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
require.NoError(t, engine.Initialize(&PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory",
|
||||
}))
|
||||
|
||||
policyDoc := &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Sid: "AllowPutInOwnFolder",
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:PutObject"},
|
||||
Resource: []string{"arn:aws:s3:::softs/${jwt:project_path}/*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, engine.AddPolicy("", "GitlabProjectUploadPolicy", policyDoc))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
mat := map[string]interface{}{
|
||||
"jwt:project_path": "some/namespace/project",
|
||||
}
|
||||
other := map[string]interface{}{
|
||||
"jwt:project_path": "other/namespace/project",
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
action string
|
||||
resource string
|
||||
reqCtx map[string]interface{}
|
||||
want Effect
|
||||
}{
|
||||
{
|
||||
name: "user writes into own project folder - allowed",
|
||||
action: "s3:PutObject",
|
||||
resource: "arn:aws:s3:::softs/some/namespace/project/build.zip",
|
||||
reqCtx: mat,
|
||||
want: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "user tries to write into another project - falls through to default Deny",
|
||||
action: "s3:PutObject",
|
||||
resource: "arn:aws:s3:::softs/other/namespace/project/build.zip",
|
||||
reqCtx: mat,
|
||||
want: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "different user writes into their own project - allowed",
|
||||
action: "s3:PutObject",
|
||||
resource: "arn:aws:s3:::softs/other/namespace/project/build.zip",
|
||||
reqCtx: other,
|
||||
want: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "user tries to GetObject - only PutObject allowed, default Deny",
|
||||
action: "s3:GetObject",
|
||||
resource: "arn:aws:s3:::softs/some/namespace/project/build.zip",
|
||||
reqCtx: mat,
|
||||
want: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "missing project_path claim - placeholder prevents match, default Deny",
|
||||
action: "s3:PutObject",
|
||||
resource: "arn:aws:s3:::softs/some/namespace/project/build.zip",
|
||||
reqCtx: map[string]interface{}{},
|
||||
want: EffectDeny,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "user",
|
||||
Action: tc.action,
|
||||
Resource: tc.resource,
|
||||
RequestContext: tc.reqCtx,
|
||||
}
|
||||
|
||||
result, err := engine.Evaluate(ctx, "", evalCtx, []string{"GitlabProjectUploadPolicy"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, result.Effect,
|
||||
"matching statements: %+v", result.MatchingStatements)
|
||||
})
|
||||
}
|
||||
}
|
||||
2036
weed/iam/policy/policy_engine.go
Normal file
2036
weed/iam/policy/policy_engine.go
Normal file
File diff suppressed because it is too large
Load Diff
386
weed/iam/policy/policy_engine_distributed_test.go
Normal file
386
weed/iam/policy/policy_engine_distributed_test.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDistributedPolicyEngine verifies that multiple PolicyEngine instances with identical configurations
|
||||
// behave consistently across distributed environments
|
||||
func TestDistributedPolicyEngine(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Common configuration for all instances
|
||||
commonConfig := &PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory", // For testing - would be "filer" in production
|
||||
StoreConfig: map[string]interface{}{},
|
||||
}
|
||||
|
||||
// Create multiple PolicyEngine instances simulating distributed deployment
|
||||
instance1 := NewPolicyEngine()
|
||||
instance2 := NewPolicyEngine()
|
||||
instance3 := NewPolicyEngine()
|
||||
|
||||
// Initialize all instances with identical configuration
|
||||
err := instance1.Initialize(commonConfig)
|
||||
require.NoError(t, err, "Instance 1 should initialize successfully")
|
||||
|
||||
err = instance2.Initialize(commonConfig)
|
||||
require.NoError(t, err, "Instance 2 should initialize successfully")
|
||||
|
||||
err = instance3.Initialize(commonConfig)
|
||||
require.NoError(t, err, "Instance 3 should initialize successfully")
|
||||
|
||||
// Test policy consistency across instances
|
||||
t.Run("policy_storage_consistency", func(t *testing.T) {
|
||||
// Define a test policy
|
||||
testPolicy := &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Sid: "AllowS3Read",
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject", "s3:ListBucket"},
|
||||
Resource: []string{"arn:aws:s3:::test-bucket/*", "arn:aws:s3:::test-bucket"},
|
||||
},
|
||||
{
|
||||
Sid: "DenyS3Write",
|
||||
Effect: "Deny",
|
||||
Action: []string{"s3:PutObject", "s3:DeleteObject"},
|
||||
Resource: []string{"arn:aws:s3:::test-bucket/*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Store policy on instance 1
|
||||
err := instance1.AddPolicy("", "TestPolicy", testPolicy)
|
||||
require.NoError(t, err, "Should be able to store policy on instance 1")
|
||||
|
||||
// For memory storage, each instance has separate storage
|
||||
// In production with filer storage, all instances would share the same policies
|
||||
|
||||
// Verify policy exists on instance 1
|
||||
storedPolicy1, err := instance1.store.GetPolicy(ctx, "", "TestPolicy")
|
||||
require.NoError(t, err, "Policy should exist on instance 1")
|
||||
assert.Equal(t, "2012-10-17", storedPolicy1.Version)
|
||||
assert.Len(t, storedPolicy1.Statement, 2)
|
||||
|
||||
// For demonstration: store same policy on other instances
|
||||
err = instance2.AddPolicy("", "TestPolicy", testPolicy)
|
||||
require.NoError(t, err, "Should be able to store policy on instance 2")
|
||||
|
||||
err = instance3.AddPolicy("", "TestPolicy", testPolicy)
|
||||
require.NoError(t, err, "Should be able to store policy on instance 3")
|
||||
})
|
||||
|
||||
// Test policy evaluation consistency
|
||||
t.Run("evaluation_consistency", func(t *testing.T) {
|
||||
// Create evaluation context
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/TestRole/session",
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/file.txt",
|
||||
RequestContext: map[string]interface{}{
|
||||
"sourceIp": "192.168.1.100",
|
||||
},
|
||||
}
|
||||
|
||||
// Evaluate policy on all instances
|
||||
result1, err1 := instance1.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
result2, err2 := instance2.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
result3, err3 := instance3.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
|
||||
require.NoError(t, err1, "Evaluation should succeed on instance 1")
|
||||
require.NoError(t, err2, "Evaluation should succeed on instance 2")
|
||||
require.NoError(t, err3, "Evaluation should succeed on instance 3")
|
||||
|
||||
// All instances should return identical results
|
||||
assert.Equal(t, result1.Effect, result2.Effect, "Instance 1 and 2 should have same effect")
|
||||
assert.Equal(t, result2.Effect, result3.Effect, "Instance 2 and 3 should have same effect")
|
||||
assert.Equal(t, EffectAllow, result1.Effect, "Should allow s3:GetObject")
|
||||
|
||||
// Matching statements should be identical
|
||||
assert.Len(t, result1.MatchingStatements, 1, "Should have one matching statement")
|
||||
assert.Len(t, result2.MatchingStatements, 1, "Should have one matching statement")
|
||||
assert.Len(t, result3.MatchingStatements, 1, "Should have one matching statement")
|
||||
|
||||
assert.Equal(t, "AllowS3Read", result1.MatchingStatements[0].StatementSid)
|
||||
assert.Equal(t, "AllowS3Read", result2.MatchingStatements[0].StatementSid)
|
||||
assert.Equal(t, "AllowS3Read", result3.MatchingStatements[0].StatementSid)
|
||||
})
|
||||
|
||||
// Test explicit deny precedence
|
||||
t.Run("deny_precedence_consistency", func(t *testing.T) {
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/TestRole/session",
|
||||
Action: "s3:PutObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/newfile.txt",
|
||||
}
|
||||
|
||||
// All instances should consistently apply deny precedence
|
||||
result1, err1 := instance1.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
result2, err2 := instance2.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
result3, err3 := instance3.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
|
||||
require.NoError(t, err1)
|
||||
require.NoError(t, err2)
|
||||
require.NoError(t, err3)
|
||||
|
||||
// All should deny due to explicit deny statement
|
||||
assert.Equal(t, EffectDeny, result1.Effect, "Instance 1 should deny write operation")
|
||||
assert.Equal(t, EffectDeny, result2.Effect, "Instance 2 should deny write operation")
|
||||
assert.Equal(t, EffectDeny, result3.Effect, "Instance 3 should deny write operation")
|
||||
|
||||
// Should have matching deny statement
|
||||
assert.Len(t, result1.MatchingStatements, 1)
|
||||
assert.Equal(t, "DenyS3Write", result1.MatchingStatements[0].StatementSid)
|
||||
assert.Equal(t, EffectDeny, result1.MatchingStatements[0].Effect)
|
||||
})
|
||||
|
||||
// Test default effect consistency
|
||||
t.Run("default_effect_consistency", func(t *testing.T) {
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/TestRole/session",
|
||||
Action: "filer:CreateEntry", // Action not covered by any policy
|
||||
Resource: "arn:aws:filer::path/test",
|
||||
}
|
||||
|
||||
result1, err1 := instance1.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
result2, err2 := instance2.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
result3, err3 := instance3.Evaluate(ctx, "", evalCtx, []string{"TestPolicy"})
|
||||
|
||||
require.NoError(t, err1)
|
||||
require.NoError(t, err2)
|
||||
require.NoError(t, err3)
|
||||
|
||||
// All should use default effect (Deny)
|
||||
assert.Equal(t, EffectDeny, result1.Effect, "Should use default effect")
|
||||
assert.Equal(t, EffectDeny, result2.Effect, "Should use default effect")
|
||||
assert.Equal(t, EffectDeny, result3.Effect, "Should use default effect")
|
||||
|
||||
// No matching statements
|
||||
assert.Empty(t, result1.MatchingStatements, "Should have no matching statements")
|
||||
assert.Empty(t, result2.MatchingStatements, "Should have no matching statements")
|
||||
assert.Empty(t, result3.MatchingStatements, "Should have no matching statements")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPolicyEngineConfigurationConsistency tests configuration validation for distributed deployments
|
||||
func TestPolicyEngineConfigurationConsistency(t *testing.T) {
|
||||
t.Run("consistent_default_effects_required", func(t *testing.T) {
|
||||
// Different default effects could lead to inconsistent authorization
|
||||
config1 := &PolicyEngineConfig{
|
||||
DefaultEffect: "Allow",
|
||||
StoreType: "memory",
|
||||
}
|
||||
|
||||
config2 := &PolicyEngineConfig{
|
||||
DefaultEffect: "Deny", // Different default!
|
||||
StoreType: "memory",
|
||||
}
|
||||
|
||||
instance1 := NewPolicyEngine()
|
||||
instance2 := NewPolicyEngine()
|
||||
|
||||
err1 := instance1.Initialize(config1)
|
||||
err2 := instance2.Initialize(config2)
|
||||
|
||||
require.NoError(t, err1)
|
||||
require.NoError(t, err2)
|
||||
|
||||
// Test with an action not covered by any policy
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/TestRole/session",
|
||||
Action: "uncovered:action",
|
||||
Resource: "arn:aws:test:::resource",
|
||||
}
|
||||
|
||||
result1, _ := instance1.Evaluate(context.Background(), "", evalCtx, []string{})
|
||||
result2, _ := instance2.Evaluate(context.Background(), "", evalCtx, []string{})
|
||||
|
||||
// Results should be different due to different default effects
|
||||
assert.NotEqual(t, result1.Effect, result2.Effect, "Different default effects should produce different results")
|
||||
assert.Equal(t, EffectAllow, result1.Effect, "Instance 1 should allow by default")
|
||||
assert.Equal(t, EffectDeny, result2.Effect, "Instance 2 should deny by default")
|
||||
})
|
||||
|
||||
t.Run("invalid_configuration_handling", func(t *testing.T) {
|
||||
invalidConfigs := []*PolicyEngineConfig{
|
||||
{
|
||||
DefaultEffect: "Maybe", // Invalid effect
|
||||
StoreType: "memory",
|
||||
},
|
||||
{
|
||||
DefaultEffect: "Allow",
|
||||
StoreType: "nonexistent", // Invalid store type
|
||||
},
|
||||
}
|
||||
|
||||
for i, config := range invalidConfigs {
|
||||
t.Run(fmt.Sprintf("invalid_config_%d", i), func(t *testing.T) {
|
||||
instance := NewPolicyEngine()
|
||||
err := instance.Initialize(config)
|
||||
assert.Error(t, err, "Should reject invalid configuration")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPolicyStoreDistributed tests policy store behavior in distributed scenarios
|
||||
func TestPolicyStoreDistributed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("memory_store_isolation", func(t *testing.T) {
|
||||
// Memory stores are isolated per instance (not suitable for distributed)
|
||||
store1 := NewMemoryPolicyStore()
|
||||
store2 := NewMemoryPolicyStore()
|
||||
|
||||
policy := &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Store policy in store1
|
||||
err := store1.StorePolicy(ctx, "", "TestPolicy", policy)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Policy should exist in store1
|
||||
_, err = store1.GetPolicy(ctx, "", "TestPolicy")
|
||||
assert.NoError(t, err, "Policy should exist in store1")
|
||||
|
||||
// Policy should NOT exist in store2 (different instance)
|
||||
_, err = store2.GetPolicy(ctx, "", "TestPolicy")
|
||||
assert.Error(t, err, "Policy should not exist in store2")
|
||||
assert.Contains(t, err.Error(), "not found", "Should be a not found error")
|
||||
})
|
||||
|
||||
t.Run("policy_loading_error_handling", func(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
config := &PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory",
|
||||
}
|
||||
|
||||
err := engine.Initialize(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/TestRole/session",
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket/key",
|
||||
}
|
||||
|
||||
// Evaluate with non-existent policies
|
||||
result, err := engine.Evaluate(ctx, "", evalCtx, []string{"NonExistentPolicy1", "NonExistentPolicy2"})
|
||||
require.NoError(t, err, "Should not error on missing policies")
|
||||
|
||||
// Should use default effect when no policies can be loaded
|
||||
assert.Equal(t, EffectDeny, result.Effect, "Should use default effect")
|
||||
assert.Empty(t, result.MatchingStatements, "Should have no matching statements")
|
||||
})
|
||||
}
|
||||
|
||||
// TestFilerPolicyStoreConfiguration tests filer policy store configuration for distributed deployments
|
||||
func TestFilerPolicyStoreConfiguration(t *testing.T) {
|
||||
t.Run("filer_store_creation", func(t *testing.T) {
|
||||
// Test with minimal configuration
|
||||
config := map[string]interface{}{
|
||||
"filerAddress": "localhost:8888",
|
||||
}
|
||||
|
||||
store, err := NewFilerPolicyStore(config, nil)
|
||||
require.NoError(t, err, "Should create filer policy store with minimal config")
|
||||
assert.NotNil(t, store)
|
||||
})
|
||||
|
||||
t.Run("filer_store_custom_path", func(t *testing.T) {
|
||||
config := map[string]interface{}{
|
||||
"filerAddress": "prod-filer:8888",
|
||||
"basePath": "/custom/iam/policies",
|
||||
}
|
||||
|
||||
store, err := NewFilerPolicyStore(config, nil)
|
||||
require.NoError(t, err, "Should create filer policy store with custom path")
|
||||
assert.NotNil(t, store)
|
||||
})
|
||||
|
||||
t.Run("filer_store_missing_address", func(t *testing.T) {
|
||||
config := map[string]interface{}{
|
||||
"basePath": "/seaweedfs/iam/policies",
|
||||
}
|
||||
|
||||
store, err := NewFilerPolicyStore(config, nil)
|
||||
assert.NoError(t, err, "Should create filer store without filerAddress in config")
|
||||
assert.NotNil(t, store, "Store should be created successfully")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPolicyEvaluationPerformance tests performance considerations for distributed policy evaluation
|
||||
func TestPolicyEvaluationPerformance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create engine with memory store (for performance baseline)
|
||||
engine := NewPolicyEngine()
|
||||
config := &PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory",
|
||||
}
|
||||
|
||||
err := engine.Initialize(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add multiple policies
|
||||
for i := 0; i < 10; i++ {
|
||||
policy := &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Sid: fmt.Sprintf("Statement%d", i),
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject", "s3:ListBucket"},
|
||||
Resource: []string{fmt.Sprintf("arn:aws:s3:::bucket%d/*", i)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := engine.AddPolicy("", fmt.Sprintf("Policy%d", i), policy)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Test evaluation performance
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "arn:aws:sts::assumed-role/TestRole/session",
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket5/file.txt",
|
||||
}
|
||||
|
||||
policyNames := make([]string, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
policyNames[i] = fmt.Sprintf("Policy%d", i)
|
||||
}
|
||||
|
||||
// Measure evaluation time
|
||||
start := time.Now()
|
||||
for i := 0; i < 100; i++ {
|
||||
_, err := engine.Evaluate(ctx, "", evalCtx, policyNames)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
duration := time.Since(start)
|
||||
|
||||
// Should be reasonably fast (less than 10ms per evaluation on average)
|
||||
avgDuration := duration / 100
|
||||
t.Logf("Average policy evaluation time: %v", avgDuration)
|
||||
assert.Less(t, avgDuration, 10*time.Millisecond, "Policy evaluation should be fast")
|
||||
}
|
||||
531
weed/iam/policy/policy_store.go
Normal file
531
weed/iam/policy/policy_store.go
Normal file
@@ -0,0 +1,531 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/credential"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// MemoryPolicyStore implements PolicyStore using in-memory storage
|
||||
type MemoryPolicyStore struct {
|
||||
policies map[string]*PolicyDocument
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMemoryPolicyStore creates a new memory-based policy store
|
||||
func NewMemoryPolicyStore() *MemoryPolicyStore {
|
||||
return &MemoryPolicyStore{
|
||||
policies: make(map[string]*PolicyDocument),
|
||||
}
|
||||
}
|
||||
|
||||
// StorePolicy stores a policy document in memory (filerAddress ignored for memory store)
|
||||
func (s *MemoryPolicyStore) StorePolicy(ctx context.Context, filerAddress string, name string, policy *PolicyDocument) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("policy name cannot be empty")
|
||||
}
|
||||
|
||||
if policy == nil {
|
||||
return fmt.Errorf("policy cannot be nil")
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
// Deep copy the policy to prevent external modifications
|
||||
s.policies[name] = copyPolicyDocument(policy)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPolicy retrieves a policy document from memory (filerAddress ignored for memory store)
|
||||
func (s *MemoryPolicyStore) GetPolicy(ctx context.Context, filerAddress string, name string) (*PolicyDocument, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("policy name cannot be empty")
|
||||
}
|
||||
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
policy, exists := s.policies[name]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("policy not found: %s", name)
|
||||
}
|
||||
|
||||
// Return a copy to prevent external modifications
|
||||
return copyPolicyDocument(policy), nil
|
||||
}
|
||||
|
||||
// DeletePolicy deletes a policy document from memory (filerAddress ignored for memory store)
|
||||
func (s *MemoryPolicyStore) DeletePolicy(ctx context.Context, filerAddress string, name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("policy name cannot be empty")
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
delete(s.policies, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPolicies lists all policy names in memory (filerAddress ignored for memory store)
|
||||
func (s *MemoryPolicyStore) ListPolicies(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(s.policies))
|
||||
for name := range s.policies {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// copyPolicyDocument creates a deep copy of a policy document
|
||||
func copyPolicyDocument(original *PolicyDocument) *PolicyDocument {
|
||||
if original == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
copied := &PolicyDocument{
|
||||
Version: original.Version,
|
||||
Id: original.Id,
|
||||
}
|
||||
|
||||
// Copy statements
|
||||
copied.Statement = make([]Statement, len(original.Statement))
|
||||
for i, stmt := range original.Statement {
|
||||
copied.Statement[i] = Statement{
|
||||
Sid: stmt.Sid,
|
||||
Effect: stmt.Effect,
|
||||
Principal: stmt.Principal,
|
||||
NotPrincipal: stmt.NotPrincipal,
|
||||
}
|
||||
|
||||
// Copy action slice
|
||||
if stmt.Action != nil {
|
||||
copied.Statement[i].Action = make([]string, len(stmt.Action))
|
||||
copy(copied.Statement[i].Action, stmt.Action)
|
||||
}
|
||||
|
||||
// Copy NotAction slice
|
||||
if stmt.NotAction != nil {
|
||||
copied.Statement[i].NotAction = make([]string, len(stmt.NotAction))
|
||||
copy(copied.Statement[i].NotAction, stmt.NotAction)
|
||||
}
|
||||
|
||||
// Copy resource slice
|
||||
if stmt.Resource != nil {
|
||||
copied.Statement[i].Resource = make([]string, len(stmt.Resource))
|
||||
copy(copied.Statement[i].Resource, stmt.Resource)
|
||||
}
|
||||
|
||||
// Copy NotResource slice
|
||||
if stmt.NotResource != nil {
|
||||
copied.Statement[i].NotResource = make([]string, len(stmt.NotResource))
|
||||
copy(copied.Statement[i].NotResource, stmt.NotResource)
|
||||
}
|
||||
|
||||
// Copy condition map
|
||||
if stmt.Condition != nil {
|
||||
copied.Statement[i].Condition = make(map[string]map[string]interface{})
|
||||
for conditionType, conditionValues := range stmt.Condition {
|
||||
copiedConditionValues := make(map[string]interface{}, len(conditionValues))
|
||||
for conditionKey, conditionValue := range conditionValues {
|
||||
copiedConditionValues[conditionKey] = copyPolicyConditionValue(conditionValue)
|
||||
}
|
||||
copied.Statement[i].Condition[conditionType] = copiedConditionValues
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return copied
|
||||
}
|
||||
|
||||
func copyPolicyConditionValue(value interface{}) interface{} {
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
copied := make([]string, len(v))
|
||||
copy(copied, v)
|
||||
return copied
|
||||
case []interface{}:
|
||||
copied := make([]interface{}, len(v))
|
||||
for i := range v {
|
||||
copied[i] = copyPolicyConditionValue(v[i])
|
||||
}
|
||||
return copied
|
||||
case map[string]interface{}:
|
||||
copied := make(map[string]interface{}, len(v))
|
||||
for key, nestedValue := range v {
|
||||
copied[key] = copyPolicyConditionValue(nestedValue)
|
||||
}
|
||||
return copied
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// FilerPolicyStore implements PolicyStore using SeaweedFS filer
|
||||
type FilerPolicyStore struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
basePath string
|
||||
filerAddressProvider func() string
|
||||
}
|
||||
|
||||
// NewFilerPolicyStore creates a new filer-based policy store
|
||||
func NewFilerPolicyStore(config map[string]interface{}, filerAddressProvider func() string) (*FilerPolicyStore, error) {
|
||||
store := &FilerPolicyStore{
|
||||
basePath: "/etc/iam/policies", // Default path for policy storage - aligned with /etc/ convention
|
||||
filerAddressProvider: filerAddressProvider,
|
||||
}
|
||||
|
||||
// Parse configuration - only basePath and other settings, NOT filerAddress
|
||||
if config != nil {
|
||||
if basePath, ok := config["basePath"].(string); ok && basePath != "" {
|
||||
store.basePath = strings.TrimSuffix(basePath, "/")
|
||||
}
|
||||
}
|
||||
|
||||
glog.V(2).Infof("Initialized FilerPolicyStore with basePath %s", store.basePath)
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// StorePolicy stores a policy document in filer
|
||||
func (s *FilerPolicyStore) StorePolicy(ctx context.Context, filerAddress string, name string, policy *PolicyDocument) error {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && s.filerAddressProvider != nil {
|
||||
filerAddress = s.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required for FilerPolicyStore")
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("policy name cannot be empty")
|
||||
}
|
||||
if policy == nil {
|
||||
return fmt.Errorf("policy cannot be nil")
|
||||
}
|
||||
|
||||
// Serialize policy to JSON
|
||||
policyData, err := json.MarshalIndent(policy, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize policy: %v", err)
|
||||
}
|
||||
|
||||
policyPath := s.getPolicyPath(name)
|
||||
|
||||
// Store in filer
|
||||
return s.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
glog.V(3).Infof("Storing policy %s at %s", name, policyPath)
|
||||
if err := s.savePolicyFile(ctx, client, s.getPolicyFileName(name), policyData); err != nil {
|
||||
return fmt.Errorf("failed to store policy %s: %v", name, err)
|
||||
}
|
||||
if err := s.deleteLegacyPolicyFileIfPresent(ctx, client, name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetPolicy retrieves a policy document from filer
|
||||
func (s *FilerPolicyStore) GetPolicy(ctx context.Context, filerAddress string, name string) (*PolicyDocument, error) {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && s.filerAddressProvider != nil {
|
||||
filerAddress = s.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required for FilerPolicyStore")
|
||||
}
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("policy name cannot be empty")
|
||||
}
|
||||
|
||||
var policyData []byte
|
||||
err := s.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
for _, fileName := range s.getPolicyLookupFileNames(name) {
|
||||
request := &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Name: fileName,
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Looking up policy %s as %s", name, fileName)
|
||||
response, err := client.LookupDirectoryEntry(ctx, request)
|
||||
if err != nil {
|
||||
if isNotFoundPolicyStoreError(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("policy lookup failed: %v", err)
|
||||
}
|
||||
|
||||
if response.Entry == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
policyData = response.Entry.Content
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("policy not found")
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Deserialize policy from JSON
|
||||
var policy PolicyDocument
|
||||
if err := json.Unmarshal(policyData, &policy); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize policy: %v", err)
|
||||
}
|
||||
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
// DeletePolicy deletes a policy document from filer
|
||||
func (s *FilerPolicyStore) DeletePolicy(ctx context.Context, filerAddress string, name string) error {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && s.filerAddressProvider != nil {
|
||||
filerAddress = s.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required for FilerPolicyStore")
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("policy name cannot be empty")
|
||||
}
|
||||
|
||||
return s.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
for _, fileName := range s.getPolicyLookupFileNames(name) {
|
||||
request := &filer_pb.DeleteEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Name: fileName,
|
||||
IsDeleteData: true,
|
||||
IsRecursive: false,
|
||||
IgnoreRecursiveError: false,
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Deleting policy %s as %s", name, fileName)
|
||||
resp, err := client.DeleteEntry(ctx, request)
|
||||
if err != nil {
|
||||
if isNotFoundPolicyStoreError(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("failed to delete policy %s: %v", name, err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("failed to delete policy %s: %s", name, resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ListPolicies lists all policy names in filer
|
||||
func (s *FilerPolicyStore) ListPolicies(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
// Use provider function if filerAddress is not provided
|
||||
if filerAddress == "" && s.filerAddressProvider != nil {
|
||||
filerAddress = s.filerAddressProvider()
|
||||
}
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required for FilerPolicyStore")
|
||||
}
|
||||
|
||||
var policyNames []string
|
||||
|
||||
err := s.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
// List all entries in the policy directory
|
||||
request := &filer_pb.ListEntriesRequest{
|
||||
Directory: s.basePath,
|
||||
Prefix: "",
|
||||
StartFromFileName: "",
|
||||
InclusiveStartFrom: false,
|
||||
Limit: 1000, // Process in batches of 1000
|
||||
}
|
||||
|
||||
stream, err := client.ListEntries(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list policies: %v", err)
|
||||
}
|
||||
|
||||
for {
|
||||
resp, err := stream.Recv()
|
||||
if err != nil {
|
||||
break // End of stream or error
|
||||
}
|
||||
|
||||
if resp.Entry == nil || resp.Entry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
|
||||
if policyName, ok := s.policyNameFromFileName(resp.Entry.Name); ok {
|
||||
policyNames = append(policyNames, policyName)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uniquePolicyNames := make([]string, 0, len(policyNames))
|
||||
seen := make(map[string]struct{}, len(policyNames))
|
||||
for _, policyName := range policyNames {
|
||||
if _, found := seen[policyName]; found {
|
||||
continue
|
||||
}
|
||||
seen[policyName] = struct{}{}
|
||||
uniquePolicyNames = append(uniquePolicyNames, policyName)
|
||||
}
|
||||
|
||||
return uniquePolicyNames, nil
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
// withFilerClient executes a function with a filer client
|
||||
func (s *FilerPolicyStore) withFilerClient(filerAddress string, fn func(client filer_pb.SeaweedFilerClient) error) error {
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required for FilerPolicyStore")
|
||||
}
|
||||
|
||||
// Use the pb.WithGrpcFilerClient helper similar to existing SeaweedFS code
|
||||
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), s.grpcDialOption, fn)
|
||||
}
|
||||
|
||||
// getPolicyPath returns the full path for a policy
|
||||
func (s *FilerPolicyStore) getPolicyPath(policyName string) string {
|
||||
return s.basePath + "/" + s.getPolicyFileName(policyName)
|
||||
}
|
||||
|
||||
// getPolicyFileName returns the filename for a policy
|
||||
func (s *FilerPolicyStore) getPolicyFileName(policyName string) string {
|
||||
return s.getCanonicalPolicyFileName(policyName)
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) getLegacyPolicyFileName(policyName string) string {
|
||||
return "policy_" + policyName + ".json"
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) getCanonicalPolicyFileName(policyName string) string {
|
||||
return policyName + ".json"
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) getPolicyLookupFileNames(policyName string) []string {
|
||||
return []string{
|
||||
s.getCanonicalPolicyFileName(policyName),
|
||||
s.getLegacyPolicyFileName(policyName),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) policyNameFromFileName(fileName string) (string, bool) {
|
||||
if !strings.HasSuffix(fileName, ".json") {
|
||||
return "", false
|
||||
}
|
||||
policyName := strings.TrimSuffix(fileName, ".json")
|
||||
if strings.HasPrefix(fileName, "policy_") {
|
||||
policyName = strings.TrimPrefix(policyName, "policy_")
|
||||
}
|
||||
if s.isSupportedPolicyName(policyName) {
|
||||
return policyName, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) isSupportedPolicyName(policyName string) bool {
|
||||
if policyName == "" {
|
||||
return false
|
||||
}
|
||||
// Bucket policies are stored alongside IAM policies but use the internal
|
||||
// "bucket-policy:<bucket>" naming scheme, which is intentionally outside the
|
||||
// public IAM policy-name validator.
|
||||
if strings.HasPrefix(policyName, "bucket-policy:") {
|
||||
return len(policyName) > len("bucket-policy:")
|
||||
}
|
||||
return credential.ValidatePolicyName(policyName) == nil
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) deleteLegacyPolicyFileIfPresent(ctx context.Context, client filer_pb.SeaweedFilerClient, policyName string) error {
|
||||
legacyFileName := s.getLegacyPolicyFileName(policyName)
|
||||
response, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Name: legacyFileName,
|
||||
IsDeleteData: true,
|
||||
IsRecursive: false,
|
||||
IgnoreRecursiveError: false,
|
||||
})
|
||||
if err != nil {
|
||||
if isNotFoundPolicyStoreError(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to delete legacy policy %s: %v", policyName, err)
|
||||
}
|
||||
if response.Error != "" {
|
||||
return fmt.Errorf("failed to delete legacy policy %s: %s", policyName, response.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) savePolicyFile(ctx context.Context, client filer_pb.SeaweedFilerClient, fileName string, content []byte) error {
|
||||
now := time.Now().Unix()
|
||||
entry := &filer_pb.Entry{
|
||||
Name: fileName,
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: now,
|
||||
Crtime: now,
|
||||
FileMode: uint32(0600),
|
||||
Uid: uint32(0),
|
||||
Gid: uint32(0),
|
||||
FileSize: uint64(len(content)),
|
||||
},
|
||||
Content: content,
|
||||
}
|
||||
|
||||
createRequest := &filer_pb.CreateEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Entry: entry,
|
||||
}
|
||||
|
||||
if err := filer_pb.CreateEntry(ctx, client, createRequest); err == nil {
|
||||
return nil
|
||||
} else if !isAlreadyExistsPolicyStoreError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
return filer_pb.UpdateEntry(ctx, client, &filer_pb.UpdateEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Entry: entry,
|
||||
})
|
||||
}
|
||||
|
||||
func isNotFoundPolicyStoreError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return errors.Is(err, filer_pb.ErrNotFound) || status.Code(err) == codes.NotFound
|
||||
}
|
||||
|
||||
func isAlreadyExistsPolicyStoreError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return status.Code(err) == codes.AlreadyExists
|
||||
}
|
||||
314
weed/iam/policy/policy_store_test.go
Normal file
314
weed/iam/policy/policy_store_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type policyStoreTestFilerServer struct {
|
||||
filer_pb.UnimplementedSeaweedFilerServer
|
||||
mu sync.RWMutex
|
||||
entries map[string]*filer_pb.Entry
|
||||
}
|
||||
|
||||
func newPolicyStoreTestFilerServer() *policyStoreTestFilerServer {
|
||||
return &policyStoreTestFilerServer{
|
||||
entries: make(map[string]*filer_pb.Entry),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
entry, found := s.entries[policyStoreTestEntryKey(req.Directory, req.Name)]
|
||||
if !found {
|
||||
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
||||
}
|
||||
|
||||
return &filer_pb.LookupDirectoryEntryResponse{Entry: clonePolicyStoreEntry(entry)}, nil
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) CreateEntry(_ context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key := policyStoreTestEntryKey(req.Directory, req.Entry.Name)
|
||||
if _, found := s.entries[key]; found {
|
||||
return nil, status.Error(codes.AlreadyExists, "entry already exists")
|
||||
}
|
||||
|
||||
s.entries[key] = clonePolicyStoreEntry(req.Entry)
|
||||
return &filer_pb.CreateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) UpdateEntry(_ context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key := policyStoreTestEntryKey(req.Directory, req.Entry.Name)
|
||||
if _, found := s.entries[key]; !found {
|
||||
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
||||
}
|
||||
|
||||
s.entries[key] = clonePolicyStoreEntry(req.Entry)
|
||||
return &filer_pb.UpdateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) ListEntries(req *filer_pb.ListEntriesRequest, stream grpc.ServerStreamingServer[filer_pb.ListEntriesResponse]) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
names := make([]string, 0)
|
||||
for key := range s.entries {
|
||||
dir, name := splitPolicyStoreEntryKey(key)
|
||||
if dir != req.Directory {
|
||||
continue
|
||||
}
|
||||
if req.Prefix != "" && len(name) >= len(req.Prefix) && name[:len(req.Prefix)] != req.Prefix {
|
||||
continue
|
||||
}
|
||||
if req.Prefix != "" && len(name) < len(req.Prefix) {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
if err := stream.Send(&filer_pb.ListEntriesResponse{
|
||||
Entry: clonePolicyStoreEntry(s.entries[policyStoreTestEntryKey(req.Directory, name)]),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key := policyStoreTestEntryKey(req.Directory, req.Name)
|
||||
if _, found := s.entries[key]; !found {
|
||||
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
||||
}
|
||||
|
||||
delete(s.entries, key)
|
||||
return &filer_pb.DeleteEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) putPolicyFile(t *testing.T, dir string, name string, document *PolicyDocument) {
|
||||
t.Helper()
|
||||
|
||||
content, err := json.Marshal(document)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.entries[policyStoreTestEntryKey(dir, name)] = &filer_pb.Entry{
|
||||
Name: name,
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) hasEntry(dir string, name string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, found := s.entries[policyStoreTestEntryKey(dir, name)]
|
||||
return found
|
||||
}
|
||||
|
||||
func newTestFilerPolicyStore(t *testing.T) (*FilerPolicyStore, *policyStoreTestFilerServer) {
|
||||
t.Helper()
|
||||
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
server := newPolicyStoreTestFilerServer()
|
||||
grpcServer := pb.NewGrpcServer()
|
||||
filer_pb.RegisterSeaweedFilerServer(grpcServer, server)
|
||||
go func() {
|
||||
_ = grpcServer.Serve(lis)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
grpcServer.Stop()
|
||||
_ = lis.Close()
|
||||
})
|
||||
|
||||
host, portString, err := net.SplitHostPort(lis.Addr().String())
|
||||
require.NoError(t, err)
|
||||
grpcPort, err := strconv.Atoi(portString)
|
||||
require.NoError(t, err)
|
||||
|
||||
store, err := NewFilerPolicyStore(nil, func() string {
|
||||
return string(pb.NewServerAddress(host, 1, grpcPort))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
store.grpcDialOption = grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
|
||||
return store, server
|
||||
}
|
||||
|
||||
func TestFilerPolicyStoreGetPolicyPrefersCanonicalFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newTestFilerPolicyStore(t)
|
||||
|
||||
server.putPolicyFile(t, store.basePath, "cli-bucket-access-policy.json", testPolicyDocument("s3:ListBucket", "arn:aws:s3:::cli-allowed-bucket"))
|
||||
server.putPolicyFile(t, store.basePath, "policy_cli-bucket-access-policy.json", testPolicyDocument("s3:PutObject", "arn:aws:s3:::cli-forbidden-bucket/*"))
|
||||
|
||||
document, err := store.GetPolicy(ctx, "", "cli-bucket-access-policy")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, document.Statement, 1)
|
||||
assert.Equal(t, "s3:ListBucket", document.Statement[0].Action[0])
|
||||
assert.Equal(t, "arn:aws:s3:::cli-allowed-bucket", document.Statement[0].Resource[0])
|
||||
}
|
||||
|
||||
func TestFilerPolicyStoreListPoliciesIncludesCanonicalAndLegacyFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newTestFilerPolicyStore(t)
|
||||
|
||||
server.putPolicyFile(t, store.basePath, "canonical-only.json", testPolicyDocument("s3:GetObject", "arn:aws:s3:::canonical-only/*"))
|
||||
server.putPolicyFile(t, store.basePath, "policy_legacy-only.json", testPolicyDocument("s3:PutObject", "arn:aws:s3:::legacy-only/*"))
|
||||
server.putPolicyFile(t, store.basePath, "shared.json", testPolicyDocument("s3:DeleteObject", "arn:aws:s3:::shared/*"))
|
||||
server.putPolicyFile(t, store.basePath, "policy_shared.json", testPolicyDocument("s3:ListBucket", "arn:aws:s3:::shared"))
|
||||
server.putPolicyFile(t, store.basePath, "policy_invalid:name.json", testPolicyDocument("s3:GetObject", "arn:aws:s3:::ignored/*"))
|
||||
server.putPolicyFile(t, store.basePath, "bucket-policy:bucket-a.json", testPolicyDocument("s3:ListBucket", "arn:aws:s3:::bucket-a"))
|
||||
|
||||
names, err := store.ListPolicies(ctx, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.ElementsMatch(t, []string{"canonical-only", "legacy-only", "shared", "bucket-policy:bucket-a"}, names)
|
||||
}
|
||||
|
||||
func TestFilerPolicyStoreDeletePolicyRemovesCanonicalAndLegacyFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newTestFilerPolicyStore(t)
|
||||
|
||||
server.putPolicyFile(t, store.basePath, "dual-format.json", testPolicyDocument("s3:GetObject", "arn:aws:s3:::dual-format/*"))
|
||||
server.putPolicyFile(t, store.basePath, "policy_dual-format.json", testPolicyDocument("s3:PutObject", "arn:aws:s3:::dual-format/*"))
|
||||
|
||||
require.NoError(t, store.DeletePolicy(ctx, "", "dual-format"))
|
||||
assert.False(t, server.hasEntry(store.basePath, "dual-format.json"))
|
||||
assert.False(t, server.hasEntry(store.basePath, "policy_dual-format.json"))
|
||||
}
|
||||
|
||||
func TestFilerPolicyStoreStorePolicyWritesCanonicalFileAndRemovesLegacyTwin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newTestFilerPolicyStore(t)
|
||||
|
||||
server.putPolicyFile(t, store.basePath, "policy_dual-format.json", testPolicyDocument("s3:PutObject", "arn:aws:s3:::dual-format/*"))
|
||||
|
||||
require.NoError(t, store.StorePolicy(ctx, "", "dual-format", testPolicyDocument("s3:GetObject", "arn:aws:s3:::dual-format/*")))
|
||||
|
||||
assert.True(t, server.hasEntry(store.basePath, "dual-format.json"))
|
||||
assert.False(t, server.hasEntry(store.basePath, "policy_dual-format.json"))
|
||||
|
||||
document, err := store.GetPolicy(ctx, "", "dual-format")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, document.Statement, 1)
|
||||
assert.Equal(t, "s3:GetObject", document.Statement[0].Action[0])
|
||||
}
|
||||
|
||||
func TestFilerPolicyStoreStorePolicyUpdatesExistingCanonicalFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newTestFilerPolicyStore(t)
|
||||
|
||||
server.putPolicyFile(t, store.basePath, "existing.json", testPolicyDocument("s3:PutObject", "arn:aws:s3:::existing/*"))
|
||||
|
||||
require.NoError(t, store.StorePolicy(ctx, "", "existing", testPolicyDocument("s3:GetObject", "arn:aws:s3:::existing/*")))
|
||||
|
||||
document, err := store.GetPolicy(ctx, "", "existing")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, document.Statement, 1)
|
||||
assert.Equal(t, "s3:GetObject", document.Statement[0].Action[0])
|
||||
assert.Equal(t, "arn:aws:s3:::existing/*", document.Statement[0].Resource[0])
|
||||
}
|
||||
|
||||
func TestCopyPolicyDocumentClonesConditionState(t *testing.T) {
|
||||
original := &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{
|
||||
"arn:aws:s3:::test-bucket/*",
|
||||
},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"StringEquals": {
|
||||
"s3:prefix": []string{"public/", "private/"},
|
||||
},
|
||||
"Null": {
|
||||
"aws:PrincipalArn": "false",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
copied := copyPolicyDocument(original)
|
||||
require.NotNil(t, copied)
|
||||
|
||||
original.Statement[0].Condition["StringEquals"]["s3:prefix"] = []string{"mutated/"}
|
||||
original.Statement[0].Condition["Null"]["aws:PrincipalArn"] = "true"
|
||||
|
||||
assert.Equal(t, []string{"public/", "private/"}, copied.Statement[0].Condition["StringEquals"]["s3:prefix"])
|
||||
assert.Equal(t, "false", copied.Statement[0].Condition["Null"]["aws:PrincipalArn"])
|
||||
}
|
||||
|
||||
func TestIsAlreadyExistsPolicyStoreErrorUsesStatusCode(t *testing.T) {
|
||||
assert.True(t, isAlreadyExistsPolicyStoreError(status.Error(codes.AlreadyExists, "entry already exists")))
|
||||
assert.False(t, isAlreadyExistsPolicyStoreError(fmt.Errorf("entry already exists")))
|
||||
}
|
||||
|
||||
func testPolicyDocument(action string, resource string) *PolicyDocument {
|
||||
return &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: []string{action},
|
||||
Resource: []string{resource},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func clonePolicyStoreEntry(entry *filer_pb.Entry) *filer_pb.Entry {
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
return proto.Clone(entry).(*filer_pb.Entry)
|
||||
}
|
||||
|
||||
func policyStoreTestEntryKey(dir string, name string) string {
|
||||
return dir + "\x00" + name
|
||||
}
|
||||
|
||||
func splitPolicyStoreEntryKey(key string) (string, string) {
|
||||
for i := 0; i < len(key); i++ {
|
||||
if key[i] == '\x00' {
|
||||
return key[:i], key[i+1:]
|
||||
}
|
||||
}
|
||||
return key, ""
|
||||
}
|
||||
191
weed/iam/policy/policy_variable_matching_test.go
Normal file
191
weed/iam/policy/policy_variable_matching_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPolicyVariableMatchingInActionsAndResources tests that Actions and Resources
|
||||
// now support policy variables like ${aws:username} just like string conditions do
|
||||
func TestPolicyVariableMatchingInActionsAndResources(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
config := &PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory",
|
||||
}
|
||||
|
||||
err := engine.Initialize(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
filerAddress := ""
|
||||
|
||||
// Create a policy that uses policy variables in Action and Resource fields
|
||||
policyDoc := &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Sid: "AllowUserSpecificActions",
|
||||
Effect: "Allow",
|
||||
Action: []string{
|
||||
"s3:Get*", // Regular wildcard
|
||||
"s3:${aws:principaltype}*", // Policy variable in action
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:aws:s3:::user-${aws:username}/*", // Policy variable in resource
|
||||
"arn:aws:s3:::shared/${saml:username}/*", // Different policy variable
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = engine.AddPolicy(filerAddress, "user-specific-policy", policyDoc)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
principal string
|
||||
action string
|
||||
resource string
|
||||
requestContext map[string]interface{}
|
||||
expectedEffect Effect
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "policy_variable_in_action_matches",
|
||||
principal: "test-user",
|
||||
action: "s3:AssumedRole", // Should match s3:${aws:principaltype}* when principaltype=AssumedRole
|
||||
resource: "arn:aws:s3:::user-testuser/file.txt",
|
||||
requestContext: map[string]interface{}{
|
||||
"aws:username": "testuser",
|
||||
"aws:principaltype": "AssumedRole",
|
||||
},
|
||||
expectedEffect: EffectAllow,
|
||||
description: "Action with policy variable should match when variable is expanded",
|
||||
},
|
||||
{
|
||||
name: "policy_variable_in_resource_matches",
|
||||
principal: "alice",
|
||||
action: "s3:GetObject",
|
||||
resource: "arn:aws:s3:::user-alice/document.pdf", // Should match user-${aws:username}/*
|
||||
requestContext: map[string]interface{}{
|
||||
"aws:username": "alice",
|
||||
},
|
||||
expectedEffect: EffectAllow,
|
||||
description: "Resource with policy variable should match when variable is expanded",
|
||||
},
|
||||
{
|
||||
name: "saml_username_variable_in_resource",
|
||||
principal: "bob",
|
||||
action: "s3:GetObject",
|
||||
resource: "arn:aws:s3:::shared/bob/data.json", // Should match shared/${saml:username}/*
|
||||
requestContext: map[string]interface{}{
|
||||
"saml:username": "bob",
|
||||
},
|
||||
expectedEffect: EffectAllow,
|
||||
description: "SAML username variable should be expanded in resource patterns",
|
||||
},
|
||||
{
|
||||
name: "policy_variable_no_match_wrong_user",
|
||||
principal: "charlie",
|
||||
action: "s3:GetObject",
|
||||
resource: "arn:aws:s3:::user-alice/file.txt", // charlie trying to access alice's files
|
||||
requestContext: map[string]interface{}{
|
||||
"aws:username": "charlie",
|
||||
},
|
||||
expectedEffect: EffectDeny,
|
||||
description: "Policy variable should prevent access when username doesn't match",
|
||||
},
|
||||
{
|
||||
name: "missing_policy_variable_context",
|
||||
principal: "dave",
|
||||
action: "s3:GetObject",
|
||||
resource: "arn:aws:s3:::user-dave/file.txt",
|
||||
requestContext: map[string]interface{}{
|
||||
// Missing aws:username context
|
||||
},
|
||||
expectedEffect: EffectDeny,
|
||||
description: "Missing policy variable context should result in no match",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: tt.principal,
|
||||
Action: tt.action,
|
||||
Resource: tt.resource,
|
||||
RequestContext: tt.requestContext,
|
||||
}
|
||||
|
||||
result, err := engine.Evaluate(ctx, filerAddress, evalCtx, []string{"user-specific-policy"})
|
||||
require.NoError(t, err, "Policy evaluation should not error")
|
||||
|
||||
assert.Equal(t, tt.expectedEffect, result.Effect,
|
||||
"Test %s: %s. Expected %s but got %s",
|
||||
tt.name, tt.description, tt.expectedEffect, result.Effect)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionResourceConsistencyWithStringConditions verifies that Actions, Resources,
|
||||
// and string conditions all use the same AWS IAM-compliant matching logic
|
||||
func TestActionResourceConsistencyWithStringConditions(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
config := &PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
StoreType: "memory",
|
||||
}
|
||||
|
||||
err := engine.Initialize(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
filerAddress := ""
|
||||
|
||||
// Policy that uses case-insensitive matching in all three areas
|
||||
policyDoc := &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Sid: "CaseInsensitiveMatching",
|
||||
Effect: "Allow",
|
||||
Action: []string{"S3:GET*"}, // Uppercase action pattern
|
||||
Resource: []string{"arn:aws:s3:::TEST-BUCKET/*"}, // Uppercase resource pattern
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"StringLikeIgnoreCase": {
|
||||
"s3:RequestedRegion": "US-*", // Uppercase condition pattern
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = engine.AddPolicy(filerAddress, "case-insensitive-policy", policyDoc)
|
||||
require.NoError(t, err)
|
||||
|
||||
evalCtx := &EvaluationContext{
|
||||
Principal: "test-user",
|
||||
Action: "s3:getobject", // lowercase action
|
||||
Resource: "arn:aws:s3:::test-bucket/file.txt", // lowercase resource
|
||||
RequestContext: map[string]interface{}{
|
||||
"s3:RequestedRegion": "us-east-1", // lowercase condition value
|
||||
},
|
||||
}
|
||||
|
||||
result, err := engine.Evaluate(ctx, filerAddress, evalCtx, []string{"case-insensitive-policy"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// All should match due to case-insensitive AWS IAM-compliant matching
|
||||
assert.Equal(t, EffectAllow, result.Effect,
|
||||
"Actions, Resources, and Conditions should all use case-insensitive AWS IAM matching")
|
||||
|
||||
// Verify that matching statements were found
|
||||
require.Len(t, result.MatchingStatements, 1,
|
||||
"Should have exactly one matching statement")
|
||||
assert.Equal(t, "Allow", string(result.MatchingStatements[0].Effect),
|
||||
"Matching statement should have Allow effect")
|
||||
}
|
||||
22
weed/iam/providers/errors.go
Normal file
22
weed/iam/providers/errors.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package providers
|
||||
|
||||
import "errors"
|
||||
|
||||
// Typed errors for identity provider operations
|
||||
// These enable robust error checking with errors.Is() throughout the stack
|
||||
var (
|
||||
// ErrProviderTokenExpired indicates that the provided token has expired
|
||||
ErrProviderTokenExpired = errors.New("provider: token has expired")
|
||||
|
||||
// ErrProviderInvalidToken indicates that the token format is invalid or malformed
|
||||
ErrProviderInvalidToken = errors.New("provider: invalid token format")
|
||||
|
||||
// ErrProviderInvalidIssuer indicates that the token issuer is not trusted
|
||||
ErrProviderInvalidIssuer = errors.New("provider: invalid token issuer")
|
||||
|
||||
// ErrProviderInvalidAudience indicates that the token audience doesn't match expected value
|
||||
ErrProviderInvalidAudience = errors.New("provider: invalid token audience")
|
||||
|
||||
// ErrProviderMissingClaims indicates that required claims are missing from the token
|
||||
ErrProviderMissingClaims = errors.New("provider: missing required claims")
|
||||
)
|
||||
255
weed/iam/providers/provider.go
Normal file
255
weed/iam/providers/provider.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
)
|
||||
|
||||
// IdentityProvider defines the interface for external identity providers
|
||||
type IdentityProvider interface {
|
||||
// Name returns the unique name of the provider
|
||||
Name() string
|
||||
|
||||
// Initialize initializes the provider with configuration
|
||||
Initialize(config interface{}) error
|
||||
|
||||
// Authenticate authenticates a user with a token and returns external identity
|
||||
Authenticate(ctx context.Context, token string) (*ExternalIdentity, error)
|
||||
|
||||
// GetUserInfo retrieves user information by user ID
|
||||
GetUserInfo(ctx context.Context, userID string) (*ExternalIdentity, error)
|
||||
|
||||
// ValidateToken validates a token and returns claims
|
||||
ValidateToken(ctx context.Context, token string) (*TokenClaims, error)
|
||||
}
|
||||
|
||||
// ExternalIdentity represents an identity from an external provider
|
||||
type ExternalIdentity struct {
|
||||
// UserID is the unique identifier from the external provider
|
||||
UserID string `json:"userId"`
|
||||
|
||||
// Email is the user's email address
|
||||
Email string `json:"email"`
|
||||
|
||||
// DisplayName is the user's display name
|
||||
DisplayName string `json:"displayName"`
|
||||
|
||||
// Groups are the groups the user belongs to
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
|
||||
// Roles are the roles the user holds (from the token's `roles` claim).
|
||||
// Surfaced into the STS request context so resource policies can gate on
|
||||
// jwt:roles - same rationale as Groups (the claim is otherwise excluded from
|
||||
// attributes by the provider's processedClaims set, so it never reached the
|
||||
// request context and was usable only via provider-configured role mapping).
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
|
||||
// Attributes are additional user attributes
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
|
||||
// Provider is the name of the identity provider
|
||||
Provider string `json:"provider"`
|
||||
|
||||
// Issuer is the OIDC `iss` claim (or equivalent) from the source token.
|
||||
// Stable per (provider, identity) and used together with UserID to derive
|
||||
// a stable parent-user hash that survives token rotation.
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
|
||||
// PrincipalTags are key/value pairs extracted from the AWS principal-tags
|
||||
// namespace claim (`https://aws.amazon.com/tags/principal_tags`). They are
|
||||
// surfaced as `aws:PrincipalTag/<key>` in the policy request context
|
||||
// (subject to per-provider allowlist filtering at the STS layer).
|
||||
PrincipalTags map[string]string `json:"principalTags,omitempty"`
|
||||
|
||||
// ClaimPolicies are policy names pulled from the provider-configured
|
||||
// PolicyClaim. Empty when the provider isn't running in claim-based
|
||||
// policy mode or the claim was absent.
|
||||
ClaimPolicies []string `json:"claimPolicies,omitempty"`
|
||||
|
||||
// TokenExpiration is the expiration time of the source identity token
|
||||
// This is used to limit session duration to not exceed the token's exp claim
|
||||
TokenExpiration *time.Time `json:"tokenExpiration,omitempty"`
|
||||
}
|
||||
|
||||
// Validate validates the external identity structure
|
||||
func (e *ExternalIdentity) Validate() error {
|
||||
if e.UserID == "" {
|
||||
return fmt.Errorf("user ID is required")
|
||||
}
|
||||
|
||||
if e.Provider == "" {
|
||||
return fmt.Errorf("provider is required")
|
||||
}
|
||||
|
||||
if e.Email != "" {
|
||||
if _, err := mail.ParseAddress(e.Email); err != nil {
|
||||
return fmt.Errorf("invalid email format: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TokenClaims represents claims from a validated token
|
||||
type TokenClaims struct {
|
||||
// Subject (sub) - user identifier
|
||||
Subject string `json:"sub"`
|
||||
|
||||
// Issuer (iss) - token issuer
|
||||
Issuer string `json:"iss"`
|
||||
|
||||
// Audience (aud) - intended audience
|
||||
Audience string `json:"aud"`
|
||||
|
||||
// ExpiresAt (exp) - expiration time
|
||||
ExpiresAt time.Time `json:"exp"`
|
||||
|
||||
// IssuedAt (iat) - issued at time
|
||||
IssuedAt time.Time `json:"iat"`
|
||||
|
||||
// NotBefore (nbf) - not valid before time
|
||||
NotBefore time.Time `json:"nbf,omitempty"`
|
||||
|
||||
// Claims are additional claims from the token
|
||||
Claims map[string]interface{} `json:"claims,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid checks if the token claims are valid (not expired, etc.)
|
||||
func (c *TokenClaims) IsValid() bool {
|
||||
now := time.Now()
|
||||
|
||||
// Check expiration
|
||||
if !c.ExpiresAt.IsZero() && now.After(c.ExpiresAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check not before
|
||||
if !c.NotBefore.IsZero() && now.Before(c.NotBefore) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check issued at (shouldn't be in the future)
|
||||
if !c.IssuedAt.IsZero() && now.Before(c.IssuedAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GetClaimString returns a string claim value
|
||||
func (c *TokenClaims) GetClaimString(key string) (string, bool) {
|
||||
if value, exists := c.Claims[key]; exists {
|
||||
if str, ok := value.(string); ok {
|
||||
return str, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// GetClaimStringSlice returns a string slice claim value
|
||||
func (c *TokenClaims) GetClaimStringSlice(key string) ([]string, bool) {
|
||||
if value, exists := c.Claims[key]; exists {
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
return v, true
|
||||
case []interface{}:
|
||||
var result []string
|
||||
for _, item := range v {
|
||||
if str, ok := item.(string); ok {
|
||||
result = append(result, str)
|
||||
}
|
||||
}
|
||||
return result, len(result) > 0
|
||||
case string:
|
||||
// Single string can be treated as slice
|
||||
return []string{v}, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ProviderConfig represents configuration for identity providers
|
||||
type ProviderConfig struct {
|
||||
// Type of provider (oidc, ldap, saml)
|
||||
Type string `json:"type"`
|
||||
|
||||
// Name of the provider instance
|
||||
Name string `json:"name"`
|
||||
|
||||
// Enabled indicates if the provider is active
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// Config is provider-specific configuration
|
||||
Config map[string]interface{} `json:"config"`
|
||||
|
||||
// RoleMapping defines how to map external identities to roles
|
||||
RoleMapping *RoleMapping `json:"roleMapping,omitempty"`
|
||||
}
|
||||
|
||||
// RoleMapping defines rules for mapping external identities to roles
|
||||
type RoleMapping struct {
|
||||
// Rules are the mapping rules
|
||||
Rules []MappingRule `json:"rules"`
|
||||
|
||||
// DefaultRole is assigned if no rules match
|
||||
DefaultRole string `json:"defaultRole,omitempty"`
|
||||
}
|
||||
|
||||
// MappingRule defines a single mapping rule
|
||||
type MappingRule struct {
|
||||
// Claim is the claim key to check
|
||||
Claim string `json:"claim"`
|
||||
|
||||
// Value is the expected claim value (supports wildcards)
|
||||
Value string `json:"value"`
|
||||
|
||||
// Role is the role ARN to assign
|
||||
Role string `json:"role"`
|
||||
|
||||
// Condition is additional condition logic (optional)
|
||||
Condition string `json:"condition,omitempty"`
|
||||
}
|
||||
|
||||
// Matches checks if a rule matches the given claims
|
||||
func (r *MappingRule) Matches(claims *TokenClaims) bool {
|
||||
if r.Claim == "" || r.Value == "" {
|
||||
glog.V(3).Infof("Rule invalid: claim=%s, value=%s", r.Claim, r.Value)
|
||||
return false
|
||||
}
|
||||
|
||||
claimValue, exists := claims.GetClaimString(r.Claim)
|
||||
if !exists {
|
||||
glog.V(3).Infof("Claim '%s' not found as string, trying as string slice", r.Claim)
|
||||
// Try as string slice
|
||||
if claimSlice, sliceExists := claims.GetClaimStringSlice(r.Claim); sliceExists {
|
||||
glog.V(3).Infof("Claim '%s' found as string slice: %v", r.Claim, claimSlice)
|
||||
for _, val := range claimSlice {
|
||||
glog.V(3).Infof("Checking if '%s' matches rule value '%s'", val, r.Value)
|
||||
if r.matchValue(val) {
|
||||
glog.V(3).Infof("Match found: '%s' matches '%s'", val, r.Value)
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
glog.V(3).Infof("Claim '%s' not found in any format", r.Claim)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Claim '%s' found as string: '%s'", r.Claim, claimValue)
|
||||
return r.matchValue(claimValue)
|
||||
}
|
||||
|
||||
// matchValue checks if a value matches the rule value (with wildcard support)
|
||||
// Uses AWS IAM-compliant case-insensitive wildcard matching for consistency with policy engine
|
||||
func (r *MappingRule) matchValue(value string) bool {
|
||||
matched := wildcard.MatchesWildcard(strings.ToLower(r.Value), strings.ToLower(value))
|
||||
glog.V(3).Infof("AWS IAM pattern match result: '%s' matches '%s' = %t", value, r.Value, matched)
|
||||
return matched
|
||||
}
|
||||
32
weed/iam/redact.go
Normal file
32
weed/iam/redact.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package iam
|
||||
|
||||
import "net/url"
|
||||
|
||||
// sensitiveFormKeys is the set of IAM request form parameters whose values
|
||||
// must never be written to logs. Matching is case-sensitive and uses the
|
||||
// exact AWS IAM parameter name. Extend this when adding IAM actions that
|
||||
// accept credentials, passwords, session tokens, or private keys.
|
||||
var sensitiveFormKeys = map[string]struct{}{
|
||||
"SecretAccessKey": {},
|
||||
"Password": {},
|
||||
"NewPassword": {},
|
||||
"OldPassword": {},
|
||||
"PrivateKey": {},
|
||||
"SessionToken": {},
|
||||
}
|
||||
|
||||
// RedactSensitiveFormValues returns a shallow copy of values with every
|
||||
// sensitive key (see sensitiveFormKeys) replaced by "[REDACTED]". Intended
|
||||
// for debug-level logging of IAM request forms so secrets do not leak into
|
||||
// log sinks.
|
||||
func RedactSensitiveFormValues(values url.Values) url.Values {
|
||||
safe := make(url.Values, len(values))
|
||||
for k, v := range values {
|
||||
if _, sensitive := sensitiveFormKeys[k]; sensitive {
|
||||
safe[k] = []string{"[REDACTED]"}
|
||||
} else {
|
||||
safe[k] = v
|
||||
}
|
||||
}
|
||||
return safe
|
||||
}
|
||||
33
weed/iam/redact_test.go
Normal file
33
weed/iam/redact_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRedactSensitiveFormValues(t *testing.T) {
|
||||
in := url.Values{
|
||||
"Action": []string{"CreateAccessKey"},
|
||||
"UserName": []string{"alice"},
|
||||
"AccessKeyId": []string{"AKIAALICE1"},
|
||||
"SecretAccessKey": []string{"very-secret"},
|
||||
"Password": []string{"hunter2"},
|
||||
"NewPassword": []string{"new-hunter2"},
|
||||
"OldPassword": []string{"old-hunter2"},
|
||||
"PrivateKey": []string{"-----BEGIN-----"},
|
||||
"SessionToken": []string{"tok"},
|
||||
}
|
||||
out := RedactSensitiveFormValues(in)
|
||||
|
||||
assert.Equal(t, []string{"CreateAccessKey"}, out["Action"])
|
||||
assert.Equal(t, []string{"alice"}, out["UserName"])
|
||||
assert.Equal(t, []string{"AKIAALICE1"}, out["AccessKeyId"])
|
||||
for _, k := range []string{"SecretAccessKey", "Password", "NewPassword", "OldPassword", "PrivateKey", "SessionToken"} {
|
||||
assert.Equal(t, []string{"[REDACTED]"}, out[k], "key %q should be redacted", k)
|
||||
}
|
||||
|
||||
// Input is not mutated.
|
||||
assert.Equal(t, []string{"very-secret"}, in["SecretAccessKey"])
|
||||
}
|
||||
534
weed/iam/responses.go
Normal file
534
weed/iam/responses.go
Normal file
@@ -0,0 +1,534 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
)
|
||||
|
||||
// CommonResponse is embedded in IAM success response types to provide RequestId.
|
||||
type CommonResponse struct {
|
||||
ResponseMetadata struct {
|
||||
RequestId string `xml:"RequestId"`
|
||||
} `xml:"ResponseMetadata"`
|
||||
}
|
||||
|
||||
// IAMTag mirrors AWS IAM's Tag list element with the Key/Value pair shape.
|
||||
type IAMTag struct {
|
||||
Key string `xml:"Key"`
|
||||
Value string `xml:"Value"`
|
||||
}
|
||||
|
||||
// OpenIDConnectProviderListEntry is one element of ListOpenIDConnectProviders.
|
||||
type OpenIDConnectProviderListEntry struct {
|
||||
Arn string `xml:"Arn"`
|
||||
}
|
||||
|
||||
// ListOpenIDConnectProvidersResponse is the response for ListOpenIDConnectProviders.
|
||||
type ListOpenIDConnectProvidersResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListOpenIDConnectProvidersResponse"`
|
||||
ListOpenIDConnectProvidersResult struct {
|
||||
OpenIDConnectProviderList []*OpenIDConnectProviderListEntry `xml:"OpenIDConnectProviderList>member"`
|
||||
} `xml:"ListOpenIDConnectProvidersResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// GetOpenIDConnectProviderResponse is the response for GetOpenIDConnectProvider.
|
||||
type GetOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetOpenIDConnectProviderResponse"`
|
||||
GetOpenIDConnectProviderResult struct {
|
||||
Url string `xml:"Url"`
|
||||
ClientIDList []string `xml:"ClientIDList>member,omitempty"`
|
||||
ThumbprintList []string `xml:"ThumbprintList>member,omitempty"`
|
||||
Tags []*IAMTag `xml:"Tags>member,omitempty"`
|
||||
CreateDate string `xml:"CreateDate,omitempty"`
|
||||
} `xml:"GetOpenIDConnectProviderResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// CreateOpenIDConnectProviderResponse is the response for CreateOpenIDConnectProvider.
|
||||
type CreateOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateOpenIDConnectProviderResponse"`
|
||||
CreateOpenIDConnectProviderResult struct {
|
||||
OpenIDConnectProviderArn string `xml:"OpenIDConnectProviderArn"`
|
||||
Tags []*IAMTag `xml:"Tags>member,omitempty"`
|
||||
} `xml:"CreateOpenIDConnectProviderResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeleteOpenIDConnectProviderResponse is the response for DeleteOpenIDConnectProvider.
|
||||
type DeleteOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteOpenIDConnectProviderResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// AddClientIDToOpenIDConnectProviderResponse is the response for AddClientIDToOpenIDConnectProvider.
|
||||
type AddClientIDToOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AddClientIDToOpenIDConnectProviderResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// RemoveClientIDFromOpenIDConnectProviderResponse is the response for RemoveClientIDFromOpenIDConnectProvider.
|
||||
type RemoveClientIDFromOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ RemoveClientIDFromOpenIDConnectProviderResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// UpdateOpenIDConnectProviderThumbprintResponse is the response for UpdateOpenIDConnectProviderThumbprint.
|
||||
type UpdateOpenIDConnectProviderThumbprintResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateOpenIDConnectProviderThumbprintResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// TagOpenIDConnectProviderResponse is the response for TagOpenIDConnectProvider.
|
||||
type TagOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ TagOpenIDConnectProviderResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// UntagOpenIDConnectProviderResponse is the response for UntagOpenIDConnectProvider.
|
||||
type UntagOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UntagOpenIDConnectProviderResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// SetRequestId stores the request ID generated for the current HTTP request.
|
||||
func (r *CommonResponse) SetRequestId(requestID string) {
|
||||
r.ResponseMetadata.RequestId = requestID
|
||||
}
|
||||
|
||||
// RequestIDSetter is implemented by IAM responses that can carry a RequestId.
|
||||
type RequestIDSetter interface {
|
||||
SetRequestId(string)
|
||||
}
|
||||
|
||||
// ListUsersResponse is the response for ListUsers action.
|
||||
type ListUsersResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUsersResponse"`
|
||||
ListUsersResult struct {
|
||||
Users []*iam.User `xml:"Users>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
} `xml:"ListUsersResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListAccessKeysResponse is the response for ListAccessKeys action.
|
||||
type ListAccessKeysResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListAccessKeysResponse"`
|
||||
ListAccessKeysResult struct {
|
||||
AccessKeyMetadata []*iam.AccessKeyMetadata `xml:"AccessKeyMetadata>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
} `xml:"ListAccessKeysResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeleteAccessKeyResponse is the response for DeleteAccessKey action.
|
||||
type DeleteAccessKeyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteAccessKeyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// CreatePolicyResponse is the response for CreatePolicy action.
|
||||
type CreatePolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreatePolicyResponse"`
|
||||
CreatePolicyResult struct {
|
||||
Policy iam.Policy `xml:"Policy"`
|
||||
} `xml:"CreatePolicyResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeletePolicyResponse is the response for DeletePolicy action.
|
||||
type DeletePolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeletePolicyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListPoliciesResponse is the response for ListPolicies action.
|
||||
type ListPoliciesResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListPoliciesResponse"`
|
||||
ListPoliciesResult struct {
|
||||
Policies []*iam.Policy `xml:"Policies>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
Marker string `xml:"Marker,omitempty"`
|
||||
} `xml:"ListPoliciesResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// GetPolicyResponse is the response for GetPolicy action.
|
||||
type GetPolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetPolicyResponse"`
|
||||
GetPolicyResult struct {
|
||||
Policy iam.Policy `xml:"Policy"`
|
||||
} `xml:"GetPolicyResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListPolicyVersionsResponse is the response for ListPolicyVersions action.
|
||||
type ListPolicyVersionsResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListPolicyVersionsResponse"`
|
||||
ListPolicyVersionsResult struct {
|
||||
Versions []*iam.PolicyVersion `xml:"Versions>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
Marker string `xml:"Marker,omitempty"`
|
||||
} `xml:"ListPolicyVersionsResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// GetPolicyVersionResponse is the response for GetPolicyVersion action.
|
||||
type GetPolicyVersionResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetPolicyVersionResponse"`
|
||||
GetPolicyVersionResult struct {
|
||||
PolicyVersion iam.PolicyVersion `xml:"PolicyVersion"`
|
||||
} `xml:"GetPolicyVersionResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// CreatePolicyVersionResponse is the response for CreatePolicyVersion action.
|
||||
type CreatePolicyVersionResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreatePolicyVersionResponse"`
|
||||
CreatePolicyVersionResult struct {
|
||||
PolicyVersion iam.PolicyVersion `xml:"PolicyVersion"`
|
||||
} `xml:"CreatePolicyVersionResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeletePolicyVersionResponse is the response for DeletePolicyVersion action.
|
||||
type DeletePolicyVersionResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeletePolicyVersionResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// CreateUserResponse is the response for CreateUser action.
|
||||
type CreateUserResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateUserResponse"`
|
||||
CreateUserResult struct {
|
||||
User iam.User `xml:"User"`
|
||||
} `xml:"CreateUserResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeleteUserResponse is the response for DeleteUser action.
|
||||
type DeleteUserResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// GetUserResponse is the response for GetUser action.
|
||||
type GetUserResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserResponse"`
|
||||
GetUserResult struct {
|
||||
User iam.User `xml:"User"`
|
||||
} `xml:"GetUserResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// UpdateUserResponse is the response for UpdateUser action.
|
||||
type UpdateUserResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateUserResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// CreateAccessKeyResponse is the response for CreateAccessKey action.
|
||||
type CreateAccessKeyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateAccessKeyResponse"`
|
||||
CreateAccessKeyResult struct {
|
||||
AccessKey iam.AccessKey `xml:"AccessKey"`
|
||||
} `xml:"CreateAccessKeyResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// PutUserPolicyResponse is the response for PutUserPolicy action.
|
||||
type PutUserPolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutUserPolicyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeleteUserPolicyResponse is the response for DeleteUserPolicy action.
|
||||
type DeleteUserPolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserPolicyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// AttachUserPolicyResponse is the response for AttachUserPolicy action.
|
||||
type AttachUserPolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AttachUserPolicyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DetachUserPolicyResponse is the response for DetachUserPolicy action.
|
||||
type DetachUserPolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DetachUserPolicyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListAttachedUserPoliciesResponse is the response for ListAttachedUserPolicies action.
|
||||
type ListAttachedUserPoliciesResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListAttachedUserPoliciesResponse"`
|
||||
ListAttachedUserPoliciesResult struct {
|
||||
AttachedPolicies []*iam.AttachedPolicy `xml:"AttachedPolicies>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
Marker string `xml:"Marker,omitempty"`
|
||||
} `xml:"ListAttachedUserPoliciesResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListUserPoliciesResponse is the response for ListUserPolicies action.
|
||||
type ListUserPoliciesResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUserPoliciesResponse"`
|
||||
ListUserPoliciesResult struct {
|
||||
PolicyNames []string `xml:"PolicyNames>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
Marker string `xml:"Marker,omitempty"`
|
||||
} `xml:"ListUserPoliciesResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// GetUserPolicyResponse is the response for GetUserPolicy action.
|
||||
type GetUserPolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserPolicyResponse"`
|
||||
GetUserPolicyResult struct {
|
||||
UserName string `xml:"UserName"`
|
||||
PolicyName string `xml:"PolicyName"`
|
||||
PolicyDocument string `xml:"PolicyDocument"`
|
||||
} `xml:"GetUserPolicyResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ErrorResponse is the IAM error response format.
|
||||
// AWS IAM uses a bare <RequestId> at root level for errors, not <ResponseMetadata>.
|
||||
type ErrorResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ErrorResponse"`
|
||||
Error struct {
|
||||
iam.ErrorDetails
|
||||
Type string `xml:"Type"`
|
||||
} `xml:"Error"`
|
||||
RequestId string `xml:"RequestId"`
|
||||
}
|
||||
|
||||
// SetRequestId stores the request ID generated for the current HTTP request.
|
||||
func (r *ErrorResponse) SetRequestId(requestID string) {
|
||||
r.RequestId = requestID
|
||||
}
|
||||
|
||||
// Error represents an IAM API error with code and underlying error.
|
||||
type Error struct {
|
||||
Code string
|
||||
Error error
|
||||
}
|
||||
|
||||
// Policies stores IAM policies (used for managed policy storage).
|
||||
type Policies struct {
|
||||
Policies map[string]interface{} `json:"policies"`
|
||||
}
|
||||
|
||||
// SetUserStatusResponse is the response for SetUserStatus action.
|
||||
// This is a SeaweedFS extension to enable/disable users without deleting them.
|
||||
type SetUserStatusResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ SetUserStatusResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// UpdateAccessKeyResponse is the response for UpdateAccessKey action.
|
||||
type UpdateAccessKeyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateAccessKeyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ServiceAccountInfo contains service account details for API responses.
|
||||
type ServiceAccountInfo struct {
|
||||
ServiceAccountId string `xml:"ServiceAccountId"`
|
||||
ParentUser string `xml:"ParentUser"`
|
||||
Description string `xml:"Description,omitempty"`
|
||||
AccessKeyId string `xml:"AccessKeyId"`
|
||||
SecretAccessKey *string `xml:"SecretAccessKey,omitempty"` // Only returned in Create response
|
||||
Status string `xml:"Status"`
|
||||
Expiration *string `xml:"Expiration,omitempty"` // ISO 8601 format, nil = no expiration
|
||||
CreateDate string `xml:"CreateDate"`
|
||||
}
|
||||
|
||||
// CreateServiceAccountResponse is the response for CreateServiceAccount action.
|
||||
type CreateServiceAccountResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateServiceAccountResponse"`
|
||||
CreateServiceAccountResult struct {
|
||||
ServiceAccount ServiceAccountInfo `xml:"ServiceAccount"`
|
||||
} `xml:"CreateServiceAccountResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeleteServiceAccountResponse is the response for DeleteServiceAccount action.
|
||||
type DeleteServiceAccountResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteServiceAccountResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListServiceAccountsResponse is the response for ListServiceAccounts action.
|
||||
type ListServiceAccountsResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListServiceAccountsResponse"`
|
||||
ListServiceAccountsResult struct {
|
||||
ServiceAccounts []*ServiceAccountInfo `xml:"ServiceAccounts>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
} `xml:"ListServiceAccountsResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// GetServiceAccountResponse is the response for GetServiceAccount action.
|
||||
type GetServiceAccountResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetServiceAccountResponse"`
|
||||
GetServiceAccountResult struct {
|
||||
ServiceAccount ServiceAccountInfo `xml:"ServiceAccount"`
|
||||
} `xml:"GetServiceAccountResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// UpdateServiceAccountResponse is the response for UpdateServiceAccount action.
|
||||
type UpdateServiceAccountResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateServiceAccountResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// CreateGroupResponse is the response for CreateGroup action.
|
||||
type CreateGroupResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateGroupResponse"`
|
||||
CreateGroupResult struct {
|
||||
Group iam.Group `xml:"Group"`
|
||||
} `xml:"CreateGroupResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeleteGroupResponse is the response for DeleteGroup action.
|
||||
type DeleteGroupResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteGroupResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// UpdateGroupResponse is the response for UpdateGroup action.
|
||||
type UpdateGroupResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateGroupResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// GetGroupResponse is the response for GetGroup action.
|
||||
type GetGroupResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetGroupResponse"`
|
||||
GetGroupResult struct {
|
||||
Group iam.Group `xml:"Group"`
|
||||
Users []*iam.User `xml:"Users>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
Marker string `xml:"Marker,omitempty"`
|
||||
} `xml:"GetGroupResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListGroupsResponse is the response for ListGroups action.
|
||||
type ListGroupsResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListGroupsResponse"`
|
||||
ListGroupsResult struct {
|
||||
Groups []*iam.Group `xml:"Groups>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
Marker string `xml:"Marker,omitempty"`
|
||||
} `xml:"ListGroupsResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// AddUserToGroupResponse is the response for AddUserToGroup action.
|
||||
type AddUserToGroupResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AddUserToGroupResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// RemoveUserFromGroupResponse is the response for RemoveUserFromGroup action.
|
||||
type RemoveUserFromGroupResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ RemoveUserFromGroupResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// AttachGroupPolicyResponse is the response for AttachGroupPolicy action.
|
||||
type AttachGroupPolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AttachGroupPolicyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DetachGroupPolicyResponse is the response for DetachGroupPolicy action.
|
||||
type DetachGroupPolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DetachGroupPolicyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListAttachedGroupPoliciesResponse is the response for ListAttachedGroupPolicies action.
|
||||
type ListAttachedGroupPoliciesResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListAttachedGroupPoliciesResponse"`
|
||||
ListAttachedGroupPoliciesResult struct {
|
||||
AttachedPolicies []*iam.AttachedPolicy `xml:"AttachedPolicies>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
Marker string `xml:"Marker,omitempty"`
|
||||
} `xml:"ListAttachedGroupPoliciesResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// PutGroupPolicyResponse is the response for PutGroupPolicy action.
|
||||
type PutGroupPolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutGroupPolicyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// GetGroupPolicyResponse is the response for GetGroupPolicy action.
|
||||
type GetGroupPolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetGroupPolicyResponse"`
|
||||
GetGroupPolicyResult struct {
|
||||
GroupName string `xml:"GroupName"`
|
||||
PolicyName string `xml:"PolicyName"`
|
||||
PolicyDocument string `xml:"PolicyDocument"`
|
||||
} `xml:"GetGroupPolicyResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeleteGroupPolicyResponse is the response for DeleteGroupPolicy action.
|
||||
type DeleteGroupPolicyResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteGroupPolicyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListGroupPoliciesResponse is the response for ListGroupPolicies action.
|
||||
type ListGroupPoliciesResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListGroupPoliciesResponse"`
|
||||
ListGroupPoliciesResult struct {
|
||||
PolicyNames []string `xml:"PolicyNames>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
Marker string `xml:"Marker,omitempty"`
|
||||
} `xml:"ListGroupPoliciesResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListGroupsForUserResponse is the response for ListGroupsForUser action.
|
||||
type ListGroupsForUserResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListGroupsForUserResponse"`
|
||||
ListGroupsForUserResult struct {
|
||||
Groups []*iam.Group `xml:"Groups>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
Marker string `xml:"Marker,omitempty"`
|
||||
} `xml:"ListGroupsForUserResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// TagUserResponse is the response for TagUser action.
|
||||
type TagUserResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ TagUserResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// UntagUserResponse is the response for UntagUser action.
|
||||
type UntagUserResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UntagUserResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListUserTagsResponse is the response for ListUserTags action.
|
||||
type ListUserTagsResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUserTagsResponse"`
|
||||
ListUserTagsResult struct {
|
||||
Tags []*IAMTag `xml:"Tags>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
Marker string `xml:"Marker,omitempty"`
|
||||
} `xml:"ListUserTagsResult"`
|
||||
CommonResponse
|
||||
}
|
||||
52
weed/iam/responses_test.go
Normal file
52
weed/iam/responses_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestListUsersResponseXMLOrdering(t *testing.T) {
|
||||
resp := ListUsersResponse{}
|
||||
resp.SetRequestId("test-req-id")
|
||||
|
||||
output, err := xml.Marshal(resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
xmlString := string(output)
|
||||
listUsersResultIndex := strings.Index(xmlString, "<ListUsersResult>")
|
||||
responseMetadataIndex := strings.Index(xmlString, "<ResponseMetadata>")
|
||||
|
||||
assert.NotEqual(t, -1, listUsersResultIndex, "ListUsersResult should be present")
|
||||
assert.NotEqual(t, -1, responseMetadataIndex, "ResponseMetadata should be present")
|
||||
assert.Less(t, listUsersResultIndex, responseMetadataIndex,
|
||||
"ListUsersResult should appear before ResponseMetadata")
|
||||
}
|
||||
|
||||
func TestErrorResponseXMLUsesTopLevelRequestId(t *testing.T) {
|
||||
errCode := "NoSuchEntity"
|
||||
errMsg := "the requested IAM entity does not exist"
|
||||
|
||||
resp := ErrorResponse{}
|
||||
resp.Error.Type = "Sender"
|
||||
resp.Error.Code = &errCode
|
||||
resp.Error.Message = &errMsg
|
||||
resp.SetRequestId("request-123")
|
||||
|
||||
output, err := xml.Marshal(resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
xmlString := string(output)
|
||||
errorIndex := strings.Index(xmlString, "<Error>")
|
||||
requestIDIndex := strings.Index(xmlString, "<RequestId>request-123</RequestId>")
|
||||
|
||||
assert.NotEqual(t, -1, errorIndex, "Error should be present")
|
||||
assert.NotEqual(t, -1, requestIDIndex, "RequestId should be present")
|
||||
assert.NotContains(t, xmlString, "<ResponseMetadata>",
|
||||
"ErrorResponse should use bare RequestId, not ResponseMetadata wrapper")
|
||||
assert.Less(t, errorIndex, requestIDIndex,
|
||||
"RequestId should appear after Error at the root level")
|
||||
}
|
||||
23
weed/iam/sts/claim_based_policy_test.go
Normal file
23
weed/iam/sts/claim_based_policy_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package sts
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsClaimBasedPolicyRoleArn(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
arn string
|
||||
want bool
|
||||
}{
|
||||
{"empty matches", "", true},
|
||||
{"sentinel matches", ClaimBasedPolicyRoleArn, true},
|
||||
{"concrete role does not", "arn:aws:iam::123:role/admin", false},
|
||||
{"random ARN does not", "arn:aws:sts::123:assumed-role/X/Y", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := IsClaimBasedPolicyRoleArn(tc.arn); got != tc.want {
|
||||
t.Fatalf("got=%v want=%v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
166
weed/iam/sts/constants.go
Normal file
166
weed/iam/sts/constants.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Store Types
|
||||
const (
|
||||
StoreTypeMemory = "memory"
|
||||
StoreTypeFiler = "filer"
|
||||
StoreTypeRedis = "redis"
|
||||
)
|
||||
|
||||
// Provider Types
|
||||
const (
|
||||
ProviderTypeOIDC = "oidc"
|
||||
ProviderTypeLDAP = "ldap"
|
||||
ProviderTypeSAML = "saml"
|
||||
)
|
||||
|
||||
// Policy Effects
|
||||
const (
|
||||
EffectAllow = "Allow"
|
||||
EffectDeny = "Deny"
|
||||
)
|
||||
|
||||
// Default Paths - aligned with filer /etc/ convention
|
||||
const (
|
||||
DefaultSessionBasePath = "/etc/iam/sessions"
|
||||
DefaultPolicyBasePath = "/etc/iam/policies"
|
||||
DefaultRoleBasePath = "/etc/iam/roles"
|
||||
)
|
||||
|
||||
// Default Values
|
||||
const (
|
||||
DefaultTokenDuration = 3600 // 1 hour in seconds
|
||||
DefaultMaxSessionLength = 43200 // 12 hours in seconds
|
||||
DefaultIssuer = "seaweedfs-sts"
|
||||
DefaultStoreType = StoreTypeFiler // Default store type for persistence
|
||||
MinSigningKeyLength = 16 // Minimum signing key length in bytes
|
||||
)
|
||||
|
||||
// Configuration Field Names
|
||||
const (
|
||||
ConfigFieldFilerAddress = "filerAddress"
|
||||
ConfigFieldBasePath = "basePath"
|
||||
ConfigFieldIssuer = "issuer"
|
||||
ConfigFieldClientID = "clientId"
|
||||
ConfigFieldClientIDs = "clientIds"
|
||||
ConfigFieldThumbprints = "thumbprints"
|
||||
ConfigFieldAllowedPrincipalTagKeys = "allowedPrincipalTagKeys"
|
||||
ConfigFieldPolicyClaim = "policyClaim"
|
||||
ConfigFieldClientSecret = "clientSecret"
|
||||
ConfigFieldJWKSUri = "jwksUri"
|
||||
ConfigFieldScopes = "scopes"
|
||||
ConfigFieldUserInfoUri = "userInfoUri"
|
||||
ConfigFieldRedirectUri = "redirectUri"
|
||||
ConfigFieldTLSCACert = "tlsCaCert"
|
||||
ConfigFieldTLSInsecureSkipVerify = "tlsInsecureSkipVerify"
|
||||
)
|
||||
|
||||
// Error Messages
|
||||
const (
|
||||
ErrConfigCannotBeNil = "config cannot be nil"
|
||||
ErrProviderCannotBeNil = "provider cannot be nil"
|
||||
ErrProviderNameEmpty = "provider name cannot be empty"
|
||||
ErrProviderTypeEmpty = "provider type cannot be empty"
|
||||
ErrTokenCannotBeEmpty = "token cannot be empty"
|
||||
ErrSessionTokenCannotBeEmpty = "session token cannot be empty"
|
||||
ErrSessionIDCannotBeEmpty = "session ID cannot be empty"
|
||||
ErrSTSServiceNotInitialized = "STS service not initialized"
|
||||
ErrProviderNotInitialized = "provider not initialized"
|
||||
ErrInvalidTokenDuration = "token duration must be positive"
|
||||
ErrInvalidMaxSessionLength = "max session length must be positive"
|
||||
ErrIssuerRequired = "issuer is required"
|
||||
ErrSigningKeyTooShort = "signing key must be at least %d bytes"
|
||||
ErrFilerAddressRequired = "filer address is required"
|
||||
ErrClientIDRequired = "clientId is required for OIDC provider"
|
||||
ErrUnsupportedStoreType = "unsupported store type: %s"
|
||||
ErrUnsupportedProviderType = "unsupported provider type: %s"
|
||||
ErrInvalidTokenFormat = "invalid session token format: %w"
|
||||
ErrSessionValidationFailed = "session validation failed: %w"
|
||||
ErrInvalidToken = "invalid token: %w"
|
||||
ErrTokenNotValid = "token is not valid"
|
||||
ErrInvalidTokenClaims = "invalid token claims"
|
||||
ErrInvalidIssuer = "invalid issuer"
|
||||
ErrMissingSessionID = "missing session ID"
|
||||
)
|
||||
|
||||
// Typed errors for robust error checking with errors.Is()
|
||||
// These enable the HTTP layer to use errors.Is() instead of fragile string matching
|
||||
var (
|
||||
// ErrTokenExpired indicates that the provided token has expired
|
||||
ErrTypedTokenExpired = errors.New("token has expired")
|
||||
|
||||
// ErrTypedInvalidToken indicates that the token format is invalid or malformed
|
||||
ErrTypedInvalidToken = errors.New("invalid token format")
|
||||
|
||||
// ErrTypedInvalidIssuer indicates that the token issuer is not trusted
|
||||
ErrTypedInvalidIssuer = errors.New("invalid token issuer")
|
||||
|
||||
// ErrTypedInvalidAudience indicates that the token audience doesn't match expected value
|
||||
ErrTypedInvalidAudience = errors.New("invalid token audience")
|
||||
|
||||
// ErrTypedMissingClaims indicates that required claims are missing from the token
|
||||
ErrTypedMissingClaims = errors.New("missing required claims")
|
||||
)
|
||||
|
||||
// JWT Claims
|
||||
const (
|
||||
JWTClaimIssuer = "iss"
|
||||
JWTClaimSubject = "sub"
|
||||
JWTClaimAudience = "aud"
|
||||
JWTClaimExpiration = "exp"
|
||||
JWTClaimIssuedAt = "iat"
|
||||
JWTClaimTokenType = "token_type"
|
||||
)
|
||||
|
||||
// Token Types
|
||||
const (
|
||||
TokenTypeSession = "session"
|
||||
TokenTypeAccess = "access"
|
||||
TokenTypeRefresh = "refresh"
|
||||
)
|
||||
|
||||
// AWS STS Actions
|
||||
const (
|
||||
ActionAssumeRole = "sts:AssumeRole"
|
||||
ActionAssumeRoleWithWebIdentity = "sts:AssumeRoleWithWebIdentity"
|
||||
ActionAssumeRoleWithCredentials = "sts:AssumeRoleWithCredentials"
|
||||
ActionGetFederationToken = "sts:GetFederationToken"
|
||||
ActionValidateSession = "sts:ValidateSession"
|
||||
)
|
||||
|
||||
// Session File Prefixes
|
||||
const (
|
||||
SessionFilePrefix = "session_"
|
||||
SessionFileExt = ".json"
|
||||
PolicyFilePrefix = "policy_"
|
||||
PolicyFileExt = ".json"
|
||||
RoleFileExt = ".json"
|
||||
)
|
||||
|
||||
// HTTP Headers
|
||||
const (
|
||||
HeaderAuthorization = "Authorization"
|
||||
HeaderContentType = "Content-Type"
|
||||
HeaderUserAgent = "User-Agent"
|
||||
)
|
||||
|
||||
// Content Types
|
||||
const (
|
||||
ContentTypeJSON = "application/json"
|
||||
ContentTypeFormURLEncoded = "application/x-www-form-urlencoded"
|
||||
)
|
||||
|
||||
// Default Test Values
|
||||
const (
|
||||
TestSigningKey32Chars = "test-signing-key-32-characters-long"
|
||||
TestIssuer = "test-sts"
|
||||
TestClientID = "test-client"
|
||||
TestSessionID = "test-session-123"
|
||||
TestValidToken = "valid_test_token"
|
||||
TestInvalidToken = "invalid_token"
|
||||
TestExpiredToken = "expired_token"
|
||||
)
|
||||
100
weed/iam/sts/credential_prefix_test.go
Normal file
100
weed/iam/sts/credential_prefix_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestTemporaryCredentialPrefix verifies that temporary credentials use ASIA prefix
|
||||
// (not AKIA which is for permanent IAM user credentials)
|
||||
func TestTemporaryCredentialPrefix(t *testing.T) {
|
||||
sessionId := "test-session-for-prefix"
|
||||
expiration := time.Now().Add(time.Hour)
|
||||
|
||||
credGen := NewCredentialGenerator()
|
||||
cred, err := credGen.GenerateTemporaryCredentials(sessionId, expiration)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cred)
|
||||
|
||||
// Verify ASIA prefix for temporary credentials
|
||||
assert.True(t, strings.HasPrefix(cred.AccessKeyId, "ASIA"),
|
||||
"Temporary credentials must use ASIA prefix, got: %s", cred.AccessKeyId)
|
||||
|
||||
// Verify it's NOT using AKIA (permanent credentials)
|
||||
assert.False(t, strings.HasPrefix(cred.AccessKeyId, "AKIA"),
|
||||
"Temporary credentials must NOT use AKIA prefix (that's for permanent IAM keys)")
|
||||
}
|
||||
|
||||
// TestTemporaryCredentialFormat verifies the full format of temporary credentials
|
||||
func TestTemporaryCredentialFormat(t *testing.T) {
|
||||
sessionId := "format-test-session"
|
||||
expiration := time.Now().Add(time.Hour)
|
||||
|
||||
credGen := NewCredentialGenerator()
|
||||
cred, err := credGen.GenerateTemporaryCredentials(sessionId, expiration)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cred)
|
||||
|
||||
// AWS temporary access key format: ASIA + 16 hex characters = 20 chars total
|
||||
assert.Equal(t, 20, len(cred.AccessKeyId),
|
||||
"Access key ID should be 20 characters (ASIA + 16 hex chars)")
|
||||
|
||||
// Verify it starts with ASIA
|
||||
assert.True(t, strings.HasPrefix(cred.AccessKeyId, "ASIA"),
|
||||
"Access key must start with ASIA prefix")
|
||||
|
||||
// Verify the rest is hex (after ASIA prefix)
|
||||
hexPart := cred.AccessKeyId[4:]
|
||||
assert.Equal(t, 16, len(hexPart), "Hex part should be 16 characters")
|
||||
_, err = hex.DecodeString(hexPart)
|
||||
assert.NoError(t, err, "The part after ASIA prefix should be valid hex")
|
||||
|
||||
// Verify secret key is not empty and is a valid base64-encoded SHA256 hash
|
||||
assert.NotEmpty(t, cred.SecretAccessKey)
|
||||
assert.Equal(t, 44, len(cred.SecretAccessKey),
|
||||
"SecretAccessKey should be 44 characters for a base64-encoded 32-byte hash")
|
||||
_, err = base64.StdEncoding.DecodeString(cred.SecretAccessKey)
|
||||
assert.NoError(t, err, "SecretAccessKey should be a valid base64 string")
|
||||
|
||||
// Verify session token is not empty
|
||||
assert.NotEmpty(t, cred.SessionToken)
|
||||
}
|
||||
|
||||
// A duration that came from configuration is bounded the same as one from a
|
||||
// request: the catalog passes -s3.iceberg.credentialDurationSeconds straight
|
||||
// through.
|
||||
func TestAssumeRoleForPrincipalValidatesDuration(t *testing.T) {
|
||||
svc := NewSTSService()
|
||||
err := svc.Initialize(&STSConfig{
|
||||
TokenDuration: FlexibleDuration{time.Hour},
|
||||
MaxSessionLength: FlexibleDuration{12 * time.Hour},
|
||||
Issuer: "test-issuer",
|
||||
SigningKey: []byte("test-signing-key-at-least-32-bytes-long"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize() error = %v", err)
|
||||
}
|
||||
|
||||
for _, seconds := range []int64{60, 100000} {
|
||||
duration := seconds
|
||||
_, err := svc.AssumeRoleForPrincipal(context.Background(), &AssumeRoleForPrincipalRequest{
|
||||
RoleArn: "arn:aws:iam::role/IcebergTableAccess",
|
||||
Principal: "admin",
|
||||
DurationSeconds: &duration,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("AssumeRoleForPrincipal(%ds) error = nil, want a validation error", seconds)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "DurationSeconds") {
|
||||
t.Errorf("AssumeRoleForPrincipal(%ds) error = %v, want it to name DurationSeconds", seconds, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
105
weed/iam/sts/oidc_provider_lookup_test.go
Normal file
105
weed/iam/sts/oidc_provider_lookup_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
)
|
||||
|
||||
// stubIdentityProvider is the minimal IdentityProvider needed to drive
|
||||
// lookupOIDCProviderForAccount; the lookup never calls Authenticate.
|
||||
type stubIdentityProvider struct{ name string }
|
||||
|
||||
func (s *stubIdentityProvider) Name() string { return s.name }
|
||||
func (s *stubIdentityProvider) Initialize(interface{}) error { return nil }
|
||||
func (s *stubIdentityProvider) Authenticate(context.Context, string) (*providers.ExternalIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubIdentityProvider) GetUserInfo(context.Context, string) (*providers.ExternalIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubIdentityProvider) ValidateToken(context.Context, string) (*providers.TokenClaims, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestLookupOIDCProviderForAccountPrefersAccountMatch(t *testing.T) {
|
||||
const issuer = "https://example.com"
|
||||
accountA := &stubIdentityProvider{name: "A"}
|
||||
accountB := &stubIdentityProvider{name: "B"}
|
||||
|
||||
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{}}
|
||||
s.SetIAMManagedOIDCProviders(map[string][]ScopedOIDCProvider{
|
||||
issuer: {
|
||||
{AccountID: "111111111111", Provider: accountA},
|
||||
{AccountID: "222222222222", Provider: accountB},
|
||||
},
|
||||
})
|
||||
|
||||
got, ok := s.lookupOIDCProviderForAccount(issuer, "222222222222")
|
||||
if !ok || got != accountB {
|
||||
t.Fatalf("expected accountB provider for matching account, got %v ok=%v", got, ok)
|
||||
}
|
||||
got, ok = s.lookupOIDCProviderForAccount(issuer, "111111111111")
|
||||
if !ok || got != accountA {
|
||||
t.Fatalf("expected accountA provider for matching account, got %v ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupOIDCProviderForAccountFallsBackToGlobal(t *testing.T) {
|
||||
const issuer = "https://example.com"
|
||||
global := &stubIdentityProvider{name: "global"}
|
||||
accountA := &stubIdentityProvider{name: "A"}
|
||||
|
||||
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{}}
|
||||
s.SetIAMManagedOIDCProviders(map[string][]ScopedOIDCProvider{
|
||||
issuer: {
|
||||
{AccountID: "", Provider: global},
|
||||
{AccountID: "111111111111", Provider: accountA},
|
||||
},
|
||||
})
|
||||
|
||||
// Account not represented in records → global match.
|
||||
got, ok := s.lookupOIDCProviderForAccount(issuer, "999999999999")
|
||||
if !ok || got != global {
|
||||
t.Fatalf("expected global provider as fallback, got %v ok=%v", got, ok)
|
||||
}
|
||||
// Empty account hint → never picks an account-scoped record arbitrarily;
|
||||
// only the global record is eligible.
|
||||
got, ok = s.lookupOIDCProviderForAccount(issuer, "")
|
||||
if !ok || got != global {
|
||||
t.Fatalf("expected global provider when account unknown, got %v ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupOIDCProviderForAccountSkipsAccountSpecificWhenAccountUnknown(t *testing.T) {
|
||||
const issuer = "https://example.com"
|
||||
accountA := &stubIdentityProvider{name: "A"}
|
||||
|
||||
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{}}
|
||||
s.SetIAMManagedOIDCProviders(map[string][]ScopedOIDCProvider{
|
||||
issuer: {
|
||||
{AccountID: "111111111111", Provider: accountA},
|
||||
},
|
||||
})
|
||||
|
||||
// No account hint AND no global record → must fall through (return false),
|
||||
// not silently pick the account-A entry. Picking an arbitrary entry is the
|
||||
// pre-fix bug that lets a token be validated by the wrong tenant's record.
|
||||
if _, ok := s.lookupOIDCProviderForAccount(issuer, ""); ok {
|
||||
t.Fatalf("expected no match when account unknown and only account-scoped records exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupOIDCProviderForAccountFallsBackToStatic(t *testing.T) {
|
||||
const issuer = "https://example.com"
|
||||
static := &stubIdentityProvider{name: "static"}
|
||||
|
||||
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{issuer: static}}
|
||||
s.SetIAMManagedOIDCProviders(nil)
|
||||
|
||||
got, ok := s.lookupOIDCProviderForAccount(issuer, "111111111111")
|
||||
if !ok || got != static {
|
||||
t.Fatalf("expected static-config provider as last resort, got %v ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
132
weed/iam/sts/parent_user_test.go
Normal file
132
weed/iam/sts/parent_user_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestComputeParentUserStability(t *testing.T) {
|
||||
// Same (sub, iss) must produce the same hash, regardless of order or
|
||||
// whitespace mutations callers should never apply.
|
||||
a := ComputeParentUser("alice", "https://idp.example/")
|
||||
b := ComputeParentUser("alice", "https://idp.example/")
|
||||
if a == "" {
|
||||
t.Fatal("parent user should not be empty for non-empty sub")
|
||||
}
|
||||
if a != b {
|
||||
t.Fatalf("expected stable hash, got %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeParentUserDistinguishesIssuer(t *testing.T) {
|
||||
// The point of incorporating iss is that the same `sub` from two providers
|
||||
// must not collide. If this assertion ever fails, the hash input is wrong.
|
||||
a := ComputeParentUser("alice", "https://idp-a.example/")
|
||||
b := ComputeParentUser("alice", "https://idp-b.example/")
|
||||
if a == b {
|
||||
t.Fatalf("hashes for different issuers must differ, both = %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeParentUserDistinguishesSubject(t *testing.T) {
|
||||
a := ComputeParentUser("alice", "https://idp.example/")
|
||||
b := ComputeParentUser("bob", "https://idp.example/")
|
||||
if a == b {
|
||||
t.Fatalf("hashes for different subjects must differ, both = %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeParentUserEmptySub(t *testing.T) {
|
||||
if got := ComputeParentUser("", "https://idp.example/"); got != "" {
|
||||
t.Fatalf("empty sub should produce empty parent user, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeParentUserEmptyIss(t *testing.T) {
|
||||
// Without an issuer, two different IDPs that both name a user "alice"
|
||||
// would collide on the same parent_user. Refuse rather than hash.
|
||||
if got := ComputeParentUser("alice", ""); got != "" {
|
||||
t.Fatalf("empty iss should produce empty parent user, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeParentUserEncoding(t *testing.T) {
|
||||
got := ComputeParentUser("alice", "https://idp.example/")
|
||||
// Base64 RawURL has no padding and uses URL-safe alphabet — important
|
||||
// because parent_user shows up in filer paths and audit log fields.
|
||||
if strings.ContainsAny(got, "=+/") {
|
||||
t.Fatalf("parent user should be base64 raw url, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionClaimsRoundTripParentUser(t *testing.T) {
|
||||
parent := ComputeParentUser("alice", "https://idp.example/")
|
||||
claims := NewSTSSessionClaims("sid-1", "issuer", time.Now().Add(time.Hour)).
|
||||
WithRoleInfo("arn:aws:iam::123:role/r", "arn:aws:sts::123:assumed-role/r/s", "arn:aws:sts::123:assumed-role/r/s").
|
||||
WithParentUser(parent)
|
||||
|
||||
info := claims.ToSessionInfo()
|
||||
if info.ParentUser != parent {
|
||||
t.Fatalf("ParentUser lost on round-trip: got %q want %q", info.ParentUser, parent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityClaimPrefersPreferredUsername(t *testing.T) {
|
||||
ctx := map[string]interface{}{
|
||||
"preferred_username": "grant.west",
|
||||
"email": "grant.west@example.com",
|
||||
"sub": "alice-sub",
|
||||
}
|
||||
if got := ResolveIdentityClaim(ctx); got != "grant.west" {
|
||||
t.Fatalf("expected preferred_username, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityClaimFallsBackThroughPriority(t *testing.T) {
|
||||
if got := ResolveIdentityClaim(map[string]interface{}{"email": "g@example.com", "sub": "s"}); got != "g@example.com" {
|
||||
t.Fatalf("expected email fallback, got %q", got)
|
||||
}
|
||||
if got := ResolveIdentityClaim(map[string]interface{}{"sub": "s"}); got != "s" {
|
||||
t.Fatalf("expected sub fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityClaimEmpty(t *testing.T) {
|
||||
if got := ResolveIdentityClaim(nil); got != "" {
|
||||
t.Fatalf("expected empty for nil ctx, got %q", got)
|
||||
}
|
||||
if got := ResolveIdentityClaim(map[string]interface{}{}); got != "" {
|
||||
t.Fatalf("expected empty for empty ctx, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityClaimSkipsNonString(t *testing.T) {
|
||||
ctx := map[string]interface{}{
|
||||
"preferred_username": []string{"no"},
|
||||
"sub": "real-sub",
|
||||
}
|
||||
if got := ResolveIdentityClaim(ctx); got != "real-sub" {
|
||||
t.Fatalf("expected sub after skipping non-string claim, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityClaimTrimsWhitespace(t *testing.T) {
|
||||
ctx := map[string]interface{}{
|
||||
"preferred_username": " grant.west ",
|
||||
"email": "grant.west@example.com",
|
||||
}
|
||||
if got := ResolveIdentityClaim(ctx); got != "grant.west" {
|
||||
t.Fatalf("expected trimmed preferred_username, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityClaimSkipsWhitespaceOnly(t *testing.T) {
|
||||
ctx := map[string]interface{}{
|
||||
"preferred_username": " ",
|
||||
"email": "grant.west@example.com",
|
||||
}
|
||||
if got := ResolveIdentityClaim(ctx); got != "grant.west@example.com" {
|
||||
t.Fatalf("expected email after skipping whitespace-only preferred_username, got %q", got)
|
||||
}
|
||||
}
|
||||
303
weed/iam/sts/provider_factory.go
Normal file
303
weed/iam/sts/provider_factory.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/ldap"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/oidc"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
)
|
||||
|
||||
// ProviderFactory creates identity providers from configuration
|
||||
type ProviderFactory struct{}
|
||||
|
||||
// NewProviderFactory creates a new provider factory
|
||||
func NewProviderFactory() *ProviderFactory {
|
||||
return &ProviderFactory{}
|
||||
}
|
||||
|
||||
// CreateProvider creates an identity provider from configuration
|
||||
func (f *ProviderFactory) CreateProvider(config *ProviderConfig) (providers.IdentityProvider, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf(ErrConfigCannotBeNil)
|
||||
}
|
||||
|
||||
if config.Name == "" {
|
||||
return nil, fmt.Errorf(ErrProviderNameEmpty)
|
||||
}
|
||||
|
||||
if config.Type == "" {
|
||||
return nil, fmt.Errorf(ErrProviderTypeEmpty)
|
||||
}
|
||||
|
||||
if !config.Enabled {
|
||||
glog.V(2).Infof("Provider %s is disabled, skipping", config.Name)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
glog.V(2).Infof("Creating provider: name=%s, type=%s", config.Name, config.Type)
|
||||
|
||||
switch config.Type {
|
||||
case ProviderTypeOIDC:
|
||||
return f.createOIDCProvider(config)
|
||||
case ProviderTypeLDAP:
|
||||
return f.createLDAPProvider(config)
|
||||
case ProviderTypeSAML:
|
||||
return f.createSAMLProvider(config)
|
||||
default:
|
||||
return nil, fmt.Errorf(ErrUnsupportedProviderType, config.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// createOIDCProvider creates an OIDC provider from configuration
|
||||
func (f *ProviderFactory) createOIDCProvider(config *ProviderConfig) (providers.IdentityProvider, error) {
|
||||
oidcConfig, err := f.convertToOIDCConfig(config.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert OIDC config: %w", err)
|
||||
}
|
||||
|
||||
provider := oidc.NewOIDCProvider(config.Name)
|
||||
if err := provider.Initialize(oidcConfig); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize OIDC provider: %w", err)
|
||||
}
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// createLDAPProvider creates an LDAP provider from configuration
|
||||
func (f *ProviderFactory) createLDAPProvider(config *ProviderConfig) (providers.IdentityProvider, error) {
|
||||
provider := ldap.NewLDAPProvider(config.Name)
|
||||
if err := provider.Initialize(config.Config); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize LDAP provider: %w", err)
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// createSAMLProvider creates a SAML provider from configuration
|
||||
func (f *ProviderFactory) createSAMLProvider(config *ProviderConfig) (providers.IdentityProvider, error) {
|
||||
// TODO: Implement SAML provider when available
|
||||
return nil, fmt.Errorf("SAML provider not implemented yet")
|
||||
}
|
||||
|
||||
// convertToOIDCConfig converts generic config map to OIDC config struct
|
||||
func (f *ProviderFactory) convertToOIDCConfig(configMap map[string]interface{}) (*oidc.OIDCConfig, error) {
|
||||
config := &oidc.OIDCConfig{}
|
||||
|
||||
// Required fields
|
||||
if issuer, ok := configMap[ConfigFieldIssuer].(string); ok {
|
||||
config.Issuer = issuer
|
||||
} else {
|
||||
return nil, fmt.Errorf(ErrIssuerRequired)
|
||||
}
|
||||
|
||||
clientIDPresent := false
|
||||
if clientID, ok := configMap[ConfigFieldClientID].(string); ok && clientID != "" {
|
||||
config.ClientID = clientID
|
||||
clientIDPresent = true
|
||||
}
|
||||
// Accept the AWS-compatible plural form. Either field satisfies the
|
||||
// "at least one client id" requirement; both can be set together.
|
||||
if rawList, ok := configMap[ConfigFieldClientIDs]; ok {
|
||||
if list, err := f.convertToStringSlice(rawList); err == nil && len(list) > 0 {
|
||||
config.ClientIDs = list
|
||||
clientIDPresent = true
|
||||
}
|
||||
}
|
||||
if !clientIDPresent {
|
||||
return nil, fmt.Errorf(ErrClientIDRequired)
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
if clientSecret, ok := configMap[ConfigFieldClientSecret].(string); ok {
|
||||
config.ClientSecret = clientSecret
|
||||
}
|
||||
|
||||
if jwksUri, ok := configMap[ConfigFieldJWKSUri].(string); ok {
|
||||
config.JWKSUri = jwksUri
|
||||
}
|
||||
|
||||
if userInfoUri, ok := configMap[ConfigFieldUserInfoUri].(string); ok {
|
||||
config.UserInfoUri = userInfoUri
|
||||
}
|
||||
|
||||
// Convert scopes array
|
||||
if scopesInterface, ok := configMap[ConfigFieldScopes]; ok {
|
||||
scopes, err := f.convertToStringSlice(scopesInterface)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert scopes: %w", err)
|
||||
}
|
||||
config.Scopes = scopes
|
||||
}
|
||||
|
||||
if tlsCaCert, ok := configMap[ConfigFieldTLSCACert].(string); ok {
|
||||
config.TLSCACert = tlsCaCert
|
||||
}
|
||||
|
||||
if rawThumbprints, ok := configMap[ConfigFieldThumbprints]; ok {
|
||||
if list, err := f.convertToStringSlice(rawThumbprints); err == nil {
|
||||
config.Thumbprints = list
|
||||
}
|
||||
}
|
||||
|
||||
if rawAllowed, ok := configMap[ConfigFieldAllowedPrincipalTagKeys]; ok {
|
||||
if list, err := f.convertToStringSlice(rawAllowed); err == nil {
|
||||
config.AllowedPrincipalTagKeys = list
|
||||
}
|
||||
}
|
||||
|
||||
if policyClaim, ok := configMap[ConfigFieldPolicyClaim].(string); ok {
|
||||
config.PolicyClaim = policyClaim
|
||||
}
|
||||
|
||||
if tlsInsecureSkipVerify, ok := configMap[ConfigFieldTLSInsecureSkipVerify].(bool); ok {
|
||||
config.TLSInsecureSkipVerify = tlsInsecureSkipVerify
|
||||
}
|
||||
|
||||
// Convert claims mapping
|
||||
if claimsMapInterface, ok := configMap["claimsMapping"]; ok {
|
||||
claimsMap, err := f.convertToStringMap(claimsMapInterface)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert claimsMapping: %w", err)
|
||||
}
|
||||
config.ClaimsMapping = claimsMap
|
||||
}
|
||||
|
||||
// Convert role mapping
|
||||
if roleMappingInterface, ok := configMap["roleMapping"]; ok {
|
||||
roleMapping, err := f.convertToRoleMapping(roleMappingInterface)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert roleMapping: %w", err)
|
||||
}
|
||||
config.RoleMapping = roleMapping
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Converted OIDC config: issuer=%s, clientId=%s, jwksUri=%s",
|
||||
config.Issuer, config.ClientID, config.JWKSUri)
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// convertToStringSlice converts interface{} to []string
|
||||
func (f *ProviderFactory) convertToStringSlice(value interface{}) ([]string, error) {
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
return v, nil
|
||||
case []interface{}:
|
||||
result := make([]string, len(v))
|
||||
for i, item := range v {
|
||||
if str, ok := item.(string); ok {
|
||||
result[i] = str
|
||||
} else {
|
||||
return nil, fmt.Errorf("non-string item in slice: %v", item)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("cannot convert %T to []string", value)
|
||||
}
|
||||
}
|
||||
|
||||
// convertToStringMap converts interface{} to map[string]string
|
||||
func (f *ProviderFactory) convertToStringMap(value interface{}) (map[string]string, error) {
|
||||
switch v := value.(type) {
|
||||
case map[string]string:
|
||||
return v, nil
|
||||
case map[string]interface{}:
|
||||
result := make(map[string]string)
|
||||
for key, val := range v {
|
||||
if str, ok := val.(string); ok {
|
||||
result[key] = str
|
||||
} else {
|
||||
return nil, fmt.Errorf("non-string value for key %s: %v", key, val)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("cannot convert %T to map[string]string", value)
|
||||
}
|
||||
}
|
||||
|
||||
// LoadProvidersFromConfig creates providers from configuration
|
||||
func (f *ProviderFactory) LoadProvidersFromConfig(configs []*ProviderConfig) (map[string]providers.IdentityProvider, error) {
|
||||
providersMap := make(map[string]providers.IdentityProvider)
|
||||
|
||||
for _, config := range configs {
|
||||
if config == nil {
|
||||
glog.V(1).Infof("Skipping nil provider config")
|
||||
continue
|
||||
}
|
||||
|
||||
glog.V(2).Infof("Loading provider: %s (type: %s, enabled: %t)",
|
||||
config.Name, config.Type, config.Enabled)
|
||||
|
||||
if !config.Enabled {
|
||||
glog.V(2).Infof("Provider %s is disabled, skipping", config.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
provider, err := f.CreateProvider(config)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to create provider %s: %v", config.Name, err)
|
||||
return nil, fmt.Errorf("failed to create provider %s: %w", config.Name, err)
|
||||
}
|
||||
|
||||
if provider != nil {
|
||||
providersMap[config.Name] = provider
|
||||
glog.V(1).Infof("Successfully loaded provider: %s", config.Name)
|
||||
}
|
||||
}
|
||||
|
||||
glog.V(1).Infof("Loaded %d identity providers from configuration", len(providersMap))
|
||||
return providersMap, nil
|
||||
}
|
||||
|
||||
// convertToRoleMapping converts interface{} to *providers.RoleMapping
|
||||
func (f *ProviderFactory) convertToRoleMapping(value interface{}) (*providers.RoleMapping, error) {
|
||||
roleMappingMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("roleMapping must be an object")
|
||||
}
|
||||
|
||||
roleMapping := &providers.RoleMapping{}
|
||||
|
||||
// Convert rules
|
||||
if rulesInterface, ok := roleMappingMap["rules"]; ok {
|
||||
rulesSlice, ok := rulesInterface.([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rules must be an array")
|
||||
}
|
||||
|
||||
rules := make([]providers.MappingRule, len(rulesSlice))
|
||||
for i, ruleInterface := range rulesSlice {
|
||||
ruleMap, ok := ruleInterface.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rule must be an object")
|
||||
}
|
||||
|
||||
rule := providers.MappingRule{}
|
||||
if claim, ok := ruleMap["claim"].(string); ok {
|
||||
rule.Claim = claim
|
||||
}
|
||||
if value, ok := ruleMap["value"].(string); ok {
|
||||
rule.Value = value
|
||||
}
|
||||
if role, ok := ruleMap["role"].(string); ok {
|
||||
rule.Role = role
|
||||
}
|
||||
if condition, ok := ruleMap["condition"].(string); ok {
|
||||
rule.Condition = condition
|
||||
}
|
||||
|
||||
rules[i] = rule
|
||||
}
|
||||
roleMapping.Rules = rules
|
||||
}
|
||||
|
||||
// Convert default role
|
||||
if defaultRole, ok := roleMappingMap["defaultRole"].(string); ok {
|
||||
roleMapping.DefaultRole = defaultRole
|
||||
}
|
||||
|
||||
return roleMapping, nil
|
||||
}
|
||||
248
weed/iam/sts/session_claims.go
Normal file
248
weed/iam/sts/session_claims.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
)
|
||||
|
||||
// ComputeParentUser returns a stable per-identity hash derived from the OIDC
|
||||
// (sub, iss) tuple. Only the (sub, iss) pair is guaranteed stable across token
|
||||
// refreshes per OpenID Connect Core 1.0 §5.7, so any per-user state (audit
|
||||
// logs, quotas) must key off this value rather than the access-key or session
|
||||
// id. The hash is base64-rawurl-encoded SHA-256 over "openid:<sub>:<iss>" so
|
||||
// it stays filesystem-safe and bounded in length for storage in audit paths.
|
||||
func ComputeParentUser(sub, iss string) string {
|
||||
if sub == "" || iss == "" {
|
||||
return ""
|
||||
}
|
||||
h := sha256.Sum256([]byte("openid:" + sub + ":" + iss))
|
||||
return base64.RawURLEncoding.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// identityClaimPriority is the order in which ResolveIdentityClaim looks for a
|
||||
// human-readable, authoritative identity attribute in an STS request context.
|
||||
// The context is populated at federation time from the validated OIDC token
|
||||
// (see AssumeRoleWithWebIdentity), so every entry here is server-asserted and
|
||||
// not client-supplied. preferred_username/email/name are conventional OIDC
|
||||
// user claims; sub is the always-present stable subject identifier and the
|
||||
// final fallback so a federated session never audits as fully anonymous.
|
||||
var identityClaimPriority = []string{"preferred_username", "email", "name", "sub"}
|
||||
|
||||
// ResolveIdentityClaim returns the most human-readable authoritative identity
|
||||
// claim available in ctx, or "" when none is present. ctx is the STS request
|
||||
// context (sessionInfo.RequestContext) populated from the validated OIDC token
|
||||
// at federation time. Non-string values are skipped so a structured claim
|
||||
// never leaks into an audit-facing field. Whitespace-only values are treated
|
||||
// as absent so a blank preferred claim does not mask a usable email or sub.
|
||||
func ResolveIdentityClaim(ctx map[string]interface{}) string {
|
||||
if len(ctx) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, key := range identityClaimPriority {
|
||||
if v, ok := ctx[key].(string); ok {
|
||||
if trimmed := strings.TrimSpace(v); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// defaultCredentialGenerator is a reusable instance for generating temporary credentials
|
||||
// Reusing a single instance across all calls to ToSessionInfo() reduces allocation overhead
|
||||
// since this method may be called frequently during signature verification
|
||||
var defaultCredentialGenerator = NewCredentialGenerator()
|
||||
|
||||
// STSSessionClaims represents comprehensive session information embedded in JWT tokens
|
||||
// This eliminates the need for separate session storage by embedding all session
|
||||
// metadata directly in the token itself - enabling true stateless operation
|
||||
type STSSessionClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
|
||||
// Session identification
|
||||
SessionId string `json:"sid"` // session_id (abbreviated for smaller tokens)
|
||||
SessionName string `json:"snam"` // session_name (abbreviated for smaller tokens)
|
||||
TokenType string `json:"typ"` // token_type
|
||||
|
||||
// Role information
|
||||
RoleArn string `json:"role"` // role_arn
|
||||
AssumedRole string `json:"assumed"` // assumed_role_user
|
||||
Principal string `json:"principal"` // principal_arn
|
||||
|
||||
// Authorization data
|
||||
Policies []string `json:"pol,omitempty"` // policies (abbreviated)
|
||||
// SessionPolicy contains inline session policy JSON (optional)
|
||||
SessionPolicy string `json:"spol,omitempty"`
|
||||
|
||||
// Identity provider information
|
||||
IdentityProvider string `json:"idp"` // identity_provider
|
||||
ExternalUserId string `json:"ext_uid"` // external_user_id
|
||||
ProviderIssuer string `json:"prov_iss"` // provider_issuer
|
||||
|
||||
// Request context (optional, for policy evaluation)
|
||||
RequestContext map[string]interface{} `json:"req_ctx,omitempty"`
|
||||
|
||||
// Session metadata
|
||||
AssumedAt time.Time `json:"assumed_at"` // when role was assumed
|
||||
MaxDuration int64 `json:"max_dur,omitempty"` // maximum session duration in seconds
|
||||
|
||||
// ParentUser is a stable hash of (sub, iss) for tokens minted from an OIDC
|
||||
// identity. It survives token rotation since only the (sub, iss) tuple is
|
||||
// guaranteed stable per OpenID Connect Core 1.0. Empty for non-federated
|
||||
// session types.
|
||||
ParentUser string `json:"puid,omitempty"`
|
||||
}
|
||||
|
||||
// NewSTSSessionClaims creates new STS session claims with all required information
|
||||
func NewSTSSessionClaims(sessionId, issuer string, expiresAt time.Time) *STSSessionClaims {
|
||||
now := time.Now()
|
||||
return &STSSessionClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: issuer,
|
||||
Subject: sessionId,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
// jti = sessionId. The session id is already a unique random
|
||||
// identifier, so reusing it as the JWT id avoids a second secret
|
||||
// while still giving the revocation layer a stable lookup key.
|
||||
ID: sessionId,
|
||||
},
|
||||
SessionId: sessionId,
|
||||
TokenType: TokenTypeSession,
|
||||
AssumedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// ToSessionInfo converts JWT claims back to SessionInfo structure
|
||||
// This enables seamless integration with existing code expecting SessionInfo
|
||||
func (c *STSSessionClaims) ToSessionInfo() *SessionInfo {
|
||||
var expiresAt time.Time
|
||||
if c.ExpiresAt != nil {
|
||||
expiresAt = c.ExpiresAt.Time
|
||||
}
|
||||
|
||||
// Generate temporary credentials from the session ID
|
||||
// This is deterministic based on the session ID, so the same credentials are regenerated
|
||||
credentials, err := defaultCredentialGenerator.GenerateTemporaryCredentials(c.SessionId, expiresAt)
|
||||
if err != nil {
|
||||
// Log the error with context - credential generation failure is important for debugging
|
||||
errMsg := fmt.Errorf("generate temporary credentials for session %s: %w", c.SessionId, err)
|
||||
glog.Warningf("Failed to generate credentials for STS session: %v", errMsg)
|
||||
// Return session info without credentials - validation will catch this as invalid
|
||||
credentials = nil
|
||||
}
|
||||
|
||||
return &SessionInfo{
|
||||
SessionId: c.SessionId,
|
||||
SessionName: c.SessionName,
|
||||
RoleArn: c.RoleArn,
|
||||
AssumedRoleUser: c.AssumedRole,
|
||||
Principal: c.Principal,
|
||||
Policies: c.Policies,
|
||||
SessionPolicy: c.SessionPolicy,
|
||||
ExpiresAt: expiresAt,
|
||||
IdentityProvider: c.IdentityProvider,
|
||||
ExternalUserId: c.ExternalUserId,
|
||||
ProviderIssuer: c.ProviderIssuer,
|
||||
RequestContext: c.RequestContext,
|
||||
ParentUser: c.ParentUser,
|
||||
// Provide the Subject (sub) from registered claims
|
||||
Subject: c.Subject,
|
||||
Credentials: credentials,
|
||||
}
|
||||
}
|
||||
|
||||
// IsValid checks if the session claims are valid (not expired, etc.)
|
||||
func (c *STSSessionClaims) IsValid() bool {
|
||||
now := time.Now()
|
||||
|
||||
// Check expiration
|
||||
if c.ExpiresAt != nil && c.ExpiresAt.Before(now) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check not-before
|
||||
if c.NotBefore != nil && c.NotBefore.After(now) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Ensure required fields are present
|
||||
if c.SessionId == "" || c.RoleArn == "" || c.Principal == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GetSessionId returns the session identifier
|
||||
func (c *STSSessionClaims) GetSessionId() string {
|
||||
return c.SessionId
|
||||
}
|
||||
|
||||
// GetExpiresAt returns the expiration time
|
||||
func (c *STSSessionClaims) GetExpiresAt() time.Time {
|
||||
if c.ExpiresAt != nil {
|
||||
return c.ExpiresAt.Time
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// WithRoleInfo sets role-related information in the claims
|
||||
func (c *STSSessionClaims) WithRoleInfo(roleArn, assumedRole, principal string) *STSSessionClaims {
|
||||
c.RoleArn = roleArn
|
||||
c.AssumedRole = assumedRole
|
||||
c.Principal = principal
|
||||
return c
|
||||
}
|
||||
|
||||
// WithPolicies sets the policies associated with this session
|
||||
func (c *STSSessionClaims) WithPolicies(policies []string) *STSSessionClaims {
|
||||
c.Policies = policies
|
||||
return c
|
||||
}
|
||||
|
||||
// WithSessionPolicy sets the inline session policy JSON for this session
|
||||
func (c *STSSessionClaims) WithSessionPolicy(policy string) *STSSessionClaims {
|
||||
c.SessionPolicy = policy
|
||||
return c
|
||||
}
|
||||
|
||||
// WithIdentityProvider sets identity provider information
|
||||
func (c *STSSessionClaims) WithIdentityProvider(providerName, externalUserId, providerIssuer string) *STSSessionClaims {
|
||||
c.IdentityProvider = providerName
|
||||
c.ExternalUserId = externalUserId
|
||||
c.ProviderIssuer = providerIssuer
|
||||
return c
|
||||
}
|
||||
|
||||
// WithRequestContext sets request context for policy evaluation
|
||||
func (c *STSSessionClaims) WithRequestContext(ctx map[string]interface{}) *STSSessionClaims {
|
||||
c.RequestContext = ctx
|
||||
return c
|
||||
}
|
||||
|
||||
// WithMaxDuration sets the maximum session duration
|
||||
func (c *STSSessionClaims) WithMaxDuration(duration time.Duration) *STSSessionClaims {
|
||||
c.MaxDuration = int64(duration.Seconds())
|
||||
return c
|
||||
}
|
||||
|
||||
// WithSessionName sets the session name
|
||||
func (c *STSSessionClaims) WithSessionName(sessionName string) *STSSessionClaims {
|
||||
c.SessionName = sessionName
|
||||
return c
|
||||
}
|
||||
|
||||
// WithParentUser sets the stable per-identity hash for the session. See
|
||||
// ComputeParentUser for the derivation rule.
|
||||
func (c *STSSessionClaims) WithParentUser(parentUser string) *STSSessionClaims {
|
||||
c.ParentUser = parentUser
|
||||
return c
|
||||
}
|
||||
285
weed/iam/sts/session_claims_test.go
Normal file
285
weed/iam/sts/session_claims_test.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSTSSessionClaimsToSessionInfo tests the ToSessionInfo conversion
|
||||
func TestSTSSessionClaimsToSessionInfo(t *testing.T) {
|
||||
sessionId := "test-session-123"
|
||||
issuer := "test-issuer"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
|
||||
claims := NewSTSSessionClaims(sessionId, issuer, expiresAt).
|
||||
WithSessionName("test-session-name").
|
||||
WithRoleInfo(
|
||||
"arn:aws:iam::123456789012:role/test-role",
|
||||
"arn:aws:iam::123456789012:assumed-role/test-role/session",
|
||||
"arn:aws:iam::123456789012:assumed-role/test-role/session",
|
||||
).
|
||||
WithIdentityProvider("oidc", "user-123", "https://issuer.example.com").
|
||||
WithMaxDuration(time.Hour)
|
||||
|
||||
sessionInfo := claims.ToSessionInfo()
|
||||
|
||||
// Verify basic claims are converted
|
||||
assert.Equal(t, sessionId, sessionInfo.SessionId)
|
||||
assert.Equal(t, "test-session-name", sessionInfo.SessionName)
|
||||
assert.Equal(t, "arn:aws:iam::123456789012:role/test-role", sessionInfo.RoleArn)
|
||||
assert.Equal(t, "arn:aws:iam::123456789012:assumed-role/test-role/session", sessionInfo.AssumedRoleUser)
|
||||
assert.Equal(t, "oidc", sessionInfo.IdentityProvider)
|
||||
assert.Equal(t, "user-123", sessionInfo.ExternalUserId)
|
||||
|
||||
// Verify credentials are generated
|
||||
assert.NotNil(t, sessionInfo.Credentials, "credentials should be populated")
|
||||
assert.NotEmpty(t, sessionInfo.Credentials.AccessKeyId, "access key should be generated")
|
||||
assert.NotEmpty(t, sessionInfo.Credentials.SecretAccessKey, "secret key should be generated")
|
||||
// Credential expiration may have sub-second differences, so just check they're close
|
||||
assert.True(t, sessionInfo.Credentials.Expiration.Sub(expiresAt) < time.Second, "credential expiration should match session expiration")
|
||||
|
||||
// Verify expiration is preserved (within 1 second tolerance for timing differences)
|
||||
assert.WithinDuration(t, expiresAt, sessionInfo.ExpiresAt, 1*time.Second)
|
||||
}
|
||||
|
||||
// TestSTSSessionClaimsToSessionInfoCredentialGeneration tests that credentials are deterministically generated for access key and session token
|
||||
func TestSTSSessionClaimsToSessionInfoCredentialGeneration(t *testing.T) {
|
||||
sessionId := "deterministic-session-id"
|
||||
issuer := "test-issuer"
|
||||
expiresAt := time.Now().Add(time.Hour).Truncate(time.Second)
|
||||
|
||||
claims1 := NewSTSSessionClaims(sessionId, issuer, expiresAt)
|
||||
sessionInfo1 := claims1.ToSessionInfo()
|
||||
|
||||
// Create another claims object with the same session ID and expiration
|
||||
claims2 := NewSTSSessionClaims(sessionId, issuer, expiresAt)
|
||||
sessionInfo2 := claims2.ToSessionInfo()
|
||||
|
||||
// Verify that both have valid credentials
|
||||
assert.NotNil(t, sessionInfo1.Credentials, "credentials should be populated")
|
||||
assert.NotNil(t, sessionInfo2.Credentials, "credentials should be populated")
|
||||
|
||||
// Verify deterministic generation: same SessionId should produce identical access key ID
|
||||
// (based on hash of session ID, not random)
|
||||
assert.Equal(t, sessionInfo1.Credentials.AccessKeyId, sessionInfo2.Credentials.AccessKeyId,
|
||||
"same session ID should produce identical access key ID (deterministic hash-based generation)")
|
||||
|
||||
// Session token is also deterministic (hash-based on session ID)
|
||||
assert.Equal(t, sessionInfo1.Credentials.SessionToken, sessionInfo2.Credentials.SessionToken,
|
||||
"same session ID should produce identical session token (deterministic hash-based generation)")
|
||||
|
||||
// Secret access key is NOW deterministic (hash-based on session ID, not random!)
|
||||
// This is critical for signature verification: the same session ID must regenerate
|
||||
// the same secret key so that signature verification succeeds.
|
||||
assert.Equal(t, sessionInfo1.Credentials.SecretAccessKey, sessionInfo2.Credentials.SecretAccessKey,
|
||||
"same session ID should produce identical secret access key (deterministic hash-based generation)")
|
||||
|
||||
// Expiration should match
|
||||
assert.WithinDuration(t, sessionInfo1.Credentials.Expiration, sessionInfo2.Credentials.Expiration, 1*time.Second,
|
||||
"credentials expiration should match")
|
||||
}
|
||||
|
||||
// TestSTSSessionClaimsToSessionInfoPreservesAllFields tests that all fields are preserved
|
||||
func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) {
|
||||
sessionId := "test-session-id"
|
||||
issuer := "test-issuer"
|
||||
expiresAt := time.Now().Add(2 * time.Hour)
|
||||
|
||||
policies := []string{"policy1", "policy2"}
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"]}]}`
|
||||
requestContext := map[string]interface{}{
|
||||
"sourceIp": "192.168.1.1",
|
||||
"userAgent": "test-agent",
|
||||
}
|
||||
|
||||
claims := NewSTSSessionClaims(sessionId, issuer, expiresAt).
|
||||
WithSessionName("session-name").
|
||||
WithRoleInfo("role-arn", "assumed-role", "principal").
|
||||
WithIdentityProvider("provider", "external-id", "issuer").
|
||||
WithPolicies(policies).
|
||||
WithSessionPolicy(sessionPolicy).
|
||||
WithRequestContext(requestContext).
|
||||
WithMaxDuration(2 * time.Hour)
|
||||
|
||||
sessionInfo := claims.ToSessionInfo()
|
||||
|
||||
// Verify all fields are preserved
|
||||
assert.Equal(t, sessionId, sessionInfo.SessionId)
|
||||
assert.Equal(t, "session-name", sessionInfo.SessionName)
|
||||
assert.Equal(t, "role-arn", sessionInfo.RoleArn)
|
||||
assert.Equal(t, "assumed-role", sessionInfo.AssumedRoleUser)
|
||||
assert.Equal(t, "principal", sessionInfo.Principal)
|
||||
assert.Equal(t, "provider", sessionInfo.IdentityProvider)
|
||||
assert.Equal(t, "external-id", sessionInfo.ExternalUserId)
|
||||
assert.Equal(t, "issuer", sessionInfo.ProviderIssuer)
|
||||
assert.Equal(t, policies, sessionInfo.Policies)
|
||||
assert.Equal(t, sessionPolicy, sessionInfo.SessionPolicy)
|
||||
assert.Equal(t, requestContext, sessionInfo.RequestContext)
|
||||
assert.WithinDuration(t, expiresAt, sessionInfo.ExpiresAt, 1*time.Second)
|
||||
}
|
||||
|
||||
// TestSTSSessionClaimsToSessionInfoEmptyFields tests handling of empty/nil fields
|
||||
func TestSTSSessionClaimsToSessionInfoEmptyFields(t *testing.T) {
|
||||
sessionId := "minimal-session"
|
||||
issuer := "issuer"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
|
||||
// Create claims with minimal fields
|
||||
claims := NewSTSSessionClaims(sessionId, issuer, expiresAt)
|
||||
|
||||
sessionInfo := claims.ToSessionInfo()
|
||||
|
||||
// Verify basic fields are preserved
|
||||
assert.Equal(t, sessionId, sessionInfo.SessionId)
|
||||
assert.Empty(t, sessionInfo.SessionName)
|
||||
assert.Empty(t, sessionInfo.RoleArn)
|
||||
|
||||
// Verify credentials are still generated even with minimal fields
|
||||
assert.NotNil(t, sessionInfo.Credentials)
|
||||
assert.NotEmpty(t, sessionInfo.Credentials.AccessKeyId)
|
||||
assert.NotEmpty(t, sessionInfo.Credentials.SecretAccessKey)
|
||||
}
|
||||
|
||||
// TestSTSSessionClaimsToSessionInfoCredentialExpiration tests credential expiration
|
||||
func TestSTSSessionClaimsToSessionInfoCredentialExpiration(t *testing.T) {
|
||||
sessionId := "test-session"
|
||||
issuer := "issuer"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expiresAt time.Time
|
||||
expectNotExpired bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "future_expiration",
|
||||
expiresAt: time.Now().Add(time.Hour),
|
||||
expectNotExpired: true,
|
||||
description: "Credentials should not be expired if ExpiresAt is in the future",
|
||||
},
|
||||
{
|
||||
name: "past_expiration",
|
||||
expiresAt: time.Now().Add(-time.Hour),
|
||||
expectNotExpired: false,
|
||||
description: "Credentials should be expired if ExpiresAt is in the past",
|
||||
},
|
||||
{
|
||||
name: "near_future_expiration",
|
||||
expiresAt: time.Now().Add(time.Minute),
|
||||
expectNotExpired: true,
|
||||
description: "Credentials should not be expired even if close to expiration",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
claims := NewSTSSessionClaims(sessionId, issuer, tc.expiresAt)
|
||||
sessionInfo := claims.ToSessionInfo()
|
||||
|
||||
assert.NotNil(t, sessionInfo.Credentials)
|
||||
// Check expiration within 1 second due to timing precision (symmetric tolerance)
|
||||
assert.WithinDuration(t, tc.expiresAt, sessionInfo.Credentials.Expiration, time.Second,
|
||||
"credential expiration should be within 1 second of session expiration")
|
||||
// We set tc.expiresAt to past/future values to exercise expiration handling.
|
||||
// Assert the credentials' expiration relative to now to exercise code behavior
|
||||
if tc.expectNotExpired {
|
||||
assert.True(t, time.Now().Before(sessionInfo.Credentials.Expiration), tc.description)
|
||||
} else {
|
||||
assert.True(t, time.Now().After(sessionInfo.Credentials.Expiration), tc.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionInfoIntegration tests the full integration of session info flow
|
||||
func TestSessionInfoIntegration(t *testing.T) {
|
||||
// Create a session claim
|
||||
sessionId, err := GenerateSessionId()
|
||||
require.NoError(t, err)
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
claims := NewSTSSessionClaims(sessionId, "test-issuer", expiresAt).
|
||||
WithSessionName("integration-test").
|
||||
WithRoleInfo(
|
||||
"arn:aws:iam::123456789012:role/integration",
|
||||
"arn:aws:iam::123456789012:assumed-role/integration/test",
|
||||
"arn:aws:iam::123456789012:assumed-role/integration/test",
|
||||
).
|
||||
WithIdentityProvider("test-provider", "user-id", "https://test.example.com")
|
||||
|
||||
// Convert to SessionInfo
|
||||
sessionInfo := claims.ToSessionInfo()
|
||||
|
||||
// Verify the session info has valid credentials
|
||||
assert.NotNil(t, sessionInfo.Credentials)
|
||||
assert.NotEmpty(t, sessionInfo.Credentials.AccessKeyId)
|
||||
assert.NotEmpty(t, sessionInfo.Credentials.SecretAccessKey)
|
||||
|
||||
// Verify basic session properties
|
||||
assert.Equal(t, sessionId, sessionInfo.SessionId)
|
||||
assert.Equal(t, "integration-test", sessionInfo.SessionName)
|
||||
assert.False(t, sessionInfo.ExpiresAt.IsZero())
|
||||
|
||||
// Verify that the session is valid
|
||||
assert.True(t, sessionInfo.ExpiresAt.After(time.Now()), "session should not be expired")
|
||||
assert.False(t, sessionInfo.Credentials.Expiration.Before(time.Now()), "credentials should not be expired")
|
||||
}
|
||||
|
||||
// TestSecretAccessKeyDeterminism verifies that secret access keys are deterministically
|
||||
// generated from the session ID. This is CRITICAL for STS signature verification:
|
||||
// The client generates a secret key and signs the request. When the server receives
|
||||
// the request, it must regenerate the exact same secret key from the JWT claims
|
||||
// to verify the signature. If the secret key is random, verification will always fail.
|
||||
func TestSecretAccessKeyDeterminism(t *testing.T) {
|
||||
sessionId := "critical-determinism-test"
|
||||
expiration := time.Now().Add(time.Hour)
|
||||
|
||||
// Generate credentials multiple times with the same session ID
|
||||
credGen := NewCredentialGenerator()
|
||||
|
||||
cred1, err := credGen.GenerateTemporaryCredentials(sessionId, expiration)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cred1)
|
||||
|
||||
cred2, err := credGen.GenerateTemporaryCredentials(sessionId, expiration)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cred2)
|
||||
|
||||
cred3, err := credGen.GenerateTemporaryCredentials(sessionId, expiration)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cred3)
|
||||
|
||||
// All three should have IDENTICAL secret access keys
|
||||
assert.Equal(t, cred1.SecretAccessKey, cred2.SecretAccessKey,
|
||||
"same sessionId must produce identical secret key on first and second call")
|
||||
assert.Equal(t, cred2.SecretAccessKey, cred3.SecretAccessKey,
|
||||
"same sessionId must produce identical secret key on second and third call")
|
||||
|
||||
// All three should have IDENTICAL access key IDs
|
||||
assert.Equal(t, cred1.AccessKeyId, cred2.AccessKeyId,
|
||||
"same sessionId must produce identical access key ID")
|
||||
assert.Equal(t, cred2.AccessKeyId, cred3.AccessKeyId,
|
||||
"same sessionId must produce identical access key ID")
|
||||
|
||||
// All three should have IDENTICAL session tokens
|
||||
assert.Equal(t, cred1.SessionToken, cred2.SessionToken,
|
||||
"same sessionId must produce identical session token")
|
||||
assert.Equal(t, cred2.SessionToken, cred3.SessionToken,
|
||||
"same sessionId must produce identical session token")
|
||||
|
||||
// Different session IDs should produce different secrets
|
||||
otherSessionId := "different-session"
|
||||
credOther, err := credGen.GenerateTemporaryCredentials(otherSessionId, expiration)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, credOther)
|
||||
|
||||
assert.NotEqual(t, cred1.SecretAccessKey, credOther.SecretAccessKey,
|
||||
"different session IDs must produce different secret keys")
|
||||
assert.NotEqual(t, cred1.AccessKeyId, credOther.AccessKeyId,
|
||||
"different session IDs must produce different access key IDs")
|
||||
assert.NotEqual(t, cred1.SessionToken, credOther.SessionToken,
|
||||
"different session IDs must produce different session tokens")
|
||||
}
|
||||
35
weed/iam/sts/session_policy.go
Normal file
35
weed/iam/sts/session_policy.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
)
|
||||
|
||||
// NormalizeSessionPolicy validates and normalizes inline session policy JSON.
|
||||
// It returns an empty string if the input is empty or whitespace.
|
||||
func NormalizeSessionPolicy(policyJSON string) (string, error) {
|
||||
trimmed := strings.TrimSpace(policyJSON)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
const maxSessionPolicySize = 2048
|
||||
if len(trimmed) > maxSessionPolicySize {
|
||||
return "", fmt.Errorf("session policy exceeds maximum size of %d characters", maxSessionPolicySize)
|
||||
}
|
||||
|
||||
var policyDoc policy.PolicyDocument
|
||||
if err := json.Unmarshal([]byte(trimmed), &policyDoc); err != nil {
|
||||
return "", fmt.Errorf("invalid session policy JSON: %w", err)
|
||||
}
|
||||
if err := policy.ValidatePolicyDocument(&policyDoc); err != nil {
|
||||
return "", fmt.Errorf("invalid session policy document: %w", err)
|
||||
}
|
||||
normalized, err := json.Marshal(&policyDoc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to normalize session policy: %w", err)
|
||||
}
|
||||
return string(normalized), nil
|
||||
}
|
||||
1186
weed/iam/sts/sts_service.go
Normal file
1186
weed/iam/sts/sts_service.go
Normal file
File diff suppressed because it is too large
Load Diff
57
weed/iam/sts/sts_service_duration_test.go
Normal file
57
weed/iam/sts/sts_service_duration_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func newSTSServiceWithMaxSession(t *testing.T, maxSession time.Duration) *STSService {
|
||||
t.Helper()
|
||||
s := NewSTSService()
|
||||
assert.NoError(t, s.Initialize(&STSConfig{
|
||||
Issuer: "test-issuer",
|
||||
SigningKey: []byte("test-signing-key-at-least-32-bytes-long"),
|
||||
TokenDuration: FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: FlexibleDuration{Duration: maxSession},
|
||||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
func secondsPtr(v int64) *int64 { return &v }
|
||||
|
||||
func TestValidateSessionDurationSeconds_RespectsConfiguredMaxSessionLength(t *testing.T) {
|
||||
s := newSTSServiceWithMaxSession(t, 168*time.Hour)
|
||||
assert.NoError(t, s.validateSessionDurationSeconds(secondsPtr(604800)))
|
||||
}
|
||||
|
||||
func TestValidateSessionDurationSeconds_RejectsAboveConfiguredMaxSessionLength(t *testing.T) {
|
||||
s := newSTSServiceWithMaxSession(t, 24*time.Hour)
|
||||
err := s.validateSessionDurationSeconds(secondsPtr(90000))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestValidateSessionDurationSeconds_FallsBackToDefaultWhenUnset(t *testing.T) {
|
||||
s := &STSService{}
|
||||
assert.NoError(t, s.validateSessionDurationSeconds(secondsPtr(43200)))
|
||||
err := s.validateSessionDurationSeconds(secondsPtr(43201))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestValidateSessionDurationSeconds_EnforcesMinimum(t *testing.T) {
|
||||
s := newSTSServiceWithMaxSession(t, 168*time.Hour)
|
||||
assert.Error(t, s.validateSessionDurationSeconds(secondsPtr(899)))
|
||||
}
|
||||
|
||||
func TestValidateSessionDurationSeconds_NilReturnsNil(t *testing.T) {
|
||||
s := newSTSServiceWithMaxSession(t, 168*time.Hour)
|
||||
assert.NoError(t, s.validateSessionDurationSeconds(nil))
|
||||
}
|
||||
|
||||
func TestValidateSessionDurationSeconds_SubMinimumMaxSessionLengthKeepsCapping(t *testing.T) {
|
||||
s := newSTSServiceWithMaxSession(t, 5*time.Minute)
|
||||
assert.NoError(t, s.validateSessionDurationSeconds(secondsPtr(900)))
|
||||
assert.NoError(t, s.validateSessionDurationSeconds(secondsPtr(43200)))
|
||||
assert.Error(t, s.validateSessionDurationSeconds(secondsPtr(43201)))
|
||||
}
|
||||
38
weed/iam/sts/sts_service_test.go
Normal file
38
weed/iam/sts/sts_service_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCalculateSessionDuration(t *testing.T) {
|
||||
svc := NewSTSService()
|
||||
if err := svc.Initialize(&STSConfig{
|
||||
TokenDuration: FlexibleDuration{time.Hour},
|
||||
MaxSessionLength: FlexibleDuration{12 * time.Hour},
|
||||
Issuer: "test-issuer",
|
||||
SigningKey: []byte("test-signing-key-at-least-32-bytes-long"),
|
||||
}); err != nil {
|
||||
t.Fatalf("Initialize() error = %v", err)
|
||||
}
|
||||
|
||||
seconds := func(v int64) *int64 { return &v }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
durationSeconds *int64
|
||||
want time.Duration
|
||||
}{
|
||||
{"default from config", nil, time.Hour},
|
||||
{"explicit request", seconds(1800), 30 * time.Minute},
|
||||
{"capped at MaxSessionLength", seconds(86400), 12 * time.Hour},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := svc.calculateSessionDuration(tc.durationSeconds); got != tc.want {
|
||||
t.Errorf("calculateSessionDuration(%v) = %v, want %v", tc.durationSeconds, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
4
weed/iam/sts/test_utils.go
Normal file
4
weed/iam/sts/test_utils.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package sts
|
||||
|
||||
// MockTrustPolicyValidator is a simple mock for testing STS functionality
|
||||
type MockTrustPolicyValidator struct{}
|
||||
246
weed/iam/sts/token_utils.go
Normal file
246
weed/iam/sts/token_utils.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
|
||||
)
|
||||
|
||||
// TokenGenerator handles token generation and validation
|
||||
type TokenGenerator struct {
|
||||
signingKey []byte
|
||||
issuer string
|
||||
}
|
||||
|
||||
// NewTokenGenerator creates a new token generator
|
||||
func NewTokenGenerator(signingKey []byte, issuer string) *TokenGenerator {
|
||||
return &TokenGenerator{
|
||||
signingKey: signingKey,
|
||||
issuer: issuer,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateSessionToken creates a signed JWT session token (legacy method for compatibility)
|
||||
func (t *TokenGenerator) GenerateSessionToken(sessionId string, expiresAt time.Time) (string, error) {
|
||||
claims := NewSTSSessionClaims(sessionId, t.issuer, expiresAt)
|
||||
return t.GenerateJWTWithClaims(claims)
|
||||
}
|
||||
|
||||
// GenerateJWTWithClaims creates a signed JWT token with comprehensive session claims
|
||||
func (t *TokenGenerator) GenerateJWTWithClaims(claims *STSSessionClaims) (string, error) {
|
||||
if claims == nil {
|
||||
return "", fmt.Errorf("claims cannot be nil")
|
||||
}
|
||||
|
||||
// Ensure issuer is set from token generator
|
||||
if claims.Issuer == "" {
|
||||
claims.Issuer = t.issuer
|
||||
}
|
||||
|
||||
// SECURITY: Use deterministic signing results for troubleshooting if needed,
|
||||
// but standard HS256 with common secret is usually sufficient.
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(t.signingKey)
|
||||
}
|
||||
|
||||
// ValidateSessionToken validates and extracts claims from a session token
|
||||
func (t *TokenGenerator) ValidateSessionToken(tokenString string) (*SessionTokenClaims, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return t.signingKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, fmt.Errorf(ErrTokenNotValid)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(ErrInvalidTokenClaims)
|
||||
}
|
||||
|
||||
// Verify issuer
|
||||
if iss, ok := claims[JWTClaimIssuer].(string); !ok || iss != t.issuer {
|
||||
return nil, fmt.Errorf(ErrInvalidIssuer)
|
||||
}
|
||||
|
||||
// Extract session ID
|
||||
sessionId, ok := claims[JWTClaimSubject].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(ErrMissingSessionID)
|
||||
}
|
||||
|
||||
return &SessionTokenClaims{
|
||||
SessionId: sessionId,
|
||||
ExpiresAt: time.Unix(int64(claims[JWTClaimExpiration].(float64)), 0),
|
||||
IssuedAt: time.Unix(int64(claims[JWTClaimIssuedAt].(float64)), 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateJWTWithClaims validates and extracts comprehensive session claims from a JWT token
|
||||
func (t *TokenGenerator) ValidateJWTWithClaims(tokenString string) (*STSSessionClaims, error) {
|
||||
// 1. Parse into MapClaims to capture ALL claims including custom ones
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return t.signingKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, fmt.Errorf(ErrTokenNotValid)
|
||||
}
|
||||
|
||||
mapClaims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(ErrInvalidTokenClaims)
|
||||
}
|
||||
|
||||
// 2. Decode into STSSessionClaims using JSON round-trip to respect tags
|
||||
jsonBytes, err := json.Marshal(mapClaims)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal claims: %v", err)
|
||||
}
|
||||
|
||||
claims := &STSSessionClaims{}
|
||||
if err := json.Unmarshal(jsonBytes, claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal claims: %v", err)
|
||||
}
|
||||
|
||||
// 3. Ensure RequestContext contains all claims for policy evaluation
|
||||
// This preserves custom claims (like jwt:preferred_username) that are not in the struct
|
||||
if claims.RequestContext == nil {
|
||||
claims.RequestContext = make(map[string]interface{})
|
||||
}
|
||||
for k, v := range mapClaims {
|
||||
// Add valid claim values to RequestContext
|
||||
// We don't overwrite existing RequestContext keys if they were explicitly set
|
||||
if _, exists := claims.RequestContext[k]; !exists {
|
||||
claims.RequestContext[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Validate issuer
|
||||
if claims.Issuer != t.issuer {
|
||||
return nil, fmt.Errorf(ErrInvalidIssuer)
|
||||
}
|
||||
|
||||
// Validate that required fields are present
|
||||
if claims.SessionId == "" {
|
||||
return nil, fmt.Errorf(ErrMissingSessionID)
|
||||
}
|
||||
|
||||
// Additional validation using the claims' own validation method
|
||||
if !claims.IsValid() {
|
||||
return nil, fmt.Errorf(ErrTokenNotValid)
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// SessionTokenClaims represents parsed session token claims
|
||||
type SessionTokenClaims struct {
|
||||
SessionId string
|
||||
ExpiresAt time.Time
|
||||
IssuedAt time.Time
|
||||
}
|
||||
|
||||
// CredentialGenerator generates AWS-compatible temporary credentials
|
||||
type CredentialGenerator struct{}
|
||||
|
||||
// NewCredentialGenerator creates a new credential generator
|
||||
func NewCredentialGenerator() *CredentialGenerator {
|
||||
return &CredentialGenerator{}
|
||||
}
|
||||
|
||||
// GenerateTemporaryCredentials creates temporary AWS credentials
|
||||
func (c *CredentialGenerator) GenerateTemporaryCredentials(sessionId string, expiration time.Time) (*Credentials, error) {
|
||||
accessKeyId, err := c.generateTemporaryAccessKeyId(sessionId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate access key ID: %w", err)
|
||||
}
|
||||
|
||||
secretAccessKey, err := c.generateSecretAccessKey(sessionId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate secret access key: %w", err)
|
||||
}
|
||||
|
||||
sessionToken, err := c.generateSessionTokenId(sessionId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate session token: %w", err)
|
||||
}
|
||||
|
||||
return &Credentials{
|
||||
AccessKeyId: accessKeyId,
|
||||
SecretAccessKey: secretAccessKey,
|
||||
SessionToken: sessionToken,
|
||||
Expiration: expiration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateTemporaryAccessKeyId generates an AWS-style access key ID for temporary STS credentials
|
||||
func (c *CredentialGenerator) generateTemporaryAccessKeyId(sessionId string) (string, error) {
|
||||
// Create a deterministic but unique access key ID based on session
|
||||
hash := sha256.Sum256([]byte("access-key:" + sessionId))
|
||||
// Use ASIA prefix for temporary credentials (STS), not AKIA (permanent IAM keys)
|
||||
return "ASIA" + hex.EncodeToString(hash[:8]), nil // AWS format: ASIA + 16 chars
|
||||
}
|
||||
|
||||
// generateSecretAccessKey generates a deterministic secret access key based on sessionId
|
||||
// This ensures the same secret key is regenerated from the JWT claims during signature verification
|
||||
func (c *CredentialGenerator) generateSecretAccessKey(sessionId string) (string, error) {
|
||||
// Create deterministic secret key based on session ID (not random!)
|
||||
// This is critical for STS because:
|
||||
// 1. AssumeRoleWithWebIdentity generates the secret key once
|
||||
// 2. During signature verification, ToSessionInfo() regenerates credentials from JWT
|
||||
// 3. Both must generate the same secret key for signature verification to succeed
|
||||
hash := sha256.Sum256([]byte("secret-key:" + sessionId))
|
||||
return base64.StdEncoding.EncodeToString(hash[:]), nil
|
||||
}
|
||||
|
||||
// generateSessionTokenId generates a session token identifier
|
||||
func (c *CredentialGenerator) generateSessionTokenId(sessionId string) (string, error) {
|
||||
// Create session token with session ID embedded
|
||||
hash := sha256.Sum256([]byte("session-token:" + sessionId))
|
||||
return "ST" + hex.EncodeToString(hash[:16]), nil // Custom format
|
||||
}
|
||||
|
||||
// generateSessionId generates a unique session ID
|
||||
func GenerateSessionId() (string, error) {
|
||||
randomBytes := make([]byte, 16)
|
||||
_, err := rand.Read(randomBytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(randomBytes), nil
|
||||
}
|
||||
|
||||
// generateAssumedRoleArn generates the ARN for an assumed role user
|
||||
func GenerateAssumedRoleArn(roleArn, sessionName string) string {
|
||||
// Convert role ARN to assumed role user ARN
|
||||
// arn:aws:iam::role/RoleName -> arn:aws:sts::assumed-role/RoleName/SessionName
|
||||
roleName := utils.ExtractRoleNameFromArn(roleArn)
|
||||
if roleName == "" {
|
||||
// This should not happen if validation is done properly upstream
|
||||
return fmt.Sprintf("arn:aws:sts::assumed-role/INVALID-ARN/%s", sessionName)
|
||||
}
|
||||
return fmt.Sprintf("arn:aws:sts::assumed-role/%s/%s", roleName, sessionName)
|
||||
}
|
||||
175
weed/iam/util/generic_cache.go
Normal file
175
weed/iam/util/generic_cache.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/karlseguin/ccache/v2"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
)
|
||||
|
||||
// CacheableStore defines the interface for stores that can be cached
|
||||
type CacheableStore[T any] interface {
|
||||
Get(ctx context.Context, filerAddress string, key string) (T, error)
|
||||
Store(ctx context.Context, filerAddress string, key string, value T) error
|
||||
Delete(ctx context.Context, filerAddress string, key string) error
|
||||
List(ctx context.Context, filerAddress string) ([]string, error)
|
||||
}
|
||||
|
||||
// CopyFunction defines how to deep copy cached values
|
||||
type CopyFunction[T any] func(T) T
|
||||
|
||||
// CachedStore provides generic TTL caching for any store type
|
||||
type CachedStore[T any] struct {
|
||||
baseStore CacheableStore[T]
|
||||
cache *ccache.Cache
|
||||
listCache *ccache.Cache
|
||||
copyFunc CopyFunction[T]
|
||||
ttl time.Duration
|
||||
listTTL time.Duration
|
||||
}
|
||||
|
||||
// CachedStoreConfig holds configuration for the generic cached store
|
||||
type CachedStoreConfig struct {
|
||||
TTL time.Duration
|
||||
ListTTL time.Duration
|
||||
MaxCacheSize int64
|
||||
}
|
||||
|
||||
// NewCachedStore creates a new generic cached store
|
||||
func NewCachedStore[T any](
|
||||
baseStore CacheableStore[T],
|
||||
copyFunc CopyFunction[T],
|
||||
config CachedStoreConfig,
|
||||
) *CachedStore[T] {
|
||||
// Apply defaults
|
||||
if config.TTL == 0 {
|
||||
config.TTL = 5 * time.Minute
|
||||
}
|
||||
if config.ListTTL == 0 {
|
||||
config.ListTTL = 1 * time.Minute
|
||||
}
|
||||
if config.MaxCacheSize == 0 {
|
||||
config.MaxCacheSize = 1000
|
||||
}
|
||||
|
||||
// Create ccache instances
|
||||
pruneCount := config.MaxCacheSize >> 3
|
||||
if pruneCount <= 0 {
|
||||
pruneCount = 100
|
||||
}
|
||||
|
||||
return &CachedStore[T]{
|
||||
baseStore: baseStore,
|
||||
cache: ccache.New(ccache.Configure().MaxSize(config.MaxCacheSize).ItemsToPrune(uint32(pruneCount))),
|
||||
listCache: ccache.New(ccache.Configure().MaxSize(100).ItemsToPrune(10)),
|
||||
copyFunc: copyFunc,
|
||||
ttl: config.TTL,
|
||||
listTTL: config.ListTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves an item with caching
|
||||
func (c *CachedStore[T]) Get(ctx context.Context, filerAddress string, key string) (T, error) {
|
||||
// Try cache first
|
||||
item := c.cache.Get(key)
|
||||
if item != nil {
|
||||
// Cache hit - return cached item (DO NOT extend TTL)
|
||||
value := item.Value().(T)
|
||||
glog.V(4).Infof("Cache hit for key %s", key)
|
||||
return c.copyFunc(value), nil
|
||||
}
|
||||
|
||||
// Cache miss - fetch from base store
|
||||
glog.V(4).Infof("Cache miss for key %s, fetching from store", key)
|
||||
value, err := c.baseStore.Get(ctx, filerAddress, key)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
|
||||
// Cache the result with TTL
|
||||
c.cache.Set(key, c.copyFunc(value), c.ttl)
|
||||
glog.V(3).Infof("Cached key %s with TTL %v", key, c.ttl)
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// Store stores an item and invalidates cache
|
||||
func (c *CachedStore[T]) Store(ctx context.Context, filerAddress string, key string, value T) error {
|
||||
// Store in base store
|
||||
err := c.baseStore.Store(ctx, filerAddress, key, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate cache entries
|
||||
c.cache.Delete(key)
|
||||
c.listCache.Clear() // Invalidate list cache
|
||||
|
||||
glog.V(3).Infof("Stored and invalidated cache for key %s", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete deletes an item and invalidates cache
|
||||
func (c *CachedStore[T]) Delete(ctx context.Context, filerAddress string, key string) error {
|
||||
// Delete from base store
|
||||
err := c.baseStore.Delete(ctx, filerAddress, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate cache entries
|
||||
c.cache.Delete(key)
|
||||
c.listCache.Clear() // Invalidate list cache
|
||||
|
||||
glog.V(3).Infof("Deleted and invalidated cache for key %s", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// List lists all items with caching
|
||||
func (c *CachedStore[T]) List(ctx context.Context, filerAddress string) ([]string, error) {
|
||||
const listCacheKey = "item_list"
|
||||
|
||||
// Try list cache first
|
||||
item := c.listCache.Get(listCacheKey)
|
||||
if item != nil {
|
||||
// Cache hit - return cached list (DO NOT extend TTL)
|
||||
items := item.Value().([]string)
|
||||
glog.V(4).Infof("List cache hit, returning %d items", len(items))
|
||||
return append([]string(nil), items...), nil // Return a copy
|
||||
}
|
||||
|
||||
// Cache miss - fetch from base store
|
||||
glog.V(4).Infof("List cache miss, fetching from store")
|
||||
items, err := c.baseStore.List(ctx, filerAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the result with TTL (store a copy)
|
||||
itemsCopy := append([]string(nil), items...)
|
||||
c.listCache.Set(listCacheKey, itemsCopy, c.listTTL)
|
||||
glog.V(3).Infof("Cached list with %d entries, TTL %v", len(items), c.listTTL)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ClearCache clears all cached entries
|
||||
func (c *CachedStore[T]) ClearCache() {
|
||||
c.cache.Clear()
|
||||
c.listCache.Clear()
|
||||
glog.V(2).Infof("Cleared all cache entries")
|
||||
}
|
||||
|
||||
// GetCacheStats returns cache statistics
|
||||
func (c *CachedStore[T]) GetCacheStats() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"itemCache": map[string]interface{}{
|
||||
"size": c.cache.ItemCount(),
|
||||
"ttl": c.ttl.String(),
|
||||
},
|
||||
"listCache": map[string]interface{}{
|
||||
"size": c.listCache.ItemCount(),
|
||||
"ttl": c.listTTL.String(),
|
||||
},
|
||||
}
|
||||
}
|
||||
296
weed/iam/utils/arn_utils.go
Normal file
296
weed/iam/utils/arn_utils.go
Normal file
@@ -0,0 +1,296 @@
|
||||
// Package utils provides utility functions for AWS IAM ARN parsing and role extraction.
|
||||
package utils
|
||||
|
||||
import "strings"
|
||||
|
||||
// ARN parsing constants for AWS IAM and STS services
|
||||
const (
|
||||
// stsPrefix is the common prefix for all AWS STS ARNs
|
||||
stsPrefix = "arn:aws:sts::"
|
||||
|
||||
// stsAssumedRoleMarker is the marker that identifies assumed role ARNs
|
||||
stsAssumedRoleMarker = "assumed-role/"
|
||||
|
||||
// iamPrefix is the common prefix for all AWS IAM ARNs
|
||||
iamPrefix = "arn:aws:iam::"
|
||||
|
||||
// iamRoleMarker is the marker that identifies IAM role ARNs
|
||||
iamRoleMarker = "role/"
|
||||
|
||||
// iamUserMarker is the marker that identifies IAM user ARNs
|
||||
iamUserMarker = "user/"
|
||||
)
|
||||
|
||||
// ARNInfo contains structured information about a parsed AWS ARN.
|
||||
// This provides more context than a simple string extraction, making it
|
||||
// easier to debug issues and support different ARN formats.
|
||||
type ARNInfo struct {
|
||||
// Original is the original ARN string that was parsed
|
||||
Original string
|
||||
|
||||
// RoleName is the extracted role name (without "role/" prefix)
|
||||
// May include path components (e.g., "Division/Team/RoleName")
|
||||
// Empty string indicates an invalid ARN
|
||||
RoleName string
|
||||
|
||||
// AccountID is the AWS account ID if present in the ARN
|
||||
// Empty string for legacy format ARNs (arn:aws:iam::role/Name)
|
||||
// Non-empty for standard format ARNs (arn:aws:iam::ACCOUNT:role/Name)
|
||||
AccountID string
|
||||
}
|
||||
|
||||
// ExtractRoleNameFromPrincipal extracts the role name from an AWS principal ARN.
|
||||
//
|
||||
// It handles both STS assumed role and IAM role ARN formats, supporting both
|
||||
// legacy (without account ID) and standard AWS (with account ID) formats:
|
||||
// - arn:aws:sts::assumed-role/RoleName/SessionName (legacy STS format)
|
||||
// - arn:aws:sts::ACCOUNT:assumed-role/RoleName/SessionName (standard STS format)
|
||||
// - arn:aws:iam::role/RoleName (legacy IAM format)
|
||||
// - arn:aws:iam::ACCOUNT:role/RoleName (standard IAM format)
|
||||
//
|
||||
// For STS assumed-role ARNs, it extracts the role name from the "assumed-role/"
|
||||
// component, which is the second part of the path (e.g., "Role" from
|
||||
// "arn:aws:sts::ACCOUNT:assumed-role/Role/Session"). For IAM role ARNs, it
|
||||
// delegates to ExtractRoleNameFromArn.
|
||||
//
|
||||
// Returns an empty string if the principal format is invalid or unrecognized.
|
||||
//
|
||||
// Parameters:
|
||||
// - principal: The AWS principal ARN string to extract the role name from
|
||||
//
|
||||
// Returns:
|
||||
// - The extracted role name (without "role/" prefix)
|
||||
// - Empty string if the principal is invalid or no role name is found
|
||||
func ExtractRoleNameFromPrincipal(principal string) string {
|
||||
// Handle STS assumed role format
|
||||
if strings.HasPrefix(principal, stsPrefix) {
|
||||
remainder := principal[len(stsPrefix):]
|
||||
|
||||
// Validate ARN structure: should be either "assumed-role/..." or "ACCOUNT:assumed-role/..."
|
||||
// Split on ':' to separate account ID (if present) from resource type
|
||||
resourcePart := remainder
|
||||
if colonIdx := strings.Index(remainder, ":"); colonIdx != -1 {
|
||||
// Standard format with account ID: "ACCOUNT:assumed-role/..."
|
||||
resourcePart = remainder[colonIdx+1:]
|
||||
}
|
||||
|
||||
// Verify the resource type is exactly "assumed-role/"
|
||||
if !strings.HasPrefix(resourcePart, stsAssumedRoleMarker) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract role name after "assumed-role/"
|
||||
afterMarker := resourcePart[len(stsAssumedRoleMarker):]
|
||||
if slash := strings.Index(afterMarker, "/"); slash != -1 {
|
||||
return afterMarker[:slash]
|
||||
}
|
||||
return afterMarker
|
||||
}
|
||||
|
||||
// Handle IAM role format
|
||||
return ExtractRoleNameFromArn(principal)
|
||||
}
|
||||
|
||||
// ExtractUserNameFromPrincipal extracts the user name from an AWS IAM principal ARN.
|
||||
//
|
||||
// It handles both legacy and standard AWS IAM user ARN formats:
|
||||
// - arn:aws:iam::user/UserName (legacy format without account ID)
|
||||
// - arn:aws:iam::ACCOUNT:user/UserName (standard AWS format with account ID)
|
||||
//
|
||||
// Returns an empty string if the principal does not represent an IAM user.
|
||||
func ExtractUserNameFromPrincipal(principal string) string {
|
||||
if !strings.HasPrefix(principal, iamPrefix) {
|
||||
return ""
|
||||
}
|
||||
|
||||
remainder := principal[len(iamPrefix):]
|
||||
resourcePart := remainder
|
||||
if colonIdx := strings.Index(remainder, ":"); colonIdx != -1 {
|
||||
resourcePart = remainder[colonIdx+1:]
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(resourcePart, iamUserMarker) {
|
||||
return ""
|
||||
}
|
||||
|
||||
userName := resourcePart[len(iamUserMarker):]
|
||||
if userName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return userName
|
||||
}
|
||||
|
||||
// ExtractRoleNameFromArn extracts the role name from an AWS IAM role ARN.
|
||||
//
|
||||
// It handles both legacy and standard AWS IAM role ARN formats:
|
||||
// - arn:aws:iam::role/RoleName (legacy format without account ID)
|
||||
// - arn:aws:iam::ACCOUNT:role/RoleName (standard AWS format with account ID)
|
||||
//
|
||||
// The function validates the ARN structure to ensure the resource type is exactly
|
||||
// "role", preventing security issues where malicious ARNs like
|
||||
// "arn:aws:iam::123456789012:user/role/malicious" could be accepted.
|
||||
//
|
||||
// If the ARN contains a path component (e.g., "role/Division/Team/RoleName"),
|
||||
// the entire path is returned after "role/".
|
||||
//
|
||||
// Returns an empty string if:
|
||||
// - The ARN does not start with "arn:aws:iam::"
|
||||
// - The resource type is not exactly "role"
|
||||
// - The input is empty or invalid
|
||||
//
|
||||
// This function is commonly used in STS (Security Token Service) role assumption
|
||||
// validation to extract the role name from principal ARNs or to validate IAM
|
||||
// role ARNs during credential assumption checks.
|
||||
//
|
||||
// Parameters:
|
||||
// - roleArn: The IAM role ARN string to extract the role name from
|
||||
//
|
||||
// Returns:
|
||||
// - The extracted role name (without "role/" prefix, may include path)
|
||||
// - Empty string if the ARN is invalid or no role name is found
|
||||
func ExtractRoleNameFromArn(roleArn string) string {
|
||||
if !strings.HasPrefix(roleArn, iamPrefix) {
|
||||
return ""
|
||||
}
|
||||
|
||||
remainder := roleArn[len(iamPrefix):]
|
||||
|
||||
// Validate ARN structure: should be either "role/..." or "ACCOUNT:role/..."
|
||||
// Split on ':' to separate account ID (if present) from resource type
|
||||
resourcePart := remainder
|
||||
if colonIdx := strings.Index(remainder, ":"); colonIdx != -1 {
|
||||
// Standard format with account ID: "ACCOUNT:role/..."
|
||||
resourcePart = remainder[colonIdx+1:]
|
||||
}
|
||||
|
||||
// Verify the resource type is exactly "role/"
|
||||
if !strings.HasPrefix(resourcePart, iamRoleMarker) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract role name after "role/"
|
||||
return resourcePart[len(iamRoleMarker):]
|
||||
}
|
||||
|
||||
// ParseRoleARN parses an AWS IAM role ARN and returns structured information.
|
||||
//
|
||||
// It handles both legacy and standard AWS IAM role ARN formats:
|
||||
// - arn:aws:iam::role/RoleName (legacy format without account ID)
|
||||
// - arn:aws:iam::ACCOUNT:role/RoleName (standard AWS format with account ID)
|
||||
//
|
||||
// The function validates the ARN structure to ensure the resource type is exactly
|
||||
// "role", extracting the role name and account ID (if present).
|
||||
//
|
||||
// Parameters:
|
||||
// - roleArn: The IAM role ARN string to parse
|
||||
//
|
||||
// Returns:
|
||||
// - ARNInfo struct containing parsed information
|
||||
// - RoleName will be empty if parsing fails or ARN is malformed
|
||||
func ParseRoleARN(roleArn string) ARNInfo {
|
||||
info := ARNInfo{
|
||||
Original: roleArn,
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(roleArn, iamPrefix) {
|
||||
return info
|
||||
}
|
||||
|
||||
remainder := roleArn[len(iamPrefix):]
|
||||
|
||||
// Validate ARN structure: should be either "role/..." or "ACCOUNT:role/..."
|
||||
// Split on ':' to separate account ID (if present) from resource type
|
||||
resourcePart := remainder
|
||||
accountPart := ""
|
||||
|
||||
if colonIdx := strings.Index(remainder, ":"); colonIdx != -1 {
|
||||
// Standard format with account ID: "ACCOUNT:role/..."
|
||||
accountPart = remainder[:colonIdx]
|
||||
resourcePart = remainder[colonIdx+1:]
|
||||
}
|
||||
|
||||
// Verify the resource type is exactly "role/"
|
||||
if !strings.HasPrefix(resourcePart, iamRoleMarker) {
|
||||
// Invalid resource type
|
||||
return info
|
||||
}
|
||||
|
||||
// Extract role name (everything after "role/")
|
||||
info.RoleName = resourcePart[len(iamRoleMarker):]
|
||||
if info.RoleName == "" {
|
||||
// Empty role name is invalid
|
||||
return info
|
||||
}
|
||||
|
||||
// Set account ID if present
|
||||
info.AccountID = accountPart
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// ParsePrincipalARN parses an AWS principal ARN (STS or IAM) and returns structured information.
|
||||
//
|
||||
// It handles both STS assumed role and IAM role ARN formats:
|
||||
// - arn:aws:sts::assumed-role/RoleName/SessionName (legacy STS format)
|
||||
// - arn:aws:sts::ACCOUNT:assumed-role/RoleName/SessionName (standard STS format)
|
||||
// - arn:aws:iam::role/RoleName (legacy IAM format)
|
||||
// - arn:aws:iam::ACCOUNT:role/RoleName (standard IAM format)
|
||||
//
|
||||
// The function validates the ARN structure to ensure the resource type is exactly
|
||||
// "assumed-role" for STS or delegates to ParseRoleARN for IAM ARNs.
|
||||
//
|
||||
// Parameters:
|
||||
// - principal: The AWS principal ARN string to parse
|
||||
//
|
||||
// Returns:
|
||||
// - ARNInfo struct containing parsed information
|
||||
// - RoleName will be empty if parsing fails or ARN is malformed
|
||||
func ParsePrincipalARN(principal string) ARNInfo {
|
||||
// Handle STS assumed role format
|
||||
if strings.HasPrefix(principal, stsPrefix) {
|
||||
info := ARNInfo{
|
||||
Original: principal,
|
||||
}
|
||||
|
||||
remainder := principal[len(stsPrefix):]
|
||||
|
||||
// Validate ARN structure: should be either "assumed-role/..." or "ACCOUNT:assumed-role/..."
|
||||
// Split on ':' to separate account ID (if present) from resource type
|
||||
resourcePart := remainder
|
||||
accountPart := ""
|
||||
|
||||
if colonIdx := strings.Index(remainder, ":"); colonIdx != -1 {
|
||||
// Standard format with account ID: "ACCOUNT:assumed-role/..."
|
||||
accountPart = remainder[:colonIdx]
|
||||
resourcePart = remainder[colonIdx+1:]
|
||||
}
|
||||
|
||||
// Verify the resource type is exactly "assumed-role/"
|
||||
if !strings.HasPrefix(resourcePart, stsAssumedRoleMarker) {
|
||||
// Invalid resource type
|
||||
return info
|
||||
}
|
||||
|
||||
// Extract role name (between "assumed-role/" and next "/")
|
||||
afterMarker := resourcePart[len(stsAssumedRoleMarker):]
|
||||
if slash := strings.Index(afterMarker, "/"); slash != -1 {
|
||||
info.RoleName = afterMarker[:slash]
|
||||
} else {
|
||||
info.RoleName = afterMarker
|
||||
}
|
||||
|
||||
// Validate that role name is not empty
|
||||
if info.RoleName == "" {
|
||||
return info
|
||||
}
|
||||
|
||||
// Set account ID if present
|
||||
info.AccountID = accountPart
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// Handle IAM role format
|
||||
return ParseRoleARN(principal)
|
||||
}
|
||||
646
weed/iam/utils/arn_utils_test.go
Normal file
646
weed/iam/utils/arn_utils_test.go
Normal file
@@ -0,0 +1,646 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestExtractRoleNameFromArn tests the ExtractRoleNameFromArn function with
|
||||
// comprehensive test cases covering:
|
||||
// - Legacy IAM role ARN format (arn:aws:iam::role/RoleName)
|
||||
// - Standard AWS IAM role ARN format (arn:aws:iam::ACCOUNT:role/RoleName)
|
||||
// - Role names with path components (e.g., role/Path/To/RoleName)
|
||||
// - Invalid and edge case ARNs (missing prefix, wrong service, empty strings)
|
||||
//
|
||||
// The test uses table-driven test pattern with multiple scenarios for each
|
||||
// format to ensure robust handling of both legacy and modern AWS ARN formats.
|
||||
func TestExtractRoleNameFromArn(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
roleArn string
|
||||
expected string
|
||||
}{
|
||||
// Legacy format (without account ID)
|
||||
{
|
||||
name: "legacy_format_simple_role_name",
|
||||
roleArn: "arn:aws:iam::role/default",
|
||||
expected: "default",
|
||||
},
|
||||
{
|
||||
name: "legacy_format_custom_role_name",
|
||||
roleArn: "arn:aws:iam::role/MyRole",
|
||||
expected: "MyRole",
|
||||
},
|
||||
{
|
||||
name: "legacy_format_with_path",
|
||||
roleArn: "arn:aws:iam::role/Path/MyRole",
|
||||
expected: "Path/MyRole",
|
||||
},
|
||||
{
|
||||
name: "legacy_format_with_nested_path",
|
||||
roleArn: "arn:aws:iam::role/Division/Team/Role",
|
||||
expected: "Division/Team/Role",
|
||||
},
|
||||
// Standard AWS format (with account ID)
|
||||
{
|
||||
name: "standard_format_simple_role_name",
|
||||
roleArn: "arn:aws:iam::123456789012:role/default",
|
||||
expected: "default",
|
||||
},
|
||||
{
|
||||
name: "standard_format_custom_role_name",
|
||||
roleArn: "arn:aws:iam::999999999999:role/MyRole",
|
||||
expected: "MyRole",
|
||||
},
|
||||
{
|
||||
name: "standard_format_with_path",
|
||||
roleArn: "arn:aws:iam::123456789012:role/Path/MyRole",
|
||||
expected: "Path/MyRole",
|
||||
},
|
||||
{
|
||||
name: "standard_format_with_nested_path",
|
||||
roleArn: "arn:aws:iam::123456789012:role/Division/Team/Role",
|
||||
expected: "Division/Team/Role",
|
||||
},
|
||||
// Edge cases and invalid formats
|
||||
{
|
||||
name: "invalid_arn_missing_prefix",
|
||||
roleArn: "invalid-arn",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "invalid_arn_incomplete",
|
||||
roleArn: "arn:aws:iam::",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "invalid_arn_no_role_marker",
|
||||
roleArn: "arn:aws:iam::123456789012:user/username",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "invalid_arn_wrong_service",
|
||||
roleArn: "arn:aws:sts::assumed-role/Role/Session",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty_string",
|
||||
roleArn: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "role_marker_no_name",
|
||||
roleArn: "arn:aws:iam::role/",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "standard_format_role_marker_no_name",
|
||||
roleArn: "arn:aws:iam::123456789012:role/",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := ExtractRoleNameFromArn(tc.roleArn)
|
||||
if result != tc.expected {
|
||||
t.Errorf("ExtractRoleNameFromArn(%q) = %q, want %q", tc.roleArn, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractRoleNameFromPrincipal tests the ExtractRoleNameFromPrincipal function
|
||||
// with comprehensive test cases covering:
|
||||
// - STS assumed role ARN format (arn:aws:sts::assumed-role/RoleName/SessionName)
|
||||
// - Standard AWS STS format (arn:aws:sts::ACCOUNT:assumed-role/RoleName/SessionName)
|
||||
// - IAM role ARN format delegated to ExtractRoleNameFromArn
|
||||
// - Both legacy and standard IAM role formats with and without paths
|
||||
// - Invalid and edge case principals (wrong format, empty strings)
|
||||
//
|
||||
// The test ensures that ExtractRoleNameFromPrincipal correctly handles both
|
||||
// STS temporary credentials and permanent IAM role ARNs used in different
|
||||
// authentication and authorization workflows.
|
||||
func TestExtractRoleNameFromPrincipal(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
principal string
|
||||
expected string
|
||||
}{
|
||||
// STS assumed role format (legacy)
|
||||
{
|
||||
name: "sts_assumed_role_legacy",
|
||||
principal: "arn:aws:sts::assumed-role/RoleName/SessionName",
|
||||
expected: "RoleName",
|
||||
},
|
||||
{
|
||||
name: "sts_assumed_role_legacy_no_session",
|
||||
principal: "arn:aws:sts::assumed-role/RoleName",
|
||||
expected: "RoleName",
|
||||
},
|
||||
// STS assumed role format (standard with account ID)
|
||||
{
|
||||
name: "sts_assumed_role_standard",
|
||||
principal: "arn:aws:sts::123456789012:assumed-role/RoleName/SessionName",
|
||||
expected: "RoleName",
|
||||
},
|
||||
{
|
||||
name: "sts_assumed_role_standard_no_session",
|
||||
principal: "arn:aws:sts::123456789012:assumed-role/RoleName",
|
||||
expected: "RoleName",
|
||||
},
|
||||
// IAM role format (legacy)
|
||||
{
|
||||
name: "iam_role_legacy",
|
||||
principal: "arn:aws:iam::role/RoleName",
|
||||
expected: "RoleName",
|
||||
},
|
||||
{
|
||||
name: "iam_role_legacy_with_path",
|
||||
principal: "arn:aws:iam::role/Path/RoleName",
|
||||
expected: "Path/RoleName",
|
||||
},
|
||||
// IAM role format (standard)
|
||||
{
|
||||
name: "iam_role_standard",
|
||||
principal: "arn:aws:iam::123456789012:role/RoleName",
|
||||
expected: "RoleName",
|
||||
},
|
||||
{
|
||||
name: "iam_role_standard_with_path",
|
||||
principal: "arn:aws:iam::123456789012:role/Path/RoleName",
|
||||
expected: "Path/RoleName",
|
||||
},
|
||||
// Invalid formats
|
||||
{
|
||||
name: "invalid_principal",
|
||||
principal: "invalid-arn",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty_string",
|
||||
principal: "",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := ExtractRoleNameFromPrincipal(tc.principal)
|
||||
if result != tc.expected {
|
||||
t.Errorf("ExtractRoleNameFromPrincipal(%q) = %q, want %q", tc.principal, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRoleARN tests the ParseRoleARN function with structured ARNInfo output
|
||||
func TestParseRoleARN(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
roleArn string
|
||||
expected ARNInfo
|
||||
}{
|
||||
{
|
||||
name: "legacy_format_simple_role",
|
||||
roleArn: "arn:aws:iam::role/MyRole",
|
||||
expected: ARNInfo{
|
||||
Original: "arn:aws:iam::role/MyRole",
|
||||
RoleName: "MyRole",
|
||||
AccountID: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "legacy_format_with_path",
|
||||
roleArn: "arn:aws:iam::role/Division/Team/MyRole",
|
||||
expected: ARNInfo{
|
||||
Original: "arn:aws:iam::role/Division/Team/MyRole",
|
||||
RoleName: "Division/Team/MyRole",
|
||||
AccountID: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "standard_format_simple_role",
|
||||
roleArn: "arn:aws:iam::123456789012:role/MyRole",
|
||||
expected: ARNInfo{
|
||||
Original: "arn:aws:iam::123456789012:role/MyRole",
|
||||
RoleName: "MyRole",
|
||||
AccountID: "123456789012",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "standard_format_with_path",
|
||||
roleArn: "arn:aws:iam::999999999999:role/Path/To/MyRole",
|
||||
expected: ARNInfo{
|
||||
Original: "arn:aws:iam::999999999999:role/Path/To/MyRole",
|
||||
RoleName: "Path/To/MyRole",
|
||||
AccountID: "999999999999",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid_arn_missing_prefix",
|
||||
roleArn: "invalid-arn",
|
||||
expected: ARNInfo{
|
||||
Original: "invalid-arn",
|
||||
RoleName: "",
|
||||
AccountID: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid_arn_no_role_marker",
|
||||
roleArn: "arn:aws:iam::123456789012:user/username",
|
||||
expected: ARNInfo{
|
||||
Original: "arn:aws:iam::123456789012:user/username",
|
||||
RoleName: "",
|
||||
AccountID: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid_arn_empty_role_name",
|
||||
roleArn: "arn:aws:iam::role/",
|
||||
expected: ARNInfo{
|
||||
Original: "arn:aws:iam::role/",
|
||||
RoleName: "",
|
||||
AccountID: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid_arn_empty_role_name_standard_format",
|
||||
roleArn: "arn:aws:iam::123456789012:role/",
|
||||
expected: ARNInfo{
|
||||
Original: "arn:aws:iam::123456789012:role/",
|
||||
RoleName: "",
|
||||
AccountID: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := ParseRoleARN(tc.roleArn)
|
||||
if result.Original != tc.expected.Original {
|
||||
t.Errorf("ParseRoleARN(%q).Original = %q, want %q", tc.roleArn, result.Original, tc.expected.Original)
|
||||
}
|
||||
if result.RoleName != tc.expected.RoleName {
|
||||
t.Errorf("ParseRoleARN(%q).RoleName = %q, want %q", tc.roleArn, result.RoleName, tc.expected.RoleName)
|
||||
}
|
||||
if result.AccountID != tc.expected.AccountID {
|
||||
t.Errorf("ParseRoleARN(%q).AccountID = %q, want %q", tc.roleArn, result.AccountID, tc.expected.AccountID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePrincipalARN tests the ParsePrincipalARN function with structured ARNInfo output
|
||||
func TestParsePrincipalARN(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
principal string
|
||||
expected ARNInfo
|
||||
}{
|
||||
{
|
||||
name: "sts_assumed_role_legacy",
|
||||
principal: "arn:aws:sts::assumed-role/MyRole/SessionName",
|
||||
expected: ARNInfo{
|
||||
Original: "arn:aws:sts::assumed-role/MyRole/SessionName",
|
||||
RoleName: "MyRole",
|
||||
AccountID: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sts_assumed_role_standard",
|
||||
principal: "arn:aws:sts::123456789012:assumed-role/MyRole/SessionName",
|
||||
expected: ARNInfo{
|
||||
Original: "arn:aws:sts::123456789012:assumed-role/MyRole/SessionName",
|
||||
RoleName: "MyRole",
|
||||
AccountID: "123456789012",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sts_assumed_role_no_session",
|
||||
principal: "arn:aws:sts::assumed-role/MyRole",
|
||||
expected: ARNInfo{
|
||||
Original: "arn:aws:sts::assumed-role/MyRole",
|
||||
RoleName: "MyRole",
|
||||
AccountID: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "iam_role_legacy",
|
||||
principal: "arn:aws:iam::role/MyRole",
|
||||
expected: ARNInfo{
|
||||
Original: "arn:aws:iam::role/MyRole",
|
||||
RoleName: "MyRole",
|
||||
AccountID: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "iam_role_standard",
|
||||
principal: "arn:aws:iam::123456789012:role/MyRole",
|
||||
expected: ARNInfo{
|
||||
Original: "arn:aws:iam::123456789012:role/MyRole",
|
||||
RoleName: "MyRole",
|
||||
AccountID: "123456789012",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "iam_role_with_path",
|
||||
principal: "arn:aws:iam::999999999999:role/Division/Team/MyRole",
|
||||
expected: ARNInfo{
|
||||
Original: "arn:aws:iam::999999999999:role/Division/Team/MyRole",
|
||||
RoleName: "Division/Team/MyRole",
|
||||
AccountID: "999999999999",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid_principal",
|
||||
principal: "invalid-arn",
|
||||
expected: ARNInfo{
|
||||
Original: "invalid-arn",
|
||||
RoleName: "",
|
||||
AccountID: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid_sts_empty_role_name",
|
||||
principal: "arn:aws:sts::assumed-role/",
|
||||
expected: ARNInfo{
|
||||
Original: "arn:aws:sts::assumed-role/",
|
||||
RoleName: "",
|
||||
AccountID: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid_sts_empty_role_name_standard_format",
|
||||
principal: "arn:aws:sts::123456789012:assumed-role/",
|
||||
expected: ARNInfo{
|
||||
Original: "arn:aws:sts::123456789012:assumed-role/",
|
||||
RoleName: "",
|
||||
AccountID: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := ParsePrincipalARN(tc.principal)
|
||||
if result.Original != tc.expected.Original {
|
||||
t.Errorf("ParsePrincipalARN(%q).Original = %q, want %q", tc.principal, result.Original, tc.expected.Original)
|
||||
}
|
||||
if result.RoleName != tc.expected.RoleName {
|
||||
t.Errorf("ParsePrincipalARN(%q).RoleName = %q, want %q", tc.principal, result.RoleName, tc.expected.RoleName)
|
||||
}
|
||||
if result.AccountID != tc.expected.AccountID {
|
||||
t.Errorf("ParsePrincipalARN(%q).AccountID = %q, want %q", tc.principal, result.AccountID, tc.expected.AccountID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityMaliciousUserARNs tests that user ARNs with "role/" in the path are correctly rejected
|
||||
// to prevent security vulnerabilities where malicious ARNs could bypass validation.
|
||||
func TestSecurityMaliciousUserARNs(t *testing.T) {
|
||||
maliciousARNs := []struct {
|
||||
arn string
|
||||
description string
|
||||
}{
|
||||
{"arn:aws:iam::123456789012:user/role/malicious", "user ARN with role/ in path"},
|
||||
{"arn:aws:iam::123456789012:policy/role/some-policy", "policy ARN with role/ in name"},
|
||||
{"arn:aws:iam::123456789012:group/role/some-group", "group ARN with role/ in name"},
|
||||
{"arn:aws:iam::user/role/test", "legacy user ARN with role/ in path"},
|
||||
}
|
||||
|
||||
for _, tc := range maliciousARNs {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
roleName := ExtractRoleNameFromArn(tc.arn)
|
||||
if roleName != "" {
|
||||
t.Errorf("Security issue: %s was accepted and returned role name '%s'", tc.description, roleName)
|
||||
}
|
||||
|
||||
arnInfo := ParseRoleARN(tc.arn)
|
||||
if arnInfo.RoleName != "" {
|
||||
t.Errorf("Security issue: %s was accepted by ParseRoleARN and returned role name '%s'", tc.description, arnInfo.RoleName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityMaliciousSTSUserARNs tests that STS user ARNs with "assumed-role/" are correctly rejected
|
||||
// to prevent security vulnerabilities.
|
||||
func TestSecurityMaliciousSTSUserARNs(t *testing.T) {
|
||||
maliciousARNs := []struct {
|
||||
arn string
|
||||
description string
|
||||
}{
|
||||
{"arn:aws:sts::123456789012:user/assumed-role/malicious", "STS user with assumed-role in path"},
|
||||
{"arn:aws:sts::user/assumed-role/test", "legacy STS user with assumed-role in path"},
|
||||
}
|
||||
|
||||
for _, tc := range maliciousARNs {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
roleName := ExtractRoleNameFromPrincipal(tc.arn)
|
||||
if roleName != "" {
|
||||
t.Errorf("Security issue: %s was accepted and returned role name '%s'", tc.description, roleName)
|
||||
}
|
||||
|
||||
arnInfo := ParsePrincipalARN(tc.arn)
|
||||
if arnInfo.RoleName != "" {
|
||||
t.Errorf("Security issue: %s was accepted by ParsePrincipalARN and returned role name '%s'", tc.description, arnInfo.RoleName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeCaseMultipleRoleMarkers tests ARNs with multiple "role/" markers in the path.
|
||||
// AWS role names can legitimately contain slashes for path components, so "role/role/name"
|
||||
// should be accepted as a valid role name "role/name".
|
||||
func TestEdgeCaseMultipleRoleMarkers(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
arn string
|
||||
expected string
|
||||
useSTS bool
|
||||
}{
|
||||
{
|
||||
name: "legacy_format_role_in_path",
|
||||
arn: "arn:aws:iam::role/role/name",
|
||||
expected: "role/name",
|
||||
useSTS: false,
|
||||
},
|
||||
{
|
||||
name: "standard_format_role_in_path",
|
||||
arn: "arn:aws:iam::123456789012:role/role/name",
|
||||
expected: "role/name",
|
||||
useSTS: false,
|
||||
},
|
||||
{
|
||||
name: "multiple_role_markers_in_path",
|
||||
arn: "arn:aws:iam::123456789012:role/role/role/role",
|
||||
expected: "role/role/role",
|
||||
useSTS: false,
|
||||
},
|
||||
{
|
||||
name: "sts_assumed_role_with_role_in_path",
|
||||
arn: "arn:aws:sts::123456789012:assumed-role/role/SessionId",
|
||||
expected: "role",
|
||||
useSTS: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.useSTS {
|
||||
result := ExtractRoleNameFromPrincipal(tc.arn)
|
||||
if result != tc.expected {
|
||||
t.Errorf("ExtractRoleNameFromPrincipal(%q) = %q, want %q", tc.arn, result, tc.expected)
|
||||
}
|
||||
} else {
|
||||
result := ExtractRoleNameFromArn(tc.arn)
|
||||
if result != tc.expected {
|
||||
t.Errorf("ExtractRoleNameFromArn(%q) = %q, want %q", tc.arn, result, tc.expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeCaseConsecutiveSlashes tests ARNs with consecutive slashes which are
|
||||
// preserved as valid path components. These are technically allowed in role names,
|
||||
// though they're rare in practice.
|
||||
func TestEdgeCaseConsecutiveSlashes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
roleArn string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "consecutive_slashes_immediately_after_role",
|
||||
roleArn: "arn:aws:iam::role//name",
|
||||
expected: "/name",
|
||||
},
|
||||
{
|
||||
name: "consecutive_slashes_in_path",
|
||||
roleArn: "arn:aws:iam::123456789012:role/Division//Team/Role",
|
||||
expected: "Division//Team/Role",
|
||||
},
|
||||
{
|
||||
name: "multiple_consecutive_slashes",
|
||||
roleArn: "arn:aws:iam::123456789012:role/////Name",
|
||||
expected: "////Name",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := ExtractRoleNameFromArn(tc.roleArn)
|
||||
if result != tc.expected {
|
||||
t.Errorf("ExtractRoleNameFromArn(%q) = %q, want %q", tc.roleArn, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeCaseSpecialCharactersInRoleName tests valid AWS role name special characters.
|
||||
// AWS IAM role names support: letters, numbers, and special characters +=,.@-_
|
||||
func TestEdgeCaseSpecialCharactersInRoleName(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
roleArn string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "role_name_with_hyphens",
|
||||
roleArn: "arn:aws:iam::123456789012:role/My-Role-Name",
|
||||
expected: "My-Role-Name",
|
||||
},
|
||||
{
|
||||
name: "role_name_with_underscores",
|
||||
roleArn: "arn:aws:iam::123456789012:role/My_Role_Name",
|
||||
expected: "My_Role_Name",
|
||||
},
|
||||
{
|
||||
name: "role_name_with_dots",
|
||||
roleArn: "arn:aws:iam::123456789012:role/my.role.name",
|
||||
expected: "my.role.name",
|
||||
},
|
||||
{
|
||||
name: "role_name_with_at_sign",
|
||||
roleArn: "arn:aws:iam::123456789012:role/Role@Domain",
|
||||
expected: "Role@Domain",
|
||||
},
|
||||
{
|
||||
name: "role_name_with_plus_and_equals",
|
||||
roleArn: "arn:aws:iam::123456789012:role/Role+=Name",
|
||||
expected: "Role+=Name",
|
||||
},
|
||||
{
|
||||
name: "role_name_with_commas",
|
||||
roleArn: "arn:aws:iam::123456789012:role/Role,Name",
|
||||
expected: "Role,Name",
|
||||
},
|
||||
{
|
||||
name: "role_name_with_mixed_special_chars",
|
||||
roleArn: "arn:aws:iam::123456789012:role/App-Env.Region+Shard@Version",
|
||||
expected: "App-Env.Region+Shard@Version",
|
||||
},
|
||||
{
|
||||
name: "path_with_special_characters",
|
||||
roleArn: "arn:aws:iam::123456789012:role/Org-1/Team.Dev+Staging@us-east-1/App",
|
||||
expected: "Org-1/Team.Dev+Staging@us-east-1/App",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := ExtractRoleNameFromArn(tc.roleArn)
|
||||
if result != tc.expected {
|
||||
t.Errorf("ExtractRoleNameFromArn(%q) = %q, want %q", tc.roleArn, result, tc.expected)
|
||||
}
|
||||
|
||||
// Also test ParseRoleARN to ensure structured parsing works
|
||||
arnInfo := ParseRoleARN(tc.roleArn)
|
||||
if arnInfo.RoleName != tc.expected {
|
||||
t.Errorf("ParseRoleARN(%q).RoleName = %q, want %q", tc.roleArn, arnInfo.RoleName, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeCaseExtremelyLongRoleName tests role names near AWS limits.
|
||||
// AWS IAM role names can be up to 64 characters, and paths can be up to 512 characters total.
|
||||
func TestEdgeCaseExtremelyLongRoleName(t *testing.T) {
|
||||
// Create a role name at the 64 character limit for a single role name segment
|
||||
longRoleName := "a-role-name-that-is-nearly-at-the-sixty-four-character-limit-yes"
|
||||
if len(longRoleName) > 64 {
|
||||
t.Skipf("Test role name is too long: %d characters", len(longRoleName))
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
roleArn string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "role_name_at_max_length",
|
||||
roleArn: "arn:aws:iam::123456789012:role/" + longRoleName,
|
||||
expected: longRoleName,
|
||||
},
|
||||
{
|
||||
name: "role_with_long_path_components",
|
||||
roleArn: "arn:aws:iam::123456789012:role/organization/department/team/application/environment/role",
|
||||
expected: "organization/department/team/application/environment/role",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := ExtractRoleNameFromArn(tc.roleArn)
|
||||
if result != tc.expected {
|
||||
t.Errorf("ExtractRoleNameFromArn(%q) = %q, want %q", tc.roleArn, result, tc.expected)
|
||||
}
|
||||
|
||||
// Also test ParseRoleARN
|
||||
arnInfo := ParseRoleARN(tc.roleArn)
|
||||
if arnInfo.RoleName != tc.expected {
|
||||
t.Errorf("ParseRoleARN(%q).RoleName = %q, want %q", tc.roleArn, arnInfo.RoleName, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
68
weed/iam/validation.go
Normal file
68
weed/iam/validation.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
)
|
||||
|
||||
// ValidateCallerSuppliedAccessKeyId checks that a caller-supplied AccessKeyId
|
||||
// is 4 to 128 ASCII alphanumeric characters. Returns nil if valid.
|
||||
//
|
||||
// The alphanumeric restriction avoids characters that would break SigV4
|
||||
// canonicalization (e.g. '/' and '=' appear as delimiters in Credential
|
||||
// headers), so this is a stricter superset of the rule AWS enforces.
|
||||
func ValidateCallerSuppliedAccessKeyId(accessKeyId string) error {
|
||||
if len(accessKeyId) < 4 || len(accessKeyId) > 128 {
|
||||
return fmt.Errorf("AccessKeyId must be 4 to 128 alphanumeric characters")
|
||||
}
|
||||
for _, r := range accessKeyId {
|
||||
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')) {
|
||||
return fmt.Errorf("AccessKeyId must be 4 to 128 alphanumeric characters")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCallerSuppliedSecretAccessKey checks that a caller-supplied
|
||||
// SecretAccessKey is 8 to 128 characters. Returns nil if valid.
|
||||
func ValidateCallerSuppliedSecretAccessKey(secretAccessKey string) error {
|
||||
if len(secretAccessKey) < 8 || len(secretAccessKey) > 128 {
|
||||
return fmt.Errorf("SecretAccessKey must be between 8 and 128 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AccessKeyOwner identifies which entity in an S3ApiConfiguration already owns
|
||||
// a given AccessKeyId. Returned by FindAccessKeyOwner for collision checks on
|
||||
// caller-supplied credentials.
|
||||
type AccessKeyOwner struct {
|
||||
// Type is "user" or "service account".
|
||||
Type string
|
||||
// Name is the identity's Name (for users) or the service account's Id.
|
||||
Name string
|
||||
}
|
||||
|
||||
// FindAccessKeyOwner scans s3cfg for an identity or service account whose
|
||||
// credentials already contain accessKeyId. Returns nil if the key is free.
|
||||
//
|
||||
// Callers should log Name only at debug level — error responses returned to
|
||||
// the caller should not include owner identity to avoid information leaks.
|
||||
func FindAccessKeyOwner(s3cfg *iam_pb.S3ApiConfiguration, accessKeyId string) *AccessKeyOwner {
|
||||
if s3cfg == nil || accessKeyId == "" {
|
||||
return nil
|
||||
}
|
||||
for _, ident := range s3cfg.Identities {
|
||||
for _, cred := range ident.Credentials {
|
||||
if cred.AccessKey == accessKeyId {
|
||||
return &AccessKeyOwner{Type: "user", Name: ident.Name}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, sa := range s3cfg.ServiceAccounts {
|
||||
if sa.Credential != nil && sa.Credential.AccessKey == accessKeyId {
|
||||
return &AccessKeyOwner{Type: "service account", Name: sa.Id}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
104
weed/iam/validation_test.go
Normal file
104
weed/iam/validation_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidateCallerSuppliedAccessKeyId(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", true},
|
||||
{"three chars", "abc", true},
|
||||
{"four chars ok", "abcd", false},
|
||||
{"mixed case alnum", "MyAppKey123", false},
|
||||
{"128 chars ok", strings.Repeat("a", 128), false},
|
||||
{"129 chars too long", strings.Repeat("a", 129), true},
|
||||
{"slash rejected", "foo/bar", true},
|
||||
{"equals rejected", "foo=bar", true},
|
||||
{"dash rejected", "foo-bar", true},
|
||||
{"underscore rejected", "foo_bar", true},
|
||||
{"unicode rejected", "fooö123", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateCallerSuppliedAccessKeyId(tc.input)
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCallerSuppliedSecretAccessKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", true},
|
||||
{"seven chars", "abcdefg", true},
|
||||
{"eight chars ok", "abcdefgh", false},
|
||||
{"128 chars ok", strings.Repeat("a", 128), false},
|
||||
{"129 chars too long", strings.Repeat("a", 129), true},
|
||||
{"non-alnum allowed in secret", "sec/ret=1", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateCallerSuppliedSecretAccessKey(tc.input)
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAccessKeyOwner(t *testing.T) {
|
||||
s3cfg := &iam_pb.S3ApiConfiguration{
|
||||
Identities: []*iam_pb.Identity{
|
||||
{
|
||||
Name: "alice",
|
||||
Credentials: []*iam_pb.Credential{
|
||||
{AccessKey: "AKIAALICE1", SecretKey: "s"},
|
||||
},
|
||||
},
|
||||
{Name: "bob"},
|
||||
},
|
||||
ServiceAccounts: []*iam_pb.ServiceAccount{
|
||||
{Id: "svc-1", Credential: &iam_pb.Credential{AccessKey: "SVCKEY1"}},
|
||||
{Id: "svc-2"},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("matches user", func(t *testing.T) {
|
||||
owner := FindAccessKeyOwner(s3cfg, "AKIAALICE1")
|
||||
assert.NotNil(t, owner)
|
||||
assert.Equal(t, "user", owner.Type)
|
||||
assert.Equal(t, "alice", owner.Name)
|
||||
})
|
||||
t.Run("matches service account", func(t *testing.T) {
|
||||
owner := FindAccessKeyOwner(s3cfg, "SVCKEY1")
|
||||
assert.NotNil(t, owner)
|
||||
assert.Equal(t, "service account", owner.Type)
|
||||
assert.Equal(t, "svc-1", owner.Name)
|
||||
})
|
||||
t.Run("no match", func(t *testing.T) {
|
||||
assert.Nil(t, FindAccessKeyOwner(s3cfg, "NOTTAKEN"))
|
||||
})
|
||||
t.Run("empty key", func(t *testing.T) {
|
||||
assert.Nil(t, FindAccessKeyOwner(s3cfg, ""))
|
||||
})
|
||||
t.Run("nil config", func(t *testing.T) {
|
||||
assert.Nil(t, FindAccessKeyOwner(nil, "anything"))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user