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:
33
test/s3/iam/Dockerfile.s3
Normal file
33
test/s3/iam/Dockerfile.s3
Normal file
@@ -0,0 +1,33 @@
|
||||
# Multi-stage build for SeaweedFS S3 with IAM
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git make curl wget
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build SeaweedFS with IAM integration
|
||||
RUN cd weed && go build -o /usr/local/bin/weed
|
||||
|
||||
# Final runtime image
|
||||
FROM alpine:latest
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates wget curl
|
||||
|
||||
# Copy weed binary
|
||||
COPY --from=builder /usr/local/bin/weed /usr/local/bin/weed
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p /etc/seaweedfs /data
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost:8333/ || exit 1
|
||||
|
||||
# Set entrypoint
|
||||
ENTRYPOINT ["/usr/local/bin/weed"]
|
||||
311
test/s3/iam/Makefile
Normal file
311
test/s3/iam/Makefile
Normal file
@@ -0,0 +1,311 @@
|
||||
# SeaweedFS S3 IAM Integration Tests Makefile
|
||||
|
||||
.PHONY: all test clean setup start-services stop-services wait-for-services help
|
||||
|
||||
# Default target
|
||||
all: test
|
||||
|
||||
# Test configuration
|
||||
WEED_BINARY ?= $(shell go env GOPATH)/bin/weed
|
||||
LOG_LEVEL ?= 2
|
||||
S3_PORT ?= 8333
|
||||
FILER_PORT ?= 8888
|
||||
MASTER_PORT ?= 9333
|
||||
VOLUME_PORT ?= 8081
|
||||
TEST_TIMEOUT ?= 30m
|
||||
|
||||
# Service PIDs
|
||||
MASTER_PID_FILE = /tmp/weed-master.pid
|
||||
VOLUME_PID_FILE = /tmp/weed-volume.pid
|
||||
FILER_PID_FILE = /tmp/weed-filer.pid
|
||||
S3_PID_FILE = /tmp/weed-s3.pid
|
||||
MINI_PID_FILE = /tmp/weed-mini.pid
|
||||
|
||||
help: ## Show this help message
|
||||
@echo "SeaweedFS S3 IAM Integration Tests"
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo " make [target]"
|
||||
@echo ""
|
||||
@echo "Standard Targets:"
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-25s %s\n", $$1, $$2}' $(MAKEFILE_LIST) | head -20
|
||||
@echo ""
|
||||
@echo "New Test Targets (Previously Skipped):"
|
||||
@echo " test-distributed Run distributed IAM tests"
|
||||
@echo " test-performance Run performance tests"
|
||||
@echo " test-stress Run stress tests"
|
||||
@echo " test-versioning-stress Run S3 versioning stress tests"
|
||||
@echo " test-keycloak-full Run complete Keycloak integration tests"
|
||||
@echo " test-all-previously-skipped Run all previously skipped tests"
|
||||
@echo " setup-all-tests Setup environment for all tests"
|
||||
@echo ""
|
||||
@echo "Docker Compose Targets:"
|
||||
@echo " docker-test Run tests with Docker Compose including Keycloak"
|
||||
@echo " docker-up Start all services with Docker Compose"
|
||||
@echo " docker-down Stop all Docker Compose services"
|
||||
@echo " docker-logs Show logs from all services"
|
||||
|
||||
test: clean setup start-services run-tests stop-services ## Run complete IAM integration test suite
|
||||
|
||||
test-quick: run-tests ## Run tests assuming services are already running
|
||||
|
||||
run-tests: ## Execute the Go tests
|
||||
@echo "Running S3 IAM Integration Tests..."
|
||||
go test -v -timeout $(TEST_TIMEOUT) ./...
|
||||
|
||||
setup: ## Setup test environment
|
||||
@echo "Setting up test environment..."
|
||||
@mkdir -p test-volume-data/filerldb2
|
||||
@mkdir -p test-volume-data/m9333
|
||||
@if [ ! -f iam_config.json ]; then \
|
||||
echo "Creating iam_config.json from iam_config.local.json..."; \
|
||||
cp iam_config.local.json iam_config.json; \
|
||||
fi
|
||||
|
||||
start-services: ## Start SeaweedFS services for testing
|
||||
@echo "Starting SeaweedFS services using weed mini..."
|
||||
@mkdir -p test-volume-data
|
||||
@$(WEED_BINARY) mini \
|
||||
-dir=test-volume-data \
|
||||
-s3.port=$(S3_PORT) \
|
||||
-s3.config=test_config.json \
|
||||
-s3.iam.config=$(CURDIR)/iam_config.json \
|
||||
-s3.iam.readOnly=false \
|
||||
> weed-mini.log 2>&1 & \
|
||||
echo $$! > $(MINI_PID_FILE)
|
||||
|
||||
@echo "Waiting for services to be ready..."
|
||||
@$(MAKE) wait-for-services
|
||||
@echo "OK All services started and ready"
|
||||
|
||||
wait-for-services: ## Wait for all services to be ready
|
||||
@echo "Waiting for services to be ready..."
|
||||
@echo "Checking S3 API server..."
|
||||
@for i in $$(seq 1 30); do \
|
||||
if curl -s http://localhost:$(S3_PORT) > /dev/null 2>&1; then \
|
||||
echo "OK S3 API server is ready"; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
sleep 1; \
|
||||
done; \
|
||||
echo "ERROR S3 API failed to start"; \
|
||||
exit 1
|
||||
|
||||
@echo "Pre-allocating volumes for concurrent operations..."
|
||||
@curl -s "http://localhost:$(MASTER_PORT)/vol/grow?collection=default&count=10&replication=000" > /dev/null || echo "WARNING Volume pre-allocation failed, but continuing..."
|
||||
@sleep 3
|
||||
@echo "OK All services are ready"
|
||||
|
||||
stop-services: ## Stop all SeaweedFS services
|
||||
@echo "Stopping SeaweedFS services..."
|
||||
@if [ -f $(MINI_PID_FILE) ]; then \
|
||||
echo "Stopping weed mini..."; \
|
||||
kill $$(cat $(MINI_PID_FILE)) 2>/dev/null || true; \
|
||||
rm -f $(MINI_PID_FILE); \
|
||||
fi
|
||||
@if [ -f $(S3_PID_FILE) ]; then \
|
||||
echo "Stopping S3 API server..."; \
|
||||
kill $$(cat $(S3_PID_FILE)) 2>/dev/null || true; \
|
||||
rm -f $(S3_PID_FILE); \
|
||||
fi
|
||||
@if [ -f $(FILER_PID_FILE) ]; then \
|
||||
echo "Stopping filer server..."; \
|
||||
kill $$(cat $(FILER_PID_FILE)) 2>/dev/null || true; \
|
||||
rm -f $(FILER_PID_FILE); \
|
||||
fi
|
||||
@if [ -f $(VOLUME_PID_FILE) ]; then \
|
||||
echo "Stopping volume server..."; \
|
||||
kill $$(cat $(VOLUME_PID_FILE)) 2>/dev/null || true; \
|
||||
rm -f $(VOLUME_PID_FILE); \
|
||||
fi
|
||||
@if [ -f $(MASTER_PID_FILE) ]; then \
|
||||
echo "Stopping master server..."; \
|
||||
kill $$(cat $(MASTER_PID_FILE)) 2>/dev/null || true; \
|
||||
rm -f $(MASTER_PID_FILE); \
|
||||
fi
|
||||
@echo "OK All services stopped"
|
||||
|
||||
clean: stop-services ## Clean up test environment
|
||||
@echo "Cleaning up test environment..."
|
||||
@rm -rf test-volume-data
|
||||
@rm -f weed-*.log
|
||||
@rm -f *.test
|
||||
@rm -f iam_config.json
|
||||
@rm -f .test_env
|
||||
@docker rm -f keycloak-iam-test >/dev/null 2>&1 || true
|
||||
@docker rm -f openldap-iam-test >/dev/null 2>&1 || true
|
||||
@echo "Cleanup complete"
|
||||
|
||||
logs: ## Show service logs
|
||||
@echo "Service Logs:"
|
||||
@echo "=== Master Log ==="
|
||||
@tail -20 weed-master.log 2>/dev/null || echo "No master log"
|
||||
@echo ""
|
||||
@echo "=== Volume Log ==="
|
||||
@tail -20 weed-volume.log 2>/dev/null || echo "No volume log"
|
||||
@echo ""
|
||||
@echo "=== Filer Log ==="
|
||||
@tail -20 weed-filer.log 2>/dev/null || echo "No filer log"
|
||||
@echo ""
|
||||
@echo "=== S3 API Log ==="
|
||||
@tail -20 weed-s3.log 2>/dev/null || echo "No S3 log"
|
||||
|
||||
status: ## Check service status
|
||||
@echo "Service Status:"
|
||||
@echo -n "Master: "; curl -s http://localhost:$(MASTER_PORT)/cluster/status > /dev/null 2>&1 && echo "✅ Running" || echo "❌ Not running"
|
||||
@echo -n "Filer: "; curl -s http://localhost:$(FILER_PORT)/status > /dev/null 2>&1 && echo "✅ Running" || echo "❌ Not running"
|
||||
@echo -n "S3 API: "; curl -s http://localhost:$(S3_PORT) > /dev/null 2>&1 && echo "✅ Running" || echo "❌ Not running"
|
||||
|
||||
debug: start-services wait-for-services ## Start services and keep them running for debugging
|
||||
@echo "🐛 Services started in debug mode. Press Ctrl+C to stop..."
|
||||
@trap 'make stop-services' INT; \
|
||||
while true; do \
|
||||
sleep 1; \
|
||||
done
|
||||
|
||||
# Test specific scenarios
|
||||
test-auth: ## Test only authentication scenarios
|
||||
go test -v -run TestS3IAMAuthentication ./...
|
||||
|
||||
test-policy: ## Test only policy enforcement
|
||||
go test -v -run TestS3IAMPolicyEnforcement ./...
|
||||
|
||||
test-expiration: ## Test only session expiration
|
||||
go test -v -run TestS3IAMSessionExpiration ./...
|
||||
|
||||
test-multipart: ## Test only multipart upload IAM integration
|
||||
go test -v -run TestS3IAMMultipartUploadPolicyEnforcement ./...
|
||||
|
||||
test-bucket-policy: ## Test only bucket policy integration
|
||||
go test -v -run TestS3IAMBucketPolicyIntegration ./...
|
||||
|
||||
test-context: ## Test only contextual policy enforcement
|
||||
go test -v -run TestS3IAMContextualPolicyEnforcement ./...
|
||||
|
||||
test-presigned: ## Test only presigned URL integration
|
||||
go test -v -run TestS3IAMPresignedURLIntegration ./...
|
||||
|
||||
test-group: ## Run IAM group management tests
|
||||
go test -v -run "TestIAMGroup" ./...
|
||||
|
||||
test-sts: ## Run all STS tests
|
||||
go test -v -run "TestSTS" ./...
|
||||
|
||||
test-sts-assume-role: ## Run AssumeRole STS tests
|
||||
go test -v -run "TestSTSAssumeRole" ./...
|
||||
|
||||
test-sts-ldap: ## Run LDAP STS tests
|
||||
go test -v -run "TestSTSLDAP" ./...
|
||||
|
||||
test-sts-suite: start-services ## Run all STS tests with full environment setup/teardown
|
||||
@echo "Running STS test suite..."
|
||||
-go test -v -run "TestSTS" ./...
|
||||
@$(MAKE) stop-services
|
||||
|
||||
# Performance testing
|
||||
benchmark: setup start-services wait-for-services ## Run performance benchmarks
|
||||
@echo "🏁 Running IAM performance benchmarks..."
|
||||
go test -bench=. -benchmem -timeout $(TEST_TIMEOUT) ./...
|
||||
@make stop-services
|
||||
|
||||
# Continuous integration
|
||||
ci: ## Run tests suitable for CI environment
|
||||
@echo "🔄 Running CI tests..."
|
||||
@export CGO_ENABLED=0; make test
|
||||
|
||||
# Development helpers
|
||||
watch: ## Watch for file changes and re-run tests
|
||||
@echo "👀 Watching for changes..."
|
||||
@command -v entr >/dev/null 2>&1 || (echo "entr is required for watch mode. Install with: brew install entr" && exit 1)
|
||||
@find . -name "*.go" | entr -r make test-quick
|
||||
|
||||
install-deps: ## Install test dependencies
|
||||
@echo "📦 Installing test dependencies..."
|
||||
go mod tidy
|
||||
go get -u github.com/stretchr/testify
|
||||
go get -u github.com/aws/aws-sdk-go
|
||||
go get -u github.com/golang-jwt/jwt/v5
|
||||
|
||||
# Docker support
|
||||
docker-test-legacy: ## Run tests in Docker container (legacy)
|
||||
@echo "🐳 Running tests in Docker..."
|
||||
docker build -f Dockerfile.test -t seaweedfs-s3-iam-test .
|
||||
docker run --rm -v $(PWD)/../../../:/app seaweedfs-s3-iam-test
|
||||
|
||||
# Docker Compose support with Keycloak
|
||||
docker-up: ## Start all services with Docker Compose (including Keycloak)
|
||||
@echo "🐳 Starting services with Docker Compose including Keycloak..."
|
||||
@docker compose up -d
|
||||
@echo "⏳ Waiting for services to be healthy..."
|
||||
@timeout 120 bash -c 'until curl -s http://localhost:8080/health/ready > /dev/null 2>&1; do sleep 2; done' || (echo "❌ Keycloak failed to become ready" && exit 1)
|
||||
@timeout 60 bash -c 'until curl -s http://localhost:8333 > /dev/null 2>&1; do sleep 2; done' || (echo "❌ S3 API failed to become ready" && exit 1)
|
||||
@timeout 60 bash -c 'until curl -s http://localhost:8888 > /dev/null 2>&1; do sleep 2; done' || (echo "❌ Filer failed to become ready" && exit 1)
|
||||
@timeout 60 bash -c 'until curl -s http://localhost:9333 > /dev/null 2>&1; do sleep 2; done' || (echo "❌ Master failed to become ready" && exit 1)
|
||||
@echo "✅ All services are healthy and ready"
|
||||
|
||||
docker-down: ## Stop all Docker Compose services
|
||||
@echo "🐳 Stopping Docker Compose services..."
|
||||
@docker compose down -v
|
||||
@echo "✅ All services stopped"
|
||||
|
||||
docker-logs: ## Show logs from all services
|
||||
@docker compose logs -f
|
||||
|
||||
docker-test: docker-up ## Run tests with Docker Compose including Keycloak
|
||||
@echo "🧪 Running Keycloak integration tests..."
|
||||
@export KEYCLOAK_URL="http://localhost:8080" && \
|
||||
export S3_ENDPOINT="http://localhost:8333" && \
|
||||
go test -v -timeout $(TEST_TIMEOUT) -run "TestKeycloak" ./...
|
||||
@echo "🐳 Stopping services after tests..."
|
||||
@make docker-down
|
||||
|
||||
docker-build: ## Build custom SeaweedFS image for Docker tests
|
||||
@echo "🏗️ Building custom SeaweedFS image..."
|
||||
@docker build -f Dockerfile.s3 -t seaweedfs-iam:latest ../../..
|
||||
@echo "✅ Image built successfully"
|
||||
|
||||
# All PHONY targets
|
||||
.PHONY: test test-quick run-tests setup start-services stop-services wait-for-services clean logs status debug
|
||||
.PHONY: test-auth test-policy test-expiration test-multipart test-bucket-policy test-context test-presigned test-group test-sts test-sts-assume-role test-sts-ldap
|
||||
.PHONY: benchmark ci watch install-deps docker-test docker-up docker-down docker-logs docker-build
|
||||
.PHONY: test-distributed test-performance test-stress test-versioning-stress test-keycloak-full test-all-previously-skipped setup-all-tests help-advanced
|
||||
|
||||
|
||||
|
||||
# New test targets for previously skipped tests
|
||||
|
||||
test-distributed: ## Run distributed IAM tests
|
||||
@echo "🌐 Running distributed IAM tests..."
|
||||
@export ENABLE_DISTRIBUTED_TESTS=true && go test -v -timeout $(TEST_TIMEOUT) -run "TestS3IAMDistributedTests" ./...
|
||||
|
||||
test-performance: ## Run performance tests
|
||||
@echo "🏁 Running performance tests..."
|
||||
@export ENABLE_PERFORMANCE_TESTS=true && go test -v -timeout $(TEST_TIMEOUT) -run "TestS3IAMPerformanceTests" ./...
|
||||
|
||||
test-stress: ## Run stress tests
|
||||
@echo "💪 Running stress tests..."
|
||||
@export ENABLE_STRESS_TESTS=true && ./run_stress_tests.sh
|
||||
|
||||
test-versioning-stress: ## Run S3 versioning stress tests
|
||||
@echo "📚 Running versioning stress tests..."
|
||||
@cd ../versioning && ./enable_stress_tests.sh
|
||||
|
||||
test-keycloak-full: docker-up ## Run complete Keycloak integration tests
|
||||
@echo "🔐 Running complete Keycloak integration tests..."
|
||||
@export KEYCLOAK_URL="http://localhost:8080" && \
|
||||
export S3_ENDPOINT="http://localhost:8333" && \
|
||||
go test -v -timeout $(TEST_TIMEOUT) -run "TestKeycloak" ./...
|
||||
@make docker-down
|
||||
|
||||
test-all-previously-skipped: ## Run all previously skipped tests
|
||||
@echo "🎯 Running all previously skipped tests..."
|
||||
@./run_all_tests.sh
|
||||
|
||||
.PHONY: cleanup
|
||||
cleanup: clean
|
||||
|
||||
setup-all-tests: ## Setup environment for all tests (including Keycloak)
|
||||
@echo "🚀 Setting up complete test environment..."
|
||||
@./setup_all_tests.sh
|
||||
|
||||
|
||||
166
test/s3/iam/Makefile.docker
Normal file
166
test/s3/iam/Makefile.docker
Normal file
@@ -0,0 +1,166 @@
|
||||
# Makefile for SeaweedFS S3 IAM Integration Tests with Docker Compose
|
||||
.PHONY: help docker-build docker-up docker-down docker-logs docker-test docker-clean docker-status docker-keycloak-setup
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# Docker Compose configuration
|
||||
COMPOSE_FILE := docker-compose.yml
|
||||
PROJECT_NAME := seaweedfs-iam-test
|
||||
|
||||
help: ## Show this help message
|
||||
@echo "SeaweedFS S3 IAM Integration Tests - Docker Compose"
|
||||
@echo ""
|
||||
@echo "Available commands:"
|
||||
@echo ""
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
@echo ""
|
||||
@echo "Environment:"
|
||||
@echo " COMPOSE_FILE: $(COMPOSE_FILE)"
|
||||
@echo " PROJECT_NAME: $(PROJECT_NAME)"
|
||||
|
||||
docker-build: ## Build local SeaweedFS image for testing
|
||||
@echo "🔨 Building local SeaweedFS image..."
|
||||
@echo "Creating build directory..."
|
||||
@cd ../../.. && mkdir -p .docker-build
|
||||
@echo "Building weed binary..."
|
||||
@cd ../../.. && cd weed && go build -o ../.docker-build/weed
|
||||
@echo "Copying required files to build directory..."
|
||||
@cd ../../.. && cp docker/filer.toml .docker-build/ && cp docker/entrypoint.sh .docker-build/
|
||||
@echo "Building Docker image..."
|
||||
@cd ../../.. && docker build -f docker/Dockerfile.local -t local/seaweedfs:latest .docker-build/
|
||||
@echo "Cleaning up build directory..."
|
||||
@cd ../../.. && rm -rf .docker-build
|
||||
@echo "✅ Built local/seaweedfs:latest"
|
||||
|
||||
docker-up: ## Start all services with Docker Compose
|
||||
@echo "🚀 Starting SeaweedFS S3 IAM integration environment..."
|
||||
@docker-compose -p $(PROJECT_NAME) -f $(COMPOSE_FILE) up -d
|
||||
@echo ""
|
||||
@echo "✅ Environment started! Services will be available at:"
|
||||
@echo " 🔐 Keycloak: http://localhost:8080 (admin/admin)"
|
||||
@echo " 🗄️ S3 API: http://localhost:8333"
|
||||
@echo " 📁 Filer: http://localhost:8888"
|
||||
@echo " 🎯 Master: http://localhost:9333"
|
||||
@echo ""
|
||||
@echo "⏳ Waiting for all services to be healthy..."
|
||||
@docker-compose -p $(PROJECT_NAME) -f $(COMPOSE_FILE) ps
|
||||
|
||||
docker-down: ## Stop and remove all containers
|
||||
@echo "🛑 Stopping SeaweedFS S3 IAM integration environment..."
|
||||
@docker-compose -p $(PROJECT_NAME) -f $(COMPOSE_FILE) down -v
|
||||
@echo "✅ Environment stopped and cleaned up"
|
||||
|
||||
docker-restart: docker-down docker-up ## Restart the entire environment
|
||||
|
||||
docker-logs: ## Show logs from all services
|
||||
@docker-compose -p $(PROJECT_NAME) -f $(COMPOSE_FILE) logs -f
|
||||
|
||||
docker-logs-s3: ## Show logs from S3 service only
|
||||
@docker-compose -p $(PROJECT_NAME) -f $(COMPOSE_FILE) logs -f weed-s3
|
||||
|
||||
docker-logs-keycloak: ## Show logs from Keycloak service only
|
||||
@docker-compose -p $(PROJECT_NAME) -f $(COMPOSE_FILE) logs -f keycloak
|
||||
|
||||
docker-status: ## Check status of all services
|
||||
@echo "📊 Service Status:"
|
||||
@docker-compose -p $(PROJECT_NAME) -f $(COMPOSE_FILE) ps
|
||||
@echo ""
|
||||
@echo "🏥 Health Checks:"
|
||||
@docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep $(PROJECT_NAME) || true
|
||||
|
||||
docker-test: docker-wait-healthy ## Run integration tests against Docker environment
|
||||
@echo "🧪 Running SeaweedFS S3 IAM integration tests..."
|
||||
@echo ""
|
||||
@KEYCLOAK_URL=http://localhost:8080 go test -v -timeout 10m ./...
|
||||
|
||||
docker-test-single: ## Run a single test (use TEST_NAME=TestName)
|
||||
@if [ -z "$(TEST_NAME)" ]; then \
|
||||
echo "❌ Please specify TEST_NAME, e.g., make docker-test-single TEST_NAME=TestKeycloakAuthentication"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "🧪 Running single test: $(TEST_NAME)"
|
||||
@KEYCLOAK_URL=http://localhost:8080 go test -v -run "$(TEST_NAME)" -timeout 5m ./...
|
||||
|
||||
docker-keycloak-setup: ## Manually run Keycloak setup (usually automatic)
|
||||
@echo "🔧 Running Keycloak setup manually..."
|
||||
@docker-compose -p $(PROJECT_NAME) -f $(COMPOSE_FILE) run --rm keycloak-setup
|
||||
|
||||
docker-clean: ## Clean up everything (containers, volumes, images)
|
||||
@echo "🧹 Cleaning up Docker environment..."
|
||||
@docker-compose -p $(PROJECT_NAME) -f $(COMPOSE_FILE) down -v --remove-orphans
|
||||
@docker system prune -f
|
||||
@echo "✅ Cleanup complete"
|
||||
|
||||
docker-shell-s3: ## Get shell access to S3 container
|
||||
@docker-compose -p $(PROJECT_NAME) -f $(COMPOSE_FILE) exec weed-s3 sh
|
||||
|
||||
docker-shell-keycloak: ## Get shell access to Keycloak container
|
||||
@docker-compose -p $(PROJECT_NAME) -f $(COMPOSE_FILE) exec keycloak bash
|
||||
|
||||
docker-debug: ## Show debug information
|
||||
@echo "🔍 Docker Environment Debug Information"
|
||||
@echo ""
|
||||
@echo "📋 Docker Compose Config:"
|
||||
@docker-compose -p $(PROJECT_NAME) -f $(COMPOSE_FILE) config
|
||||
@echo ""
|
||||
@echo "📊 Container Status:"
|
||||
@docker-compose -p $(PROJECT_NAME) -f $(COMPOSE_FILE) ps
|
||||
@echo ""
|
||||
@echo "🌐 Network Information:"
|
||||
@docker network ls | grep $(PROJECT_NAME) || echo "No networks found"
|
||||
@echo ""
|
||||
@echo "💾 Volume Information:"
|
||||
@docker volume ls | grep $(PROJECT_NAME) || echo "No volumes found"
|
||||
|
||||
# Quick test targets
|
||||
docker-test-auth: ## Quick test of authentication only
|
||||
@KEYCLOAK_URL=http://localhost:8080 go test -v -run "TestKeycloakAuthentication" -timeout 2m ./...
|
||||
|
||||
docker-test-roles: ## Quick test of role mapping only
|
||||
@KEYCLOAK_URL=http://localhost:8080 go test -v -run "TestKeycloakRoleMapping" -timeout 2m ./...
|
||||
|
||||
docker-test-s3ops: ## Quick test of S3 operations only
|
||||
@KEYCLOAK_URL=http://localhost:8080 go test -v -run "TestKeycloakS3Operations" -timeout 2m ./...
|
||||
|
||||
# Development workflow
|
||||
docker-dev: docker-down docker-up docker-test ## Complete dev workflow: down -> up -> test
|
||||
|
||||
# Show service URLs for easy access
|
||||
docker-urls: ## Display all service URLs
|
||||
@echo "🌐 Service URLs:"
|
||||
@echo ""
|
||||
@echo " 🔐 Keycloak Admin: http://localhost:8080 (admin/admin)"
|
||||
@echo " 🔐 Keycloak Realm: http://localhost:8080/realms/seaweedfs-test"
|
||||
@echo " 📁 S3 API: http://localhost:8333"
|
||||
@echo " 📂 Filer UI: http://localhost:8888"
|
||||
@echo " 🎯 Master UI: http://localhost:9333"
|
||||
@echo " 💾 Volume Server: http://localhost:8080"
|
||||
@echo ""
|
||||
@echo " 📖 Test Users:"
|
||||
@echo " • admin-user (password: adminuser123) - s3-admin role"
|
||||
@echo " • read-user (password: readuser123) - s3-read-only role"
|
||||
@echo " • write-user (password: writeuser123) - s3-read-write role"
|
||||
@echo " • write-only-user (password: writeonlyuser123) - s3-write-only role"
|
||||
|
||||
# Wait targets for CI/CD
|
||||
docker-wait-healthy: ## Wait for all services to be healthy
|
||||
@echo "⏳ Waiting for all services to be healthy..."
|
||||
@timeout 300 bash -c ' \
|
||||
required_services="keycloak weed-master weed-volume weed-filer weed-s3"; \
|
||||
while true; do \
|
||||
all_healthy=true; \
|
||||
for service in $$required_services; do \
|
||||
if ! docker-compose -p $(PROJECT_NAME) -f $(COMPOSE_FILE) ps $$service | grep -q "healthy"; then \
|
||||
echo "Waiting for $$service to be healthy..."; \
|
||||
all_healthy=false; \
|
||||
break; \
|
||||
fi; \
|
||||
done; \
|
||||
if [ "$$all_healthy" = "true" ]; then \
|
||||
break; \
|
||||
fi; \
|
||||
sleep 5; \
|
||||
done \
|
||||
'
|
||||
@echo "✅ All required services are healthy"
|
||||
241
test/s3/iam/README-Docker.md
Normal file
241
test/s3/iam/README-Docker.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# SeaweedFS S3 IAM Integration with Docker Compose
|
||||
|
||||
This directory contains a complete Docker Compose setup for testing SeaweedFS S3 IAM integration with Keycloak OIDC authentication.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
1. **Build local SeaweedFS image:**
|
||||
```bash
|
||||
make -f Makefile.docker docker-build
|
||||
```
|
||||
|
||||
2. **Start the environment:**
|
||||
```bash
|
||||
make -f Makefile.docker docker-up
|
||||
```
|
||||
|
||||
3. **Run the tests:**
|
||||
```bash
|
||||
make -f Makefile.docker docker-test
|
||||
```
|
||||
|
||||
4. **Stop the environment:**
|
||||
```bash
|
||||
make -f Makefile.docker docker-down
|
||||
```
|
||||
|
||||
## 📋 What's Included
|
||||
|
||||
The Docker Compose setup includes:
|
||||
|
||||
- **🔐 Keycloak** - Identity provider with OIDC support
|
||||
- **🎯 SeaweedFS Master** - Metadata management
|
||||
- **💾 SeaweedFS Volume** - Data storage
|
||||
- **📁 SeaweedFS Filer** - File system interface
|
||||
- **📊 SeaweedFS S3** - S3-compatible API with IAM integration
|
||||
- **🔧 Keycloak Setup** - Automated realm and user configuration
|
||||
|
||||
## 🌐 Service URLs
|
||||
|
||||
After starting with `docker-up`, services are available at:
|
||||
|
||||
| Service | URL | Credentials |
|
||||
|---------|-----|-------------|
|
||||
| 🔐 Keycloak Admin | http://localhost:8080 | admin/admin |
|
||||
| 📊 S3 API | http://localhost:8333 | JWT tokens |
|
||||
| 📁 Filer | http://localhost:8888 | - |
|
||||
| 🎯 Master | http://localhost:9333 | - |
|
||||
|
||||
## 👥 Test Users
|
||||
|
||||
The setup automatically creates test users in Keycloak:
|
||||
|
||||
| Username | Password | Role | Permissions |
|
||||
|----------|----------|------|-------------|
|
||||
| admin-user | adminuser123 | s3-admin | Full S3 access |
|
||||
| read-user | readuser123 | s3-read-only | Read-only access |
|
||||
| write-user | writeuser123 | s3-read-write | Read and write |
|
||||
| write-only-user | writeonlyuser123 | s3-write-only | Write only |
|
||||
|
||||
## 🧪 Running Tests
|
||||
|
||||
### All Tests
|
||||
```bash
|
||||
make -f Makefile.docker docker-test
|
||||
```
|
||||
|
||||
### Specific Test Categories
|
||||
```bash
|
||||
# Authentication tests only
|
||||
make -f Makefile.docker docker-test-auth
|
||||
|
||||
# Role mapping tests only
|
||||
make -f Makefile.docker docker-test-roles
|
||||
|
||||
# S3 operations tests only
|
||||
make -f Makefile.docker docker-test-s3ops
|
||||
```
|
||||
|
||||
### Single Test
|
||||
```bash
|
||||
make -f Makefile.docker docker-test-single TEST_NAME=TestKeycloakAuthentication
|
||||
```
|
||||
|
||||
## 🔧 Development Workflow
|
||||
|
||||
### Complete workflow (recommended)
|
||||
```bash
|
||||
# Build, start, test, and clean up
|
||||
make -f Makefile.docker docker-build
|
||||
make -f Makefile.docker docker-dev
|
||||
```
|
||||
This runs: build → down → up → test
|
||||
|
||||
### Using Published Images (Alternative)
|
||||
If you want to use published Docker Hub images instead of building locally:
|
||||
```bash
|
||||
export SEAWEEDFS_IMAGE=chrislusf/seaweedfs:latest
|
||||
make -f Makefile.docker docker-up
|
||||
```
|
||||
|
||||
### Manual steps
|
||||
```bash
|
||||
# Build image (required first time, or after code changes)
|
||||
make -f Makefile.docker docker-build
|
||||
|
||||
# Start services
|
||||
make -f Makefile.docker docker-up
|
||||
|
||||
# Watch logs
|
||||
make -f Makefile.docker docker-logs
|
||||
|
||||
# Check status
|
||||
make -f Makefile.docker docker-status
|
||||
|
||||
# Run tests
|
||||
make -f Makefile.docker docker-test
|
||||
|
||||
# Stop services
|
||||
make -f Makefile.docker docker-down
|
||||
```
|
||||
|
||||
## 🔍 Debugging
|
||||
|
||||
### View logs
|
||||
```bash
|
||||
# All services
|
||||
make -f Makefile.docker docker-logs
|
||||
|
||||
# S3 service only (includes role mapping debug)
|
||||
make -f Makefile.docker docker-logs-s3
|
||||
|
||||
# Keycloak only
|
||||
make -f Makefile.docker docker-logs-keycloak
|
||||
```
|
||||
|
||||
### Get shell access
|
||||
```bash
|
||||
# S3 container
|
||||
make -f Makefile.docker docker-shell-s3
|
||||
|
||||
# Keycloak container
|
||||
make -f Makefile.docker docker-shell-keycloak
|
||||
```
|
||||
|
||||
## 📁 File Structure
|
||||
|
||||
```
|
||||
seaweedfs/test/s3/iam/
|
||||
├── docker-compose.yml # Main Docker Compose configuration
|
||||
├── Makefile.docker # Docker-specific Makefile
|
||||
├── setup_keycloak_docker.sh # Keycloak setup for containers
|
||||
├── README-Docker.md # This file
|
||||
├── iam_config.json # IAM configuration (auto-generated)
|
||||
├── test_config.json # S3 service configuration
|
||||
└── *_test.go # Go integration tests
|
||||
```
|
||||
|
||||
## 🔄 Configuration
|
||||
|
||||
### IAM Configuration
|
||||
The `setup_keycloak_docker.sh` script automatically generates `iam_config.json` with:
|
||||
|
||||
- **OIDC Provider**: Keycloak configuration with proper container networking
|
||||
- **Role Mapping**: Maps Keycloak roles to SeaweedFS IAM roles
|
||||
- **Policies**: Defines S3 permissions for each role
|
||||
- **Trust Relationships**: Allows Keycloak users to assume SeaweedFS roles
|
||||
|
||||
### Role Mapping Rules
|
||||
```json
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-admin",
|
||||
"role": "arn:aws:iam::role/KeycloakAdminRole"
|
||||
}
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Services not starting
|
||||
```bash
|
||||
# Check service status
|
||||
make -f Makefile.docker docker-status
|
||||
|
||||
# View logs for specific service
|
||||
docker-compose -p seaweedfs-iam-test logs <service-name>
|
||||
```
|
||||
|
||||
### Keycloak setup issues
|
||||
```bash
|
||||
# Re-run Keycloak setup manually
|
||||
make -f Makefile.docker docker-keycloak-setup
|
||||
|
||||
# Check Keycloak logs
|
||||
make -f Makefile.docker docker-logs-keycloak
|
||||
```
|
||||
|
||||
### Role mapping not working
|
||||
```bash
|
||||
# Check S3 logs for role mapping debug messages
|
||||
make -f Makefile.docker docker-logs-s3 | grep -i "role\|claim\|mapping"
|
||||
```
|
||||
|
||||
### Port conflicts
|
||||
If ports are already in use, modify `docker-compose.yml`:
|
||||
```yaml
|
||||
ports:
|
||||
- "8081:8080" # Change external port
|
||||
```
|
||||
|
||||
## 🧹 Cleanup
|
||||
|
||||
```bash
|
||||
# Stop containers and remove volumes
|
||||
make -f Makefile.docker docker-down
|
||||
|
||||
# Complete cleanup (containers, volumes, images)
|
||||
make -f Makefile.docker docker-clean
|
||||
```
|
||||
|
||||
## 🎯 Key Features
|
||||
|
||||
- **Local Code Testing**: Uses locally built SeaweedFS images to test current code
|
||||
- **Isolated Environment**: No conflicts with local services
|
||||
- **Consistent Networking**: Services communicate via Docker network
|
||||
- **Automated Setup**: Keycloak realm and users created automatically
|
||||
- **Debug Logging**: Verbose logging enabled for troubleshooting
|
||||
- **Health Checks**: Proper service dependency management
|
||||
- **Volume Persistence**: Data persists between restarts (until docker-down)
|
||||
|
||||
## 🚦 CI/CD Integration
|
||||
|
||||
For automated testing:
|
||||
|
||||
```bash
|
||||
# Build image, run tests with proper cleanup
|
||||
make -f Makefile.docker docker-build
|
||||
make -f Makefile.docker docker-up
|
||||
make -f Makefile.docker docker-wait-healthy
|
||||
make -f Makefile.docker docker-test
|
||||
make -f Makefile.docker docker-down
|
||||
```
|
||||
506
test/s3/iam/README.md
Normal file
506
test/s3/iam/README.md
Normal file
@@ -0,0 +1,506 @@
|
||||
# SeaweedFS S3 IAM Integration Tests
|
||||
|
||||
This directory contains comprehensive integration tests for the SeaweedFS S3 API with Advanced IAM (Identity and Access Management) system integration.
|
||||
|
||||
## Overview
|
||||
|
||||
**Important**: The STS service uses a **stateless JWT design** where all session information is embedded directly in the JWT token. No external session storage is required.
|
||||
|
||||
The S3 IAM integration tests validate the complete end-to-end functionality of:
|
||||
|
||||
- **JWT Authentication**: OIDC token-based authentication with S3 API
|
||||
- **Policy Enforcement**: Fine-grained access control for S3 operations
|
||||
- **Stateless Session Management**: JWT-based session token validation and expiration (no external storage)
|
||||
- **Role-Based Access Control (RBAC)**: IAM roles with different permission levels
|
||||
- **Bucket Policies**: Resource-based access control integration
|
||||
- **Multipart Upload IAM**: Policy enforcement for multipart operations
|
||||
- **Contextual Policies**: IP-based, time-based, and conditional access control
|
||||
- **Presigned URLs**: IAM-integrated temporary access URL generation
|
||||
|
||||
## Test Architecture
|
||||
|
||||
### Components Tested
|
||||
|
||||
1. **S3 API Gateway** - SeaweedFS S3-compatible API server with IAM integration
|
||||
2. **IAM Manager** - Core IAM orchestration and policy evaluation
|
||||
3. **STS Service** - Security Token Service for temporary credentials
|
||||
4. **Policy Engine** - AWS IAM-compatible policy evaluation
|
||||
5. **Identity Providers** - OIDC and LDAP authentication providers
|
||||
6. **Policy Store** - Persistent policy storage using SeaweedFS filer
|
||||
|
||||
### Test Framework
|
||||
|
||||
- **S3IAMTestFramework**: Comprehensive test utilities and setup
|
||||
- **Mock OIDC Provider**: In-memory OIDC server with JWT signing
|
||||
- **Service Management**: Automatic SeaweedFS service lifecycle management
|
||||
- **Resource Cleanup**: Automatic cleanup of buckets and test data
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Authentication Tests (`TestS3IAMAuthentication`)
|
||||
|
||||
- ✅ **Valid JWT Token**: Successful authentication with proper OIDC tokens
|
||||
- ✅ **Invalid JWT Token**: Rejection of malformed or invalid tokens
|
||||
- ✅ **Expired JWT Token**: Proper handling of expired authentication tokens
|
||||
|
||||
### 2. Policy Enforcement Tests (`TestS3IAMPolicyEnforcement`)
|
||||
|
||||
- ✅ **Read-Only Policy**: Users can only read objects and list buckets
|
||||
- ✅ **Write-Only Policy**: Users can only create/delete objects but not read
|
||||
- ✅ **Admin Policy**: Full access to all S3 operations including bucket management
|
||||
|
||||
### 3. Session Expiration Tests (`TestS3IAMSessionExpiration`)
|
||||
|
||||
- ✅ **Short-Lived Sessions**: Creation and validation of time-limited sessions
|
||||
- ✅ **Manual Expiration**: Testing session expiration enforcement
|
||||
- ✅ **Expired Session Rejection**: Proper access denial for expired sessions
|
||||
|
||||
### 4. Multipart Upload Tests (`TestS3IAMMultipartUploadPolicyEnforcement`)
|
||||
|
||||
- ✅ **Admin Multipart Access**: Full multipart upload capabilities
|
||||
- ✅ **Read-Only Denial**: Rejection of multipart operations for read-only users
|
||||
- ✅ **Complete Upload Flow**: Initiate → Upload Parts → Complete workflow
|
||||
|
||||
### 5. Bucket Policy Tests (`TestS3IAMBucketPolicyIntegration`)
|
||||
|
||||
- ✅ **Public Read Policy**: Bucket-level policies allowing public access
|
||||
- ✅ **Explicit Deny Policy**: Bucket policies that override IAM permissions
|
||||
- ✅ **Policy CRUD Operations**: Get/Put/Delete bucket policy operations
|
||||
|
||||
### 6. Contextual Policy Tests (`TestS3IAMContextualPolicyEnforcement`)
|
||||
|
||||
- 🔧 **IP-Based Restrictions**: Source IP validation in policy conditions
|
||||
- 🔧 **Time-Based Restrictions**: Temporal access control policies
|
||||
- 🔧 **User-Agent Restrictions**: Request context-based policy evaluation
|
||||
|
||||
### 7. Presigned URL Tests (`TestS3IAMPresignedURLIntegration`)
|
||||
|
||||
- ✅ **URL Generation**: IAM-validated presigned URL creation
|
||||
- ✅ **Permission Validation**: Ensuring users have required permissions
|
||||
- 🔧 **HTTP Request Testing**: Direct HTTP calls to presigned URLs
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Go 1.26+** with modules enabled
|
||||
2. **SeaweedFS Binary** (`weed`) built with IAM support
|
||||
3. **Test Dependencies**:
|
||||
```bash
|
||||
go get github.com/stretchr/testify
|
||||
go get github.com/aws/aws-sdk-go
|
||||
go get github.com/golang-jwt/jwt/v5
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
#### Complete Test Suite
|
||||
```bash
|
||||
# Run all tests with service management
|
||||
make test
|
||||
|
||||
# Quick test run (assumes services running)
|
||||
make test-quick
|
||||
```
|
||||
|
||||
#### Specific Test Categories
|
||||
```bash
|
||||
# Test only authentication
|
||||
make test-auth
|
||||
|
||||
# Test only policy enforcement
|
||||
make test-policy
|
||||
|
||||
# Test only session expiration
|
||||
make test-expiration
|
||||
|
||||
# Test only multipart uploads
|
||||
make test-multipart
|
||||
|
||||
# Test only bucket policies
|
||||
make test-bucket-policy
|
||||
```
|
||||
|
||||
#### Development & Debugging
|
||||
```bash
|
||||
# Start services and keep running
|
||||
make debug
|
||||
|
||||
# Show service logs
|
||||
make logs
|
||||
|
||||
# Check service status
|
||||
make status
|
||||
|
||||
# Watch for changes and re-run tests
|
||||
make watch
|
||||
```
|
||||
|
||||
### Manual Service Management
|
||||
|
||||
If you prefer to manage services manually:
|
||||
|
||||
```bash
|
||||
# Start services
|
||||
make start-services
|
||||
|
||||
# Wait for services to be ready
|
||||
make wait-for-services
|
||||
|
||||
# Run tests
|
||||
make run-tests
|
||||
|
||||
# Stop services
|
||||
make stop-services
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Test Configuration (`test_config.json`)
|
||||
|
||||
The test configuration defines:
|
||||
|
||||
- **Identity Providers**: OIDC and LDAP configurations
|
||||
- **IAM Roles**: Role definitions with trust policies
|
||||
- **IAM Policies**: Permission policies for different access levels
|
||||
- **Policy Stores**: Persistent storage configurations for IAM policies and roles
|
||||
|
||||
### Service Ports
|
||||
|
||||
| Service | Port | Purpose |
|
||||
|---------|------|---------|
|
||||
| Master | 9333 | Cluster coordination |
|
||||
| Volume | 8080 | Object storage |
|
||||
| Filer | 8888 | Metadata & IAM storage |
|
||||
| S3 API | 8333 | S3-compatible API with IAM |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# SeaweedFS binary location
|
||||
export WEED_BINARY=../../../weed
|
||||
|
||||
# Service ports (optional)
|
||||
export S3_PORT=8333
|
||||
export FILER_PORT=8888
|
||||
export MASTER_PORT=9333
|
||||
export VOLUME_PORT=8080
|
||||
|
||||
# Test timeout
|
||||
export TEST_TIMEOUT=30m
|
||||
|
||||
# Log level (0-4)
|
||||
export LOG_LEVEL=2
|
||||
```
|
||||
|
||||
## Test Data & Cleanup
|
||||
|
||||
### Automatic Cleanup
|
||||
|
||||
The test framework automatically:
|
||||
- 🗑️ **Deletes test buckets** created during tests
|
||||
- 🗑️ **Removes test objects** and multipart uploads
|
||||
- 🗑️ **Cleans up IAM sessions** and temporary tokens
|
||||
- 🗑️ **Stops services** after test completion
|
||||
|
||||
### Manual Cleanup
|
||||
|
||||
```bash
|
||||
# Clean everything
|
||||
make clean
|
||||
|
||||
# Clean while keeping services running
|
||||
rm -rf test-volume-data/
|
||||
```
|
||||
|
||||
## Extending Tests
|
||||
|
||||
### Adding New Test Scenarios
|
||||
|
||||
1. **Create Test Function**:
|
||||
```go
|
||||
func TestS3IAMNewFeature(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
// Test implementation
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use Test Framework**:
|
||||
```go
|
||||
// Create authenticated S3 client
|
||||
s3Client, err := framework.CreateS3ClientWithJWT("user", "TestRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test S3 operations
|
||||
err = framework.CreateBucket(s3Client, "test-bucket")
|
||||
require.NoError(t, err)
|
||||
```
|
||||
|
||||
3. **Add to Makefile**:
|
||||
```makefile
|
||||
test-new-feature: ## Test new feature
|
||||
go test -v -run TestS3IAMNewFeature ./...
|
||||
```
|
||||
|
||||
### Creating Custom Policies
|
||||
|
||||
Add policies to `test_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"policies": {
|
||||
"CustomPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::specific-bucket/*"],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:prefix": ["allowed-prefix/"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Identity Providers
|
||||
|
||||
1. **Mock Provider Setup**:
|
||||
```go
|
||||
// In test framework
|
||||
func (f *S3IAMTestFramework) setupCustomProvider() {
|
||||
provider := custom.NewCustomProvider("test-custom")
|
||||
// Configure and register
|
||||
}
|
||||
```
|
||||
|
||||
2. **Configuration**:
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"custom": {
|
||||
"test-custom": {
|
||||
"endpoint": "http://localhost:8080",
|
||||
"clientId": "custom-client"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Services Not Starting
|
||||
```bash
|
||||
# Check if ports are available
|
||||
netstat -an | grep -E "(8333|8888|9333|8080)"
|
||||
|
||||
# Check service logs
|
||||
make logs
|
||||
|
||||
# Try different ports
|
||||
export S3_PORT=18333
|
||||
make start-services
|
||||
```
|
||||
|
||||
#### 2. JWT Token Issues
|
||||
```bash
|
||||
# Verify OIDC mock server
|
||||
curl http://localhost:8080/.well-known/openid_configuration
|
||||
|
||||
# Check JWT token format in logs
|
||||
make logs | grep -i jwt
|
||||
```
|
||||
|
||||
#### 3. Permission Denied Errors
|
||||
```bash
|
||||
# Verify IAM configuration
|
||||
cat test_config.json | jq '.policies'
|
||||
|
||||
# Check policy evaluation in logs
|
||||
export LOG_LEVEL=4
|
||||
make start-services
|
||||
```
|
||||
|
||||
#### 4. Test Timeouts
|
||||
```bash
|
||||
# Increase timeout
|
||||
export TEST_TIMEOUT=60m
|
||||
make test
|
||||
|
||||
# Run individual tests
|
||||
make test-auth
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Start services in debug mode to inspect manually:
|
||||
|
||||
```bash
|
||||
# Start and keep running
|
||||
make debug
|
||||
|
||||
# In another terminal, run specific operations
|
||||
aws s3 ls --endpoint-url http://localhost:8333
|
||||
|
||||
# Stop when done (Ctrl+C in debug terminal)
|
||||
```
|
||||
|
||||
### Log Analysis
|
||||
|
||||
```bash
|
||||
# Service-specific logs
|
||||
tail -f weed-s3.log # S3 API server
|
||||
tail -f weed-filer.log # Filer (IAM storage)
|
||||
tail -f weed-master.log # Master server
|
||||
tail -f weed-volume.log # Volume server
|
||||
|
||||
# Filter for IAM-related logs
|
||||
make logs | grep -i iam
|
||||
make logs | grep -i jwt
|
||||
make logs | grep -i policy
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
### Benchmarks
|
||||
|
||||
```bash
|
||||
# Run performance benchmarks
|
||||
make benchmark
|
||||
|
||||
# Profile memory usage
|
||||
go test -bench=. -memprofile=mem.prof
|
||||
go tool pprof mem.prof
|
||||
```
|
||||
|
||||
### Load Testing
|
||||
|
||||
For load testing with IAM:
|
||||
|
||||
1. **Create Multiple Clients**:
|
||||
```go
|
||||
// Generate multiple JWT tokens
|
||||
tokens := framework.GenerateMultipleJWTTokens(100)
|
||||
|
||||
// Create concurrent clients
|
||||
var wg sync.WaitGroup
|
||||
for _, token := range tokens {
|
||||
wg.Add(1)
|
||||
go func(token string) {
|
||||
defer wg.Done()
|
||||
// Perform S3 operations
|
||||
}(token)
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
2. **Measure Performance**:
|
||||
```bash
|
||||
# Run with verbose output
|
||||
go test -v -bench=BenchmarkS3IAMOperations
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
name: S3 IAM Integration Tests
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
s3-iam-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Build SeaweedFS
|
||||
run: go build -o weed ./main.go
|
||||
|
||||
- name: Run S3 IAM Tests
|
||||
run: |
|
||||
cd test/s3/iam
|
||||
make ci
|
||||
```
|
||||
|
||||
### Jenkins Pipeline
|
||||
|
||||
```groovy
|
||||
pipeline {
|
||||
agent any
|
||||
stages {
|
||||
stage('Build') {
|
||||
steps {
|
||||
sh 'go build -o weed ./main.go'
|
||||
}
|
||||
}
|
||||
stage('S3 IAM Tests') {
|
||||
steps {
|
||||
dir('test/s3/iam') {
|
||||
sh 'make ci'
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
dir('test/s3/iam') {
|
||||
sh 'make clean'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
### Adding New Tests
|
||||
|
||||
1. **Follow Test Patterns**:
|
||||
- Use `S3IAMTestFramework` for setup
|
||||
- Include cleanup with `defer framework.Cleanup()`
|
||||
- Use descriptive test names and subtests
|
||||
- Assert both success and failure cases
|
||||
|
||||
2. **Update Documentation**:
|
||||
- Add test descriptions to this README
|
||||
- Include Makefile targets for new test categories
|
||||
- Document any new configuration options
|
||||
|
||||
3. **Ensure Test Reliability**:
|
||||
- Tests should be deterministic and repeatable
|
||||
- Include proper error handling and assertions
|
||||
- Use appropriate timeouts for async operations
|
||||
|
||||
### Code Style
|
||||
|
||||
- Follow standard Go testing conventions
|
||||
- Use `require.NoError()` for critical assertions
|
||||
- Use `assert.Equal()` for value comparisons
|
||||
- Include descriptive error messages in assertions
|
||||
|
||||
## Support
|
||||
|
||||
For issues with S3 IAM integration tests:
|
||||
|
||||
1. **Check Logs**: Use `make logs` to inspect service logs
|
||||
2. **Verify Configuration**: Ensure `test_config.json` is correct
|
||||
3. **Test Services**: Run `make status` to check service health
|
||||
4. **Clean Environment**: Try `make clean && make test`
|
||||
|
||||
## License
|
||||
|
||||
This test suite is part of the SeaweedFS project and follows the same licensing terms.
|
||||
559
test/s3/iam/STS_DISTRIBUTED.md
Normal file
559
test/s3/iam/STS_DISTRIBUTED.md
Normal file
@@ -0,0 +1,559 @@
|
||||
# Distributed STS Service for SeaweedFS S3 Gateway
|
||||
|
||||
This document explains how to configure and deploy the STS (Security Token Service) for distributed SeaweedFS S3 Gateway deployments with consistent identity provider configurations.
|
||||
|
||||
## Problem Solved
|
||||
|
||||
Previously, identity providers had to be **manually registered** on each S3 gateway instance, leading to:
|
||||
|
||||
- ❌ **Inconsistent authentication**: Different instances might have different providers
|
||||
- ❌ **Manual synchronization**: No guarantee all instances have same provider configs
|
||||
- ❌ **Authentication failures**: Users getting different responses from different instances
|
||||
- ❌ **Operational complexity**: Difficult to manage provider configurations at scale
|
||||
|
||||
## Solution: Configuration-Driven Providers
|
||||
|
||||
The STS service now supports **automatic provider loading** from configuration files, ensuring:
|
||||
|
||||
- ✅ **Consistent providers**: All instances load identical providers from config
|
||||
- ✅ **Automatic synchronization**: Configuration-driven, no manual registration needed
|
||||
- ✅ **Reliable authentication**: Same behavior from all instances
|
||||
- ✅ **Easy management**: Update config file, restart services
|
||||
|
||||
## Configuration Schema
|
||||
|
||||
### Basic STS Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"sts": {
|
||||
"tokenDuration": "1h",
|
||||
"maxSessionLength": "12h",
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "base64-encoded-signing-key-32-chars-min"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The STS service uses a **stateless JWT design** where all session information is embedded directly in the JWT token. No external session storage is required.
|
||||
|
||||
### Configuration-Driven Providers
|
||||
|
||||
```json
|
||||
{
|
||||
"sts": {
|
||||
"tokenDuration": "1h",
|
||||
"maxSessionLength": "12h",
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "base64-encoded-signing-key",
|
||||
"providers": [
|
||||
{
|
||||
"name": "keycloak-oidc",
|
||||
"type": "oidc",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"issuer": "https://keycloak.company.com/realms/seaweedfs",
|
||||
"clientId": "seaweedfs-s3",
|
||||
"clientSecret": "super-secret-key",
|
||||
"jwksUri": "https://keycloak.company.com/realms/seaweedfs/protocol/openid-connect/certs",
|
||||
"scopes": ["openid", "profile", "email", "roles"],
|
||||
"claimsMapping": {
|
||||
"usernameClaim": "preferred_username",
|
||||
"groupsClaim": "roles"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "backup-oidc",
|
||||
"type": "oidc",
|
||||
"enabled": false,
|
||||
"config": {
|
||||
"issuer": "https://backup-oidc.company.com",
|
||||
"clientId": "seaweedfs-backup"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dev-mock-provider",
|
||||
"type": "mock",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"issuer": "http://localhost:9999",
|
||||
"clientId": "mock-client"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Provider Types
|
||||
|
||||
### 1. OIDC Provider (`"type": "oidc"`)
|
||||
|
||||
For production authentication with OpenID Connect providers like Keycloak, Auth0, Google, etc.
|
||||
|
||||
**Required Configuration:**
|
||||
- `issuer`: OIDC issuer URL
|
||||
- `clientId`: OAuth2 client ID
|
||||
|
||||
**Optional Configuration:**
|
||||
- `clientSecret`: OAuth2 client secret (for confidential clients)
|
||||
- `jwksUri`: JSON Web Key Set URI (auto-discovered if not provided)
|
||||
- `userInfoUri`: UserInfo endpoint URI (auto-discovered if not provided)
|
||||
- `scopes`: OAuth2 scopes to request (default: `["openid"]`)
|
||||
- `claimsMapping`: Map OIDC claims to identity attributes
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
{
|
||||
"name": "corporate-keycloak",
|
||||
"type": "oidc",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"issuer": "https://sso.company.com/realms/production",
|
||||
"clientId": "seaweedfs-prod",
|
||||
"clientSecret": "confidential-secret",
|
||||
"scopes": ["openid", "profile", "email", "groups"],
|
||||
"claimsMapping": {
|
||||
"usernameClaim": "preferred_username",
|
||||
"groupsClaim": "groups",
|
||||
"emailClaim": "email"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Mock Provider (`"type": "mock"`)
|
||||
|
||||
For development, testing, and staging environments.
|
||||
|
||||
**Configuration:**
|
||||
- `issuer`: Mock issuer URL (default: `http://localhost:9999`)
|
||||
- `clientId`: Mock client ID
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
{
|
||||
"name": "dev-mock",
|
||||
"type": "mock",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"issuer": "http://dev-mock:9999",
|
||||
"clientId": "dev-client"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Built-in Test Tokens:**
|
||||
- `valid_test_token`: Returns test user with developer groups
|
||||
- `valid-oidc-token`: Compatible with integration tests
|
||||
- `expired_token`: Returns token expired error
|
||||
- `invalid_token`: Returns invalid token error
|
||||
|
||||
### 3. Future Provider Types
|
||||
|
||||
The factory pattern supports easy addition of new provider types:
|
||||
|
||||
- `"type": "ldap"`: LDAP/Active Directory authentication
|
||||
- `"type": "saml"`: SAML 2.0 authentication
|
||||
- `"type": "oauth2"`: Generic OAuth2 providers
|
||||
- `"type": "custom"`: Custom authentication backends
|
||||
|
||||
## Deployment Patterns
|
||||
|
||||
### Single Instance (Development)
|
||||
|
||||
```bash
|
||||
# Standard deployment with config-driven providers
|
||||
weed s3 -filer=localhost:8888 -port=8333 -iam.config=/path/to/sts_config.json
|
||||
```
|
||||
|
||||
### Multiple Instances (Production)
|
||||
|
||||
```bash
|
||||
# Instance 1
|
||||
weed s3 -filer=prod-filer:8888 -port=8333 -iam.config=/shared/sts_distributed.json
|
||||
|
||||
# Instance 2
|
||||
weed s3 -filer=prod-filer:8888 -port=8334 -iam.config=/shared/sts_distributed.json
|
||||
|
||||
# Instance N
|
||||
weed s3 -filer=prod-filer:8888 -port=833N -iam.config=/shared/sts_distributed.json
|
||||
```
|
||||
|
||||
**Critical Requirements for Distributed Deployment:**
|
||||
|
||||
1. **Identical Configuration Files**: All instances must use the exact same configuration file
|
||||
2. **Same Signing Keys**: All instances must have identical `signingKey` values
|
||||
3. **Same Issuer**: All instances must use the same `issuer` value
|
||||
|
||||
**Note**: STS now uses stateless JWT tokens, eliminating the need for shared session storage.
|
||||
|
||||
### High Availability Setup
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml for production deployment
|
||||
services:
|
||||
filer:
|
||||
image: seaweedfs/seaweedfs:latest
|
||||
command: "filer -master=master:9333"
|
||||
volumes:
|
||||
- filer-data:/data
|
||||
|
||||
s3-gateway-1:
|
||||
image: seaweedfs/seaweedfs:latest
|
||||
command: "s3 -filer=filer:8888 -port=8333 -iam.config=/config/sts_distributed.json"
|
||||
ports:
|
||||
- "8333:8333"
|
||||
volumes:
|
||||
- ./sts_distributed.json:/config/sts_distributed.json:ro
|
||||
depends_on: [filer]
|
||||
|
||||
s3-gateway-2:
|
||||
image: seaweedfs/seaweedfs:latest
|
||||
command: "s3 -filer=filer:8888 -port=8333 -iam.config=/config/sts_distributed.json"
|
||||
ports:
|
||||
- "8334:8333"
|
||||
volumes:
|
||||
- ./sts_distributed.json:/config/sts_distributed.json:ro
|
||||
depends_on: [filer]
|
||||
|
||||
s3-gateway-3:
|
||||
image: seaweedfs/seaweedfs:latest
|
||||
command: "s3 -filer=filer:8888 -port=8333 -iam.config=/config/sts_distributed.json"
|
||||
ports:
|
||||
- "8335:8333"
|
||||
volumes:
|
||||
- ./sts_distributed.json:/config/sts_distributed.json:ro
|
||||
depends_on: [filer]
|
||||
|
||||
load-balancer:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on: [s3-gateway-1, s3-gateway-2, s3-gateway-3]
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### 1. OIDC Authentication Flow
|
||||
|
||||
```
|
||||
1. User authenticates with OIDC provider (Keycloak, Auth0, etc.)
|
||||
↓
|
||||
2. User receives OIDC JWT token from provider
|
||||
↓
|
||||
3. User calls SeaweedFS STS AssumeRoleWithWebIdentity
|
||||
POST /sts/assume-role-with-web-identity
|
||||
{
|
||||
"RoleArn": "arn:aws:iam::role/S3AdminRole",
|
||||
"WebIdentityToken": "eyJ0eXAiOiJKV1QiLCJhbGc...",
|
||||
"RoleSessionName": "user-session"
|
||||
}
|
||||
↓
|
||||
4. STS validates OIDC token with configured provider
|
||||
- Verifies JWT signature using provider's JWKS
|
||||
- Validates issuer, audience, expiration
|
||||
- Extracts user identity and groups
|
||||
↓
|
||||
5. STS checks role trust policy
|
||||
- Verifies user/groups can assume the requested role
|
||||
- Validates conditions in trust policy
|
||||
↓
|
||||
6. STS generates temporary credentials
|
||||
- Creates temporary access key, secret key, session token
|
||||
- Session token is signed JWT with all session information embedded (stateless)
|
||||
↓
|
||||
7. User receives temporary credentials
|
||||
{
|
||||
"Credentials": {
|
||||
"AccessKeyId": "AKIA...",
|
||||
"SecretAccessKey": "base64-secret",
|
||||
"SessionToken": "eyJ0eXAiOiJKV1QiLCJhbGc...",
|
||||
"Expiration": "2024-01-01T12:00:00Z"
|
||||
}
|
||||
}
|
||||
↓
|
||||
8. User makes S3 requests with temporary credentials
|
||||
- AWS SDK signs requests with temporary credentials
|
||||
- SeaweedFS S3 gateway validates session token
|
||||
- Gateway checks permissions via policy engine
|
||||
```
|
||||
|
||||
### 2. Cross-Instance Token Validation
|
||||
|
||||
```
|
||||
User Request → Load Balancer → Any S3 Gateway Instance
|
||||
↓
|
||||
Extract JWT Session Token
|
||||
↓
|
||||
Validate JWT Token
|
||||
(Self-contained - no external storage needed)
|
||||
↓
|
||||
Check Permissions
|
||||
(Shared policy engine)
|
||||
↓
|
||||
Allow/Deny Request
|
||||
```
|
||||
|
||||
## Trust Policy Conditions
|
||||
|
||||
Step 5 above evaluates the role's trust policy against context keys derived from the
|
||||
OIDC token's claims. The available keys are:
|
||||
|
||||
| Condition key | Source |
|
||||
|---------------|--------|
|
||||
| `oidc:iss` | `iss` claim (issuer URL) |
|
||||
| `oidc:sub` | `sub` claim |
|
||||
| `oidc:aud` | `aud` claim |
|
||||
| `oidc:<claim>` | any other token claim, e.g. `oidc:roles`, `oidc:groups`, `oidc:email` |
|
||||
| `aws:FederatedProvider` | the provider `name` (e.g. `keycloak-oidc`) when its configured issuer matches the token, otherwise the raw issuer URL |
|
||||
| `aws:userid` | `sub` claim (same value as `oidc:sub` during trust-policy evaluation) |
|
||||
| `sts:DurationSeconds` | requested session duration, when supplied |
|
||||
|
||||
During trust-policy evaluation `aws:userid` is the raw `sub` claim. Once the
|
||||
role has been assumed, the keys seen by request authorization differ: there
|
||||
`aws:userid` is a stable per-identity hash of `sub` and `iss` (see
|
||||
`ComputeParentUser`), so do not assume the two contexts carry the same value.
|
||||
|
||||
Custom claims are always exposed under the `oidc:` prefix, so a trust policy must use
|
||||
`oidc:roles` (not a bare `roles`) to match a `roles` claim:
|
||||
|
||||
```json
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"oidc:roles": "s3-admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A multi-valued claim (such as a `roles` array) matches when any of its values equals
|
||||
the condition value. The same `oidc:` keys can be interpolated into policy resources,
|
||||
e.g. `arn:aws:s3:::bucket/${oidc:sub}/*`.
|
||||
|
||||
### roleMapping vs. trust policy
|
||||
|
||||
A provider's `roleMapping` and a role's trust policy apply to two different entry
|
||||
points and are not interchangeable:
|
||||
|
||||
- **Direct OIDC** — an S3 request carrying `Authorization: Bearer <OIDC-JWT>`. The
|
||||
gateway applies `roleMapping` to choose the caller's role from the token claims; the
|
||||
first matching rule (or `defaultRole`) wins.
|
||||
- **STS `AssumeRoleWithWebIdentity`** — the caller names the role explicitly via
|
||||
`RoleArn`, and that role's trust policy decides whether the assumption is allowed.
|
||||
`roleMapping` does not select the role on this path; instead the token claims are
|
||||
surfaced as the `oidc:` condition keys above for the trust policy to evaluate.
|
||||
|
||||
## Configuration Management
|
||||
|
||||
### Development Environment
|
||||
|
||||
```json
|
||||
{
|
||||
"sts": {
|
||||
"tokenDuration": "1h",
|
||||
"maxSessionLength": "12h",
|
||||
"issuer": "seaweedfs-dev-sts",
|
||||
"signingKey": "ZGV2LXNpZ25pbmcta2V5LTMyLWNoYXJhY3RlcnMtbG9uZw==",
|
||||
"providers": [
|
||||
{
|
||||
"name": "dev-mock",
|
||||
"type": "mock",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"issuer": "http://localhost:9999",
|
||||
"clientId": "dev-mock-client"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Production Environment
|
||||
|
||||
```json
|
||||
{
|
||||
"sts": {
|
||||
"tokenDuration": "1h",
|
||||
"maxSessionLength": "12h",
|
||||
"issuer": "seaweedfs-prod-sts",
|
||||
"signingKey": "cHJvZC1zaWduaW5nLWtleS0zMi1jaGFyYWN0ZXJzLWxvbmctcmFuZG9t",
|
||||
"providers": [
|
||||
{
|
||||
"name": "corporate-sso",
|
||||
"type": "oidc",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"issuer": "https://sso.company.com/realms/production",
|
||||
"clientId": "seaweedfs-prod",
|
||||
"clientSecret": "${SSO_CLIENT_SECRET}",
|
||||
"scopes": ["openid", "profile", "email", "groups"],
|
||||
"claimsMapping": {
|
||||
"usernameClaim": "preferred_username",
|
||||
"groupsClaim": "groups"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "backup-auth",
|
||||
"type": "oidc",
|
||||
"enabled": false,
|
||||
"config": {
|
||||
"issuer": "https://backup-sso.company.com",
|
||||
"clientId": "seaweedfs-backup"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Operational Best Practices
|
||||
|
||||
### 1. Configuration Management
|
||||
|
||||
- **Version Control**: Store configurations in Git with proper versioning
|
||||
- **Environment Separation**: Use separate configs for dev/staging/production
|
||||
- **Secret Management**: Use environment variable substitution for secrets
|
||||
- **Configuration Validation**: Test configurations before deployment
|
||||
|
||||
### 2. Security Considerations
|
||||
|
||||
- **Signing Key Security**: Use strong, randomly generated signing keys (32+ bytes)
|
||||
- **Key Rotation**: Implement signing key rotation procedures
|
||||
- **Secret Storage**: Store client secrets in secure secret management systems
|
||||
- **TLS Encryption**: Always use HTTPS for OIDC providers in production
|
||||
|
||||
### 3. Monitoring and Troubleshooting
|
||||
|
||||
- **Provider Health**: Monitor OIDC provider availability and response times
|
||||
- **Session Metrics**: Track active sessions, token validation errors
|
||||
- **Configuration Drift**: Alert on configuration inconsistencies between instances
|
||||
- **Authentication Logs**: Log authentication attempts for security auditing
|
||||
|
||||
### 4. Capacity Planning
|
||||
|
||||
- **Provider Performance**: Monitor OIDC provider response times and rate limits
|
||||
- **Token Validation**: Monitor JWT validation performance and caching
|
||||
- **Memory Usage**: Monitor JWT token validation caching and provider metadata
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Manual Provider Registration
|
||||
|
||||
**Before (Manual Registration):**
|
||||
```go
|
||||
// Each instance needs this code
|
||||
keycloakProvider := oidc.NewOIDCProvider("keycloak-oidc")
|
||||
keycloakProvider.Initialize(keycloakConfig)
|
||||
stsService.RegisterProvider(keycloakProvider)
|
||||
```
|
||||
|
||||
**After (Configuration-Driven):**
|
||||
```json
|
||||
{
|
||||
"sts": {
|
||||
"providers": [
|
||||
{
|
||||
"name": "keycloak-oidc",
|
||||
"type": "oidc",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"issuer": "https://keycloak.company.com/realms/seaweedfs",
|
||||
"clientId": "seaweedfs-s3"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Migration Steps
|
||||
|
||||
1. **Create Configuration File**: Convert manual provider registrations to JSON config
|
||||
2. **Test Single Instance**: Deploy config to one instance and verify functionality
|
||||
3. **Validate Consistency**: Ensure all instances load identical providers
|
||||
4. **Rolling Deployment**: Update instances one by one with new configuration
|
||||
5. **Remove Manual Code**: Clean up manual provider registration code
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Provider Inconsistency
|
||||
|
||||
**Symptoms**: Authentication works on some instances but not others
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
# Check provider counts on each instance
|
||||
curl http://instance1:8333/sts/providers | jq '.providers | length'
|
||||
curl http://instance2:8334/sts/providers | jq '.providers | length'
|
||||
```
|
||||
**Solution**: Ensure all instances use identical configuration files
|
||||
|
||||
#### 2. Token Validation Failures
|
||||
|
||||
**Symptoms**: "Invalid signature" or "Invalid issuer" errors
|
||||
**Diagnosis**: Check signing key and issuer consistency
|
||||
**Solution**: Verify `signingKey` and `issuer` are identical across all instances
|
||||
|
||||
#### 3. Provider Loading Failures
|
||||
|
||||
**Symptoms**: Providers not loaded at startup
|
||||
**Diagnosis**: Check logs for provider initialization errors
|
||||
**Solution**: Validate provider configuration against schema
|
||||
|
||||
#### 4. OIDC Provider Connectivity
|
||||
|
||||
**Symptoms**: "Failed to fetch JWKS" errors
|
||||
**Diagnosis**: Test OIDC provider connectivity from all instances
|
||||
**Solution**: Check network connectivity, DNS resolution, certificates
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Test configuration loading
|
||||
weed s3 -iam.config=/path/to/config.json -test.config
|
||||
|
||||
# Validate JWT tokens
|
||||
curl -X POST http://localhost:8333/sts/validate-token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"sessionToken": "eyJ0eXAiOiJKV1QiLCJhbGc..."}'
|
||||
|
||||
# List loaded providers
|
||||
curl http://localhost:8333/sts/providers
|
||||
|
||||
# Check session store
|
||||
curl http://localhost:8333/sts/sessions/count
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Token Validation Performance
|
||||
|
||||
- **JWT Validation**: ~1-5ms per token validation
|
||||
- **JWKS Caching**: Cache JWKS responses to reduce OIDC provider load
|
||||
- **Session Lookup**: Filer session lookup adds ~10-20ms latency
|
||||
- **Concurrent Requests**: Each instance can handle 1000+ concurrent validations
|
||||
|
||||
### Scaling Recommendations
|
||||
|
||||
- **Horizontal Scaling**: Add more S3 gateway instances behind load balancer
|
||||
- **Session Store Optimization**: Use SSD storage for filer session store
|
||||
- **Provider Caching**: Implement JWKS caching to reduce provider load
|
||||
- **Connection Pooling**: Use connection pooling for filer communication
|
||||
|
||||
## Summary
|
||||
|
||||
The configuration-driven provider system solves critical distributed deployment issues:
|
||||
|
||||
- ✅ **Automatic Provider Loading**: No manual registration code required
|
||||
- ✅ **Configuration Consistency**: All instances load identical providers from config
|
||||
- ✅ **Easy Management**: Update config file, restart services
|
||||
- ✅ **Production Ready**: Supports OIDC, proper session management, distributed storage
|
||||
- ✅ **Backwards Compatible**: Existing manual registration still works
|
||||
|
||||
This enables SeaweedFS S3 Gateway to **scale horizontally** with **consistent authentication** across all instances, making it truly **production-ready for enterprise deployments**.
|
||||
20
test/s3/iam/docker-compose-simple.yml
Normal file
20
test/s3/iam/docker-compose-simple.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
# Keycloak Identity Provider
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.0.7
|
||||
container_name: keycloak-test-simple
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
KC_HTTP_ENABLED: "true"
|
||||
KC_HOSTNAME_STRICT: "false"
|
||||
KC_HOSTNAME_STRICT_HTTPS: "false"
|
||||
command: start-dev
|
||||
networks:
|
||||
- test-network
|
||||
|
||||
networks:
|
||||
test-network:
|
||||
driver: bridge
|
||||
160
test/s3/iam/docker-compose.test.yml
Normal file
160
test/s3/iam/docker-compose.test.yml
Normal file
@@ -0,0 +1,160 @@
|
||||
# Docker Compose for SeaweedFS S3 IAM Integration Tests
|
||||
services:
|
||||
# SeaweedFS Master
|
||||
seaweedfs-master:
|
||||
image: chrislusf/seaweedfs:latest
|
||||
container_name: seaweedfs-master-test
|
||||
command: master -mdir=/data -defaultReplication=000 -port=9333
|
||||
ports:
|
||||
- "9333:9333"
|
||||
volumes:
|
||||
- master-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9333/cluster/status"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- seaweedfs-test
|
||||
|
||||
# SeaweedFS Volume
|
||||
seaweedfs-volume:
|
||||
image: chrislusf/seaweedfs:latest
|
||||
container_name: seaweedfs-volume-test
|
||||
command: volume -dir=/data -port=8083 -master=seaweedfs-master:9333
|
||||
ports:
|
||||
- "8083:8083"
|
||||
volumes:
|
||||
- volume-data:/data
|
||||
depends_on:
|
||||
seaweedfs-master:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8083/status"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- seaweedfs-test
|
||||
|
||||
# SeaweedFS Filer
|
||||
seaweedfs-filer:
|
||||
image: chrislusf/seaweedfs:latest
|
||||
container_name: seaweedfs-filer-test
|
||||
command: filer -port=8888 -master=seaweedfs-master:9333 -defaultStoreDir=/data
|
||||
ports:
|
||||
- "8888:8888"
|
||||
volumes:
|
||||
- filer-data:/data
|
||||
depends_on:
|
||||
seaweedfs-master:
|
||||
condition: service_healthy
|
||||
seaweedfs-volume:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8888/status"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- seaweedfs-test
|
||||
|
||||
# SeaweedFS S3 API
|
||||
seaweedfs-s3:
|
||||
image: chrislusf/seaweedfs:latest
|
||||
container_name: seaweedfs-s3-test
|
||||
command: s3 -port=8333 -filer=seaweedfs-filer:8888 -config=/config/test_config.json
|
||||
ports:
|
||||
- "8333:8333"
|
||||
volumes:
|
||||
- ./test_config.json:/config/test_config.json:ro
|
||||
depends_on:
|
||||
seaweedfs-filer:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8333/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- seaweedfs-test
|
||||
|
||||
# Test Runner
|
||||
integration-tests:
|
||||
build:
|
||||
context: ../../../
|
||||
dockerfile: test/s3/iam/Dockerfile.s3
|
||||
container_name: seaweedfs-s3-iam-tests
|
||||
environment:
|
||||
- WEED_BINARY=weed
|
||||
- S3_PORT=8333
|
||||
- FILER_PORT=8888
|
||||
- MASTER_PORT=9333
|
||||
- VOLUME_PORT=8083
|
||||
- TEST_TIMEOUT=30m
|
||||
- LOG_LEVEL=2
|
||||
depends_on:
|
||||
seaweedfs-s3:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app/test/s3/iam
|
||||
- test-results:/app/test-results
|
||||
networks:
|
||||
- seaweedfs-test
|
||||
command: ["make", "test"]
|
||||
|
||||
# Optional: Mock LDAP Server for LDAP testing
|
||||
ldap-server:
|
||||
image: osixia/openldap:1.5.0
|
||||
container_name: ldap-server-test
|
||||
environment:
|
||||
LDAP_ORGANISATION: "Example Corp"
|
||||
LDAP_DOMAIN: "example.com"
|
||||
LDAP_ADMIN_PASSWORD: "admin-password"
|
||||
LDAP_CONFIG_PASSWORD: "config-password"
|
||||
LDAP_READONLY_USER: "true"
|
||||
LDAP_READONLY_USER_USERNAME: "readonly"
|
||||
LDAP_READONLY_USER_PASSWORD: "readonly-password"
|
||||
ports:
|
||||
- "389:389"
|
||||
- "636:636"
|
||||
volumes:
|
||||
- ldap-data:/var/lib/ldap
|
||||
- ldap-config:/etc/ldap/slapd.d
|
||||
networks:
|
||||
- seaweedfs-test
|
||||
|
||||
# Optional: LDAP Admin UI
|
||||
ldap-admin:
|
||||
image: osixia/phpldapadmin:latest
|
||||
container_name: ldap-admin-test
|
||||
environment:
|
||||
PHPLDAPADMIN_LDAP_HOSTS: "ldap-server"
|
||||
PHPLDAPADMIN_HTTPS: "false"
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- ldap-server
|
||||
networks:
|
||||
- seaweedfs-test
|
||||
|
||||
volumes:
|
||||
master-data:
|
||||
driver: local
|
||||
volume-data:
|
||||
driver: local
|
||||
filer-data:
|
||||
driver: local
|
||||
ldap-data:
|
||||
driver: local
|
||||
ldap-config:
|
||||
driver: local
|
||||
test-results:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
seaweedfs-test:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
160
test/s3/iam/docker-compose.yml
Normal file
160
test/s3/iam/docker-compose.yml
Normal file
@@ -0,0 +1,160 @@
|
||||
services:
|
||||
# Keycloak Identity Provider
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.0.7
|
||||
container_name: keycloak-iam-test
|
||||
hostname: keycloak
|
||||
environment:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
KC_HTTP_ENABLED: "true"
|
||||
KC_HOSTNAME_STRICT: "false"
|
||||
KC_HOSTNAME_STRICT_HTTPS: "false"
|
||||
KC_HTTP_RELATIVE_PATH: /
|
||||
ports:
|
||||
- "8080:8080"
|
||||
command: start-dev
|
||||
networks:
|
||||
- seaweedfs-iam
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
# SeaweedFS Master
|
||||
weed-master:
|
||||
image: ${SEAWEEDFS_IMAGE:-local/seaweedfs:latest}
|
||||
container_name: weed-master
|
||||
hostname: weed-master
|
||||
ports:
|
||||
- "9333:9333"
|
||||
- "19333:19333"
|
||||
command: "master -ip=weed-master -port=9333 -mdir=/data"
|
||||
volumes:
|
||||
- master-data:/data
|
||||
networks:
|
||||
- seaweedfs-iam
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9333/cluster/status"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# SeaweedFS Volume Server
|
||||
weed-volume:
|
||||
image: ${SEAWEEDFS_IMAGE:-local/seaweedfs:latest}
|
||||
container_name: weed-volume
|
||||
hostname: weed-volume
|
||||
ports:
|
||||
- "8083:8083"
|
||||
- "18083:18083"
|
||||
command: "volume -ip=weed-volume -port=8083 -dir=/data -master=weed-master:9333 -dataCenter=dc1 -rack=rack1"
|
||||
volumes:
|
||||
- volume-data:/data
|
||||
networks:
|
||||
- seaweedfs-iam
|
||||
depends_on:
|
||||
weed-master:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8083/status"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# SeaweedFS Filer
|
||||
weed-filer:
|
||||
image: ${SEAWEEDFS_IMAGE:-local/seaweedfs:latest}
|
||||
container_name: weed-filer
|
||||
hostname: weed-filer
|
||||
ports:
|
||||
- "8888:8888"
|
||||
- "18888:18888"
|
||||
command: "filer -ip=weed-filer -port=8888 -master=weed-master:9333 -defaultStoreDir=/data"
|
||||
volumes:
|
||||
- filer-data:/data
|
||||
networks:
|
||||
- seaweedfs-iam
|
||||
depends_on:
|
||||
weed-master:
|
||||
condition: service_healthy
|
||||
weed-volume:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8888/status"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# SeaweedFS S3 API with IAM
|
||||
weed-s3:
|
||||
image: ${SEAWEEDFS_IMAGE:-local/seaweedfs:latest}
|
||||
container_name: weed-s3
|
||||
hostname: weed-s3
|
||||
ports:
|
||||
- "8333:8333"
|
||||
environment:
|
||||
WEED_FILER: "weed-filer:8888"
|
||||
WEED_IAM_CONFIG: "/config/iam_config.json"
|
||||
WEED_S3_CONFIG: "/config/test_config.json"
|
||||
GLOG_v: "3"
|
||||
command: >
|
||||
sh -c "
|
||||
echo 'Starting S3 API with IAM...' &&
|
||||
weed -v=3 s3 -ip=weed-s3 -port=8333
|
||||
-filer=weed-filer:8888
|
||||
-config=/config/test_config.json
|
||||
-iam.config=/config/iam_config.json
|
||||
"
|
||||
volumes:
|
||||
- ./iam_config.json:/config/iam_config.json:ro
|
||||
- ./test_config.json:/config/test_config.json:ro
|
||||
networks:
|
||||
- seaweedfs-iam
|
||||
depends_on:
|
||||
weed-filer:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
keycloak-setup:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8333"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# Keycloak Setup Service
|
||||
keycloak-setup:
|
||||
image: alpine/curl:8.4.0
|
||||
container_name: keycloak-setup
|
||||
volumes:
|
||||
- ./setup_keycloak_docker.sh:/setup.sh:ro
|
||||
- .:/workspace:rw
|
||||
working_dir: /workspace
|
||||
networks:
|
||||
- seaweedfs-iam
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
command: >
|
||||
sh -c "
|
||||
apk add --no-cache bash jq &&
|
||||
chmod +x /setup.sh &&
|
||||
/setup.sh
|
||||
"
|
||||
|
||||
volumes:
|
||||
master-data:
|
||||
volume-data:
|
||||
filer-data:
|
||||
|
||||
networks:
|
||||
seaweedfs-iam:
|
||||
driver: bridge
|
||||
3
test/s3/iam/empty_s3_config.json
Normal file
3
test/s3/iam/empty_s3_config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"identities": []
|
||||
}
|
||||
16
test/s3/iam/go.mod
Normal file
16
test/s3/iam/go.mod
Normal file
@@ -0,0 +1,16 @@
|
||||
module github.com/seaweedfs/seaweedfs/test/s3/iam
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go v1.44.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/stretchr/testify v1.8.4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
31
test/s3/iam/go.sum
Normal file
31
test/s3/iam/go.sum
Normal file
@@ -0,0 +1,31 @@
|
||||
github.com/aws/aws-sdk-go v1.44.0 h1:jwtHuNqfnJxL4DKHBUVUmQlfueQqBW7oXP6yebZR/R0=
|
||||
github.com/aws/aws-sdk-go v1.44.0/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk=
|
||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
293
test/s3/iam/iam_config.github.json
Normal file
293
test/s3/iam/iam_config.github.json
Normal file
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"sts": {
|
||||
"tokenDuration": "1h",
|
||||
"maxSessionLength": "12h",
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "dGVzdC1zaWduaW5nLWtleS0zMi1jaGFyYWN0ZXJzLWxvbmc="
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
"name": "test-oidc",
|
||||
"type": "mock",
|
||||
"config": {
|
||||
"issuer": "test-oidc-issuer",
|
||||
"clientId": "test-oidc-client"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "keycloak",
|
||||
"type": "oidc",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"issuer": "http://localhost:8080/realms/seaweedfs-test",
|
||||
"clientId": "seaweedfs-s3",
|
||||
"clientSecret": "seaweedfs-s3-secret",
|
||||
"jwksUri": "http://localhost:8080/realms/seaweedfs-test/protocol/openid-connect/certs",
|
||||
"userInfoUri": "http://localhost:8080/realms/seaweedfs-test/protocol/openid-connect/userinfo",
|
||||
"scopes": ["openid", "profile", "email"],
|
||||
"claimsMapping": {
|
||||
"username": "preferred_username",
|
||||
"email": "email",
|
||||
"name": "name"
|
||||
},
|
||||
"roleMapping": {
|
||||
"rules": [
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-admin",
|
||||
"role": "arn:aws:iam::role/KeycloakAdminRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-read-only",
|
||||
"role": "arn:aws:iam::role/KeycloakReadOnlyRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-write-only",
|
||||
"role": "arn:aws:iam::role/KeycloakWriteOnlyRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-read-write",
|
||||
"role": "arn:aws:iam::role/KeycloakReadWriteRole"
|
||||
}
|
||||
],
|
||||
"defaultRole": "arn:aws:iam::role/KeycloakReadOnlyRole"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"policy": {
|
||||
"defaultEffect": "Deny"
|
||||
},
|
||||
"roles": [
|
||||
{
|
||||
"roleName": "TestAdminRole",
|
||||
"roleArn": "arn:aws:iam::role/TestAdminRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "test-oidc"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3AdminPolicy"],
|
||||
"description": "Admin role for testing"
|
||||
},
|
||||
{
|
||||
"roleName": "TestReadOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/TestReadOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "test-oidc"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3ReadOnlyPolicy"],
|
||||
"description": "Read-only role for testing"
|
||||
},
|
||||
{
|
||||
"roleName": "TestWriteOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/TestWriteOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "test-oidc"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3WriteOnlyPolicy"],
|
||||
"description": "Write-only role for testing"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakAdminRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakAdminRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3AdminPolicy"],
|
||||
"description": "Admin role for Keycloak users"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakReadOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakReadOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3ReadOnlyPolicy"],
|
||||
"description": "Read-only role for Keycloak users"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakWriteOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakWriteOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3WriteOnlyPolicy"],
|
||||
"description": "Write-only role for Keycloak users"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakReadWriteRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakReadWriteRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3ReadWritePolicy"],
|
||||
"description": "Read-write role for Keycloak users"
|
||||
}
|
||||
],
|
||||
"policies": [
|
||||
{
|
||||
"name": "S3AdminPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": ["*"]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["sts:ValidateSession"],
|
||||
"Resource": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3ReadOnlyPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:ListBucket"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["sts:ValidateSession"],
|
||||
"Resource": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3WriteOnlyPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:*"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:ListBucket"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["sts:ValidateSession"],
|
||||
"Resource": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3ReadWritePolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:*"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["sts:ValidateSession"],
|
||||
"Resource": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
347
test/s3/iam/iam_config.json
Normal file
347
test/s3/iam/iam_config.json
Normal file
@@ -0,0 +1,347 @@
|
||||
{
|
||||
"sts": {
|
||||
"tokenDuration": "1h",
|
||||
"maxSessionLength": "12h",
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "dGVzdC1zaWduaW5nLWtleS0zMi1jaGFyYWN0ZXJzLWxvbmc="
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
"name": "test-oidc",
|
||||
"type": "mock",
|
||||
"config": {
|
||||
"issuer": "test-oidc-issuer",
|
||||
"clientId": "test-oidc-client"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "keycloak",
|
||||
"type": "oidc",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"issuer": "http://localhost:8080/realms/seaweedfs-test",
|
||||
"clientId": "seaweedfs-s3",
|
||||
"clientSecret": "seaweedfs-s3-secret",
|
||||
"jwksUri": "http://localhost:8080/realms/seaweedfs-test/protocol/openid-connect/certs",
|
||||
"userInfoUri": "http://localhost:8080/realms/seaweedfs-test/protocol/openid-connect/userinfo",
|
||||
"scopes": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email"
|
||||
],
|
||||
"claimsMapping": {
|
||||
"username": "preferred_username",
|
||||
"email": "email",
|
||||
"name": "name"
|
||||
},
|
||||
"roleMapping": {
|
||||
"rules": [
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-admin",
|
||||
"role": "arn:aws:iam::role/KeycloakAdminRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-read-only",
|
||||
"role": "arn:aws:iam::role/KeycloakReadOnlyRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-write-only",
|
||||
"role": "arn:aws:iam::role/KeycloakWriteOnlyRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-read-write",
|
||||
"role": "arn:aws:iam::role/KeycloakReadWriteRole"
|
||||
}
|
||||
],
|
||||
"defaultRole": "arn:aws:iam::role/KeycloakReadOnlyRole"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"policy": {
|
||||
"defaultEffect": "Deny"
|
||||
},
|
||||
"roles": [
|
||||
{
|
||||
"roleName": "TestAdminRole",
|
||||
"roleArn": "arn:aws:iam::role/TestAdminRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "test-oidc"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3AdminPolicy"
|
||||
],
|
||||
"description": "Admin role for testing"
|
||||
},
|
||||
{
|
||||
"roleName": "TestReadOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/TestReadOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "test-oidc"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3ReadOnlyPolicy"
|
||||
],
|
||||
"description": "Read-only role for testing"
|
||||
},
|
||||
{
|
||||
"roleName": "TestWriteOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/TestWriteOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "test-oidc"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3WriteOnlyPolicy"
|
||||
],
|
||||
"description": "Write-only role for testing"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakAdminRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakAdminRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3AdminPolicy"
|
||||
],
|
||||
"description": "Admin role for Keycloak users"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakReadOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakReadOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3ReadOnlyPolicy"
|
||||
],
|
||||
"description": "Read-only role for Keycloak users"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakWriteOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakWriteOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3WriteOnlyPolicy"
|
||||
],
|
||||
"description": "Write-only role for Keycloak users"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakReadWriteRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakReadWriteRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3ReadWritePolicy"
|
||||
],
|
||||
"description": "Read-write role for Keycloak users"
|
||||
}
|
||||
],
|
||||
"policies": [
|
||||
{
|
||||
"name": "S3AdminPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:*",
|
||||
"iam:*"
|
||||
],
|
||||
"Resource": [
|
||||
"*",
|
||||
"arn:aws:iam:::*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"sts:ValidateSession"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3ReadOnlyPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:ListBucket"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"sts:ValidateSession"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3WriteOnlyPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:*"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:ListBucket"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"sts:ValidateSession"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3ReadWritePolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:*"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"sts:ValidateSession"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
347
test/s3/iam/iam_config.local.json
Normal file
347
test/s3/iam/iam_config.local.json
Normal file
@@ -0,0 +1,347 @@
|
||||
{
|
||||
"sts": {
|
||||
"tokenDuration": "1h",
|
||||
"maxSessionLength": "12h",
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "dGVzdC1zaWduaW5nLWtleS0zMi1jaGFyYWN0ZXJzLWxvbmc="
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
"name": "test-oidc",
|
||||
"type": "mock",
|
||||
"config": {
|
||||
"issuer": "test-oidc-issuer",
|
||||
"clientId": "test-oidc-client"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "keycloak",
|
||||
"type": "oidc",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"issuer": "http://localhost:8080/realms/seaweedfs-test",
|
||||
"clientId": "seaweedfs-s3",
|
||||
"clientSecret": "seaweedfs-s3-secret",
|
||||
"jwksUri": "http://localhost:8080/realms/seaweedfs-test/protocol/openid-connect/certs",
|
||||
"userInfoUri": "http://localhost:8080/realms/seaweedfs-test/protocol/openid-connect/userinfo",
|
||||
"scopes": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email"
|
||||
],
|
||||
"claimsMapping": {
|
||||
"username": "preferred_username",
|
||||
"email": "email",
|
||||
"name": "name"
|
||||
},
|
||||
"roleMapping": {
|
||||
"rules": [
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-admin",
|
||||
"role": "arn:aws:iam::role/KeycloakAdminRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-read-only",
|
||||
"role": "arn:aws:iam::role/KeycloakReadOnlyRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-write-only",
|
||||
"role": "arn:aws:iam::role/KeycloakWriteOnlyRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-read-write",
|
||||
"role": "arn:aws:iam::role/KeycloakReadWriteRole"
|
||||
}
|
||||
],
|
||||
"defaultRole": "arn:aws:iam::role/KeycloakReadOnlyRole"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"policy": {
|
||||
"defaultEffect": "Deny"
|
||||
},
|
||||
"roles": [
|
||||
{
|
||||
"roleName": "TestAdminRole",
|
||||
"roleArn": "arn:aws:iam::role/TestAdminRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "test-oidc"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3AdminPolicy"
|
||||
],
|
||||
"description": "Admin role for testing"
|
||||
},
|
||||
{
|
||||
"roleName": "TestReadOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/TestReadOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "test-oidc"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3ReadOnlyPolicy"
|
||||
],
|
||||
"description": "Read-only role for testing"
|
||||
},
|
||||
{
|
||||
"roleName": "TestWriteOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/TestWriteOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "test-oidc"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3WriteOnlyPolicy"
|
||||
],
|
||||
"description": "Write-only role for testing"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakAdminRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakAdminRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3AdminPolicy"
|
||||
],
|
||||
"description": "Admin role for Keycloak users"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakReadOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakReadOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3ReadOnlyPolicy"
|
||||
],
|
||||
"description": "Read-only role for Keycloak users"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakWriteOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakWriteOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3WriteOnlyPolicy"
|
||||
],
|
||||
"description": "Write-only role for Keycloak users"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakReadWriteRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakReadWriteRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3ReadWritePolicy"
|
||||
],
|
||||
"description": "Read-write role for Keycloak users"
|
||||
}
|
||||
],
|
||||
"policies": [
|
||||
{
|
||||
"name": "S3AdminPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:*",
|
||||
"iam:*"
|
||||
],
|
||||
"Resource": [
|
||||
"*",
|
||||
"arn:aws:iam:::*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"sts:ValidateSession"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3ReadOnlyPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:ListBucket"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"sts:ValidateSession"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3WriteOnlyPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:*"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:ListBucket"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"sts:ValidateSession"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3ReadWritePolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:*"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"sts:ValidateSession"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
173
test/s3/iam/iam_config_distributed.json
Normal file
173
test/s3/iam/iam_config_distributed.json
Normal file
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"sts": {
|
||||
"tokenDuration": "1h",
|
||||
"maxSessionLength": "12h",
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "dGVzdC1zaWduaW5nLWtleS0zMi1jaGFyYWN0ZXJzLWxvbmc=",
|
||||
"providers": [
|
||||
{
|
||||
"name": "keycloak-oidc",
|
||||
"type": "oidc",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"issuer": "http://keycloak:8080/realms/seaweedfs-test",
|
||||
"clientId": "seaweedfs-s3",
|
||||
"clientSecret": "seaweedfs-s3-secret",
|
||||
"jwksUri": "http://keycloak:8080/realms/seaweedfs-test/protocol/openid-connect/certs",
|
||||
"scopes": ["openid", "profile", "email", "roles"],
|
||||
"claimsMapping": {
|
||||
"usernameClaim": "preferred_username",
|
||||
"groupsClaim": "roles"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mock-provider",
|
||||
"type": "mock",
|
||||
"enabled": false,
|
||||
"config": {
|
||||
"issuer": "http://localhost:9999",
|
||||
"jwksEndpoint": "http://localhost:9999/jwks"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"policy": {
|
||||
"defaultEffect": "Deny"
|
||||
},
|
||||
"roleStore": {},
|
||||
|
||||
"roles": [
|
||||
{
|
||||
"roleName": "S3AdminRole",
|
||||
"roleArn": "arn:aws:iam::role/S3AdminRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak-oidc"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"roles": "s3-admin"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3AdminPolicy"],
|
||||
"description": "Full S3 administrator access role"
|
||||
},
|
||||
{
|
||||
"roleName": "S3ReadOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak-oidc"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"roles": "s3-read-only"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3ReadOnlyPolicy"],
|
||||
"description": "Read-only access to S3 resources"
|
||||
},
|
||||
{
|
||||
"roleName": "S3ReadWriteRole",
|
||||
"roleArn": "arn:aws:iam::role/S3ReadWriteRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak-oidc"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"roles": "s3-read-write"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3ReadWritePolicy"],
|
||||
"description": "Read-write access to S3 resources"
|
||||
}
|
||||
],
|
||||
"policies": [
|
||||
{
|
||||
"name": "S3AdminPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:*",
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3ReadOnlyPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectAcl",
|
||||
"s3:GetObjectVersion",
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3ReadWritePolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectAcl",
|
||||
"s3:GetObjectVersion",
|
||||
"s3:PutObject",
|
||||
"s3:PutObjectAcl",
|
||||
"s3:DeleteObject",
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
158
test/s3/iam/iam_config_docker.json
Normal file
158
test/s3/iam/iam_config_docker.json
Normal file
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"sts": {
|
||||
"tokenDuration": "1h",
|
||||
"maxSessionLength": "12h",
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "dGVzdC1zaWduaW5nLWtleS0zMi1jaGFyYWN0ZXJzLWxvbmc=",
|
||||
"providers": [
|
||||
{
|
||||
"name": "keycloak-oidc",
|
||||
"type": "oidc",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"issuer": "http://keycloak:8080/realms/seaweedfs-test",
|
||||
"clientId": "seaweedfs-s3",
|
||||
"clientSecret": "seaweedfs-s3-secret",
|
||||
"jwksUri": "http://keycloak:8080/realms/seaweedfs-test/protocol/openid-connect/certs",
|
||||
"scopes": ["openid", "profile", "email", "roles"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"policy": {
|
||||
"defaultEffect": "Deny"
|
||||
},
|
||||
"roles": [
|
||||
{
|
||||
"roleName": "S3AdminRole",
|
||||
"roleArn": "arn:aws:iam::role/S3AdminRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak-oidc"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"oidc:roles": "s3-admin"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3AdminPolicy"],
|
||||
"description": "Full S3 administrator access role"
|
||||
},
|
||||
{
|
||||
"roleName": "S3ReadOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak-oidc"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"oidc:roles": "s3-read-only"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3ReadOnlyPolicy"],
|
||||
"description": "Read-only access to S3 resources"
|
||||
},
|
||||
{
|
||||
"roleName": "S3ReadWriteRole",
|
||||
"roleArn": "arn:aws:iam::role/S3ReadWriteRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak-oidc"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"oidc:roles": "s3-read-write"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3ReadWritePolicy"],
|
||||
"description": "Read-write access to S3 resources"
|
||||
}
|
||||
],
|
||||
"policies": [
|
||||
{
|
||||
"name": "S3AdminPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:*",
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3ReadOnlyPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectAcl",
|
||||
"s3:GetObjectVersion",
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3ReadWritePolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectAcl",
|
||||
"s3:GetObjectVersion",
|
||||
"s3:PutObject",
|
||||
"s3:PutObjectAcl",
|
||||
"s3:DeleteObject",
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
119
test/s3/iam/run_all_tests.sh
Executable file
119
test/s3/iam/run_all_tests.sh
Executable file
@@ -0,0 +1,119 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Master Test Runner - Enables and runs all previously skipped tests
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo -e "${BLUE}🎯 SeaweedFS S3 IAM Complete Test Suite${NC}"
|
||||
echo -e "${BLUE}=====================================${NC}"
|
||||
|
||||
# Set environment variables to enable all tests
|
||||
export ENABLE_DISTRIBUTED_TESTS=true
|
||||
export ENABLE_PERFORMANCE_TESTS=true
|
||||
export ENABLE_STRESS_TESTS=true
|
||||
export KEYCLOAK_URL="http://localhost:8080"
|
||||
export S3_ENDPOINT="http://localhost:8333"
|
||||
export TEST_TIMEOUT=60m
|
||||
export CGO_ENABLED=0
|
||||
|
||||
# Function to run test category
|
||||
run_test_category() {
|
||||
local category="$1"
|
||||
local test_pattern="$2"
|
||||
local description="$3"
|
||||
|
||||
echo -e "${YELLOW}🧪 Running $description...${NC}"
|
||||
|
||||
if go test -v -timeout=$TEST_TIMEOUT -run "$test_pattern" ./...; then
|
||||
echo -e "${GREEN}[OK] $description completed successfully${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}[FAIL] $description failed${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Track results
|
||||
TOTAL_CATEGORIES=0
|
||||
PASSED_CATEGORIES=0
|
||||
|
||||
# 1. Standard IAM Integration Tests
|
||||
echo -e "\n${BLUE}1. Standard IAM Integration Tests${NC}"
|
||||
TOTAL_CATEGORIES=$((TOTAL_CATEGORIES + 1))
|
||||
if run_test_category "standard" "TestS3IAM(?!.*Distributed|.*Performance)" "Standard IAM Integration Tests"; then
|
||||
PASSED_CATEGORIES=$((PASSED_CATEGORIES + 1))
|
||||
fi
|
||||
|
||||
# 2. Keycloak Integration Tests (if Keycloak is available)
|
||||
echo -e "\n${BLUE}2. Keycloak Integration Tests${NC}"
|
||||
TOTAL_CATEGORIES=$((TOTAL_CATEGORIES + 1))
|
||||
if curl -s "http://localhost:8080/health/ready" > /dev/null 2>&1; then
|
||||
if run_test_category "keycloak" "TestKeycloak" "Keycloak Integration Tests"; then
|
||||
PASSED_CATEGORIES=$((PASSED_CATEGORIES + 1))
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ Keycloak not available, skipping Keycloak tests${NC}"
|
||||
echo -e "${YELLOW}💡 Run './setup_all_tests.sh' to start Keycloak${NC}"
|
||||
fi
|
||||
|
||||
# 3. Distributed Tests
|
||||
echo -e "\n${BLUE}3. Distributed IAM Tests${NC}"
|
||||
TOTAL_CATEGORIES=$((TOTAL_CATEGORIES + 1))
|
||||
if run_test_category "distributed" "TestS3IAMDistributedTests" "Distributed IAM Tests"; then
|
||||
PASSED_CATEGORIES=$((PASSED_CATEGORIES + 1))
|
||||
fi
|
||||
|
||||
# 4. Performance Tests
|
||||
echo -e "\n${BLUE}4. Performance Tests${NC}"
|
||||
TOTAL_CATEGORIES=$((TOTAL_CATEGORIES + 1))
|
||||
if run_test_category "performance" "TestS3IAMPerformanceTests" "Performance Tests"; then
|
||||
PASSED_CATEGORIES=$((PASSED_CATEGORIES + 1))
|
||||
fi
|
||||
|
||||
# 5. Benchmarks
|
||||
echo -e "\n${BLUE}5. Benchmark Tests${NC}"
|
||||
TOTAL_CATEGORIES=$((TOTAL_CATEGORIES + 1))
|
||||
if go test -bench=. -benchmem -timeout=$TEST_TIMEOUT ./...; then
|
||||
echo -e "${GREEN}[OK] Benchmark tests completed successfully${NC}"
|
||||
PASSED_CATEGORIES=$((PASSED_CATEGORIES + 1))
|
||||
else
|
||||
echo -e "${RED}[FAIL] Benchmark tests failed${NC}"
|
||||
fi
|
||||
|
||||
# 6. Versioning Stress Tests
|
||||
echo -e "\n${BLUE}6. S3 Versioning Stress Tests${NC}"
|
||||
TOTAL_CATEGORIES=$((TOTAL_CATEGORIES + 1))
|
||||
if [ -f "../versioning/enable_stress_tests.sh" ]; then
|
||||
if (cd ../versioning && ./enable_stress_tests.sh); then
|
||||
echo -e "${GREEN}[OK] Versioning stress tests completed successfully${NC}"
|
||||
PASSED_CATEGORIES=$((PASSED_CATEGORIES + 1))
|
||||
else
|
||||
echo -e "${RED}[FAIL] Versioning stress tests failed${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ Versioning stress tests not available${NC}"
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo -e "\n${BLUE}📊 Test Summary${NC}"
|
||||
echo -e "${BLUE}===============${NC}"
|
||||
echo -e "Total test categories: $TOTAL_CATEGORIES"
|
||||
echo -e "Passed: ${GREEN}$PASSED_CATEGORIES${NC}"
|
||||
echo -e "Failed: ${RED}$((TOTAL_CATEGORIES - PASSED_CATEGORIES))${NC}"
|
||||
|
||||
if [ $PASSED_CATEGORIES -eq $TOTAL_CATEGORIES ]; then
|
||||
echo -e "\n${GREEN}🎉 All test categories passed!${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "\n${RED}[FAIL] Some test categories failed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
46
test/s3/iam/run_custom_test.sh
Normal file
46
test/s3/iam/run_custom_test.sh
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
# Build weed binary
|
||||
echo "Building weed binary..."
|
||||
cd "$PROJECT_ROOT/weed" && go install
|
||||
|
||||
# Kill existing server
|
||||
if lsof -Pi :8333 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
|
||||
kill $(lsof -t -i:8333) 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Start server using weed mini for simpler all-in-one deployment
|
||||
weed mini \
|
||||
-s3 \
|
||||
-s3.port=8333 \
|
||||
-s3.config="$SCRIPT_DIR/empty_s3_config.json" \
|
||||
-s3.iam.config="$SCRIPT_DIR/test_iam_config.json" \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
> /tmp/weed_test_server_custom.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server
|
||||
MAX_WAIT=30
|
||||
COUNTER=0
|
||||
while ! curl -s http://localhost:8333/status > /dev/null 2>&1; do
|
||||
sleep 1
|
||||
COUNTER=$((COUNTER + 1))
|
||||
if [ $COUNTER -ge $MAX_WAIT ]; then
|
||||
echo "Server failed to start"
|
||||
cat /tmp/weed_test_server_custom.log
|
||||
kill $SERVER_PID
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
trap "kill $SERVER_PID" EXIT
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
if [ $# -eq 0 ]; then
|
||||
go test -v -run TestS3IAMMultipartUploadPolicyEnforcement .
|
||||
else
|
||||
go test -v "$@" .
|
||||
fi
|
||||
26
test/s3/iam/run_performance_tests.sh
Executable file
26
test/s3/iam/run_performance_tests.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Performance Test Runner for SeaweedFS S3 IAM
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${YELLOW}🏁 Running S3 IAM Performance Tests${NC}"
|
||||
|
||||
# Enable performance tests
|
||||
export ENABLE_PERFORMANCE_TESTS=true
|
||||
export TEST_TIMEOUT=60m
|
||||
|
||||
# Run benchmarks
|
||||
echo -e "${YELLOW}📊 Running benchmarks...${NC}"
|
||||
go test -bench=. -benchmem -timeout=$TEST_TIMEOUT ./...
|
||||
|
||||
# Run performance tests
|
||||
echo -e "${YELLOW}🧪 Running performance test suite...${NC}"
|
||||
go test -v -timeout=$TEST_TIMEOUT -run "TestS3IAMPerformanceTests" ./...
|
||||
|
||||
echo -e "${GREEN}[OK] Performance tests completed${NC}"
|
||||
36
test/s3/iam/run_stress_tests.sh
Executable file
36
test/s3/iam/run_stress_tests.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Stress Test Runner for SeaweedFS S3 IAM
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${YELLOW}💪 Running S3 IAM Stress Tests${NC}"
|
||||
|
||||
# Enable stress tests
|
||||
export ENABLE_STRESS_TESTS=true
|
||||
export TEST_TIMEOUT=60m
|
||||
|
||||
# Run stress tests multiple times
|
||||
STRESS_ITERATIONS=5
|
||||
|
||||
echo -e "${YELLOW}🔄 Running stress tests with $STRESS_ITERATIONS iterations...${NC}"
|
||||
|
||||
for i in $(seq 1 $STRESS_ITERATIONS); do
|
||||
echo -e "${YELLOW}📊 Iteration $i/$STRESS_ITERATIONS${NC}"
|
||||
|
||||
if ! go test -v -timeout=$TEST_TIMEOUT -run "TestS3IAMDistributedTests.*concurrent" ./... -count=1; then
|
||||
echo -e "${RED}❌ Stress test failed on iteration $i${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Brief pause between iterations
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo -e "${GREEN}[OK] All stress test iterations completed successfully${NC}"
|
||||
90
test/s3/iam/run_tests.sh
Executable file
90
test/s3/iam/run_tests.sh
Executable file
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# Test runner for S3 policy variables integration tests
|
||||
# This script starts a SeaweedFS server with the required IAM configuration
|
||||
# and runs the integration tests.
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}=== S3 Policy Variables Integration Test Runner ===${NC}"
|
||||
|
||||
# Get the directory of this script
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
# Always build to ensure latest changes are tested
|
||||
echo -e "${YELLOW}Building weed binary...${NC}"
|
||||
cd "$PROJECT_ROOT/weed" && go install
|
||||
if ! command -v weed &> /dev/null; then
|
||||
echo -e "${RED}Failed to build weed binary${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Kill any existing weed server on port 8333
|
||||
echo "Checking for existing weed server..."
|
||||
if lsof -Pi :8333 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
|
||||
echo -e "${YELLOW}Killing existing weed server on port 8333...${NC}"
|
||||
kill $(lsof -t -i:8333) 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Start weed server with IAM configuration
|
||||
echo -e "${GREEN}Starting weed server with IAM configuration...${NC}"
|
||||
weed server \
|
||||
-s3 \
|
||||
-s3.port=8333 \
|
||||
-s3.iam.config="$SCRIPT_DIR/test_iam_config.json" \
|
||||
-filer \
|
||||
-volume.max=0 \
|
||||
-master.volumeSizeLimitMB=100 \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
-s3.iam.readOnly=false \
|
||||
> /tmp/weed_test_server.log 2>&1 &
|
||||
|
||||
SERVER_PID=$!
|
||||
echo "Server started with PID: $SERVER_PID"
|
||||
|
||||
# Wait for server to be ready
|
||||
echo "Waiting for server to be ready..."
|
||||
MAX_WAIT=30
|
||||
COUNTER=0
|
||||
while ! curl -s http://localhost:8333/status > /dev/null 2>&1; do
|
||||
sleep 1
|
||||
COUNTER=$((COUNTER + 1))
|
||||
if [ $COUNTER -ge $MAX_WAIT ]; then
|
||||
echo -e "${RED}Server failed to start within ${MAX_WAIT} seconds${NC}"
|
||||
echo "Server log:"
|
||||
cat /tmp/weed_test_server.log
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "${GREEN}Server is ready!${NC}"
|
||||
|
||||
# Run the tests
|
||||
echo -e "${GREEN}Running integration tests...${NC}"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Trap to ensure server is killed on exit
|
||||
trap "echo -e '${YELLOW}Shutting down server...${NC}'; kill $SERVER_PID 2>/dev/null || true" EXIT
|
||||
|
||||
# Run the tests
|
||||
go test -v -run TestS3PolicyVariables .
|
||||
|
||||
TEST_RESULT=$?
|
||||
|
||||
if [ $TEST_RESULT -eq 0 ]; then
|
||||
echo -e "${GREEN}=== All tests passed! ===${NC}"
|
||||
else
|
||||
echo -e "${RED}=== Tests failed ===${NC}"
|
||||
echo "Server log (last 50 lines):"
|
||||
tail -50 /tmp/weed_test_server.log
|
||||
fi
|
||||
|
||||
exit $TEST_RESULT
|
||||
384
test/s3/iam/s3_iam_admin_test.go
Normal file
384
test/s3/iam/s3_iam_admin_test.go
Normal file
@@ -0,0 +1,384 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestIAMUserManagement tests user management operations
|
||||
func TestIAMUserManagement(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
// Create IAM client with admin privileges
|
||||
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("create_and_get_user", func(t *testing.T) {
|
||||
userName := "test-user-mgm"
|
||||
|
||||
// Create user
|
||||
createResp, err := iamClient.CreateUser(&iam.CreateUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, userName, *createResp.User.UserName)
|
||||
|
||||
// Get user
|
||||
getResp, err := iamClient.GetUser(&iam.GetUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, userName, *getResp.User.UserName)
|
||||
|
||||
// List users to verify existence
|
||||
listResp, err := iamClient.ListUsers(&iam.ListUsersInput{})
|
||||
require.NoError(t, err)
|
||||
found := false
|
||||
for _, user := range listResp.Users {
|
||||
if *user.UserName == userName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Created user should be listed")
|
||||
|
||||
// Clean up
|
||||
_, err = iamClient.DeleteUser(&iam.DeleteUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("update_user", func(t *testing.T) {
|
||||
userName := "user-to-update"
|
||||
newUserName := "user-updated"
|
||||
|
||||
// Create user
|
||||
_, err := iamClient.CreateUser(&iam.CreateUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
// Try to delete both just in case
|
||||
iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)})
|
||||
iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(newUserName)})
|
||||
}()
|
||||
|
||||
// Update user name
|
||||
_, err = iamClient.UpdateUser(&iam.UpdateUserInput{
|
||||
UserName: aws.String(userName),
|
||||
NewUserName: aws.String(newUserName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify update (GetUser with NEW name should work)
|
||||
getResp, err := iamClient.GetUser(&iam.GetUserInput{
|
||||
UserName: aws.String(newUserName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newUserName, *getResp.User.UserName)
|
||||
|
||||
// GetUser with OLD name should fail
|
||||
_, err = iamClient.GetUser(&iam.GetUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestIAMAccessKeyManagement tests access key operations
|
||||
func TestIAMAccessKeyManagement(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
userName := "test-user-keys"
|
||||
_, err = iamClient.CreateUser(&iam.CreateUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)})
|
||||
|
||||
t.Run("create_list_delete_access_key", func(t *testing.T) {
|
||||
// Create access key
|
||||
createResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, *createResp.AccessKey.AccessKeyId)
|
||||
assert.NotEmpty(t, *createResp.AccessKey.SecretAccessKey)
|
||||
assert.Equal(t, "Active", *createResp.AccessKey.Status)
|
||||
|
||||
// List access keys
|
||||
listResp, err := iamClient.ListAccessKeys(&iam.ListAccessKeysInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(listResp.AccessKeyMetadata))
|
||||
assert.Equal(t, *createResp.AccessKey.AccessKeyId, *listResp.AccessKeyMetadata[0].AccessKeyId)
|
||||
|
||||
// Delete access key
|
||||
_, err = iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{
|
||||
UserName: aws.String(userName),
|
||||
AccessKeyId: createResp.AccessKey.AccessKeyId,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify deletion
|
||||
listResp, err = iamClient.ListAccessKeys(&iam.ListAccessKeysInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(listResp.AccessKeyMetadata))
|
||||
})
|
||||
|
||||
t.Run("update_access_key_status", func(t *testing.T) {
|
||||
// Create access key
|
||||
createResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{
|
||||
UserName: aws.String(userName),
|
||||
AccessKeyId: createResp.AccessKey.AccessKeyId,
|
||||
})
|
||||
|
||||
// Update to Inactive
|
||||
_, err = iamClient.UpdateAccessKey(&iam.UpdateAccessKeyInput{
|
||||
UserName: aws.String(userName),
|
||||
AccessKeyId: createResp.AccessKey.AccessKeyId,
|
||||
Status: aws.String("Inactive"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify update in ListAccessKeys
|
||||
listResp, err := iamClient.ListAccessKeys(&iam.ListAccessKeysInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
found := false
|
||||
for _, key := range listResp.AccessKeyMetadata {
|
||||
if *key.AccessKeyId == *createResp.AccessKey.AccessKeyId {
|
||||
assert.Equal(t, "Inactive", *key.Status)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found)
|
||||
})
|
||||
}
|
||||
|
||||
// TestIAMPolicyManagement tests policy operations
|
||||
func TestIAMPolicyManagement(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("create_managed_policy", func(t *testing.T) {
|
||||
policyName := "test-managed-policy"
|
||||
policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"*"}]}`
|
||||
|
||||
createResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(policyDoc),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, policyName, *createResp.Policy.PolicyName)
|
||||
assert.NotEmpty(t, *createResp.Policy.Arn)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = iamClient.DeletePolicy(&iam.DeletePolicyInput{
|
||||
PolicyArn: createResp.Policy.Arn,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("managed_policy_crud_lifecycle", func(t *testing.T) {
|
||||
policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::*"}]}`
|
||||
|
||||
policyNames := []string{"test-managed-policy-lifecycle-a", "test-managed-policy-lifecycle-b"}
|
||||
policyArns := make([]*string, 0, len(policyNames))
|
||||
for _, policyName := range policyNames {
|
||||
createResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(policyDoc),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
policyArns = append(policyArns, createResp.Policy.Arn)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
for _, policyArn := range policyArns {
|
||||
_, _ = iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn})
|
||||
}
|
||||
})
|
||||
|
||||
listResp, err := iamClient.ListPolicies(&iam.ListPoliciesInput{})
|
||||
require.NoError(t, err)
|
||||
|
||||
foundByName := map[string]bool{}
|
||||
for _, policy := range listResp.Policies {
|
||||
if policy.PolicyName != nil {
|
||||
foundByName[*policy.PolicyName] = true
|
||||
}
|
||||
}
|
||||
for _, policyName := range policyNames {
|
||||
assert.True(t, foundByName[policyName], "policy %s should be listed", policyName)
|
||||
}
|
||||
|
||||
getResp, err := iamClient.GetPolicy(&iam.GetPolicyInput{PolicyArn: policyArns[0]})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, getResp.Policy)
|
||||
assert.Equal(t, policyNames[0], aws.StringValue(getResp.Policy.PolicyName))
|
||||
assert.Equal(t, aws.StringValue(policyArns[0]), aws.StringValue(getResp.Policy.Arn))
|
||||
|
||||
_, err = iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArns[0]})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = iamClient.GetPolicy(&iam.GetPolicyInput{PolicyArn: policyArns[0]})
|
||||
require.Error(t, err)
|
||||
awsErr, ok := err.(awserr.Error)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code())
|
||||
|
||||
listAfterDeleteResp, err := iamClient.ListPolicies(&iam.ListPoliciesInput{})
|
||||
require.NoError(t, err)
|
||||
deletedPolicyFound := false
|
||||
remainingPolicyFound := false
|
||||
for _, policy := range listAfterDeleteResp.Policies {
|
||||
if policy.PolicyName == nil {
|
||||
continue
|
||||
}
|
||||
if *policy.PolicyName == policyNames[0] {
|
||||
deletedPolicyFound = true
|
||||
}
|
||||
if *policy.PolicyName == policyNames[1] {
|
||||
remainingPolicyFound = true
|
||||
}
|
||||
}
|
||||
assert.False(t, deletedPolicyFound, "deleted policy should no longer be listed")
|
||||
assert.True(t, remainingPolicyFound, "remaining policy should still be listed")
|
||||
|
||||
policyArns[0] = nil
|
||||
})
|
||||
|
||||
t.Run("managed_policy_versions", func(t *testing.T) {
|
||||
policyName := "test-managed-policy-version"
|
||||
policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"*"}]}`
|
||||
|
||||
createResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(policyDoc),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: createResp.Policy.Arn})
|
||||
})
|
||||
|
||||
listVersionsResp, err := iamClient.ListPolicyVersions(&iam.ListPolicyVersionsInput{
|
||||
PolicyArn: createResp.Policy.Arn,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, listVersionsResp.Versions)
|
||||
assert.Equal(t, "v1", aws.StringValue(listVersionsResp.Versions[0].VersionId))
|
||||
assert.Equal(t, true, aws.BoolValue(listVersionsResp.Versions[0].IsDefaultVersion))
|
||||
|
||||
getVersionResp, err := iamClient.GetPolicyVersion(&iam.GetPolicyVersionInput{
|
||||
PolicyArn: createResp.Policy.Arn,
|
||||
VersionId: aws.String("v1"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, getVersionResp.PolicyVersion)
|
||||
assert.Equal(t, "v1", aws.StringValue(getVersionResp.PolicyVersion.VersionId))
|
||||
assert.Contains(t, aws.StringValue(getVersionResp.PolicyVersion.Document), "s3:ListBucket")
|
||||
|
||||
_, err = iamClient.GetPolicyVersion(&iam.GetPolicyVersionInput{
|
||||
PolicyArn: createResp.Policy.Arn,
|
||||
VersionId: aws.String("v2"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
awsErr, ok := err.(awserr.Error)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code())
|
||||
})
|
||||
|
||||
t.Run("user_inline_policy", func(t *testing.T) {
|
||||
userName := "test-user-policy"
|
||||
_, err := iamClient.CreateUser(&iam.CreateUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)})
|
||||
|
||||
// List user policies before any inline policy is attached
|
||||
listResp, err := iamClient.ListUserPolicies(&iam.ListUserPoliciesInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, listResp.PolicyNames, "New user should have no inline policies")
|
||||
|
||||
policyName := "test-inline-policy"
|
||||
policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::*"}]}`
|
||||
|
||||
// Put user policy
|
||||
_, err = iamClient.PutUserPolicy(&iam.PutUserPolicyInput{
|
||||
UserName: aws.String(userName),
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(policyDoc),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// List user policies after attaching inline policy
|
||||
listResp, err = iamClient.ListUserPolicies(&iam.ListUserPoliciesInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, listResp.PolicyNames, "User should have inline policies after PutUserPolicy")
|
||||
assert.False(t, *listResp.IsTruncated)
|
||||
|
||||
// Get user policy
|
||||
getResp, err := iamClient.GetUserPolicy(&iam.GetUserPolicyInput{
|
||||
UserName: aws.String(userName),
|
||||
PolicyName: aws.String(policyName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, userName, *getResp.UserName)
|
||||
assert.Equal(t, policyName, *getResp.PolicyName)
|
||||
assert.Contains(t, *getResp.PolicyDocument, "s3:Get")
|
||||
|
||||
// Delete user policy
|
||||
_, err = iamClient.DeleteUserPolicy(&iam.DeleteUserPolicyInput{
|
||||
UserName: aws.String(userName),
|
||||
PolicyName: aws.String(policyName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// List user policies after deletion
|
||||
listResp, err = iamClient.ListUserPolicies(&iam.ListUserPoliciesInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, listResp.PolicyNames, "User should have no inline policies after DeleteUserPolicy")
|
||||
})
|
||||
|
||||
t.Run("list_user_policies_nonexistent_user", func(t *testing.T) {
|
||||
_, err := iamClient.ListUserPolicies(&iam.ListUserPoliciesInput{
|
||||
UserName: aws.String("nonexistent-user-for-list-policies"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
awsErr, ok := err.(awserr.Error)
|
||||
require.True(t, ok, "Expected AWS error type")
|
||||
assert.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code())
|
||||
})
|
||||
}
|
||||
435
test/s3/iam/s3_iam_distributed_test.go
Normal file
435
test/s3/iam/s3_iam_distributed_test.go
Normal file
@@ -0,0 +1,435 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestS3IAMDistributedTests tests IAM functionality across multiple S3 gateway instances
|
||||
func TestS3IAMDistributedTests(t *testing.T) {
|
||||
// Skip if not in distributed test mode
|
||||
if os.Getenv("ENABLE_DISTRIBUTED_TESTS") != "true" {
|
||||
t.Skip("Distributed tests not enabled. Set ENABLE_DISTRIBUTED_TESTS=true")
|
||||
}
|
||||
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
t.Run("distributed_session_consistency", func(t *testing.T) {
|
||||
// Test that sessions created on one instance are visible on others
|
||||
// This requires filer-based session storage
|
||||
|
||||
// Create S3 clients that would connect to different gateway instances
|
||||
// In a real distributed setup, these would point to different S3 gateway ports
|
||||
client1, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
client2, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Both clients should be able to perform operations
|
||||
bucketName := "test-distributed-session"
|
||||
|
||||
err = framework.CreateBucket(client1, bucketName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Client2 should see the bucket created by client1
|
||||
// Retry logic for eventually consistent storage
|
||||
var found bool
|
||||
for i := 0; i < 20; i++ {
|
||||
listResult, err := client2.ListBuckets(&s3.ListBucketsInput{})
|
||||
require.NoError(t, err)
|
||||
|
||||
found = false
|
||||
for _, bucket := range listResult.Buckets {
|
||||
if *bucket.Name == bucketName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
break
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
assert.True(t, found, "Bucket should be visible across distributed instances")
|
||||
|
||||
// Cleanup
|
||||
_, err = client1.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("distributed_role_consistency", func(t *testing.T) {
|
||||
// Test that role definitions are consistent across instances
|
||||
// This requires filer-based role storage
|
||||
|
||||
// Create clients with different roles
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
readOnlyClient, err := framework.CreateS3ClientWithJWT("read-user", "TestReadOnlyRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := "test-distributed-roles"
|
||||
objectKey := "test-object.txt"
|
||||
|
||||
// Admin should be able to create bucket
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Admin should be able to put object
|
||||
err = framework.PutTestObject(adminClient, bucketName, objectKey, "test content")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Read-only user should be able to get object
|
||||
content, err := framework.GetTestObject(readOnlyClient, bucketName, objectKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test content", content)
|
||||
|
||||
// Read-only user should NOT be able to put object
|
||||
err = framework.PutTestObject(readOnlyClient, bucketName, "forbidden-object.txt", "forbidden content")
|
||||
require.Error(t, err, "Read-only user should not be able to put objects")
|
||||
|
||||
// Cleanup
|
||||
err = framework.DeleteTestObject(adminClient, bucketName, objectKey)
|
||||
require.NoError(t, err)
|
||||
_, err = adminClient.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("distributed_concurrent_operations", func(t *testing.T) {
|
||||
// Test concurrent operations across distributed instances with robust retry mechanisms
|
||||
// This approach implements proper retry logic instead of tolerating errors to catch real concurrency issues
|
||||
const numGoroutines = 3 // Reduced concurrency for better CI reliability
|
||||
const numOperationsPerGoroutine = 2 // Minimal operations per goroutine
|
||||
const maxRetries = 3 // Maximum retry attempts for transient failures
|
||||
const retryDelay = 200 * time.Millisecond // Increased delay for better stability
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, numGoroutines*numOperationsPerGoroutine)
|
||||
|
||||
// Helper function to determine if an error is retryable
|
||||
isRetryableError := func(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errorMsg := err.Error()
|
||||
return strings.Contains(errorMsg, "timeout") ||
|
||||
strings.Contains(errorMsg, "connection reset") ||
|
||||
strings.Contains(errorMsg, "connection refused") ||
|
||||
strings.Contains(errorMsg, "temporary failure") ||
|
||||
strings.Contains(errorMsg, "TooManyRequests") ||
|
||||
strings.Contains(errorMsg, "ServiceUnavailable") ||
|
||||
strings.Contains(errorMsg, "InternalError")
|
||||
}
|
||||
|
||||
// Helper function to execute operations with retry logic
|
||||
executeWithRetry := func(operation func() error, operationName string) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(retryDelay * time.Duration(attempt)) // Linear backoff
|
||||
}
|
||||
|
||||
lastErr = operation()
|
||||
if lastErr == nil {
|
||||
return nil // Success
|
||||
}
|
||||
|
||||
if !isRetryableError(lastErr) {
|
||||
// Non-retryable error - fail immediately
|
||||
return fmt.Errorf("%s failed with non-retryable error: %w", operationName, lastErr)
|
||||
}
|
||||
|
||||
// Retryable error - continue to next attempt
|
||||
if attempt < maxRetries {
|
||||
t.Logf("Retrying %s (attempt %d/%d) after error: %v", operationName, attempt+1, maxRetries, lastErr)
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
return fmt.Errorf("%s failed after %d retries, last error: %w", operationName, maxRetries, lastErr)
|
||||
}
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
|
||||
client, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
if err != nil {
|
||||
errors <- fmt.Errorf("failed to create S3 client for goroutine %d: %w", goroutineID, err)
|
||||
return
|
||||
}
|
||||
|
||||
for j := 0; j < numOperationsPerGoroutine; j++ {
|
||||
bucketName := fmt.Sprintf("test-concurrent-%d-%d", goroutineID, j)
|
||||
objectKey := "test-object.txt"
|
||||
objectContent := fmt.Sprintf("content-%d-%d", goroutineID, j)
|
||||
|
||||
// Execute full operation sequence with individual retries
|
||||
operationFailed := false
|
||||
|
||||
// 1. Create bucket with retry
|
||||
if err := executeWithRetry(func() error {
|
||||
return framework.CreateBucket(client, bucketName)
|
||||
}, fmt.Sprintf("CreateBucket-%s", bucketName)); err != nil {
|
||||
errors <- err
|
||||
operationFailed = true
|
||||
}
|
||||
|
||||
if !operationFailed {
|
||||
// 2. Put object with retry
|
||||
if err := executeWithRetry(func() error {
|
||||
return framework.PutTestObject(client, bucketName, objectKey, objectContent)
|
||||
}, fmt.Sprintf("PutObject-%s/%s", bucketName, objectKey)); err != nil {
|
||||
errors <- err
|
||||
operationFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !operationFailed {
|
||||
// 3. Get object with retry
|
||||
if err := executeWithRetry(func() error {
|
||||
_, err := framework.GetTestObject(client, bucketName, objectKey)
|
||||
return err
|
||||
}, fmt.Sprintf("GetObject-%s/%s", bucketName, objectKey)); err != nil {
|
||||
errors <- err
|
||||
operationFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !operationFailed {
|
||||
// 4. Delete object with retry
|
||||
if err := executeWithRetry(func() error {
|
||||
return framework.DeleteTestObject(client, bucketName, objectKey)
|
||||
}, fmt.Sprintf("DeleteObject-%s/%s", bucketName, objectKey)); err != nil {
|
||||
errors <- err
|
||||
operationFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Always attempt bucket cleanup, even if previous operations failed
|
||||
if err := executeWithRetry(func() error {
|
||||
_, err := client.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
return err
|
||||
}, fmt.Sprintf("DeleteBucket-%s", bucketName)); err != nil {
|
||||
// Only log cleanup failures, don't fail the test
|
||||
t.Logf("Warning: Failed to cleanup bucket %s: %v", bucketName, err)
|
||||
}
|
||||
|
||||
// Increased delay between operation sequences to reduce server load and improve stability
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
// Collect and analyze errors - with retry logic, we should see very few errors
|
||||
var errorList []error
|
||||
for err := range errors {
|
||||
errorList = append(errorList, err)
|
||||
}
|
||||
|
||||
totalOperations := numGoroutines * numOperationsPerGoroutine
|
||||
|
||||
// Report results
|
||||
if len(errorList) == 0 {
|
||||
t.Logf("All %d concurrent operations completed successfully with retry mechanisms!", totalOperations)
|
||||
} else {
|
||||
t.Logf("Concurrent operations summary:")
|
||||
t.Logf(" Total operations: %d", totalOperations)
|
||||
t.Logf(" Failed operations: %d (%.1f%% error rate)", len(errorList), float64(len(errorList))/float64(totalOperations)*100)
|
||||
|
||||
// Log first few errors for debugging
|
||||
for i, err := range errorList {
|
||||
if i >= 3 { // Limit to first 3 errors
|
||||
t.Logf(" ... and %d more errors", len(errorList)-3)
|
||||
break
|
||||
}
|
||||
t.Logf(" Error %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
// With proper retry mechanisms, we should expect near-zero failures
|
||||
// Any remaining errors likely indicate real concurrency issues or system problems
|
||||
if len(errorList) > 0 {
|
||||
t.Errorf("%d operation(s) failed even after retry mechanisms (%.1f%% failure rate). This indicates potential system issues or race conditions that need investigation.",
|
||||
len(errorList), float64(len(errorList))/float64(totalOperations)*100)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestS3IAMPerformanceTests tests IAM performance characteristics
|
||||
func TestS3IAMPerformanceTests(t *testing.T) {
|
||||
// Skip if not in performance test mode
|
||||
if os.Getenv("ENABLE_PERFORMANCE_TESTS") != "true" {
|
||||
t.Skip("Performance tests not enabled. Set ENABLE_PERFORMANCE_TESTS=true")
|
||||
}
|
||||
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
t.Run("authentication_performance", func(t *testing.T) {
|
||||
// Test authentication performance
|
||||
const numRequests = 100
|
||||
|
||||
client, err := framework.CreateS3ClientWithJWT("perf-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := "test-auth-performance"
|
||||
err = framework.CreateBucket(client, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_, err := client.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
for i := 0; i < numRequests; i++ {
|
||||
_, err := client.ListBuckets(&s3.ListBucketsInput{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
duration := time.Since(start)
|
||||
avgLatency := duration / numRequests
|
||||
|
||||
t.Logf("Authentication performance: %d requests in %v (avg: %v per request)",
|
||||
numRequests, duration, avgLatency)
|
||||
|
||||
// Performance assertion - should be under 100ms per request on average
|
||||
assert.Less(t, avgLatency, 100*time.Millisecond,
|
||||
"Average authentication latency should be under 100ms")
|
||||
})
|
||||
|
||||
t.Run("authorization_performance", func(t *testing.T) {
|
||||
// Test authorization performance with different policy complexities
|
||||
const numRequests = 50
|
||||
|
||||
client, err := framework.CreateS3ClientWithJWT("perf-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := "test-authz-performance"
|
||||
err = framework.CreateBucket(client, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_, err := client.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
for i := 0; i < numRequests; i++ {
|
||||
objectKey := fmt.Sprintf("perf-object-%d.txt", i)
|
||||
err := framework.PutTestObject(client, bucketName, objectKey, "performance test content")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = framework.GetTestObject(client, bucketName, objectKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = framework.DeleteTestObject(client, bucketName, objectKey)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
duration := time.Since(start)
|
||||
avgLatency := duration / (numRequests * 3) // 3 operations per iteration
|
||||
|
||||
t.Logf("Authorization performance: %d operations in %v (avg: %v per operation)",
|
||||
numRequests*3, duration, avgLatency)
|
||||
|
||||
// Performance assertion - should be under 50ms per operation on average
|
||||
assert.Less(t, avgLatency, 50*time.Millisecond,
|
||||
"Average authorization latency should be under 50ms")
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkS3IAMAuthentication benchmarks JWT authentication
|
||||
func BenchmarkS3IAMAuthentication(b *testing.B) {
|
||||
if os.Getenv("ENABLE_PERFORMANCE_TESTS") != "true" {
|
||||
b.Skip("Performance tests not enabled. Set ENABLE_PERFORMANCE_TESTS=true")
|
||||
}
|
||||
|
||||
framework := NewS3IAMTestFramework(&testing.T{})
|
||||
defer framework.Cleanup()
|
||||
|
||||
client, err := framework.CreateS3ClientWithJWT("bench-user", "TestAdminRole")
|
||||
require.NoError(b, err)
|
||||
|
||||
bucketName := "test-bench-auth"
|
||||
err = framework.CreateBucket(client, bucketName)
|
||||
require.NoError(b, err)
|
||||
defer func() {
|
||||
_, err := client.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(b, err)
|
||||
}()
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_, err := client.ListBuckets(&s3.ListBucketsInput{})
|
||||
if err != nil {
|
||||
b.Error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkS3IAMAuthorization benchmarks policy evaluation
|
||||
func BenchmarkS3IAMAuthorization(b *testing.B) {
|
||||
if os.Getenv("ENABLE_PERFORMANCE_TESTS") != "true" {
|
||||
b.Skip("Performance tests not enabled. Set ENABLE_PERFORMANCE_TESTS=true")
|
||||
}
|
||||
|
||||
framework := NewS3IAMTestFramework(&testing.T{})
|
||||
defer framework.Cleanup()
|
||||
|
||||
client, err := framework.CreateS3ClientWithJWT("bench-user", "TestAdminRole")
|
||||
require.NoError(b, err)
|
||||
|
||||
bucketName := "test-bench-authz"
|
||||
err = framework.CreateBucket(client, bucketName)
|
||||
require.NoError(b, err)
|
||||
defer func() {
|
||||
_, err := client.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(b, err)
|
||||
}()
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
objectKey := fmt.Sprintf("bench-object-%d.txt", i)
|
||||
err := framework.PutTestObject(client, bucketName, objectKey, "benchmark content")
|
||||
if err != nil {
|
||||
b.Error(err)
|
||||
}
|
||||
i++
|
||||
}
|
||||
})
|
||||
}
|
||||
982
test/s3/iam/s3_iam_framework.go
Normal file
982
test/s3/iam/s3_iam_framework.go
Normal file
@@ -0,0 +1,982 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptorand "crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
mathrand "math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
TestS3Endpoint = "http://localhost:8333"
|
||||
TestRegion = "us-west-2"
|
||||
|
||||
// Keycloak configuration
|
||||
DefaultKeycloakURL = "http://localhost:8080"
|
||||
KeycloakRealm = "seaweedfs-test"
|
||||
KeycloakClientID = "seaweedfs-s3"
|
||||
KeycloakClientSecret = "seaweedfs-s3-secret"
|
||||
)
|
||||
|
||||
// S3IAMTestFramework provides utilities for S3+IAM integration testing
|
||||
type S3IAMTestFramework struct {
|
||||
t *testing.T
|
||||
mockOIDC *httptest.Server
|
||||
privateKey *rsa.PrivateKey
|
||||
publicKey *rsa.PublicKey
|
||||
createdBuckets []string
|
||||
ctx context.Context
|
||||
keycloakClient *KeycloakClient
|
||||
useKeycloak bool
|
||||
}
|
||||
|
||||
// KeycloakClient handles authentication with Keycloak
|
||||
type KeycloakClient struct {
|
||||
baseURL string
|
||||
realm string
|
||||
clientID string
|
||||
clientSecret string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// KeycloakTokenResponse represents Keycloak token response
|
||||
type KeycloakTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// NewS3IAMTestFramework creates a new test framework instance
|
||||
func NewS3IAMTestFramework(t *testing.T) *S3IAMTestFramework {
|
||||
framework := &S3IAMTestFramework{
|
||||
t: t,
|
||||
ctx: context.Background(),
|
||||
createdBuckets: make([]string, 0),
|
||||
}
|
||||
|
||||
// Check if we should use Keycloak or mock OIDC
|
||||
keycloakURL := os.Getenv("KEYCLOAK_URL")
|
||||
if keycloakURL == "" {
|
||||
keycloakURL = DefaultKeycloakURL
|
||||
}
|
||||
|
||||
// Test if Keycloak is available
|
||||
framework.useKeycloak = framework.isKeycloakAvailable(keycloakURL)
|
||||
|
||||
if framework.useKeycloak {
|
||||
t.Logf("Using real Keycloak instance at %s", keycloakURL)
|
||||
framework.keycloakClient = NewKeycloakClient(keycloakURL, KeycloakRealm, KeycloakClientID, KeycloakClientSecret)
|
||||
} else {
|
||||
t.Logf("Using mock OIDC server for testing")
|
||||
// Generate RSA keys for JWT signing (mock mode)
|
||||
var err error
|
||||
framework.privateKey, err = rsa.GenerateKey(cryptorand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
framework.publicKey = &framework.privateKey.PublicKey
|
||||
|
||||
// Setup mock OIDC server
|
||||
framework.setupMockOIDCServer()
|
||||
}
|
||||
|
||||
return framework
|
||||
}
|
||||
|
||||
// NewKeycloakClient creates a new Keycloak client
|
||||
func NewKeycloakClient(baseURL, realm, clientID, clientSecret string) *KeycloakClient {
|
||||
return &KeycloakClient{
|
||||
baseURL: baseURL,
|
||||
realm: realm,
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// isKeycloakAvailable checks if Keycloak is running and accessible
|
||||
func (f *S3IAMTestFramework) isKeycloakAvailable(keycloakURL string) bool {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
// Use realms endpoint instead of health/ready for Keycloak v26+
|
||||
// First, verify master realm is reachable
|
||||
masterURL := fmt.Sprintf("%s/realms/master", keycloakURL)
|
||||
|
||||
resp, err := client.Get(masterURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
|
||||
// Also ensure the specific test realm exists; otherwise fall back to mock
|
||||
testRealmURL := fmt.Sprintf("%s/realms/%s", keycloakURL, KeycloakRealm)
|
||||
resp2, err := client.Get(testRealmURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
return resp2.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
// AuthenticateUser authenticates a user with Keycloak and returns an access token
|
||||
func (kc *KeycloakClient) AuthenticateUser(username, password string) (*KeycloakTokenResponse, error) {
|
||||
tokenURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token", kc.baseURL, kc.realm)
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "password")
|
||||
data.Set("client_id", kc.clientID)
|
||||
data.Set("client_secret", kc.clientSecret)
|
||||
data.Set("username", username)
|
||||
data.Set("password", password)
|
||||
data.Set("scope", "openid profile email")
|
||||
|
||||
resp, err := kc.httpClient.PostForm(tokenURL, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to authenticate with Keycloak: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
// Read the response body for debugging
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
bodyStr := ""
|
||||
if readErr == nil {
|
||||
bodyStr = string(body)
|
||||
}
|
||||
return nil, fmt.Errorf("Keycloak authentication failed with status: %d, response: %s", resp.StatusCode, bodyStr)
|
||||
}
|
||||
|
||||
var tokenResp KeycloakTokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode token response: %w", err)
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
// getKeycloakToken authenticates with Keycloak and returns a JWT token
|
||||
func (f *S3IAMTestFramework) getKeycloakToken(username string) (string, error) {
|
||||
if f.keycloakClient == nil {
|
||||
return "", fmt.Errorf("Keycloak client not initialized")
|
||||
}
|
||||
|
||||
// Map username to password for test users
|
||||
password := f.getTestUserPassword(username)
|
||||
if password == "" {
|
||||
return "", fmt.Errorf("unknown test user: %s", username)
|
||||
}
|
||||
|
||||
tokenResp, err := f.keycloakClient.AuthenticateUser(username, password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to authenticate user %s: %w", username, err)
|
||||
}
|
||||
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
// getTestUserPassword returns the password for test users
|
||||
func (f *S3IAMTestFramework) getTestUserPassword(username string) string {
|
||||
// Password generation matches setup_keycloak_docker.sh logic:
|
||||
// password="${username//[^a-zA-Z]/}123" (removes non-alphabetic chars + "123")
|
||||
userPasswords := map[string]string{
|
||||
"admin-user": "adminuser123", // "admin-user" -> "adminuser" + "123"
|
||||
"read-user": "readuser123", // "read-user" -> "readuser" + "123"
|
||||
"write-user": "writeuser123", // "write-user" -> "writeuser" + "123"
|
||||
"write-only-user": "writeonlyuser123", // "write-only-user" -> "writeonlyuser" + "123"
|
||||
}
|
||||
|
||||
return userPasswords[username]
|
||||
}
|
||||
|
||||
// setupMockOIDCServer creates a mock OIDC server for testing
|
||||
func (f *S3IAMTestFramework) setupMockOIDCServer() {
|
||||
|
||||
f.mockOIDC = 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",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{
|
||||
"issuer": "%s",
|
||||
"jwks_uri": "%s",
|
||||
"userinfo_endpoint": "%s"
|
||||
}`, config["issuer"], config["jwks_uri"], config["userinfo_endpoint"])
|
||||
|
||||
case "/jwks":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"kid": "test-key-id",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"n": "%s",
|
||||
"e": "AQAB"
|
||||
}
|
||||
]
|
||||
}`, f.encodePublicKey())
|
||||
|
||||
case "/userinfo":
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
userInfo := map[string]interface{}{
|
||||
"sub": "test-user",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"groups": []string{"users", "developers"},
|
||||
}
|
||||
|
||||
if strings.Contains(token, "admin") {
|
||||
userInfo["groups"] = []string{"admins"}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{
|
||||
"sub": "%s",
|
||||
"email": "%s",
|
||||
"name": "%s",
|
||||
"groups": %v
|
||||
}`, userInfo["sub"], userInfo["email"], userInfo["name"], userInfo["groups"])
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// encodePublicKey encodes the RSA public key for JWKS
|
||||
func (f *S3IAMTestFramework) encodePublicKey() string {
|
||||
return base64.RawURLEncoding.EncodeToString(f.publicKey.N.Bytes())
|
||||
}
|
||||
|
||||
// BearerTokenTransport is an HTTP transport that adds Bearer token authentication
|
||||
type BearerTokenTransport struct {
|
||||
Transport http.RoundTripper
|
||||
Token string
|
||||
}
|
||||
|
||||
// RoundTrip implements the http.RoundTripper interface
|
||||
func (t *BearerTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Clone the request to avoid modifying the original
|
||||
newReq := req.Clone(req.Context())
|
||||
|
||||
// Remove ALL existing Authorization headers first to prevent conflicts
|
||||
newReq.Header.Del("Authorization")
|
||||
newReq.Header.Del("X-Amz-Date")
|
||||
newReq.Header.Del("X-Amz-Content-Sha256")
|
||||
newReq.Header.Del("X-Amz-Signature")
|
||||
newReq.Header.Del("X-Amz-Algorithm")
|
||||
newReq.Header.Del("X-Amz-Credential")
|
||||
newReq.Header.Del("X-Amz-SignedHeaders")
|
||||
newReq.Header.Del("X-Amz-Security-Token")
|
||||
|
||||
// Add Bearer token authorization header
|
||||
newReq.Header.Set("Authorization", "Bearer "+t.Token)
|
||||
|
||||
// Extract and set the principal ARN from JWT token for security compliance
|
||||
if principal := t.extractPrincipalFromJWT(t.Token); principal != "" {
|
||||
newReq.Header.Set("X-SeaweedFS-Principal", principal)
|
||||
}
|
||||
|
||||
// Token preview for logging (first 50 chars for security)
|
||||
tokenPreview := t.Token
|
||||
if len(tokenPreview) > 50 {
|
||||
tokenPreview = tokenPreview[:50] + "..."
|
||||
}
|
||||
|
||||
// Use underlying transport
|
||||
transport := t.Transport
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
|
||||
return transport.RoundTrip(newReq)
|
||||
}
|
||||
|
||||
// extractPrincipalFromJWT extracts the principal ARN from a JWT token without validating it
|
||||
// This is used to set the X-SeaweedFS-Principal header that's required after our security fix
|
||||
func (t *BearerTokenTransport) extractPrincipalFromJWT(tokenString string) string {
|
||||
// Parse the JWT token without validation to extract the principal claim
|
||||
token, _ := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// We don't validate the signature here, just extract the claims
|
||||
// This is safe because the actual validation happens server-side
|
||||
return []byte("dummy-key"), nil
|
||||
})
|
||||
|
||||
// Even if parsing fails due to signature verification, we might still get claims
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok {
|
||||
// Try multiple possible claim names for the principal ARN
|
||||
if principal, exists := claims["principal"]; exists {
|
||||
if principalStr, ok := principal.(string); ok {
|
||||
return principalStr
|
||||
}
|
||||
}
|
||||
if assumed, exists := claims["assumed"]; exists {
|
||||
if assumedStr, ok := assumed.(string); ok {
|
||||
return assumedStr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// generateSTSSessionToken creates a session token using the actual STS service for proper validation
|
||||
func (f *S3IAMTestFramework) generateSTSSessionToken(username, roleName string, validDuration time.Duration, account string, customClaims map[string]interface{}) (string, error) {
|
||||
now := time.Now()
|
||||
signingKeyB64 := "dGVzdC1zaWduaW5nLWtleS0zMi1jaGFyYWN0ZXJzLWxvbmc="
|
||||
signingKey, err := base64.StdEncoding.DecodeString(signingKeyB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode signing key: %v", err)
|
||||
}
|
||||
|
||||
// Generate a session ID that would be created by the STS service
|
||||
sessionId := fmt.Sprintf("test-session-%s-%s-%d", username, roleName, now.Unix())
|
||||
|
||||
if account == "" {
|
||||
account = "123456789012" // Default test account
|
||||
}
|
||||
|
||||
// Create session token claims exactly matching STSSessionClaims struct
|
||||
roleArn := fmt.Sprintf("arn:aws:iam::%s:role/%s", account, roleName)
|
||||
sessionName := username
|
||||
principalArn := fmt.Sprintf("arn:aws:sts::%s:assumed-role/%s/%s", account, roleName, sessionName)
|
||||
|
||||
// Use jwt.MapClaims but with exact field names that STSSessionClaims expects
|
||||
sessionClaims := jwt.MapClaims{
|
||||
// RegisteredClaims fields
|
||||
"iss": "seaweedfs-sts",
|
||||
"sub": sessionId,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(validDuration).Unix(),
|
||||
"nbf": now.Unix(),
|
||||
|
||||
// STSSessionClaims fields (using exact JSON tags from the struct)
|
||||
"sid": sessionId, // SessionId
|
||||
"snam": sessionName, // SessionName
|
||||
"typ": "session", // TokenType
|
||||
"role": roleArn, // RoleArn
|
||||
"assumed": principalArn, // AssumedRole
|
||||
"principal": principalArn, // Principal
|
||||
"idp": "test-oidc", // IdentityProvider
|
||||
"ext_uid": username, // ExternalUserId
|
||||
"assumed_at": now.Format(time.RFC3339Nano), // AssumedAt
|
||||
"max_dur": int64(validDuration.Seconds()), // MaxDuration
|
||||
}
|
||||
|
||||
// Add custom claims (e.g., for ldap:* or jwt:* testing)
|
||||
for k, v := range customClaims {
|
||||
sessionClaims[k] = v
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims)
|
||||
tokenString, err := token.SignedString(signingKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// CreateS3ClientWithJWT creates an S3 client authenticated with a JWT token for the specified role
|
||||
func (f *S3IAMTestFramework) CreateS3ClientWithJWT(username, roleName string) (*s3.S3, error) {
|
||||
return f.CreateS3ClientWithCustomClaims(username, roleName, "", nil)
|
||||
}
|
||||
|
||||
// CreateS3ClientWithCustomClaims creates an S3 client with specific account ID and custom claims
|
||||
func (f *S3IAMTestFramework) CreateS3ClientWithCustomClaims(username, roleName, account string, claims map[string]interface{}) (*s3.S3, error) {
|
||||
var token string
|
||||
var err error
|
||||
|
||||
if f.useKeycloak && claims == nil && account == "" {
|
||||
// Use real Keycloak authentication if no custom requirements
|
||||
token, err = f.getKeycloakToken(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Keycloak token: %v", err)
|
||||
}
|
||||
} else {
|
||||
// Generate STS session token (mock mode or custom requirements)
|
||||
token, err = f.generateSTSSessionToken(username, roleName, time.Hour, account, claims)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate STS session token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create custom HTTP client with Bearer token transport
|
||||
httpClient := &http.Client{
|
||||
Transport: &BearerTokenTransport{
|
||||
Token: token,
|
||||
},
|
||||
}
|
||||
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String(TestRegion),
|
||||
Endpoint: aws.String(TestS3Endpoint),
|
||||
HTTPClient: httpClient,
|
||||
// Use anonymous credentials to avoid AWS signature generation
|
||||
Credentials: credentials.AnonymousCredentials,
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AWS session: %v", err)
|
||||
}
|
||||
|
||||
return s3.New(sess), nil
|
||||
}
|
||||
|
||||
// CreateS3ClientWithInvalidJWT creates an S3 client with an invalid JWT token
|
||||
func (f *S3IAMTestFramework) CreateS3ClientWithInvalidJWT() (*s3.S3, error) {
|
||||
invalidToken := "invalid.jwt.token"
|
||||
|
||||
// Create custom HTTP client with Bearer token transport
|
||||
httpClient := &http.Client{
|
||||
Transport: &BearerTokenTransport{
|
||||
Token: invalidToken,
|
||||
},
|
||||
}
|
||||
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String(TestRegion),
|
||||
Endpoint: aws.String(TestS3Endpoint),
|
||||
HTTPClient: httpClient,
|
||||
// Use anonymous credentials to avoid AWS signature generation
|
||||
Credentials: credentials.AnonymousCredentials,
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AWS session: %v", err)
|
||||
}
|
||||
|
||||
return s3.New(sess), nil
|
||||
}
|
||||
|
||||
// CreateS3ClientWithExpiredJWT creates an S3 client with an expired JWT token
|
||||
func (f *S3IAMTestFramework) CreateS3ClientWithExpiredJWT(username, roleName string) (*s3.S3, error) {
|
||||
// Generate expired STS session token (expired 1 hour ago)
|
||||
token, err := f.generateSTSSessionToken(username, roleName, -time.Hour, "", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate expired STS session token: %v", err)
|
||||
}
|
||||
|
||||
// Create custom HTTP client with Bearer token transport
|
||||
httpClient := &http.Client{
|
||||
Transport: &BearerTokenTransport{
|
||||
Token: token,
|
||||
},
|
||||
}
|
||||
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String(TestRegion),
|
||||
Endpoint: aws.String(TestS3Endpoint),
|
||||
HTTPClient: httpClient,
|
||||
// Use anonymous credentials to avoid AWS signature generation
|
||||
Credentials: credentials.AnonymousCredentials,
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AWS session: %v", err)
|
||||
}
|
||||
|
||||
return s3.New(sess), nil
|
||||
}
|
||||
|
||||
// CreateS3ClientWithSessionToken creates an S3 client with a session token
|
||||
func (f *S3IAMTestFramework) CreateS3ClientWithSessionToken(sessionToken string) (*s3.S3, error) {
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String(TestRegion),
|
||||
Endpoint: aws.String(TestS3Endpoint),
|
||||
Credentials: credentials.NewStaticCredentials(
|
||||
"session-access-key",
|
||||
"session-secret-key",
|
||||
sessionToken,
|
||||
),
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AWS session: %v", err)
|
||||
}
|
||||
|
||||
return s3.New(sess), nil
|
||||
}
|
||||
|
||||
// CreateS3ClientWithKeycloakToken creates an S3 client using a Keycloak JWT token
|
||||
func (f *S3IAMTestFramework) CreateS3ClientWithKeycloakToken(keycloakToken string) (*s3.S3, error) {
|
||||
// Determine response header timeout based on environment
|
||||
responseHeaderTimeout := 10 * time.Second
|
||||
overallTimeout := 30 * time.Second
|
||||
if os.Getenv("GITHUB_ACTIONS") == "true" {
|
||||
responseHeaderTimeout = 30 * time.Second // Longer timeout for CI JWT validation
|
||||
overallTimeout = 60 * time.Second
|
||||
}
|
||||
|
||||
// Create a fresh HTTP transport with appropriate timeouts
|
||||
transport := &http.Transport{
|
||||
DisableKeepAlives: true, // Force new connections for each request
|
||||
DisableCompression: true, // Disable compression to simplify requests
|
||||
MaxIdleConns: 0, // No connection pooling
|
||||
MaxIdleConnsPerHost: 0, // No connection pooling per host
|
||||
IdleConnTimeout: 1 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: responseHeaderTimeout, // Adjustable for CI environments
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
// Create a custom HTTP client with appropriate timeouts
|
||||
httpClient := &http.Client{
|
||||
Timeout: overallTimeout, // Overall request timeout (adjustable for CI)
|
||||
Transport: &BearerTokenTransport{
|
||||
Token: keycloakToken,
|
||||
Transport: transport,
|
||||
},
|
||||
}
|
||||
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String(TestRegion),
|
||||
Endpoint: aws.String(TestS3Endpoint),
|
||||
Credentials: credentials.AnonymousCredentials,
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
HTTPClient: httpClient,
|
||||
MaxRetries: aws.Int(0), // No retries to avoid delays
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AWS session: %v", err)
|
||||
}
|
||||
|
||||
return s3.New(sess), nil
|
||||
}
|
||||
|
||||
// TestKeycloakTokenDirectly tests a Keycloak token with direct HTTP request (bypassing AWS SDK)
|
||||
func (f *S3IAMTestFramework) TestKeycloakTokenDirectly(keycloakToken string) error {
|
||||
// Create a simple HTTP client with timeout
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// Create request to list buckets
|
||||
req, err := http.NewRequest("GET", TestS3Endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
// Add Bearer token
|
||||
req.Header.Set("Authorization", "Bearer "+keycloakToken)
|
||||
req.Header.Set("Host", "localhost:8333")
|
||||
|
||||
// Make request
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response
|
||||
_, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateJWTToken creates a JWT token for testing
|
||||
func (f *S3IAMTestFramework) generateJWTToken(username, roleName string, validDuration time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"iss": f.mockOIDC.URL,
|
||||
"aud": "test-client",
|
||||
"exp": now.Add(validDuration).Unix(),
|
||||
"iat": now.Unix(),
|
||||
"email": username + "@example.com",
|
||||
"name": strings.Title(username),
|
||||
}
|
||||
|
||||
// Add role-specific groups
|
||||
switch roleName {
|
||||
case "TestAdminRole":
|
||||
claims["groups"] = []string{"admins"}
|
||||
case "TestReadOnlyRole":
|
||||
claims["groups"] = []string{"users"}
|
||||
case "TestWriteOnlyRole":
|
||||
claims["groups"] = []string{"writers"}
|
||||
default:
|
||||
claims["groups"] = []string{"users"}
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
token.Header["kid"] = "test-key-id"
|
||||
|
||||
tokenString, err := token.SignedString(f.privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign token: %v", err)
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// CreateShortLivedSessionToken creates a mock session token for testing
|
||||
func (f *S3IAMTestFramework) CreateShortLivedSessionToken(username, roleName string, durationSeconds int64) (string, error) {
|
||||
// For testing purposes, create a mock session token
|
||||
// In reality, this would be generated by the STS service
|
||||
return fmt.Sprintf("mock-session-token-%s-%s-%d", username, roleName, time.Now().Unix()), nil
|
||||
}
|
||||
|
||||
// ExpireSessionForTesting simulates session expiration for testing
|
||||
func (f *S3IAMTestFramework) ExpireSessionForTesting(sessionToken string) error {
|
||||
// For integration tests, this would typically involve calling the STS service
|
||||
// For now, we just simulate success since the actual expiration will be handled by SeaweedFS
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateUniqueBucketName generates a unique bucket name for testing
|
||||
func (f *S3IAMTestFramework) GenerateUniqueBucketName(prefix string) string {
|
||||
// Use test name and timestamp to ensure uniqueness
|
||||
testName := strings.ToLower(f.t.Name())
|
||||
testName = strings.ReplaceAll(testName, "/", "-")
|
||||
testName = strings.ReplaceAll(testName, "_", "-")
|
||||
|
||||
// Truncate test name to keep total length under 63 characters
|
||||
// S3 bucket names must be 3-63 characters, lowercase, no underscores
|
||||
// Format: prefix-testname-random (need room for random suffix)
|
||||
maxTestNameLen := 63 - len(prefix) - 5 - 4 // account for dashes and random suffix
|
||||
if len(testName) > maxTestNameLen {
|
||||
testName = testName[:maxTestNameLen]
|
||||
}
|
||||
|
||||
// Add random suffix to handle parallel tests
|
||||
randomSuffix := mathrand.Intn(10000)
|
||||
|
||||
bucketName := fmt.Sprintf("%s-%s-%d", prefix, testName, randomSuffix)
|
||||
|
||||
// Ensure final name is valid
|
||||
if len(bucketName) > 63 {
|
||||
// Truncate further if necessary
|
||||
bucketName = bucketName[:63]
|
||||
}
|
||||
|
||||
return bucketName
|
||||
}
|
||||
|
||||
// CreateBucket creates a bucket and tracks it for cleanup
|
||||
func (f *S3IAMTestFramework) CreateBucket(s3Client *s3.S3, bucketName string) error {
|
||||
_, err := s3Client.CreateBucket(&s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Track bucket for cleanup
|
||||
f.createdBuckets = append(f.createdBuckets, bucketName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateBucketWithCleanup creates a bucket, cleaning up any existing bucket first
|
||||
func (f *S3IAMTestFramework) CreateBucketWithCleanup(s3Client *s3.S3, bucketName string) error {
|
||||
// First try to create the bucket normally
|
||||
_, err := s3Client.CreateBucket(&s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// If bucket already exists, clean it up first
|
||||
if awsErr, ok := err.(awserr.Error); ok && (awsErr.Code() == "BucketAlreadyExists" || awsErr.Code() == "BucketAlreadyOwnedByYou") {
|
||||
f.t.Logf("Bucket %s already exists, cleaning up first", bucketName)
|
||||
|
||||
// First try to delete the bucket completely
|
||||
f.emptyBucket(s3Client, bucketName)
|
||||
_, deleteErr := s3Client.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
if deleteErr != nil {
|
||||
f.t.Logf("Warning: Failed to delete existing bucket %s: %v", bucketName, deleteErr)
|
||||
}
|
||||
|
||||
// Add a small delay to allow deletion to propagate
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Now create it fresh
|
||||
_, err = s3Client.CreateBucket(&s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
if err != nil {
|
||||
// If it still says bucket exists after cleanup, it might be in an inconsistent state
|
||||
// In this case, just use the existing bucket since we emptied it
|
||||
if awsErr, ok := err.(awserr.Error); ok && (awsErr.Code() == "BucketAlreadyExists" || awsErr.Code() == "BucketAlreadyOwnedByYou") {
|
||||
f.t.Logf("Bucket %s still exists after cleanup, reusing it", bucketName)
|
||||
// Bucket exists and is empty, so we can proceed
|
||||
} else {
|
||||
return fmt.Errorf("failed to recreate bucket after cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Track bucket for cleanup
|
||||
f.createdBuckets = append(f.createdBuckets, bucketName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// emptyBucket removes all objects from a bucket
|
||||
func (f *S3IAMTestFramework) emptyBucket(s3Client *s3.S3, bucketName string) {
|
||||
// Delete all objects
|
||||
listResult, err := s3Client.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
if err == nil {
|
||||
for _, obj := range listResult.Contents {
|
||||
_, err := s3Client.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: obj.Key,
|
||||
})
|
||||
if err != nil {
|
||||
f.t.Logf("Warning: Failed to delete object %s/%s: %v", bucketName, *obj.Key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup cleans up test resources
|
||||
func (f *S3IAMTestFramework) Cleanup() {
|
||||
// Clean up buckets (best effort)
|
||||
if len(f.createdBuckets) > 0 {
|
||||
// Create admin client for cleanup
|
||||
adminClient, err := f.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
if err == nil {
|
||||
for _, bucket := range f.createdBuckets {
|
||||
// Try to empty bucket first
|
||||
listResult, err := adminClient.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucket),
|
||||
})
|
||||
if err == nil {
|
||||
for _, obj := range listResult.Contents {
|
||||
adminClient.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: obj.Key,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Delete bucket
|
||||
adminClient.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucket),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close mock OIDC server
|
||||
if f.mockOIDC != nil {
|
||||
f.mockOIDC.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForS3Service waits for the S3 service to be available and checks for IAM write permissions
|
||||
func (f *S3IAMTestFramework) WaitForS3Service() error {
|
||||
// Create a basic S3 client
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String(TestRegion),
|
||||
Endpoint: aws.String(TestS3Endpoint),
|
||||
Credentials: credentials.NewStaticCredentials(
|
||||
"test-access-key",
|
||||
"test-secret-key",
|
||||
"",
|
||||
),
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create AWS session: %v", err)
|
||||
}
|
||||
|
||||
s3Client := s3.New(sess)
|
||||
|
||||
// Create IAM client for write permission check
|
||||
iamClient := iam.New(sess)
|
||||
|
||||
// Try to list buckets to check if S3 service is available
|
||||
maxRetries := 30
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
_, err := s3Client.ListBuckets(&s3.ListBucketsInput{})
|
||||
if err == nil {
|
||||
// S3 is up, now check if IAM is writable
|
||||
// We try to create a dummy user. If it fails with "AccessDenied: IAM write operations are disabled",
|
||||
// we know we are still in read-only mode (or the flag didn't take effect).
|
||||
// If it fails with other errors (e.g. invalid auth), that's fine for this connectivity check.
|
||||
// Only the explicit read-only error is a blocker for our specific test scenario.
|
||||
|
||||
// Note: We use a random name to avoid conflicts if it actually succeeds
|
||||
dummyUser := fmt.Sprintf("check-writable-%d", time.Now().UnixNano())
|
||||
_, iamErr := iamClient.CreateUser(&iam.CreateUserInput{
|
||||
UserName: aws.String(dummyUser),
|
||||
})
|
||||
|
||||
if iamErr != nil {
|
||||
if reqErr, ok := iamErr.(awserr.RequestFailure); ok {
|
||||
if reqErr.Code() == "AccessDenied" && strings.Contains(reqErr.Message(), "IAM write operations are disabled") {
|
||||
f.t.Logf("Waiting for IAM to become writable... (attempt %d/%d)", i+1, maxRetries)
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Ignore other errors (like auth errors), we just want to ensure we aren't explicitly blocked by read-only mode
|
||||
} else {
|
||||
// Cleanup if it actually succeeded
|
||||
iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(dummyUser)})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
|
||||
return fmt.Errorf("S3 service not available or not writable after %d retries", maxRetries)
|
||||
}
|
||||
|
||||
// PutTestObject puts a test object in the specified bucket
|
||||
func (f *S3IAMTestFramework) PutTestObject(client *s3.S3, bucket, key, content string) error {
|
||||
_, err := client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
Body: strings.NewReader(content),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// GetTestObject retrieves a test object from the specified bucket
|
||||
func (f *S3IAMTestFramework) GetTestObject(client *s3.S3, bucket, key string) (string, error) {
|
||||
result, err := client.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer result.Body.Close()
|
||||
|
||||
content := strings.Builder{}
|
||||
_, err = io.Copy(&content, result.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return content.String(), nil
|
||||
}
|
||||
|
||||
// ListTestObjects lists objects in the specified bucket
|
||||
func (f *S3IAMTestFramework) ListTestObjects(client *s3.S3, bucket string) ([]string, error) {
|
||||
result, err := client.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucket),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var keys []string
|
||||
for _, obj := range result.Contents {
|
||||
keys = append(keys, *obj.Key)
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// DeleteTestObject deletes a test object from the specified bucket
|
||||
func (f *S3IAMTestFramework) DeleteTestObject(client *s3.S3, bucket, key string) error {
|
||||
_, err := client.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// WaitForS3Service waits for the S3 service to be available (simplified version)
|
||||
func (f *S3IAMTestFramework) WaitForS3ServiceSimple() error {
|
||||
// This is a simplified version that just checks if the endpoint responds
|
||||
// The full implementation would be in the Makefile's wait-for-services target
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateIAMClientWithJWT creates an IAM client authenticated with a JWT token for the specified role
|
||||
func (f *S3IAMTestFramework) CreateIAMClientWithJWT(username, roleName string) (*iam.IAM, error) {
|
||||
return f.CreateIAMClientWithCustomClaims(username, roleName, "", nil)
|
||||
}
|
||||
|
||||
// CreateIAMClientWithCustomClaims creates an IAM client with specific account ID and custom claims
|
||||
func (f *S3IAMTestFramework) CreateIAMClientWithCustomClaims(username, roleName, account string, claims map[string]interface{}) (*iam.IAM, error) {
|
||||
var token string
|
||||
var err error
|
||||
|
||||
if f.useKeycloak && claims == nil && account == "" {
|
||||
// Use real Keycloak authentication if no custom requirements
|
||||
token, err = f.getKeycloakToken(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Keycloak token: %v", err)
|
||||
}
|
||||
} else {
|
||||
// Generate STS session token (mock mode or custom requirements)
|
||||
token, err = f.generateSTSSessionToken(username, roleName, time.Hour, account, claims)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate STS session token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create custom HTTP client with Bearer token transport
|
||||
httpClient := &http.Client{
|
||||
Transport: &BearerTokenTransport{
|
||||
Token: token,
|
||||
},
|
||||
}
|
||||
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String(TestRegion),
|
||||
Endpoint: aws.String(TestS3Endpoint),
|
||||
HTTPClient: httpClient,
|
||||
// Use anonymous credentials to avoid AWS signature generation
|
||||
Credentials: credentials.AnonymousCredentials,
|
||||
DisableSSL: aws.Bool(true),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AWS session: %v", err)
|
||||
}
|
||||
|
||||
return iam.New(sess), nil
|
||||
}
|
||||
891
test/s3/iam/s3_iam_group_test.go
Normal file
891
test/s3/iam/s3_iam_group_test.go
Normal file
@@ -0,0 +1,891 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestIAMGroupLifecycle tests the full lifecycle of group management:
|
||||
// CreateGroup, GetGroup, ListGroups, DeleteGroup
|
||||
func TestIAMGroupLifecycle(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
groupName := "test-group-lifecycle"
|
||||
|
||||
t.Run("create_group", func(t *testing.T) {
|
||||
resp, err := iamClient.CreateGroup(&iam.CreateGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, groupName, *resp.Group.GroupName)
|
||||
})
|
||||
|
||||
t.Run("get_group", func(t *testing.T) {
|
||||
resp, err := iamClient.GetGroup(&iam.GetGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, groupName, *resp.Group.GroupName)
|
||||
})
|
||||
|
||||
t.Run("list_groups_contains_created", func(t *testing.T) {
|
||||
resp, err := iamClient.ListGroups(&iam.ListGroupsInput{})
|
||||
require.NoError(t, err)
|
||||
found := false
|
||||
for _, g := range resp.Groups {
|
||||
if *g.GroupName == groupName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Created group should appear in ListGroups")
|
||||
})
|
||||
|
||||
t.Run("create_duplicate_group_fails", func(t *testing.T) {
|
||||
_, err := iamClient.CreateGroup(&iam.CreateGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
assert.Error(t, err, "Creating a duplicate group should fail")
|
||||
})
|
||||
|
||||
t.Run("delete_group", func(t *testing.T) {
|
||||
_, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's gone
|
||||
resp, err := iamClient.ListGroups(&iam.ListGroupsInput{})
|
||||
require.NoError(t, err)
|
||||
for _, g := range resp.Groups {
|
||||
assert.NotEqual(t, groupName, *g.GroupName,
|
||||
"Deleted group should not appear in ListGroups")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete_nonexistent_group_fails", func(t *testing.T) {
|
||||
_, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{
|
||||
GroupName: aws.String("nonexistent-group-xyz"),
|
||||
})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestIAMGroupMembership tests adding and removing users from groups
|
||||
func TestIAMGroupMembership(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
groupName := "test-group-members"
|
||||
userName := "test-user-for-group"
|
||||
|
||||
// Setup: create group and user
|
||||
_, err = iamClient.CreateGroup(&iam.CreateGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)})
|
||||
|
||||
_, err = iamClient.CreateUser(&iam.CreateUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)})
|
||||
|
||||
t.Run("add_user_to_group", func(t *testing.T) {
|
||||
_, err := iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("get_group_shows_member", func(t *testing.T) {
|
||||
resp, err := iamClient.GetGroup(&iam.GetGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
found := false
|
||||
for _, u := range resp.Users {
|
||||
if *u.UserName == userName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Added user should appear in GetGroup members")
|
||||
})
|
||||
|
||||
t.Run("list_groups_for_user", func(t *testing.T) {
|
||||
resp, err := iamClient.ListGroupsForUser(&iam.ListGroupsForUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
found := false
|
||||
for _, g := range resp.Groups {
|
||||
if *g.GroupName == groupName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Group should appear in ListGroupsForUser")
|
||||
})
|
||||
|
||||
t.Run("add_duplicate_member_is_idempotent", func(t *testing.T) {
|
||||
_, err := iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
// Should succeed (idempotent) or return a benign error
|
||||
// AWS IAM allows duplicate add without error
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("remove_user_from_group", func(t *testing.T) {
|
||||
_, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify removal
|
||||
resp, err := iamClient.GetGroup(&iam.GetGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
for _, u := range resp.Users {
|
||||
assert.NotEqual(t, userName, *u.UserName,
|
||||
"Removed user should not appear in group members")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestIAMGroupPolicyAttachment tests attaching and detaching policies from groups
|
||||
func TestIAMGroupPolicyAttachment(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
groupName := "test-group-policies"
|
||||
policyName := "test-group-attach-policy"
|
||||
policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"*"}]}`
|
||||
|
||||
// Setup: create group and policy
|
||||
_, err = iamClient.CreateGroup(&iam.CreateGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
createPolicyResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(policyDoc),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
policyArn := createPolicyResp.Policy.Arn
|
||||
|
||||
// Cleanup in correct order: detach policy, delete group, delete policy
|
||||
t.Cleanup(func() {
|
||||
if _, err := iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyArn: policyArn,
|
||||
}); err != nil {
|
||||
t.Logf("cleanup: failed to detach group policy: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}); err != nil {
|
||||
t.Logf("cleanup: failed to delete group: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn}); err != nil {
|
||||
t.Logf("cleanup: failed to delete policy: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("attach_group_policy", func(t *testing.T) {
|
||||
_, err := iamClient.AttachGroupPolicy(&iam.AttachGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyArn: policyArn,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("list_attached_group_policies", func(t *testing.T) {
|
||||
resp, err := iamClient.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
found := false
|
||||
for _, p := range resp.AttachedPolicies {
|
||||
if *p.PolicyName == policyName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Attached policy should appear in ListAttachedGroupPolicies")
|
||||
})
|
||||
|
||||
t.Run("detach_group_policy", func(t *testing.T) {
|
||||
_, err := iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyArn: policyArn,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify detachment
|
||||
resp, err := iamClient.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
for _, p := range resp.AttachedPolicies {
|
||||
assert.NotEqual(t, policyName, *p.PolicyName,
|
||||
"Detached policy should not appear in ListAttachedGroupPolicies")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestIAMGroupPolicyEnforcement tests that group policies are enforced during S3 operations.
|
||||
// Creates a user with no direct policies, adds them to a group with S3 access,
|
||||
// and verifies they can access S3 through the group policy.
|
||||
func TestIAMGroupPolicyEnforcement(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
groupName := "test-enforcement-group"
|
||||
userName := "test-enforcement-user"
|
||||
policyName := "test-enforcement-policy"
|
||||
bucketName := "test-group-enforce-bucket"
|
||||
policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::` + bucketName + `","arn:aws:s3:::` + bucketName + `/*"]}]}`
|
||||
|
||||
// Create user
|
||||
_, err = iamClient.CreateUser(&iam.CreateUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create access key for the user
|
||||
keyResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
accessKeyId := *keyResp.AccessKey.AccessKeyId
|
||||
secretKey := *keyResp.AccessKey.SecretAccessKey
|
||||
|
||||
// Create an S3 client with the user's credentials
|
||||
userS3Client := createS3Client(t, accessKeyId, secretKey)
|
||||
|
||||
// Create group
|
||||
_, err = iamClient.CreateGroup(&iam.CreateGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create policy
|
||||
createPolicyResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(policyDoc),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
policyArn := createPolicyResp.Policy.Arn
|
||||
|
||||
// Cleanup in correct order: remove user from group, detach policy,
|
||||
// delete access key, delete user, delete group, delete policy
|
||||
t.Cleanup(func() {
|
||||
if _, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
UserName: aws.String(userName),
|
||||
}); err != nil {
|
||||
t.Logf("cleanup: failed to remove user from group: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyArn: policyArn,
|
||||
}); err != nil {
|
||||
t.Logf("cleanup: failed to detach group policy: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{
|
||||
UserName: aws.String(userName),
|
||||
AccessKeyId: keyResp.AccessKey.AccessKeyId,
|
||||
}); err != nil {
|
||||
t.Logf("cleanup: failed to delete access key: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}); err != nil {
|
||||
t.Logf("cleanup: failed to delete user: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}); err != nil {
|
||||
t.Logf("cleanup: failed to delete group: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn}); err != nil {
|
||||
t.Logf("cleanup: failed to delete policy: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Register bucket cleanup on parent test with admin credentials
|
||||
// (userS3Client may lack permissions by cleanup time)
|
||||
adminS3, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if _, err := adminS3.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("test-key"),
|
||||
}); err != nil {
|
||||
t.Logf("cleanup: failed to delete object: %v", err)
|
||||
}
|
||||
if _, err := adminS3.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}); err != nil {
|
||||
t.Logf("cleanup: failed to delete bucket: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user_without_group_denied", func(t *testing.T) {
|
||||
// User has no policies and is not in any group — should be denied
|
||||
_, err := userS3Client.CreateBucket(&s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.Error(t, err, "User without any policies should be denied")
|
||||
awsErr, ok := err.(awserr.Error)
|
||||
require.True(t, ok, "Expected awserr.Error")
|
||||
assert.Equal(t, "AccessDenied", awsErr.Code())
|
||||
})
|
||||
|
||||
t.Run("user_with_group_policy_allowed", func(t *testing.T) {
|
||||
// Attach policy to group
|
||||
_, err := iamClient.AttachGroupPolicy(&iam.AttachGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyArn: policyArn,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add user to group
|
||||
_, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for policy propagation, then create bucket
|
||||
require.Eventually(t, func() bool {
|
||||
_, err = userS3Client.CreateBucket(&s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
return err == nil
|
||||
}, 10*time.Second, 500*time.Millisecond, "User with group policy should be allowed")
|
||||
|
||||
// Should also be able to put/get objects
|
||||
_, err = userS3Client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("test-key"),
|
||||
Body: aws.ReadSeekCloser(strings.NewReader("test-data")),
|
||||
})
|
||||
require.NoError(t, err, "User should be able to put objects through group policy")
|
||||
})
|
||||
|
||||
t.Run("user_removed_from_group_denied", func(t *testing.T) {
|
||||
// Remove user from group
|
||||
_, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for policy propagation — user should now be denied
|
||||
var lastErr error
|
||||
require.Eventually(t, func() bool {
|
||||
_, lastErr = userS3Client.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
return lastErr != nil
|
||||
}, 10*time.Second, 500*time.Millisecond, "User removed from group should be denied")
|
||||
awsErr, ok := lastErr.(awserr.Error)
|
||||
require.True(t, ok, "Expected awserr.Error")
|
||||
assert.Equal(t, "AccessDenied", awsErr.Code())
|
||||
})
|
||||
}
|
||||
|
||||
// TestIAMGroupDisabledPolicyEnforcement tests that disabled groups do not contribute policies.
|
||||
// Uses the raw IAM API (callIAMAPI) since the AWS SDK doesn't support custom group status.
|
||||
func TestIAMGroupDisabledPolicyEnforcement(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
if !isSeaweedFSRunning(t) {
|
||||
t.Skip("SeaweedFS is not running at", TestIAMEndpoint)
|
||||
}
|
||||
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
groupName := "test-disabled-group"
|
||||
userName := "test-disabled-grp-user"
|
||||
policyName := "test-disabled-grp-policy"
|
||||
bucketName := "test-disabled-grp-bucket"
|
||||
policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::` + bucketName + `","arn:aws:s3:::` + bucketName + `/*"]}]}`
|
||||
|
||||
// Create user, group, policy
|
||||
_, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)})
|
||||
require.NoError(t, err)
|
||||
|
||||
keyResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{UserName: aws.String(userName)})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(groupName)})
|
||||
require.NoError(t, err)
|
||||
|
||||
createPolicyResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(policyName), PolicyDocument: aws.String(policyDoc),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Cleanup in correct order: remove user from group, detach policy,
|
||||
// delete access key, delete user, delete group, delete policy
|
||||
t.Cleanup(func() {
|
||||
if _, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
|
||||
GroupName: aws.String(groupName), UserName: aws.String(userName),
|
||||
}); err != nil {
|
||||
t.Logf("cleanup: failed to remove user from group: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyArn: aws.String("arn:aws:iam:::policy/" + policyName),
|
||||
}); err != nil {
|
||||
t.Logf("cleanup: failed to detach group policy: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{
|
||||
UserName: aws.String(userName), AccessKeyId: keyResp.AccessKey.AccessKeyId,
|
||||
}); err != nil {
|
||||
t.Logf("cleanup: failed to delete access key: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}); err != nil {
|
||||
t.Logf("cleanup: failed to delete user: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}); err != nil {
|
||||
t.Logf("cleanup: failed to delete group: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: createPolicyResp.Policy.Arn}); err != nil {
|
||||
t.Logf("cleanup: failed to delete policy: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Setup: attach policy, add user, create bucket with admin
|
||||
_, err = iamClient.AttachGroupPolicy(&iam.AttachGroupPolicyInput{
|
||||
GroupName: aws.String(groupName), PolicyArn: createPolicyResp.Policy.Arn,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
|
||||
GroupName: aws.String(groupName), UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
userS3Client := createS3Client(t, *keyResp.AccessKey.AccessKeyId, *keyResp.AccessKey.SecretAccessKey)
|
||||
|
||||
// Create bucket using admin first so we can test listing
|
||||
adminS3, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
_, err = adminS3.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(bucketName)})
|
||||
require.NoError(t, err)
|
||||
defer adminS3.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
t.Run("enabled_group_allows_access", func(t *testing.T) {
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := userS3Client.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
return err == nil
|
||||
}, 10*time.Second, 500*time.Millisecond, "User in enabled group should have access")
|
||||
})
|
||||
|
||||
t.Run("disabled_group_denies_access", func(t *testing.T) {
|
||||
// Disable group via raw IAM API (no SDK support for this extension)
|
||||
resp, err := callIAMAPIAuthenticated(t, framework, "UpdateGroup", url.Values{
|
||||
"GroupName": {groupName},
|
||||
"Disabled": {"true"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "UpdateGroup (disable) should return 200")
|
||||
|
||||
// Wait for propagation — user should be denied
|
||||
var lastErr error
|
||||
require.Eventually(t, func() bool {
|
||||
_, lastErr = userS3Client.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
return lastErr != nil
|
||||
}, 10*time.Second, 500*time.Millisecond, "User in disabled group should be denied access")
|
||||
awsErr, ok := lastErr.(awserr.Error)
|
||||
require.True(t, ok, "Expected awserr.Error")
|
||||
assert.Equal(t, "AccessDenied", awsErr.Code())
|
||||
})
|
||||
|
||||
t.Run("re_enabled_group_restores_access", func(t *testing.T) {
|
||||
// Re-enable the group
|
||||
resp, err := callIAMAPIAuthenticated(t, framework, "UpdateGroup", url.Values{
|
||||
"GroupName": {groupName},
|
||||
"Disabled": {"false"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "UpdateGroup (re-enable) should return 200")
|
||||
|
||||
// Wait for propagation — user should have access again
|
||||
require.Eventually(t, func() bool {
|
||||
_, err = userS3Client.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
return err == nil
|
||||
}, 10*time.Second, 500*time.Millisecond, "User in re-enabled group should have access again")
|
||||
})
|
||||
}
|
||||
|
||||
// TestIAMGroupUserDeletionSideEffect tests that deleting a user removes them from all groups.
|
||||
func TestIAMGroupUserDeletionSideEffect(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
groupName := "test-deletion-group"
|
||||
userName := "test-deletion-user"
|
||||
|
||||
// Create group and user
|
||||
_, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(groupName)})
|
||||
require.NoError(t, err)
|
||||
defer iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)})
|
||||
|
||||
_, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
// Best-effort: user may already be deleted by the test
|
||||
iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)})
|
||||
})
|
||||
|
||||
// Add user to group
|
||||
_, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify user is in group
|
||||
getResp, err := iamClient.GetGroup(&iam.GetGroupInput{GroupName: aws.String(groupName)})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, getResp.Users, 1, "Group should have 1 member before deletion")
|
||||
|
||||
// Delete the user
|
||||
_, err = iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify user was removed from the group
|
||||
getResp, err = iamClient.GetGroup(&iam.GetGroupInput{GroupName: aws.String(groupName)})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, getResp.Users, "Group should have no members after user deletion")
|
||||
}
|
||||
|
||||
// TestIAMGroupMultipleGroups tests that a user can belong to multiple groups
|
||||
// and inherits policies from all of them.
|
||||
func TestIAMGroupMultipleGroups(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
group1 := "test-multi-group-1"
|
||||
group2 := "test-multi-group-2"
|
||||
userName := "test-multi-group-user"
|
||||
|
||||
// Create two groups
|
||||
_, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(group1)})
|
||||
require.NoError(t, err)
|
||||
defer iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(group1)})
|
||||
|
||||
_, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(group2)})
|
||||
require.NoError(t, err)
|
||||
defer iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(group2)})
|
||||
|
||||
// Create user
|
||||
_, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
|
||||
GroupName: aws.String(group1), UserName: aws.String(userName),
|
||||
})
|
||||
iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
|
||||
GroupName: aws.String(group2), UserName: aws.String(userName),
|
||||
})
|
||||
iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)})
|
||||
}()
|
||||
|
||||
// Add user to both groups
|
||||
_, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
|
||||
GroupName: aws.String(group1), UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
|
||||
GroupName: aws.String(group2), UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify user appears in both groups
|
||||
resp, err := iamClient.ListGroupsForUser(&iam.ListGroupsForUserInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
groupNames := make(map[string]bool)
|
||||
for _, g := range resp.Groups {
|
||||
groupNames[*g.GroupName] = true
|
||||
}
|
||||
assert.True(t, groupNames[group1], "User should be in group 1")
|
||||
assert.True(t, groupNames[group2], "User should be in group 2")
|
||||
}
|
||||
|
||||
// --- Response types for raw IAM API calls ---
|
||||
|
||||
type CreateGroupResponse struct {
|
||||
XMLName xml.Name `xml:"CreateGroupResponse"`
|
||||
CreateGroupResult struct {
|
||||
Group struct {
|
||||
GroupName string `xml:"GroupName"`
|
||||
} `xml:"Group"`
|
||||
} `xml:"CreateGroupResult"`
|
||||
}
|
||||
|
||||
type ListGroupsResponse struct {
|
||||
XMLName xml.Name `xml:"ListGroupsResponse"`
|
||||
ListGroupsResult struct {
|
||||
Groups []struct {
|
||||
GroupName string `xml:"GroupName"`
|
||||
} `xml:"Groups>member"`
|
||||
} `xml:"ListGroupsResult"`
|
||||
}
|
||||
|
||||
// callIAMAPIAuthenticated sends an authenticated raw IAM API request using the
|
||||
// framework's JWT token. This is needed for custom extensions not in the AWS SDK
|
||||
// (like UpdateGroup with Disabled parameter).
|
||||
func callIAMAPIAuthenticated(_ *testing.T, framework *S3IAMTestFramework, action string, params url.Values) (*http.Response, error) {
|
||||
params.Set("Action", action)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, TestIAMEndpoint+"/",
|
||||
strings.NewReader(params.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
token, err := framework.generateSTSSessionToken("admin-user", "TestAdminRole", time.Hour, "", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &BearerTokenTransport{Token: token},
|
||||
}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
// TestIAMGroupRawAPI tests group operations using raw HTTP IAM API calls,
|
||||
// verifying XML response format for group operations.
|
||||
func TestIAMGroupRawAPI(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
if !isSeaweedFSRunning(t) {
|
||||
t.Skip("SeaweedFS is not running at", TestIAMEndpoint)
|
||||
}
|
||||
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
groupName := "test-raw-api-group"
|
||||
|
||||
t.Run("create_group_raw", func(t *testing.T) {
|
||||
resp, err := callIAMAPIAuthenticated(t, framework, "CreateGroup", url.Values{
|
||||
"GroupName": {groupName},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var createResp CreateGroupResponse
|
||||
err = xml.Unmarshal(body, &createResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, groupName, createResp.CreateGroupResult.Group.GroupName)
|
||||
})
|
||||
|
||||
t.Run("list_groups_raw", func(t *testing.T) {
|
||||
resp, err := callIAMAPIAuthenticated(t, framework, "ListGroups", url.Values{})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var listResp ListGroupsResponse
|
||||
err = xml.Unmarshal(body, &listResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
found := false
|
||||
for _, g := range listResp.ListGroupsResult.Groups {
|
||||
if g.GroupName == groupName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Created group should appear in raw ListGroups")
|
||||
})
|
||||
|
||||
t.Run("delete_group_raw", func(t *testing.T) {
|
||||
resp, err := callIAMAPIAuthenticated(t, framework, "DeleteGroup", url.Values{
|
||||
"GroupName": {groupName},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestIAMGroupInlinePolicy tests group inline policy operations:
|
||||
// PutGroupPolicy, GetGroupPolicy, ListGroupPolicies, DeleteGroupPolicy
|
||||
func TestIAMGroupInlinePolicy(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Skip if running against embedded IAM (which returns NotImplemented for group inline policies)
|
||||
_, probeErr := iamClient.ListGroupPolicies(&iam.ListGroupPoliciesInput{GroupName: aws.String("probe-group-inline-support")})
|
||||
if probeErr != nil {
|
||||
if awsErr, ok := probeErr.(awserr.Error); ok && awsErr.Code() == "NotImplemented" {
|
||||
t.Skip("Skipping: group inline policies not supported in embedded IAM mode")
|
||||
}
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
groupName := "test-group-inline-policy"
|
||||
policyName := "TestInlinePolicy"
|
||||
_, err = iamClient.CreateGroup(&iam.CreateGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
// Clean up inline policies before deleting the group
|
||||
iamClient.DeleteGroupPolicy(&iam.DeleteGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyName: aws.String(policyName),
|
||||
})
|
||||
iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)})
|
||||
}()
|
||||
|
||||
t.Run("list_empty", func(t *testing.T) {
|
||||
resp, err := iamClient.ListGroupPolicies(&iam.ListGroupPoliciesInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, resp.PolicyNames)
|
||||
assert.False(t, *resp.IsTruncated)
|
||||
})
|
||||
|
||||
policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*"}]}`
|
||||
|
||||
t.Run("put_group_policy", func(t *testing.T) {
|
||||
_, err := iamClient.PutGroupPolicy(&iam.PutGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(policyDoc),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("list_after_put", func(t *testing.T) {
|
||||
resp, err := iamClient.ListGroupPolicies(&iam.ListGroupPoliciesInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, resp.PolicyNames)
|
||||
})
|
||||
|
||||
t.Run("get_group_policy", func(t *testing.T) {
|
||||
resp, err := iamClient.GetGroupPolicy(&iam.GetGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyName: aws.String(policyName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, groupName, *resp.GroupName)
|
||||
assert.Equal(t, policyName, *resp.PolicyName)
|
||||
assert.Contains(t, *resp.PolicyDocument, "s3:GetObject")
|
||||
})
|
||||
|
||||
t.Run("delete_group_policy", func(t *testing.T) {
|
||||
_, err := iamClient.DeleteGroupPolicy(&iam.DeleteGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyName: aws.String(policyName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("list_after_delete", func(t *testing.T) {
|
||||
resp, err := iamClient.ListGroupPolicies(&iam.ListGroupPoliciesInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, resp.PolicyNames)
|
||||
})
|
||||
|
||||
t.Run("nonexistent_group", func(t *testing.T) {
|
||||
_, err := iamClient.ListGroupPolicies(&iam.ListGroupPoliciesInput{
|
||||
GroupName: aws.String("nonexistent-group-for-policies"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
awsErr, ok := err.(awserr.Error)
|
||||
require.True(t, ok, "Expected AWS error type")
|
||||
assert.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code())
|
||||
})
|
||||
}
|
||||
|
||||
// createS3Client creates an S3 client with static credentials
|
||||
func createS3Client(t *testing.T, accessKey, secretKey string) *s3.S3 {
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String("us-east-1"),
|
||||
Endpoint: aws.String(TestS3Endpoint),
|
||||
Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""),
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return s3.New(sess)
|
||||
}
|
||||
309
test/s3/iam/s3_iam_inline_policy_condition_test.go
Normal file
309
test/s3/iam/s3_iam_inline_policy_condition_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
mathrand "math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// uniqueResourceSuffix returns a lowercased per-test, per-invocation suffix
|
||||
// safe for use in IAM resource and S3 bucket names. Avoids EntityAlreadyExists
|
||||
// / BucketAlreadyExists collisions when integration jobs retry or run in
|
||||
// parallel against a shared stack.
|
||||
func uniqueResourceSuffix(t *testing.T) string {
|
||||
name := strings.ToLower(t.Name())
|
||||
name = strings.ReplaceAll(name, "/", "-")
|
||||
name = strings.ReplaceAll(name, "_", "-")
|
||||
return fmt.Sprintf("%s-%d", name, mathrand.Intn(10000))
|
||||
}
|
||||
|
||||
// isAccessDenied returns true when err is an AWS error with code "AccessDenied".
|
||||
// Used to gate deny-path polling so transient setup errors don't end the
|
||||
// Eventually loop prematurely.
|
||||
func isAccessDenied(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
awsErr, ok := err.(awserr.Error)
|
||||
return ok && awsErr.Code() == "AccessDenied"
|
||||
}
|
||||
|
||||
// localSourceAllowCIDRs lists every address a loopback-targeted test client may
|
||||
// present as aws:SourceIp. The SDK reaches the server over localhost, but
|
||||
// depending on resolver order and host routing the source the server observes
|
||||
// can be IPv4 loopback, IPv6 loopback, or one of the host's RFC1918 addresses
|
||||
// (CI runners advertise the S3 endpoint on a 10.x interface). An allow policy
|
||||
// meant to match "this local client" must cover all of them or the
|
||||
// positive-condition assertion flakes.
|
||||
var localSourceAllowCIDRs = []string{
|
||||
"127.0.0.0/8",
|
||||
"::1/128",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
}
|
||||
|
||||
// sourceIpAllowPolicy builds an Allow policy for s3:* on bucketName gated by an
|
||||
// aws:SourceIp IpAddress condition over the given CIDRs.
|
||||
func sourceIpAllowPolicy(bucketName string, cidrs ...string) string {
|
||||
quoted := make([]string, len(cidrs))
|
||||
for i, c := range cidrs {
|
||||
quoted[i] = `"` + c + `"`
|
||||
}
|
||||
return `{
|
||||
"Version":"2012-10-17",
|
||||
"Statement":[{
|
||||
"Effect":"Allow",
|
||||
"Action":"s3:*",
|
||||
"Resource":["arn:aws:s3:::` + bucketName + `","arn:aws:s3:::` + bucketName + `/*"],
|
||||
"Condition":{"IpAddress":{"aws:SourceIp":[` + strings.Join(quoted, ",") + `]}}
|
||||
}]
|
||||
}`
|
||||
}
|
||||
|
||||
// TestIAMUserInlinePolicySourceIpCondition verifies that an aws:SourceIp condition
|
||||
// on a user inline policy is honored. Tests run against a local server, so a
|
||||
// policy that only allows a non-local CIDR must deny the request, and a policy
|
||||
// that allows the local client's address (see localSourceAllowCIDRs) must allow it.
|
||||
func TestIAMUserInlinePolicySourceIpCondition(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
suffix := uniqueResourceSuffix(t)
|
||||
userName := "user-" + suffix
|
||||
policyName := "policy-" + suffix
|
||||
bucketName := "bucket-" + suffix
|
||||
|
||||
_, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)})
|
||||
require.NoError(t, err)
|
||||
|
||||
keyResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
accessKeyId := *keyResp.AccessKey.AccessKeyId
|
||||
secretKey := *keyResp.AccessKey.SecretAccessKey
|
||||
|
||||
userS3 := createS3Client(t, accessKeyId, secretKey)
|
||||
|
||||
adminS3, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, framework.CreateBucketWithCleanup(adminS3, bucketName))
|
||||
|
||||
t.Cleanup(func() {
|
||||
if _, err := iamClient.DeleteUserPolicy(&iam.DeleteUserPolicyInput{
|
||||
UserName: aws.String(userName),
|
||||
PolicyName: aws.String(policyName),
|
||||
}); err != nil {
|
||||
t.Logf("cleanup: failed to delete user policy: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{
|
||||
UserName: aws.String(userName),
|
||||
AccessKeyId: keyResp.AccessKey.AccessKeyId,
|
||||
}); err != nil {
|
||||
t.Logf("cleanup: failed to delete access key: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}); err != nil {
|
||||
t.Logf("cleanup: failed to delete user: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("denies_when_source_ip_does_not_match", func(t *testing.T) {
|
||||
// SourceIp 198.51.100.0/24 is RFC5737 TEST-NET-2; the test client is on
|
||||
// the local host, so the condition must fail and the action be denied.
|
||||
_, err = iamClient.PutUserPolicy(&iam.PutUserPolicyInput{
|
||||
UserName: aws.String(userName),
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(sourceIpAllowPolicy(bucketName, "198.51.100.0/24")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var lastErr error
|
||||
require.Eventually(t, func() bool {
|
||||
_, lastErr = userS3.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("denied.txt"),
|
||||
Body: aws.ReadSeekCloser(strings.NewReader("nope")),
|
||||
})
|
||||
return isAccessDenied(lastErr)
|
||||
}, 10*time.Second, 500*time.Millisecond,
|
||||
"PutObject must be denied with AccessDenied when aws:SourceIp condition does not match (last error: %v)", lastErr)
|
||||
})
|
||||
|
||||
t.Run("allows_when_source_ip_matches", func(t *testing.T) {
|
||||
_, err = iamClient.PutUserPolicy(&iam.PutUserPolicyInput{
|
||||
UserName: aws.String(userName),
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(sourceIpAllowPolicy(bucketName, localSourceAllowCIDRs...)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
_, putErr := userS3.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("allowed.txt"),
|
||||
Body: aws.ReadSeekCloser(strings.NewReader("ok")),
|
||||
})
|
||||
if putErr != nil {
|
||||
t.Logf("allow attempt denied (source IP not in %v?): %v", localSourceAllowCIDRs, putErr)
|
||||
}
|
||||
return putErr == nil
|
||||
}, 10*time.Second, 500*time.Millisecond,
|
||||
"PutObject must succeed when aws:SourceIp condition matches the local client address")
|
||||
})
|
||||
}
|
||||
|
||||
// TestIAMGroupInlinePolicyEnforcement verifies that PutGroupPolicy is supported
|
||||
// and that the resulting inline policy is enforced for members of the group,
|
||||
// including its Condition block.
|
||||
func TestIAMGroupInlinePolicyEnforcement(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
suffix := uniqueResourceSuffix(t)
|
||||
groupName := "group-" + suffix
|
||||
userName := "user-" + suffix
|
||||
policyName := "policy-" + suffix
|
||||
bucketName := "bucket-" + suffix
|
||||
|
||||
_, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)})
|
||||
require.NoError(t, err)
|
||||
|
||||
keyResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(groupName)})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
UserName: aws.String(userName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
userS3 := createS3Client(t, *keyResp.AccessKey.AccessKeyId, *keyResp.AccessKey.SecretAccessKey)
|
||||
|
||||
adminS3, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, framework.CreateBucketWithCleanup(adminS3, bucketName))
|
||||
|
||||
t.Cleanup(func() {
|
||||
if _, err := iamClient.DeleteGroupPolicy(&iam.DeleteGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyName: aws.String(policyName),
|
||||
}); err != nil {
|
||||
t.Logf("cleanup: failed to delete group policy: %v", err)
|
||||
}
|
||||
if _, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
UserName: aws.String(userName),
|
||||
}); err != nil {
|
||||
t.Logf("cleanup: failed to remove user from group: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{
|
||||
UserName: aws.String(userName),
|
||||
AccessKeyId: keyResp.AccessKey.AccessKeyId,
|
||||
}); err != nil {
|
||||
t.Logf("cleanup: failed to delete access key: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}); err != nil {
|
||||
t.Logf("cleanup: failed to delete user: %v", err)
|
||||
}
|
||||
if _, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}); err != nil {
|
||||
t.Logf("cleanup: failed to delete group: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
allowDoc := sourceIpAllowPolicy(bucketName, localSourceAllowCIDRs...)
|
||||
denyDoc := sourceIpAllowPolicy(bucketName, "198.51.100.0/24")
|
||||
|
||||
t.Run("crud_round_trip", func(t *testing.T) {
|
||||
_, err := iamClient.PutGroupPolicy(&iam.PutGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(allowDoc),
|
||||
})
|
||||
require.NoError(t, err, "PutGroupPolicy must succeed (no longer NotImplemented)")
|
||||
|
||||
listResp, err := iamClient.ListGroupPolicies(&iam.ListGroupPoliciesInput{
|
||||
GroupName: aws.String(groupName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
found := false
|
||||
for _, name := range listResp.PolicyNames {
|
||||
if name != nil && *name == policyName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "ListGroupPolicies must return the freshly added policy")
|
||||
|
||||
getResp, err := iamClient.GetGroupPolicy(&iam.GetGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyName: aws.String(policyName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, getResp.PolicyDocument)
|
||||
assert.Contains(t, *getResp.PolicyDocument, "aws:SourceIp",
|
||||
"GetGroupPolicy must round-trip the Condition block")
|
||||
})
|
||||
|
||||
t.Run("enforces_allow_when_condition_matches", func(t *testing.T) {
|
||||
_, err := iamClient.PutGroupPolicy(&iam.PutGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(allowDoc),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
_, putErr := userS3.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("group-allowed.txt"),
|
||||
Body: aws.ReadSeekCloser(strings.NewReader("ok")),
|
||||
})
|
||||
if putErr != nil {
|
||||
t.Logf("allow attempt denied (source IP not in %v?): %v", localSourceAllowCIDRs, putErr)
|
||||
}
|
||||
return putErr == nil
|
||||
}, 10*time.Second, 500*time.Millisecond,
|
||||
"group member must be allowed when the group policy condition matches")
|
||||
})
|
||||
|
||||
t.Run("enforces_deny_when_condition_does_not_match", func(t *testing.T) {
|
||||
_, err := iamClient.PutGroupPolicy(&iam.PutGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(denyDoc),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var lastErr error
|
||||
require.Eventually(t, func() bool {
|
||||
_, lastErr = userS3.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("group-denied.txt"),
|
||||
Body: aws.ReadSeekCloser(strings.NewReader("nope")),
|
||||
})
|
||||
return isAccessDenied(lastErr)
|
||||
}, 10*time.Second, 500*time.Millisecond,
|
||||
"group member must be denied with AccessDenied when the group policy condition does not match (last error: %v)", lastErr)
|
||||
})
|
||||
}
|
||||
479
test/s3/iam/s3_iam_integration_test.go
Normal file
479
test/s3/iam/s3_iam_integration_test.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testEndpoint = "http://localhost:8333"
|
||||
testRegion = "us-west-2"
|
||||
testBucket = "test-iam-bucket"
|
||||
testObjectKey = "test-object.txt"
|
||||
testObjectData = "Hello, SeaweedFS IAM Integration!"
|
||||
)
|
||||
|
||||
// TestS3IAMAuthentication tests S3 API authentication with IAM JWT tokens
|
||||
func TestS3IAMAuthentication(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
t.Run("valid_jwt_token_authentication", func(t *testing.T) {
|
||||
// Create S3 client with valid JWT token
|
||||
s3Client, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test bucket operations
|
||||
err = framework.CreateBucket(s3Client, testBucket)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify bucket exists
|
||||
buckets, err := s3Client.ListBuckets(&s3.ListBucketsInput{})
|
||||
require.NoError(t, err)
|
||||
|
||||
found := false
|
||||
for _, bucket := range buckets.Buckets {
|
||||
if *bucket.Name == testBucket {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Created bucket should be listed")
|
||||
})
|
||||
|
||||
t.Run("invalid_jwt_token_authentication", func(t *testing.T) {
|
||||
// Create S3 client with invalid JWT token
|
||||
s3Client, err := framework.CreateS3ClientWithInvalidJWT()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt bucket operations - should fail
|
||||
err = framework.CreateBucket(s3Client, testBucket+"-invalid")
|
||||
require.Error(t, err)
|
||||
|
||||
// Verify it's an access denied error
|
||||
if awsErr, ok := err.(awserr.Error); ok {
|
||||
assert.Equal(t, "AccessDenied", awsErr.Code())
|
||||
} else {
|
||||
t.Error("Expected AWS error with AccessDenied code")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired_jwt_token_authentication", func(t *testing.T) {
|
||||
// Create S3 client with expired JWT token
|
||||
s3Client, err := framework.CreateS3ClientWithExpiredJWT("expired-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt bucket operations - should fail
|
||||
err = framework.CreateBucket(s3Client, testBucket+"-expired")
|
||||
require.Error(t, err)
|
||||
|
||||
// Verify it's an access denied error
|
||||
if awsErr, ok := err.(awserr.Error); ok {
|
||||
assert.Equal(t, "AccessDenied", awsErr.Code())
|
||||
} else {
|
||||
t.Error("Expected AWS error with AccessDenied code")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestS3IAMPolicyEnforcement tests policy enforcement for different S3 operations
|
||||
// NOTE: This test is currently skipped because the IAM framework needs to set up role policies
|
||||
// The test assumes TestReadOnlyRole and TestWriteOnlyRole are configured in the IAM system,
|
||||
// but these roles and their associated policies are not yet being created during test setup.
|
||||
// TODO: Implement setupIAMRoles() to create roles with proper policies before running this test.
|
||||
// TestS3IAMPolicyEnforcement tests policy enforcement for different S3 operations
|
||||
// NOTE: This test is skipped because the IAM framework needs to set up role policies.
|
||||
// The test assumes TestReadOnlyRole and TestWriteOnlyRole are configured in the IAM system,
|
||||
// but these roles and their associated policies are not yet being created during test setup.
|
||||
// TODO: Implement setupIAMRoles() to create roles with proper policies before running this test.
|
||||
func TestS3IAMPolicyEnforcement(t *testing.T) {
|
||||
t.Skip("Skipping: Requires IAM role and policy setup - TestReadOnlyRole and TestWriteOnlyRole policies not configured")
|
||||
}
|
||||
|
||||
// TestS3IAMSessionExpiration tests session expiration handling
|
||||
func TestS3IAMSessionExpiration(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
t.Run("session_expiration_enforcement", func(t *testing.T) {
|
||||
// Create S3 client with valid JWT token
|
||||
s3Client, err := framework.CreateS3ClientWithJWT("session-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Initially should work
|
||||
err = framework.CreateBucket(s3Client, testBucket+"-session")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create S3 client with expired JWT token
|
||||
expiredClient, err := framework.CreateS3ClientWithExpiredJWT("session-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Now operations should fail with expired token
|
||||
err = framework.CreateBucket(expiredClient, testBucket+"-session-expired")
|
||||
require.Error(t, err)
|
||||
if awsErr, ok := err.(awserr.Error); ok {
|
||||
assert.Equal(t, "AccessDenied", awsErr.Code())
|
||||
}
|
||||
|
||||
// Cleanup the successful bucket
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = adminClient.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(testBucket + "-session"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestS3IAMMultipartUploadPolicyEnforcement tests multipart upload with IAM policies
|
||||
func TestS3IAMMultipartUploadPolicyEnforcement(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
// Setup test bucket with admin client
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = framework.CreateBucket(adminClient, testBucket)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set bucket policy to deny multipart uploads from read-only users
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:*",
|
||||
"Resource": ["arn:aws:s3:::%s", "arn:aws:s3:::%s/*"]
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/read-user",
|
||||
"Action": ["s3:PutObject", "s3:CreateMultipartUpload", "s3:AbortMultipartUpload", "s3:CompleteMultipartUpload", "s3:ListMultipartUploadParts"],
|
||||
"Resource": "arn:aws:s3:::%s/*"
|
||||
}
|
||||
]
|
||||
}`, testBucket, testBucket, testBucket)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(testBucket),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("multipart_upload_with_write_permissions", func(t *testing.T) {
|
||||
// Create S3 client with admin role (has multipart permissions)
|
||||
s3Client := adminClient
|
||||
|
||||
// Initiate multipart upload
|
||||
multipartKey := "large-test-file.txt"
|
||||
initResult, err := s3Client.CreateMultipartUpload(&s3.CreateMultipartUploadInput{
|
||||
Bucket: aws.String(testBucket),
|
||||
Key: aws.String(multipartKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
uploadId := initResult.UploadId
|
||||
|
||||
// Upload a part
|
||||
partNumber := int64(1)
|
||||
partData := strings.Repeat("Test data for multipart upload. ", 1000) // ~30KB
|
||||
|
||||
uploadResult, err := s3Client.UploadPart(&s3.UploadPartInput{
|
||||
Bucket: aws.String(testBucket),
|
||||
Key: aws.String(multipartKey),
|
||||
PartNumber: aws.Int64(partNumber),
|
||||
UploadId: uploadId,
|
||||
Body: strings.NewReader(partData),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Complete multipart upload
|
||||
_, err = s3Client.CompleteMultipartUpload(&s3.CompleteMultipartUploadInput{
|
||||
Bucket: aws.String(testBucket),
|
||||
Key: aws.String(multipartKey),
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: &s3.CompletedMultipartUpload{
|
||||
Parts: []*s3.CompletedPart{
|
||||
{
|
||||
ETag: uploadResult.ETag,
|
||||
PartNumber: aws.Int64(partNumber),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify object was created
|
||||
result, err := s3Client.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(testBucket),
|
||||
Key: aws.String(multipartKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := io.ReadAll(result.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, partData, string(data))
|
||||
result.Body.Close()
|
||||
|
||||
// Cleanup
|
||||
_, err = s3Client.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(testBucket),
|
||||
Key: aws.String(multipartKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("multipart_upload_denied_for_read_only", func(t *testing.T) {
|
||||
// Create S3 client with read-only role
|
||||
readOnlyClient, err := framework.CreateS3ClientWithJWT("read-user", "TestReadOnlyRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt to initiate multipart upload - should fail due to bucket policy
|
||||
multipartKey := "denied-multipart-file.txt"
|
||||
_, err = readOnlyClient.CreateMultipartUpload(&s3.CreateMultipartUploadInput{
|
||||
Bucket: aws.String(testBucket),
|
||||
Key: aws.String(multipartKey),
|
||||
})
|
||||
require.Error(t, err)
|
||||
if awsErr, ok := err.(awserr.Error); ok {
|
||||
assert.Equal(t, "AccessDenied", awsErr.Code())
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
_, err = adminClient.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(testBucket),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestS3IAMBucketPolicyIntegration tests bucket policy integration with IAM
|
||||
func TestS3IAMBucketPolicyIntegration(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
// Setup test bucket with admin client
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Use unique bucket name to avoid collection conflicts
|
||||
bucketName := framework.GenerateUniqueBucketName("test-iam-bucket-policy")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
t.Run("bucket_policy_allows_public_read", func(t *testing.T) {
|
||||
testObjectKey := "test-object.txt"
|
||||
testObjectData := "test data for public read"
|
||||
|
||||
// Set bucket policy to allow public read access
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "PublicReadGetObject",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/*"]
|
||||
}
|
||||
]
|
||||
}`, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Put test object
|
||||
_, err = adminClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testObjectKey),
|
||||
Body: strings.NewReader(testObjectData),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test with read-only client - should now be allowed due to bucket policy
|
||||
readOnlyClient, err := framework.CreateS3ClientWithJWT("read-user", "TestReadOnlyRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := readOnlyClient.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testObjectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := io.ReadAll(result.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testObjectData, string(data))
|
||||
result.Body.Close()
|
||||
|
||||
// Clean up object and bucket policy after this test
|
||||
_, err = adminClient.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testObjectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = adminClient.DeleteBucketPolicy(&s3.DeleteBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("bucket_policy_denies_specific_action", func(t *testing.T) {
|
||||
// Set bucket policy to deny delete operations
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "DenyDelete",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:DeleteObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/*"]
|
||||
}
|
||||
]
|
||||
}`, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify that the bucket policy was stored successfully by retrieving it
|
||||
policyResult, err := adminClient.GetBucketPolicy(&s3.GetBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, *policyResult.Policy, "s3:DeleteObject")
|
||||
assert.Contains(t, *policyResult.Policy, "Deny")
|
||||
|
||||
// NOTE: Enforcement test is commented out due to known architectural limitation:
|
||||
//
|
||||
// KNOWN LIMITATION: DeleteObject uses the coarse-grained ACTION_WRITE constant,
|
||||
// which convertActionToS3Format maps to "s3:PutObject" (not "s3:DeleteObject").
|
||||
// This means the policy engine evaluates the deny policy against "s3:PutObject",
|
||||
// doesn't find a match, and allows the delete operation.
|
||||
//
|
||||
// TODO: Uncomment this test once the action mapping is refactored to use
|
||||
// specific S3 action strings throughout the S3 API handlers.
|
||||
// See: weed/s3api/s3api_bucket_policy_engine.go lines 135-146
|
||||
//
|
||||
// _, err = adminClient.DeleteObject(&s3.DeleteObjectInput{
|
||||
// Bucket: aws.String(bucketName),
|
||||
// Key: aws.String(testObjectKey),
|
||||
// })
|
||||
// require.Error(t, err, "DeleteObject should be denied by the bucket policy")
|
||||
// awsErr, ok := err.(awserr.Error)
|
||||
// require.True(t, ok, "Error should be an awserr.Error")
|
||||
// assert.Equal(t, "AccessDenied", awsErr.Code(), "Expected AccessDenied error code")
|
||||
|
||||
// Clean up bucket policy after this test
|
||||
_, err = adminClient.DeleteBucketPolicy(&s3.DeleteBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestS3IAMContextualPolicyEnforcement tests context-aware policy enforcement
|
||||
func TestS3IAMContextualPolicyEnforcement(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
// This test would verify IP-based restrictions, time-based restrictions,
|
||||
// and other context-aware policy conditions
|
||||
// For now, we'll focus on the basic structure
|
||||
|
||||
t.Run("ip_based_policy_enforcement", func(t *testing.T) {
|
||||
// IMPLEMENTATION NOTE: IP-based policy testing framework planned for future release
|
||||
// Requirements:
|
||||
// - Configure IAM policies with IpAddress/NotIpAddress conditions
|
||||
// - Multi-container test setup with controlled source IP addresses
|
||||
// - Test policy enforcement from allowed vs denied IP ranges
|
||||
t.Skip("IP-based policy testing requires advanced network configuration and multi-container setup")
|
||||
})
|
||||
|
||||
t.Run("time_based_policy_enforcement", func(t *testing.T) {
|
||||
// IMPLEMENTATION NOTE: Time-based policy testing framework planned for future release
|
||||
// Requirements:
|
||||
// - Configure IAM policies with DateGreaterThan/DateLessThan conditions
|
||||
// - Time manipulation capabilities for testing different time windows
|
||||
// - Test policy enforcement during allowed vs restricted time periods
|
||||
t.Skip("Time-based policy testing requires time manipulation capabilities")
|
||||
})
|
||||
}
|
||||
|
||||
// TestS3IAMPresignedURLIntegration tests presigned URL generation with IAM
|
||||
func TestS3IAMPresignedURLIntegration(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
// Setup test bucket with admin client
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Use unique bucket name to avoid conflicts with other tests
|
||||
bucketName := framework.GenerateUniqueBucketName("test-iam-presigned")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Put test object
|
||||
_, err = adminClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testObjectKey),
|
||||
Body: strings.NewReader(testObjectData),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("presigned_url_generation_and_usage", func(t *testing.T) {
|
||||
// ARCHITECTURAL NOTE: AWS SDK presigned URLs are incompatible with JWT Bearer authentication
|
||||
//
|
||||
// AWS SDK presigned URLs use AWS Signature Version 4 (SigV4) which requires:
|
||||
// - Access Key ID and Secret Access Key for signing
|
||||
// - Query parameter-based authentication in the URL
|
||||
//
|
||||
// SeaweedFS JWT authentication uses:
|
||||
// - Bearer tokens in the Authorization header
|
||||
// - Stateless JWT validation without AWS-style signing
|
||||
//
|
||||
// RECOMMENDATION: For JWT-authenticated applications, use direct API calls
|
||||
// with Bearer tokens rather than presigned URLs.
|
||||
|
||||
// Test direct object access with JWT Bearer token (recommended approach)
|
||||
_, err := adminClient.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testObjectKey),
|
||||
})
|
||||
require.NoError(t, err, "Direct object access with JWT Bearer token works correctly")
|
||||
|
||||
t.Log("JWT Bearer token authentication confirmed working for direct S3 API calls")
|
||||
t.Log("Note: Presigned URLs are not supported with JWT Bearer authentication by design")
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
_, err = adminClient.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testObjectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = adminClient.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
307
test/s3/iam/s3_keycloak_integration_test.go
Normal file
307
test/s3/iam/s3_keycloak_integration_test.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testKeycloakBucket = "test-keycloak-bucket"
|
||||
)
|
||||
|
||||
// TestKeycloakIntegrationAvailable checks if Keycloak is available for testing
|
||||
func TestKeycloakIntegrationAvailable(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
if !framework.useKeycloak {
|
||||
t.Skip("Keycloak not available, skipping integration tests")
|
||||
}
|
||||
|
||||
// Test Keycloak health
|
||||
assert.True(t, framework.useKeycloak, "Keycloak should be available")
|
||||
assert.NotNil(t, framework.keycloakClient, "Keycloak client should be initialized")
|
||||
}
|
||||
|
||||
// TestKeycloakAuthentication tests authentication flow with real Keycloak
|
||||
func TestKeycloakAuthentication(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
if !framework.useKeycloak {
|
||||
t.Skip("Keycloak not available, skipping integration tests")
|
||||
}
|
||||
|
||||
t.Run("admin_user_authentication", func(t *testing.T) {
|
||||
// Test admin user authentication
|
||||
token, err := framework.getKeycloakToken("admin-user")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token, "JWT token should not be empty")
|
||||
|
||||
// Verify token can be used to create S3 client
|
||||
s3Client, err := framework.CreateS3ClientWithKeycloakToken(token)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, s3Client, "S3 client should be created successfully")
|
||||
|
||||
// Test bucket operations with admin privileges
|
||||
err = framework.CreateBucket(s3Client, testKeycloakBucket)
|
||||
assert.NoError(t, err, "Admin user should be able to create buckets")
|
||||
|
||||
// Verify bucket exists
|
||||
buckets, err := s3Client.ListBuckets(&s3.ListBucketsInput{})
|
||||
require.NoError(t, err)
|
||||
|
||||
found := false
|
||||
for _, bucket := range buckets.Buckets {
|
||||
if *bucket.Name == testKeycloakBucket {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Created bucket should be listed")
|
||||
})
|
||||
|
||||
t.Run("read_only_user_authentication", func(t *testing.T) {
|
||||
// Test read-only user authentication
|
||||
token, err := framework.getKeycloakToken("read-user")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token, "JWT token should not be empty")
|
||||
|
||||
// Debug: decode token to verify it's for read-user
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) >= 2 {
|
||||
payload := parts[1]
|
||||
// JWTs use URL-safe base64 encoding without padding (RFC 4648 §5)
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
if err == nil {
|
||||
var claims map[string]interface{}
|
||||
if json.Unmarshal(decoded, &claims) == nil {
|
||||
t.Logf("Token username: %v", claims["preferred_username"])
|
||||
t.Logf("Token roles: %v", claims["roles"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First test with direct HTTP request to verify OIDC authentication works
|
||||
t.Logf("Testing with direct HTTP request...")
|
||||
err = framework.TestKeycloakTokenDirectly(token)
|
||||
require.NoError(t, err, "Direct HTTP test should succeed")
|
||||
|
||||
// Create S3 client with Keycloak token
|
||||
s3Client, err := framework.CreateS3ClientWithKeycloakToken(token)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test that read-only user can list buckets
|
||||
t.Logf("Testing ListBuckets with AWS SDK...")
|
||||
_, err = s3Client.ListBuckets(&s3.ListBucketsInput{})
|
||||
assert.NoError(t, err, "Read-only user should be able to list buckets")
|
||||
|
||||
// Test that read-only user cannot create buckets
|
||||
t.Logf("Testing CreateBucket with AWS SDK...")
|
||||
err = framework.CreateBucket(s3Client, testKeycloakBucket+"-readonly")
|
||||
assert.Error(t, err, "Read-only user should not be able to create buckets")
|
||||
})
|
||||
|
||||
t.Run("invalid_user_authentication", func(t *testing.T) {
|
||||
// Test authentication with invalid credentials
|
||||
_, err := framework.keycloakClient.AuthenticateUser("invalid-user", "invalid-password")
|
||||
assert.Error(t, err, "Authentication with invalid credentials should fail")
|
||||
})
|
||||
}
|
||||
|
||||
// TestKeycloakTokenExpiration tests JWT token expiration handling
|
||||
func TestKeycloakTokenExpiration(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
if !framework.useKeycloak {
|
||||
t.Skip("Keycloak not available, skipping integration tests")
|
||||
}
|
||||
|
||||
// Get a short-lived token (if Keycloak is configured for it)
|
||||
// Use consistent password that matches Docker setup script logic: "adminuser123"
|
||||
tokenResp, err := framework.keycloakClient.AuthenticateUser("admin-user", "adminuser123")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify token properties
|
||||
assert.NotEmpty(t, tokenResp.AccessToken, "Access token should not be empty")
|
||||
assert.Equal(t, "Bearer", tokenResp.TokenType, "Token type should be Bearer")
|
||||
assert.Greater(t, tokenResp.ExpiresIn, 0, "Token should have expiration time")
|
||||
|
||||
// Test that token works initially
|
||||
token, err := framework.getKeycloakToken("admin-user")
|
||||
require.NoError(t, err)
|
||||
|
||||
s3Client, err := framework.CreateS3ClientWithKeycloakToken(token)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s3Client.ListBuckets(&s3.ListBucketsInput{})
|
||||
assert.NoError(t, err, "Fresh token should work for S3 operations")
|
||||
}
|
||||
|
||||
// TestKeycloakRoleMapping tests role mapping from Keycloak to S3 policies
|
||||
func TestKeycloakRoleMapping(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
if !framework.useKeycloak {
|
||||
t.Skip("Keycloak not available, skipping integration tests")
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
username string
|
||||
expectedRole string
|
||||
canCreateBucket bool
|
||||
canListBuckets bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
username: "admin-user",
|
||||
expectedRole: "S3AdminRole",
|
||||
canCreateBucket: true,
|
||||
canListBuckets: true,
|
||||
description: "Admin user should have full access",
|
||||
},
|
||||
{
|
||||
username: "read-user",
|
||||
expectedRole: "S3ReadOnlyRole",
|
||||
canCreateBucket: false,
|
||||
canListBuckets: true,
|
||||
description: "Read-only user should have read-only access",
|
||||
},
|
||||
{
|
||||
username: "write-user",
|
||||
expectedRole: "S3ReadWriteRole",
|
||||
canCreateBucket: true,
|
||||
canListBuckets: true,
|
||||
description: "Read-write user should have read-write access",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.username, func(t *testing.T) {
|
||||
// Get Keycloak token for the user
|
||||
token, err := framework.getKeycloakToken(tc.username)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create S3 client with Keycloak token
|
||||
s3Client, err := framework.CreateS3ClientWithKeycloakToken(token)
|
||||
require.NoError(t, err, tc.description)
|
||||
|
||||
// Test list buckets permission
|
||||
_, err = s3Client.ListBuckets(&s3.ListBucketsInput{})
|
||||
if tc.canListBuckets {
|
||||
assert.NoError(t, err, "%s should be able to list buckets", tc.username)
|
||||
} else {
|
||||
assert.Error(t, err, "%s should not be able to list buckets", tc.username)
|
||||
}
|
||||
|
||||
// Test create bucket permission
|
||||
testBucketName := testKeycloakBucket + "-" + tc.username
|
||||
err = framework.CreateBucket(s3Client, testBucketName)
|
||||
if tc.canCreateBucket {
|
||||
assert.NoError(t, err, "%s should be able to create buckets", tc.username)
|
||||
} else {
|
||||
assert.Error(t, err, "%s should not be able to create buckets", tc.username)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeycloakS3Operations tests comprehensive S3 operations with Keycloak authentication
|
||||
func TestKeycloakS3Operations(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
if !framework.useKeycloak {
|
||||
t.Skip("Keycloak not available, skipping integration tests")
|
||||
}
|
||||
|
||||
// Use admin user for comprehensive testing
|
||||
token, err := framework.getKeycloakToken("admin-user")
|
||||
require.NoError(t, err)
|
||||
|
||||
s3Client, err := framework.CreateS3ClientWithKeycloakToken(token)
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := testKeycloakBucket + "-operations"
|
||||
|
||||
t.Run("bucket_lifecycle", func(t *testing.T) {
|
||||
// Create bucket
|
||||
err = framework.CreateBucket(s3Client, bucketName)
|
||||
require.NoError(t, err, "Should be able to create bucket")
|
||||
|
||||
// Verify bucket exists
|
||||
buckets, err := s3Client.ListBuckets(&s3.ListBucketsInput{})
|
||||
require.NoError(t, err)
|
||||
|
||||
found := false
|
||||
for _, bucket := range buckets.Buckets {
|
||||
if *bucket.Name == bucketName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Created bucket should be listed")
|
||||
})
|
||||
|
||||
t.Run("object_operations", func(t *testing.T) {
|
||||
objectKey := "test-object.txt"
|
||||
objectContent := "Hello from Keycloak-authenticated SeaweedFS!"
|
||||
|
||||
// Put object
|
||||
err = framework.PutTestObject(s3Client, bucketName, objectKey, objectContent)
|
||||
require.NoError(t, err, "Should be able to put object")
|
||||
|
||||
// Get object
|
||||
content, err := framework.GetTestObject(s3Client, bucketName, objectKey)
|
||||
require.NoError(t, err, "Should be able to get object")
|
||||
assert.Equal(t, objectContent, content, "Object content should match")
|
||||
|
||||
// List objects
|
||||
objects, err := framework.ListTestObjects(s3Client, bucketName)
|
||||
require.NoError(t, err, "Should be able to list objects")
|
||||
assert.Contains(t, objects, objectKey, "Object should be listed")
|
||||
|
||||
// Delete object
|
||||
err = framework.DeleteTestObject(s3Client, bucketName, objectKey)
|
||||
assert.NoError(t, err, "Should be able to delete object")
|
||||
})
|
||||
}
|
||||
|
||||
// TestKeycloakFailover tests fallback to mock OIDC when Keycloak is unavailable
|
||||
func TestKeycloakFailover(t *testing.T) {
|
||||
// Temporarily override Keycloak URL to simulate unavailability
|
||||
originalURL := os.Getenv("KEYCLOAK_URL")
|
||||
os.Setenv("KEYCLOAK_URL", "http://localhost:9999") // Non-existent service
|
||||
defer func() {
|
||||
if originalURL != "" {
|
||||
os.Setenv("KEYCLOAK_URL", originalURL)
|
||||
} else {
|
||||
os.Unsetenv("KEYCLOAK_URL")
|
||||
}
|
||||
}()
|
||||
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
// Should fall back to mock OIDC
|
||||
assert.False(t, framework.useKeycloak, "Should fall back to mock OIDC when Keycloak is unavailable")
|
||||
assert.Nil(t, framework.keycloakClient, "Keycloak client should not be initialized")
|
||||
assert.NotNil(t, framework.mockOIDC, "Mock OIDC server should be initialized")
|
||||
|
||||
// Test that mock authentication still works
|
||||
s3Client, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err, "Should be able to create S3 client with mock authentication")
|
||||
|
||||
// Basic operation should work
|
||||
_, err = s3Client.ListBuckets(&s3.ListBucketsInput{})
|
||||
// Note: This may still fail due to session store issues, but the client creation should work
|
||||
}
|
||||
457
test/s3/iam/s3_policy_variables_test.go
Normal file
457
test/s3/iam/s3_policy_variables_test.go
Normal file
@@ -0,0 +1,457 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestS3PolicyVariablesUsernameInResource tests ${aws:username} in resource paths
|
||||
func TestS3PolicyVariablesUsernameInResource(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := framework.GenerateUniqueBucketName("test-policy-vars")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
// Policy with ${aws:username} in resource
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/${aws:username}/*"]
|
||||
}, {
|
||||
"Sid": "DenyOthers",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"NotResource": ["arn:aws:s3:::%s/${aws:username}/*"]
|
||||
}]
|
||||
}`, bucketName, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify policy contains variable
|
||||
policyResult, err := adminClient.GetBucketPolicy(&s3.GetBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, *policyResult.Policy, "${aws:username}")
|
||||
|
||||
// Test Enforcement: Alice should be able to write to her own folder
|
||||
aliceClient, err := framework.CreateS3ClientWithJWT("alice", "TestReadOnlyRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = aliceClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("alice/file.txt"),
|
||||
Body: nil, // Empty body is fine for this test
|
||||
})
|
||||
assert.NoError(t, err, "Alice should be allowed to put to alice/file.txt")
|
||||
|
||||
// Test Enforcement: Alice should NOT be able to write to bob's folder
|
||||
_, err = aliceClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("bob/file.txt"),
|
||||
Body: nil,
|
||||
})
|
||||
assert.Error(t, err, "Alice should be denied put to bob/file.txt")
|
||||
}
|
||||
|
||||
// TestS3PolicyVariablesUsernameInResourcePath tests ${aws:username} in Resource/NotResource
|
||||
// This validates that policy variables are correctly substituted in resource ARNs
|
||||
func TestS3PolicyVariablesUsernameInResourcePath(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := framework.GenerateUniqueBucketName("test-policy-resource")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
// Policy with variable in resource ARN
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/${aws:username}/*"]
|
||||
}, {
|
||||
"Sid": "DenyOthersFolders",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"NotResource": ["arn:aws:s3:::%s/${aws:username}/*"]
|
||||
}]
|
||||
}`, bucketName, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
policyResult, err := adminClient.GetBucketPolicy(&s3.GetBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, *policyResult.Policy, "${aws:username}")
|
||||
|
||||
// Test Enforcement: Alice should be able to write to her own folder
|
||||
aliceClient, err := framework.CreateS3ClientWithJWT("alice", "TestReadOnlyRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = aliceClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("alice/file.txt"),
|
||||
Body: nil, // Empty body is fine for this test
|
||||
})
|
||||
assert.NoError(t, err, "Alice should be allowed to put to alice/file.txt")
|
||||
|
||||
// Test Enforcement: Alice should NOT be able to write to bob's folder
|
||||
_, err = aliceClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("bob/file.txt"),
|
||||
Body: nil,
|
||||
})
|
||||
assert.Error(t, err, "Alice should be denied put to bob/file.txt")
|
||||
}
|
||||
|
||||
// TestS3PolicyVariablesJWTClaims tests ${jwt:*} variables
|
||||
func TestS3PolicyVariablesJWTClaims(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := framework.GenerateUniqueBucketName("test-policy-jwt")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
// Policy with JWT claim variable
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/${jwt:preferred_username}/*"]
|
||||
}]
|
||||
}`, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
policyResult, err := adminClient.GetBucketPolicy(&s3.GetBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, *policyResult.Policy, "jwt:preferred_username")
|
||||
}
|
||||
|
||||
func TestS3PolicyVariablesUsernameIsolation(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := framework.GenerateUniqueBucketName("test-isolation")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Sid": "AllowOwnFolder",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": "arn:aws:s3:::%s/${aws:username}/*"
|
||||
}, {
|
||||
"Sid": "AllowListOwnPrefix",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::%s",
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"s3:prefix": ["${aws:username}/*", "${aws:username}"]
|
||||
}
|
||||
}
|
||||
}, {
|
||||
"Sid": "DenyOtherFolders",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"NotResource": "arn:aws:s3:::%s/${aws:username}/*"
|
||||
}, {
|
||||
"Sid": "DenyListOtherPrefixes",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::%s",
|
||||
"Condition": {
|
||||
"StringNotLike": {
|
||||
"s3:prefix": ["${aws:username}/*", "${aws:username}"]
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`, bucketName, bucketName, bucketName, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for policy to propagate (fix race condition)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
aliceClient, err := framework.CreateS3ClientWithJWT("alice", "TestReadOnlyRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bobClient, err := framework.CreateS3ClientWithJWT("bob", "TestReadOnlyRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = aliceClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("alice/data.txt"),
|
||||
Body: strings.NewReader("Alice Private Data"),
|
||||
})
|
||||
assert.NoError(t, err, "Alice should be able to upload to her own folder")
|
||||
|
||||
_, err = aliceClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("bob/data.txt"),
|
||||
Body: strings.NewReader("Alice Intrusion"),
|
||||
})
|
||||
assert.Error(t, err, "Alice should be denied access to Bob's folder")
|
||||
|
||||
_, err = bobClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("bob/data.txt"),
|
||||
Body: strings.NewReader("Bob Private Data"),
|
||||
})
|
||||
assert.NoError(t, err, "Bob should be able to upload to his own folder")
|
||||
|
||||
_, err = bobClient.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("alice/data.txt"),
|
||||
})
|
||||
assert.Error(t, err, "Bob should be denied access to Alice's folder")
|
||||
|
||||
listAlice, err := aliceClient.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Prefix: aws.String("alice/"),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(listAlice.Contents))
|
||||
|
||||
_, err = aliceClient.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Prefix: aws.String("bob/"),
|
||||
})
|
||||
assert.Error(t, err, "Alice should be denied listing Bob's folder")
|
||||
}
|
||||
|
||||
func TestS3PolicyVariablesAccountEnforcement(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := framework.GenerateUniqueBucketName("test-account")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": ["arn:aws:s3:::%s/*"],
|
||||
"Condition": {
|
||||
"StringNotEquals": {
|
||||
"aws:PrincipalAccount": ["999988887777"]
|
||||
}
|
||||
}
|
||||
}, {
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": ["arn:aws:s3:::%s/*"]
|
||||
}]
|
||||
}`, bucketName, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
authorizedClient, err := framework.CreateS3ClientWithCustomClaims("user1", "TestAdminRole", "999988887777", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
unauthorizedClient, err := framework.CreateS3ClientWithCustomClaims("user2", "TestAdminRole", "111122223333", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = authorizedClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("test.txt"),
|
||||
Body: strings.NewReader("Authorized Data"),
|
||||
})
|
||||
assert.NoError(t, err, "Authorized account should be able to upload")
|
||||
|
||||
_, err = unauthorizedClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("fail.txt"),
|
||||
Body: strings.NewReader("Unauthorized Data"),
|
||||
})
|
||||
assert.Error(t, err, "Unauthorized account should be denied")
|
||||
}
|
||||
|
||||
func TestS3PolicyVariablesJWTPreferredUsername(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := framework.GenerateUniqueBucketName("test-jwt-claim")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Sid": "AllowOwnFolder",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:PutObject",
|
||||
"Resource": "arn:aws:s3:::%s/${jwt:preferred_username}/*"
|
||||
}, {
|
||||
"Sid": "DenyOtherFolders",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": "s3:PutObject",
|
||||
"NotResource": "arn:aws:s3:::%s/${jwt:preferred_username}/*"
|
||||
}]
|
||||
}`, bucketName, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"preferred_username": "jdoe",
|
||||
}
|
||||
client, err := framework.CreateS3ClientWithCustomClaims("jdoe", "TestReadOnlyRole", "", claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("jdoe/file.txt"),
|
||||
Body: strings.NewReader("JWT Claim Data"),
|
||||
})
|
||||
assert.NoError(t, err, "Should allow access based on jwt:preferred_username")
|
||||
|
||||
_, err = client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("other/file.txt"),
|
||||
Body: strings.NewReader("JWT Claim Data"),
|
||||
})
|
||||
assert.Error(t, err, "Should deny access if prefix doesn't match jwt:preferred_username")
|
||||
}
|
||||
|
||||
func TestS3PolicyVariablesLDAPClaims(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := framework.GenerateUniqueBucketName("test-ldap-claim")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/${ldap:username}/*"]
|
||||
}, {
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/*"],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"ldap:dn": ["cn=manager,dc=example,dc=org"]
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`, bucketName, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"ldap:username": "manager",
|
||||
"ldap:dn": "cn=manager,dc=example,dc=org",
|
||||
}
|
||||
client, err := framework.CreateS3ClientWithCustomClaims("manager", "TestReadOnlyRole", "", claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("manager/data.txt"),
|
||||
Body: strings.NewReader("LDAP Upload"),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = client.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("manager/data.txt"),
|
||||
})
|
||||
assert.NoError(t, err, "Should allow download based on ldap:dn condition")
|
||||
}
|
||||
431
test/s3/iam/s3_service_account_security_test.go
Normal file
431
test/s3/iam/s3_service_account_security_test.go
Normal file
@@ -0,0 +1,431 @@
|
||||
package iam
|
||||
|
||||
// Integration tests for SeaweedFS service accounts.
|
||||
// These tests ensure comprehensive coverage of service account functionality
|
||||
// including security, access control, and expiration.
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestServiceAccountS3Access verifies that service accounts can actually
|
||||
// perform S3 operations using their credentials.
|
||||
func TestServiceAccountS3Access(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSeaweedFSRunning(t) {
|
||||
t.Skip("SeaweedFS is not running at", TestIAMEndpoint)
|
||||
}
|
||||
|
||||
// Setup: Create a parent user
|
||||
parentUserName := fmt.Sprintf("s3access-test-%d", time.Now().UnixNano())
|
||||
|
||||
// Create parent user
|
||||
resp, err := callIAMAPI(t, "CreateUser", url.Values{
|
||||
"UserName": {parentUserName},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "Failed to create parent user")
|
||||
|
||||
defer func() {
|
||||
// Cleanup: delete parent user
|
||||
callIAMAPI(t, "DeleteUser", url.Values{"UserName": {parentUserName}})
|
||||
}()
|
||||
|
||||
// Create service account for the parent user
|
||||
createResp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{
|
||||
"ParentUser": {parentUserName},
|
||||
"Description": {"S3 Access Test Service Account"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, createResp.StatusCode, "Failed to create service account")
|
||||
|
||||
body, err := io.ReadAll(createResp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var saResp CreateServiceAccountResponse
|
||||
err = xml.Unmarshal(body, &saResp)
|
||||
require.NoError(t, err, "Failed to parse CreateServiceAccount response: %s", string(body))
|
||||
|
||||
accessKeyId := saResp.CreateServiceAccountResult.ServiceAccount.AccessKeyId
|
||||
secretAccessKey := saResp.CreateServiceAccountResult.ServiceAccount.SecretAccessKey
|
||||
saId := saResp.CreateServiceAccountResult.ServiceAccount.ServiceAccountId
|
||||
|
||||
require.NotEmpty(t, accessKeyId, "AccessKeyId should not be empty")
|
||||
require.NotEmpty(t, secretAccessKey, "SecretAccessKey should not be empty")
|
||||
|
||||
defer func() {
|
||||
// Cleanup: delete service account
|
||||
callIAMAPI(t, "DeleteServiceAccount", url.Values{"ServiceAccountId": {saId}})
|
||||
}()
|
||||
|
||||
t.Run("list_buckets_with_sa_credentials", func(t *testing.T) {
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String("us-east-1"),
|
||||
Endpoint: aws.String(TestIAMEndpoint),
|
||||
Credentials: credentials.NewStaticCredentials(
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
"",
|
||||
),
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
s3Client := s3.New(sess)
|
||||
_, err = s3Client.ListBuckets(&s3.ListBucketsInput{})
|
||||
|
||||
// We don't necessarily expect success (depends on permissions),
|
||||
// but we should NOT get InvalidAccessKeyId or SignatureDoesNotMatch
|
||||
if err != nil {
|
||||
if aerr, ok := err.(awserr.Error); ok {
|
||||
assert.NotEqual(t, "InvalidAccessKeyId", aerr.Code(),
|
||||
"Service account credentials should be recognized")
|
||||
assert.NotEqual(t, "SignatureDoesNotMatch", aerr.Code(),
|
||||
"Service account signature should be valid")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create_bucket_with_sa_credentials", func(t *testing.T) {
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String("us-east-1"),
|
||||
Endpoint: aws.String(TestIAMEndpoint),
|
||||
Credentials: credentials.NewStaticCredentials(
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
"",
|
||||
),
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
s3Client := s3.New(sess)
|
||||
bucketName := fmt.Sprintf("sa-test-bucket-%d", time.Now().UnixNano())
|
||||
|
||||
_, err = s3Client.CreateBucket(&s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
|
||||
// Check that we get a proper response (success or AccessDenied based on policy)
|
||||
// but NOT InvalidAccessKeyId
|
||||
if err != nil {
|
||||
if aerr, ok := err.(awserr.Error); ok {
|
||||
assert.NotEqual(t, "InvalidAccessKeyId", aerr.Code(),
|
||||
"Service account credentials should be recognized")
|
||||
assert.NotEqual(t, "SignatureDoesNotMatch", aerr.Code(),
|
||||
"Service account signature should be valid")
|
||||
}
|
||||
} else {
|
||||
// Cleanup if bucket was created
|
||||
defer s3Client.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestServiceAccountExpiration verifies that expired service accounts
|
||||
// are properly rejected.
|
||||
func TestServiceAccountExpiration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSeaweedFSRunning(t) {
|
||||
t.Skip("SeaweedFS is not running at", TestIAMEndpoint)
|
||||
}
|
||||
|
||||
// Setup: Create a parent user
|
||||
parentUserName := fmt.Sprintf("expiry-test-%d", time.Now().UnixNano())
|
||||
|
||||
resp, err := callIAMAPI(t, "CreateUser", url.Values{
|
||||
"UserName": {parentUserName},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
defer func() {
|
||||
callIAMAPI(t, "DeleteUser", url.Values{"UserName": {parentUserName}})
|
||||
}()
|
||||
|
||||
t.Run("reject_past_expiration", func(t *testing.T) {
|
||||
// Try to create a service account with expiration in the past
|
||||
pastExpiration := time.Now().Add(-1 * time.Hour).Unix()
|
||||
|
||||
createResp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{
|
||||
"ParentUser": {parentUserName},
|
||||
"Description": {"Should fail - past expiration"},
|
||||
"Expiration": {strconv.FormatInt(pastExpiration, 10)},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
|
||||
// Should fail because expiration is in the past
|
||||
assert.NotEqual(t, http.StatusOK, createResp.StatusCode,
|
||||
"Creating service account with past expiration should fail")
|
||||
})
|
||||
|
||||
t.Run("accept_future_expiration", func(t *testing.T) {
|
||||
// Create a service account with expiration in the future
|
||||
futureExpiration := time.Now().Add(24 * time.Hour).Unix()
|
||||
|
||||
createResp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{
|
||||
"ParentUser": {parentUserName},
|
||||
"Description": {"Should succeed - future expiration"},
|
||||
"Expiration": {strconv.FormatInt(futureExpiration, 10)},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, createResp.StatusCode,
|
||||
"Creating service account with future expiration should succeed")
|
||||
|
||||
// Parse response to get service account ID for cleanup
|
||||
if createResp.StatusCode == http.StatusOK {
|
||||
body, err := io.ReadAll(createResp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var saResp CreateServiceAccountResponse
|
||||
require.NoError(t, xml.Unmarshal(body, &saResp))
|
||||
|
||||
saId := saResp.CreateServiceAccountResult.ServiceAccount.ServiceAccountId
|
||||
if saId != "" {
|
||||
t.Cleanup(func() {
|
||||
callIAMAPI(t, "DeleteServiceAccount", url.Values{
|
||||
"ServiceAccountId": {saId},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reject_past_expiration_on_update", func(t *testing.T) {
|
||||
// Create a valid service account first
|
||||
futureExpiration := time.Now().Add(24 * time.Hour).Unix()
|
||||
|
||||
createResp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{
|
||||
"ParentUser": {parentUserName},
|
||||
"Description": {"For update test"},
|
||||
"Expiration": {strconv.FormatInt(futureExpiration, 10)},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, createResp.StatusCode)
|
||||
|
||||
body, err := io.ReadAll(createResp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var saResp CreateServiceAccountResponse
|
||||
err = xml.Unmarshal(body, &saResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
saId := saResp.CreateServiceAccountResult.ServiceAccount.ServiceAccountId
|
||||
require.NotEmpty(t, saId)
|
||||
|
||||
defer func() {
|
||||
callIAMAPI(t, "DeleteServiceAccount", url.Values{"ServiceAccountId": {saId}})
|
||||
}()
|
||||
|
||||
// Try to update with past expiration
|
||||
pastExpiration := time.Now().Add(-1 * time.Hour).Unix()
|
||||
|
||||
updateResp, err := callIAMAPI(t, "UpdateServiceAccount", url.Values{
|
||||
"ServiceAccountId": {saId},
|
||||
"Expiration": {strconv.FormatInt(pastExpiration, 10)},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer updateResp.Body.Close()
|
||||
|
||||
// Should fail because expiration is in the past
|
||||
assert.NotEqual(t, http.StatusOK, updateResp.StatusCode,
|
||||
"Updating service account with past expiration should fail")
|
||||
})
|
||||
}
|
||||
|
||||
// TestServiceAccountInheritedPermissions verifies that service accounts
|
||||
// inherit their parent user's permissions.
|
||||
// This is a key security test - SAs should not have MORE permissions than parent.
|
||||
func TestServiceAccountInheritedPermissions(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSeaweedFSRunning(t) {
|
||||
t.Skip("SeaweedFS is not running at", TestIAMEndpoint)
|
||||
}
|
||||
|
||||
// Setup: Create a parent user
|
||||
parentUserName := fmt.Sprintf("inherit-test-%d", time.Now().UnixNano())
|
||||
|
||||
resp, err := callIAMAPI(t, "CreateUser", url.Values{
|
||||
"UserName": {parentUserName},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
defer func() {
|
||||
callIAMAPI(t, "DeleteUser", url.Values{"UserName": {parentUserName}})
|
||||
}()
|
||||
|
||||
// Create service account
|
||||
createResp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{
|
||||
"ParentUser": {parentUserName},
|
||||
"Description": {"Permissions inheritance test"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, createResp.StatusCode)
|
||||
|
||||
body, err := io.ReadAll(createResp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var saResp CreateServiceAccountResponse
|
||||
err = xml.Unmarshal(body, &saResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
saId := saResp.CreateServiceAccountResult.ServiceAccount.ServiceAccountId
|
||||
require.NotEmpty(t, saId)
|
||||
|
||||
defer func() {
|
||||
callIAMAPI(t, "DeleteServiceAccount", url.Values{"ServiceAccountId": {saId}})
|
||||
}()
|
||||
|
||||
t.Run("service_account_linked_to_parent", func(t *testing.T) {
|
||||
// Verify the service account is correctly linked to the parent
|
||||
getResp, err := callIAMAPI(t, "GetServiceAccount", url.Values{
|
||||
"ServiceAccountId": {saId},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(getResp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result GetServiceAccountResponse
|
||||
err = xml.Unmarshal(body, &result)
|
||||
require.NoError(t, err, "Failed to parse response: %s", string(body))
|
||||
|
||||
assert.Equal(t, parentUserName, result.GetServiceAccountResult.ServiceAccount.ParentUser,
|
||||
"Service account should be linked to correct parent user")
|
||||
})
|
||||
|
||||
t.Run("list_shows_correct_parent", func(t *testing.T) {
|
||||
// List service accounts filtered by parent
|
||||
listResp, err := callIAMAPI(t, "ListServiceAccounts", url.Values{
|
||||
"ParentUser": {parentUserName},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer listResp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(listResp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var listResult ListServiceAccountsResponse
|
||||
err = xml.Unmarshal(body, &listResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should find at least one service account for this parent
|
||||
found := false
|
||||
for _, sa := range listResult.ListServiceAccountsResult.ServiceAccounts {
|
||||
if sa.ServiceAccountId == saId {
|
||||
found = true
|
||||
assert.Equal(t, parentUserName, sa.ParentUser)
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Service account should appear in list filtered by parent")
|
||||
})
|
||||
}
|
||||
|
||||
// TestServiceAccountAccessKeyFormat verifies that service account access keys
|
||||
// follow the correct AWS format.
|
||||
func TestServiceAccountAccessKeyFormat(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSeaweedFSRunning(t) {
|
||||
t.Skip("SeaweedFS is not running at", TestIAMEndpoint)
|
||||
}
|
||||
|
||||
// Setup: Create a parent user
|
||||
parentUserName := fmt.Sprintf("keyformat-test-%d", time.Now().UnixNano())
|
||||
|
||||
resp, err := callIAMAPI(t, "CreateUser", url.Values{
|
||||
"UserName": {parentUserName},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
defer func() {
|
||||
callIAMAPI(t, "DeleteUser", url.Values{"UserName": {parentUserName}})
|
||||
}()
|
||||
|
||||
createResp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{
|
||||
"ParentUser": {parentUserName},
|
||||
"Description": {"Key format test"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, createResp.StatusCode)
|
||||
|
||||
body, err := io.ReadAll(createResp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var saResp CreateServiceAccountResponse
|
||||
err = xml.Unmarshal(body, &saResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
accessKeyId := saResp.CreateServiceAccountResult.ServiceAccount.AccessKeyId
|
||||
secretAccessKey := saResp.CreateServiceAccountResult.ServiceAccount.SecretAccessKey
|
||||
saId := saResp.CreateServiceAccountResult.ServiceAccount.ServiceAccountId
|
||||
|
||||
defer func() {
|
||||
callIAMAPI(t, "DeleteServiceAccount", url.Values{"ServiceAccountId": {saId}})
|
||||
}()
|
||||
|
||||
t.Run("access_key_has_correct_prefix", func(t *testing.T) {
|
||||
// Service account access keys should start with ABIA
|
||||
assert.True(t, len(accessKeyId) >= 4,
|
||||
"Access key should be at least 4 characters")
|
||||
assert.Equal(t, "ABIA", accessKeyId[:4],
|
||||
"Service account access key should start with ABIA prefix")
|
||||
})
|
||||
|
||||
t.Run("access_key_has_correct_length", func(t *testing.T) {
|
||||
// AWS access keys are 20 characters
|
||||
assert.Equal(t, 20, len(accessKeyId),
|
||||
"Access key should be exactly 20 characters (AWS standard)")
|
||||
})
|
||||
|
||||
t.Run("secret_key_has_correct_length", func(t *testing.T) {
|
||||
// AWS secret keys are 40 characters
|
||||
assert.Equal(t, 40, len(secretAccessKey),
|
||||
"Secret key should be exactly 40 characters (AWS standard)")
|
||||
})
|
||||
}
|
||||
379
test/s3/iam/s3_service_account_test.go
Normal file
379
test/s3/iam/s3_service_account_test.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Service Account API test constants
|
||||
const (
|
||||
TestIAMEndpoint = "http://localhost:8333"
|
||||
)
|
||||
|
||||
// ServiceAccountInfo represents the response structure for service account operations
|
||||
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"`
|
||||
Status string `xml:"Status"`
|
||||
Expiration string `xml:"Expiration,omitempty"`
|
||||
CreateDate string `xml:"CreateDate"`
|
||||
}
|
||||
|
||||
// CreateServiceAccountResponse represents the response for CreateServiceAccount
|
||||
type CreateServiceAccountResponse struct {
|
||||
XMLName xml.Name `xml:"CreateServiceAccountResponse"`
|
||||
CreateServiceAccountResult struct {
|
||||
ServiceAccount ServiceAccountInfo `xml:"ServiceAccount"`
|
||||
} `xml:"CreateServiceAccountResult"`
|
||||
}
|
||||
|
||||
// ListServiceAccountsResponse represents the response for ListServiceAccounts
|
||||
type ListServiceAccountsResponse struct {
|
||||
XMLName xml.Name `xml:"ListServiceAccountsResponse"`
|
||||
ListServiceAccountsResult struct {
|
||||
ServiceAccounts []ServiceAccountInfo `xml:"ServiceAccounts>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
} `xml:"ListServiceAccountsResult"`
|
||||
}
|
||||
|
||||
// GetServiceAccountResponse represents the response for GetServiceAccount
|
||||
type GetServiceAccountResponse struct {
|
||||
XMLName xml.Name `xml:"GetServiceAccountResponse"`
|
||||
GetServiceAccountResult struct {
|
||||
ServiceAccount ServiceAccountInfo `xml:"ServiceAccount"`
|
||||
} `xml:"GetServiceAccountResult"`
|
||||
}
|
||||
|
||||
// TestServiceAccountLifecycle tests the complete lifecycle of service accounts
|
||||
// This is a high-value test covering Create, Get, List, Update, Delete operations
|
||||
func TestServiceAccountLifecycle(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
// Check if SeaweedFS is running
|
||||
if !isSeaweedFSRunning(t) {
|
||||
t.Skip("SeaweedFS is not running at", TestIAMEndpoint)
|
||||
}
|
||||
|
||||
// First, ensure the parent user exists
|
||||
parentUserName := fmt.Sprintf("testuser-%d", time.Now().UnixNano())
|
||||
|
||||
t.Run("create_parent_user", func(t *testing.T) {
|
||||
resp, err := callIAMAPI(t, "CreateUser", url.Values{
|
||||
"UserName": {parentUserName},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "CreateUser should succeed")
|
||||
})
|
||||
|
||||
// Store service account IDs for cleanup
|
||||
var createdServiceAccounts []string
|
||||
|
||||
defer func() {
|
||||
// Cleanup: delete all service accounts first, then parent user
|
||||
for _, saId := range createdServiceAccounts {
|
||||
resp, _ := callIAMAPI(t, "DeleteServiceAccount", url.Values{
|
||||
"ServiceAccountId": {saId},
|
||||
})
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
// Now delete the parent user
|
||||
resp, _ := callIAMAPI(t, "DeleteUser", url.Values{
|
||||
"UserName": {parentUserName},
|
||||
})
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
var createdSAId string
|
||||
var createdAccessKeyId string
|
||||
var createdSecretAccessKey string
|
||||
|
||||
t.Run("create_service_account", func(t *testing.T) {
|
||||
resp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{
|
||||
"ParentUser": {parentUserName},
|
||||
"Description": {"Test service account for CI/CD"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "CreateServiceAccount should succeed")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var createResp CreateServiceAccountResponse
|
||||
err = xml.Unmarshal(body, &createResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
sa := createResp.CreateServiceAccountResult.ServiceAccount
|
||||
createdSAId = sa.ServiceAccountId
|
||||
createdAccessKeyId = sa.AccessKeyId
|
||||
createdSecretAccessKey = sa.SecretAccessKey
|
||||
|
||||
// Add to cleanup list
|
||||
createdServiceAccounts = append(createdServiceAccounts, createdSAId)
|
||||
|
||||
assert.NotEmpty(t, createdSAId, "ServiceAccountId should not be empty")
|
||||
assert.Equal(t, parentUserName, sa.ParentUser, "ParentUser should match")
|
||||
assert.Equal(t, "Test service account for CI/CD", sa.Description)
|
||||
assert.Equal(t, "Active", sa.Status)
|
||||
assert.NotEmpty(t, sa.AccessKeyId, "AccessKeyId should not be empty")
|
||||
assert.NotEmpty(t, sa.SecretAccessKey, "SecretAccessKey should be returned on create")
|
||||
assert.True(t, strings.HasPrefix(sa.AccessKeyId, "ABIA"),
|
||||
"Service account AccessKeyId should have ABIA prefix")
|
||||
|
||||
t.Logf("Created service account: ID=%s, AccessKeyId=%s", createdSAId, createdAccessKeyId)
|
||||
})
|
||||
|
||||
t.Run("get_service_account", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdSAId, "Service account should have been created")
|
||||
|
||||
resp, err := callIAMAPI(t, "GetServiceAccount", url.Values{
|
||||
"ServiceAccountId": {createdSAId},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var getResp GetServiceAccountResponse
|
||||
err = xml.Unmarshal(body, &getResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
sa := getResp.GetServiceAccountResult.ServiceAccount
|
||||
assert.Equal(t, createdSAId, sa.ServiceAccountId)
|
||||
assert.Equal(t, parentUserName, sa.ParentUser)
|
||||
assert.Empty(t, sa.SecretAccessKey, "SecretAccessKey should not be returned on Get")
|
||||
})
|
||||
|
||||
t.Run("list_service_accounts", func(t *testing.T) {
|
||||
resp, err := callIAMAPI(t, "ListServiceAccounts", url.Values{
|
||||
"ParentUser": {parentUserName},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var listResp ListServiceAccountsResponse
|
||||
err = xml.Unmarshal(body, &listResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.GreaterOrEqual(t, len(listResp.ListServiceAccountsResult.ServiceAccounts), 1,
|
||||
"Should have at least one service account for the parent user")
|
||||
})
|
||||
|
||||
t.Run("update_service_account_status", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdSAId)
|
||||
|
||||
// Disable the service account
|
||||
resp, err := callIAMAPI(t, "UpdateServiceAccount", url.Values{
|
||||
"ServiceAccountId": {createdSAId},
|
||||
"Status": {"Inactive"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Verify it's now inactive
|
||||
getResp, err := callIAMAPI(t, "GetServiceAccount", url.Values{
|
||||
"ServiceAccountId": {createdSAId},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(getResp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result GetServiceAccountResponse
|
||||
err = xml.Unmarshal(body, &result)
|
||||
require.NoError(t, err, "Failed to parse response: %s", string(body))
|
||||
|
||||
assert.Equal(t, "Inactive", result.GetServiceAccountResult.ServiceAccount.Status)
|
||||
})
|
||||
|
||||
// Test that credentials could be used (verify they work with AWS SDK)
|
||||
// This must run BEFORE delete_service_account to use valid credentials
|
||||
t.Run("use_service_account_credentials", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdAccessKeyId)
|
||||
require.NotEmpty(t, createdSecretAccessKey)
|
||||
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String("us-east-1"),
|
||||
Endpoint: aws.String(TestIAMEndpoint), // IAM and S3 usually on same port in mini-seaweed
|
||||
Credentials: credentials.NewStaticCredentials(
|
||||
createdAccessKeyId,
|
||||
createdSecretAccessKey,
|
||||
"",
|
||||
),
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
s3Client := s3.New(sess)
|
||||
_, err = s3Client.ListBuckets(&s3.ListBucketsInput{})
|
||||
|
||||
// Note: we don't necessarily expect success if no buckets/permissions
|
||||
// but we expect it not to fail with "InvalidAccessKeyId" or "SignatureDoesNotMatch"
|
||||
if err != nil {
|
||||
if aerr, ok := err.(awserr.Error); ok {
|
||||
assert.NotEqual(t, "InvalidAccessKeyId", aerr.Code(), "Credentials should be valid")
|
||||
assert.NotEqual(t, "SignatureDoesNotMatch", aerr.Code(), "Signature should be valid")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete_service_account", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdSAId)
|
||||
|
||||
resp, err := callIAMAPI(t, "DeleteServiceAccount", url.Values{
|
||||
"ServiceAccountId": {createdSAId},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Verify it no longer exists
|
||||
getResp, err := callIAMAPI(t, "GetServiceAccount", url.Values{
|
||||
"ServiceAccountId": {createdSAId},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
|
||||
// Should return an error (not found)
|
||||
assert.NotEqual(t, http.StatusOK, getResp.StatusCode,
|
||||
"GetServiceAccount should fail after deletion")
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// TestServiceAccountValidation tests validation of service account operations
|
||||
func TestServiceAccountValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSeaweedFSRunning(t) {
|
||||
t.Skip("SeaweedFS is not running at", TestIAMEndpoint)
|
||||
}
|
||||
|
||||
t.Run("create_without_parent_user", func(t *testing.T) {
|
||||
resp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{
|
||||
"Description": {"Test without parent"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"CreateServiceAccount without ParentUser should fail")
|
||||
})
|
||||
|
||||
t.Run("create_with_nonexistent_parent", func(t *testing.T) {
|
||||
resp, err := callIAMAPI(t, "CreateServiceAccount", url.Values{
|
||||
"ParentUser": {"nonexistent-user-12345"},
|
||||
"Description": {"Test with nonexistent parent"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"CreateServiceAccount with nonexistent parent should fail")
|
||||
})
|
||||
|
||||
t.Run("get_nonexistent_service_account", func(t *testing.T) {
|
||||
resp, err := callIAMAPI(t, "GetServiceAccount", url.Values{
|
||||
"ServiceAccountId": {"sa-NONEXISTENT123"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"GetServiceAccount for nonexistent ID should fail")
|
||||
})
|
||||
|
||||
t.Run("delete_nonexistent_service_account", func(t *testing.T) {
|
||||
resp, err := callIAMAPI(t, "DeleteServiceAccount", url.Values{
|
||||
"ServiceAccountId": {"sa-NONEXISTENT123"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"DeleteServiceAccount for nonexistent ID should fail")
|
||||
})
|
||||
}
|
||||
|
||||
// callIAMAPI is a helper to make IAM API calls
|
||||
func callIAMAPI(t *testing.T, action string, params url.Values) (*http.Response, error) {
|
||||
params.Set("Action", action)
|
||||
body := params.Encode()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, TestIAMEndpoint+"/",
|
||||
strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
// Sign with SigV4 when admin credentials are provided via env vars.
|
||||
// IAM write APIs (CreateUser, CreateServiceAccount, etc.) require an
|
||||
// authenticated admin caller; unsigned requests get routed to the STS
|
||||
// fallback and rejected as an unknown action.
|
||||
if accessKey := os.Getenv("STS_TEST_ACCESS_KEY"); accessKey != "" {
|
||||
secretKey := os.Getenv("STS_TEST_SECRET_KEY")
|
||||
creds := credentials.NewStaticCredentials(accessKey, secretKey, "")
|
||||
signer := v4.NewSigner(creds)
|
||||
if _, err := signer.Sign(req, strings.NewReader(body), "iam", "us-east-1", time.Now()); err != nil {
|
||||
return nil, fmt.Errorf("failed to sign IAM request: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
// isSeaweedFSRunning checks if SeaweedFS S3 API is running
|
||||
func isSeaweedFSRunning(t *testing.T) bool {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(TestIAMEndpoint + "/status")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
365
test/s3/iam/s3_sts_assume_role_test.go
Normal file
365
test/s3/iam/s3_sts_assume_role_test.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// AssumeRoleResponse represents the STS AssumeRole response
|
||||
type AssumeRoleTestResponse struct {
|
||||
XMLName xml.Name `xml:"AssumeRoleResponse"`
|
||||
Result struct {
|
||||
Credentials struct {
|
||||
AccessKeyId string `xml:"AccessKeyId"`
|
||||
SecretAccessKey string `xml:"SecretAccessKey"`
|
||||
SessionToken string `xml:"SessionToken"`
|
||||
Expiration string `xml:"Expiration"`
|
||||
} `xml:"Credentials"`
|
||||
AssumedRoleUser struct {
|
||||
AssumedRoleId string `xml:"AssumedRoleId"`
|
||||
Arn string `xml:"Arn"`
|
||||
} `xml:"AssumedRoleUser"`
|
||||
} `xml:"AssumeRoleResult"`
|
||||
}
|
||||
|
||||
// TestSTSAssumeRoleValidation tests input validation for AssumeRole endpoint
|
||||
func TestSTSAssumeRoleValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Fatal("SeaweedFS STS endpoint is not running at", TestSTSEndpoint, "- please run 'make setup-all-tests' first")
|
||||
}
|
||||
|
||||
// Check if AssumeRole is implemented by making a test call
|
||||
if !isAssumeRoleImplemented(t) {
|
||||
t.Fatal("AssumeRole action is not implemented in the running server - please rebuild weed binary with new code and restart the server")
|
||||
}
|
||||
|
||||
t.Run("missing_role_arn_defaults_to_caller_identity", func(t *testing.T) {
|
||||
// SeaweedFS intentionally allows AssumeRole without RoleArn to support
|
||||
// S3-compatible clients that omit it. In that case the session is tied
|
||||
// to the caller's own identity (User Context assumption). See
|
||||
// handleAssumeRole in weed/s3api/s3api_sts.go.
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
// RoleArn is missing on purpose
|
||||
}, "test-access-key", "test-secret-key")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode,
|
||||
"Should succeed without RoleArn (user-context assumption): %s", string(body))
|
||||
|
||||
var assumeResp AssumeRoleTestResponse
|
||||
require.NoError(t, xml.Unmarshal(body, &assumeResp),
|
||||
"Failed to parse response: %s", string(body))
|
||||
assert.NotEmpty(t, assumeResp.Result.Credentials.AccessKeyId,
|
||||
"AccessKeyId should be issued for user-context assumption")
|
||||
})
|
||||
|
||||
t.Run("missing_role_session_name", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
// RoleSessionName is missing
|
||||
}, "test-access-key", "test-secret-key")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail without RoleSessionName")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.Equal(t, "MissingParameter", errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("unsupported_action_for_anonymous", func(t *testing.T) {
|
||||
// AssumeRole requires SigV4 authentication, anonymous requests should fail
|
||||
resp, err := callSTSAPI(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should fail because AssumeRole requires AWS SigV4 authentication
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"AssumeRole should require authentication")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response for anonymous AssumeRole: status=%d, body=%s", resp.StatusCode, string(body))
|
||||
})
|
||||
|
||||
t.Run("invalid_duration_too_short", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
"DurationSeconds": {"100"}, // Less than 900 seconds minimum
|
||||
}, "test-access-key", "test-secret-key")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail with DurationSeconds < 900")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("invalid_duration_too_long", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
"DurationSeconds": {"100000"}, // More than 43200 seconds maximum
|
||||
}, "test-access-key", "test-secret-key")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail with DurationSeconds > 43200")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// isAssumeRoleImplemented checks if the running server supports AssumeRole
|
||||
func isAssumeRoleImplemented(t *testing.T) bool {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test"},
|
||||
"RoleSessionName": {"test"},
|
||||
}, "test", "test")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// If we get "NotImplemented", the action isn't supported
|
||||
var errResp STSErrorTestResponse
|
||||
if xml.Unmarshal(body, &errResp) == nil && errResp.Error.Code == "NotImplemented" {
|
||||
return false
|
||||
}
|
||||
|
||||
// If we get InvalidAction, the action isn't routed
|
||||
if errResp.Error.Code == "InvalidAction" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// TestSTSAssumeRoleWithValidCredentials tests AssumeRole with valid IAM credentials
|
||||
// This test requires a configured IAM user in SeaweedFS
|
||||
func TestSTSAssumeRoleWithValidCredentials(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
||||
}
|
||||
|
||||
// Use test credentials from environment or fall back to defaults
|
||||
accessKey := os.Getenv("STS_TEST_ACCESS_KEY")
|
||||
if accessKey == "" {
|
||||
accessKey = "admin"
|
||||
}
|
||||
secretKey := os.Getenv("STS_TEST_SECRET_KEY")
|
||||
if secretKey == "" {
|
||||
secretKey = "admin"
|
||||
}
|
||||
|
||||
t.Run("successful_assume_role", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/admin"},
|
||||
"RoleSessionName": {"integration-test-session"},
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body))
|
||||
|
||||
// If AssumeRole is not yet implemented, expect an error about unsupported action
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
t.Logf("Error response: code=%s, message=%s", errResp.Error.Code, errResp.Error.Message)
|
||||
|
||||
// This test will initially fail until AssumeRole is implemented
|
||||
// Once implemented, uncomment the assertions below
|
||||
// assert.Fail(t, "AssumeRole not yet implemented")
|
||||
} else {
|
||||
var stsResp AssumeRoleTestResponse
|
||||
err = xml.Unmarshal(body, &stsResp)
|
||||
require.NoError(t, err, "Failed to parse response: %s", string(body))
|
||||
|
||||
creds := stsResp.Result.Credentials
|
||||
assert.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should not be empty")
|
||||
assert.NotEmpty(t, creds.SecretAccessKey, "SecretAccessKey should not be empty")
|
||||
assert.NotEmpty(t, creds.SessionToken, "SessionToken should not be empty")
|
||||
assert.NotEmpty(t, creds.Expiration, "Expiration should not be empty")
|
||||
|
||||
t.Logf("Successfully obtained temporary credentials: AccessKeyId=%s", creds.AccessKeyId)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with_custom_duration", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/admin"},
|
||||
"RoleSessionName": {"duration-test-session"},
|
||||
"DurationSeconds": {"3600"}, // 1 hour
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body))
|
||||
|
||||
// Verify DurationSeconds is accepted
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
// Should not fail due to DurationSeconds parameter
|
||||
assert.NotContains(t, errResp.Error.Message, "DurationSeconds",
|
||||
"DurationSeconds parameter should be accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSTSAssumeRoleWithInvalidCredentials tests AssumeRole rejection with bad credentials
|
||||
func TestSTSAssumeRoleWithInvalidCredentials(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
||||
}
|
||||
|
||||
t.Run("invalid_access_key", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/admin"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
}, "invalid-access-key", "some-secret-key")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should fail with access denied or signature mismatch
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail with invalid access key")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response for invalid credentials: status=%d, body=%s", resp.StatusCode, string(body))
|
||||
})
|
||||
|
||||
t.Run("invalid_secret_key", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/admin"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
}, "admin", "wrong-secret-key")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should fail with signature mismatch
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail with invalid secret key")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response for wrong secret: status=%d, body=%s", resp.StatusCode, string(body))
|
||||
})
|
||||
}
|
||||
|
||||
// callSTSAPIWithSigV4 makes an STS API call with AWS Signature V4 authentication
|
||||
func callSTSAPIWithSigV4(t *testing.T, params url.Values, accessKey, secretKey string) (*http.Response, error) {
|
||||
// Prepare request body
|
||||
body := params.Encode()
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest(http.MethodPost, TestSTSEndpoint+"/",
|
||||
strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
// Do NOT set req.Header["Host"] — aws-sdk-go v1 v4.Signer reads the host
|
||||
// from req.URL.Host/req.Host, and a manual Host header makes the signer
|
||||
// emit `host;host` in SignedHeaders, producing a different signature than
|
||||
// the server calculates from canonical headers.
|
||||
|
||||
// Sign request with AWS Signature V4 using official SDK
|
||||
creds := credentials.NewStaticCredentials(accessKey, secretKey, "")
|
||||
signer := v4.NewSigner(creds)
|
||||
|
||||
// Read body for signing
|
||||
// Note: We need a ReadSeeker for the signer, or we can pass the body string/bytes to ComputeBodyHash if needed,
|
||||
// but standard Sign method takes an io.ReadSeeker for the body.
|
||||
bodyReader := strings.NewReader(body)
|
||||
_, err = signer.Sign(req, bodyReader, "sts", "us-east-1", time.Now())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
return client.Do(req)
|
||||
}
|
||||
82
test/s3/iam/s3_sts_credential_prefix_test.go
Normal file
82
test/s3/iam/s3_sts_credential_prefix_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSTSTemporaryCredentialPrefix verifies that STS temporary credentials use ASIA prefix
|
||||
// This test ensures AWS compatibility - temporary credentials should use ASIA, not AKIA
|
||||
func TestSTSTemporaryCredentialPrefix(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
||||
}
|
||||
|
||||
// Use test credentials from environment or fall back to defaults
|
||||
accessKey := os.Getenv("STS_TEST_ACCESS_KEY")
|
||||
if accessKey == "" {
|
||||
accessKey = "admin"
|
||||
}
|
||||
secretKey := os.Getenv("STS_TEST_SECRET_KEY")
|
||||
if secretKey == "" {
|
||||
secretKey = "admin"
|
||||
}
|
||||
|
||||
t.Run("assume_role_returns_asia_prefix", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/admin"},
|
||||
"RoleSessionName": {"asia-prefix-test"},
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body))
|
||||
t.Skip("AssumeRole not fully implemented yet")
|
||||
}
|
||||
|
||||
var stsResp AssumeRoleTestResponse
|
||||
err = xml.Unmarshal(body, &stsResp)
|
||||
require.NoError(t, err, "Failed to parse response: %s", string(body))
|
||||
|
||||
creds := stsResp.Result.Credentials
|
||||
require.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should not be empty")
|
||||
|
||||
// Verify ASIA prefix for temporary credentials
|
||||
assert.True(t, strings.HasPrefix(creds.AccessKeyId, "ASIA"),
|
||||
"Temporary credentials must use ASIA prefix (not AKIA for permanent keys), got: %s", creds.AccessKeyId)
|
||||
|
||||
// Verify it's NOT using AKIA (permanent credentials)
|
||||
assert.False(t, strings.HasPrefix(creds.AccessKeyId, "AKIA"),
|
||||
"Temporary credentials must NOT use AKIA prefix (that's for permanent IAM keys), got: %s", creds.AccessKeyId)
|
||||
|
||||
// Verify format: ASIA + 16 hex characters = 20 chars total
|
||||
assert.Equal(t, 20, len(creds.AccessKeyId),
|
||||
"Access key ID should be 20 characters (ASIA + 16 hex chars), got: %s", creds.AccessKeyId)
|
||||
|
||||
t.Logf("✓ Temporary credentials correctly use ASIA prefix: %s", creds.AccessKeyId)
|
||||
})
|
||||
|
||||
t.Run("assume_role_with_web_identity_returns_asia_prefix", func(t *testing.T) {
|
||||
// This test would require OIDC setup, so we'll skip it for now
|
||||
// but the same ASIA prefix validation should apply
|
||||
t.Skip("AssumeRoleWithWebIdentity requires OIDC provider setup")
|
||||
})
|
||||
}
|
||||
511
test/s3/iam/s3_sts_get_federation_token_test.go
Normal file
511
test/s3/iam/s3_sts_get_federation_token_test.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// GetFederationTokenTestResponse represents the STS GetFederationToken response
|
||||
type GetFederationTokenTestResponse struct {
|
||||
XMLName xml.Name `xml:"GetFederationTokenResponse"`
|
||||
Result struct {
|
||||
Credentials struct {
|
||||
AccessKeyId string `xml:"AccessKeyId"`
|
||||
SecretAccessKey string `xml:"SecretAccessKey"`
|
||||
SessionToken string `xml:"SessionToken"`
|
||||
Expiration string `xml:"Expiration"`
|
||||
} `xml:"Credentials"`
|
||||
FederatedUser struct {
|
||||
FederatedUserId string `xml:"FederatedUserId"`
|
||||
Arn string `xml:"Arn"`
|
||||
} `xml:"FederatedUser"`
|
||||
} `xml:"GetFederationTokenResult"`
|
||||
}
|
||||
|
||||
func getTestCredentials() (string, string) {
|
||||
accessKey := os.Getenv("STS_TEST_ACCESS_KEY")
|
||||
if accessKey == "" {
|
||||
accessKey = "admin"
|
||||
}
|
||||
secretKey := os.Getenv("STS_TEST_SECRET_KEY")
|
||||
if secretKey == "" {
|
||||
secretKey = "admin"
|
||||
}
|
||||
return accessKey, secretKey
|
||||
}
|
||||
|
||||
// isGetFederationTokenImplemented checks if the running server supports GetFederationToken
|
||||
func isGetFederationTokenImplemented(t *testing.T) bool {
|
||||
accessKey, secretKey := getTestCredentials()
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"GetFederationToken"},
|
||||
"Version": {"2011-06-15"},
|
||||
"Name": {"probe"},
|
||||
}, accessKey, secretKey)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var errResp STSErrorTestResponse
|
||||
if xml.Unmarshal(body, &errResp) == nil {
|
||||
if errResp.Error.Code == "InvalidAction" || errResp.Error.Code == "NotImplemented" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestSTSGetFederationTokenValidation tests input validation for the GetFederationToken endpoint
|
||||
func TestSTSGetFederationTokenValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Fatal("SeaweedFS STS endpoint is not running at", TestSTSEndpoint, "- please run 'make setup-all-tests' first")
|
||||
}
|
||||
|
||||
if !isGetFederationTokenImplemented(t) {
|
||||
t.Fatal("GetFederationToken action is not implemented in the running server")
|
||||
}
|
||||
|
||||
accessKey, secretKey := getTestCredentials()
|
||||
|
||||
t.Run("missing_name", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"GetFederationToken"},
|
||||
"Version": {"2011-06-15"},
|
||||
// Name is missing
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var errResp STSErrorTestResponse
|
||||
require.NoError(t, xml.Unmarshal(body, &errResp), "Failed to parse: %s", string(body))
|
||||
assert.Equal(t, "MissingParameter", errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("name_too_short", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"GetFederationToken"},
|
||||
"Version": {"2011-06-15"},
|
||||
"Name": {"A"},
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var errResp STSErrorTestResponse
|
||||
require.NoError(t, xml.Unmarshal(body, &errResp), "Failed to parse: %s", string(body))
|
||||
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("name_too_long", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"GetFederationToken"},
|
||||
"Version": {"2011-06-15"},
|
||||
"Name": {strings.Repeat("A", 33)},
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var errResp STSErrorTestResponse
|
||||
require.NoError(t, xml.Unmarshal(body, &errResp), "Failed to parse: %s", string(body))
|
||||
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("name_invalid_characters", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"GetFederationToken"},
|
||||
"Version": {"2011-06-15"},
|
||||
"Name": {"bad name"},
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var errResp STSErrorTestResponse
|
||||
require.NoError(t, xml.Unmarshal(body, &errResp), "Failed to parse: %s", string(body))
|
||||
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("duration_too_short", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"GetFederationToken"},
|
||||
"Version": {"2011-06-15"},
|
||||
"Name": {"TestApp"},
|
||||
"DurationSeconds": {"100"},
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var errResp STSErrorTestResponse
|
||||
require.NoError(t, xml.Unmarshal(body, &errResp), "Failed to parse: %s", string(body))
|
||||
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("duration_too_long", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"GetFederationToken"},
|
||||
"Version": {"2011-06-15"},
|
||||
"Name": {"TestApp"},
|
||||
"DurationSeconds": {"200000"},
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var errResp STSErrorTestResponse
|
||||
require.NoError(t, xml.Unmarshal(body, &errResp), "Failed to parse: %s", string(body))
|
||||
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("malformed_policy", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"GetFederationToken"},
|
||||
"Version": {"2011-06-15"},
|
||||
"Name": {"TestApp"},
|
||||
"Policy": {"not-valid-json"},
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var errResp STSErrorTestResponse
|
||||
require.NoError(t, xml.Unmarshal(body, &errResp), "Failed to parse: %s", string(body))
|
||||
assert.Equal(t, "MalformedPolicyDocument", errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("anonymous_rejected", func(t *testing.T) {
|
||||
// GetFederationToken requires SigV4, anonymous should fail
|
||||
resp, err := callSTSAPI(t, url.Values{
|
||||
"Action": {"GetFederationToken"},
|
||||
"Version": {"2011-06-15"},
|
||||
"Name": {"TestApp"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestSTSGetFederationTokenRejectTemporaryCredentials tests that temporary
|
||||
// credentials (session tokens) are rejected by GetFederationToken
|
||||
func TestSTSGetFederationTokenRejectTemporaryCredentials(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
||||
}
|
||||
|
||||
if !isGetFederationTokenImplemented(t) {
|
||||
t.Skip("GetFederationToken not implemented")
|
||||
}
|
||||
|
||||
accessKey, secretKey := getTestCredentials()
|
||||
|
||||
// First, obtain temporary credentials via AssumeRole
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/admin"},
|
||||
"RoleSessionName": {"temp-session"},
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skipf("AssumeRole failed (may not be configured): status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var assumeResp AssumeRoleTestResponse
|
||||
require.NoError(t, xml.Unmarshal(body, &assumeResp), "Parse AssumeRole response: %s", string(body))
|
||||
|
||||
tempAccessKey := assumeResp.Result.Credentials.AccessKeyId
|
||||
tempSecretKey := assumeResp.Result.Credentials.SecretAccessKey
|
||||
tempSessionToken := assumeResp.Result.Credentials.SessionToken
|
||||
require.NotEmpty(t, tempAccessKey)
|
||||
require.NotEmpty(t, tempSessionToken)
|
||||
|
||||
// Now try GetFederationToken with the temporary credentials
|
||||
// Include X-Amz-Security-Token header which marks this as a temp credential call
|
||||
params := url.Values{
|
||||
"Action": {"GetFederationToken"},
|
||||
"Version": {"2011-06-15"},
|
||||
"Name": {"ShouldFail"},
|
||||
}
|
||||
|
||||
reqBody := params.Encode()
|
||||
req, err := http.NewRequest(http.MethodPost, TestSTSEndpoint+"/", strings.NewReader(reqBody))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("X-Amz-Security-Token", tempSessionToken)
|
||||
|
||||
creds := credentials.NewStaticCredentials(tempAccessKey, tempSecretKey, tempSessionToken)
|
||||
signer := v4.NewSigner(creds)
|
||||
_, err = signer.Sign(req, strings.NewReader(reqBody), "sts", "us-east-1", time.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp2, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
|
||||
body2, _ := io.ReadAll(resp2.Body)
|
||||
assert.Equal(t, http.StatusForbidden, resp2.StatusCode,
|
||||
"GetFederationToken should reject temporary credentials: %s", string(body2))
|
||||
assert.Contains(t, string(body2), "temporary credentials",
|
||||
"Error should mention temporary credentials")
|
||||
}
|
||||
|
||||
// TestSTSGetFederationTokenSuccess tests a successful GetFederationToken call
|
||||
// and verifies the returned credentials can be used to access S3
|
||||
func TestSTSGetFederationTokenSuccess(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
||||
}
|
||||
|
||||
if !isGetFederationTokenImplemented(t) {
|
||||
t.Skip("GetFederationToken not implemented")
|
||||
}
|
||||
|
||||
accessKey, secretKey := getTestCredentials()
|
||||
|
||||
t.Run("basic_success", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"GetFederationToken"},
|
||||
"Version": {"2011-06-15"},
|
||||
"Name": {"AppClient"},
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp STSErrorTestResponse
|
||||
_ = xml.Unmarshal(body, &errResp)
|
||||
t.Fatalf("GetFederationToken failed: code=%s message=%s", errResp.Error.Code, errResp.Error.Message)
|
||||
}
|
||||
|
||||
var stsResp GetFederationTokenTestResponse
|
||||
require.NoError(t, xml.Unmarshal(body, &stsResp), "Parse response: %s", string(body))
|
||||
|
||||
creds := stsResp.Result.Credentials
|
||||
assert.NotEmpty(t, creds.AccessKeyId)
|
||||
assert.NotEmpty(t, creds.SecretAccessKey)
|
||||
assert.NotEmpty(t, creds.SessionToken)
|
||||
assert.NotEmpty(t, creds.Expiration)
|
||||
|
||||
fedUser := stsResp.Result.FederatedUser
|
||||
assert.Contains(t, fedUser.Arn, "federated-user/AppClient")
|
||||
assert.Contains(t, fedUser.FederatedUserId, "AppClient")
|
||||
})
|
||||
|
||||
t.Run("with_custom_duration", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"GetFederationToken"},
|
||||
"Version": {"2011-06-15"},
|
||||
"Name": {"DurationTest"},
|
||||
"DurationSeconds": {"3600"},
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body))
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var stsResp GetFederationTokenTestResponse
|
||||
require.NoError(t, xml.Unmarshal(body, &stsResp))
|
||||
assert.NotEmpty(t, stsResp.Result.Credentials.AccessKeyId)
|
||||
|
||||
// Verify expiration is roughly 1 hour from now
|
||||
expTime, err := time.Parse(time.RFC3339, stsResp.Result.Credentials.Expiration)
|
||||
require.NoError(t, err)
|
||||
diff := time.Until(expTime)
|
||||
assert.InDelta(t, 3600, diff.Seconds(), 60,
|
||||
"Expiration should be ~1 hour from now")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with_36_hour_duration", func(t *testing.T) {
|
||||
// GetFederationToken allows up to 36 hours (unlike AssumeRole's 12h max)
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"GetFederationToken"},
|
||||
"Version": {"2011-06-15"},
|
||||
"Name": {"LongDuration"},
|
||||
"DurationSeconds": {"129600"}, // 36 hours
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var stsResp GetFederationTokenTestResponse
|
||||
require.NoError(t, xml.Unmarshal(body, &stsResp))
|
||||
|
||||
expTime, err := time.Parse(time.RFC3339, stsResp.Result.Credentials.Expiration)
|
||||
require.NoError(t, err)
|
||||
diff := time.Until(expTime)
|
||||
assert.InDelta(t, 129600, diff.Seconds(), 60,
|
||||
"Expiration should be ~36 hours from now")
|
||||
} else {
|
||||
// Duration should not cause a rejection
|
||||
var errResp STSErrorTestResponse
|
||||
_ = xml.Unmarshal(body, &errResp)
|
||||
assert.NotContains(t, errResp.Error.Message, "DurationSeconds",
|
||||
"36-hour duration should be accepted by GetFederationToken")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSTSGetFederationTokenWithSessionPolicy tests that vended credentials
|
||||
// are scoped down by an inline session policy
|
||||
func TestSTSGetFederationTokenWithSessionPolicy(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
||||
}
|
||||
|
||||
if !isGetFederationTokenImplemented(t) {
|
||||
t.Skip("GetFederationToken not implemented")
|
||||
}
|
||||
|
||||
accessKey, secretKey := getTestCredentials()
|
||||
|
||||
// Create a test bucket using admin credentials
|
||||
adminSess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String("us-east-1"),
|
||||
Endpoint: aws.String(TestSTSEndpoint),
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
adminS3 := s3.New(adminSess)
|
||||
bucket := fmt.Sprintf("fed-token-test-%d", time.Now().UnixNano())
|
||||
|
||||
_, err = adminS3.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(bucket)})
|
||||
require.NoError(t, err)
|
||||
defer adminS3.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucket)})
|
||||
|
||||
_, err = adminS3.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String("test.txt"),
|
||||
Body: strings.NewReader("hello"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer adminS3.DeleteObject(&s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: aws.String("test.txt")})
|
||||
|
||||
// Get federated credentials with a session policy that only allows GetObject
|
||||
sessionPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/*"]
|
||||
}]
|
||||
}`, bucket)
|
||||
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"GetFederationToken"},
|
||||
"Version": {"2011-06-15"},
|
||||
"Name": {"ScopedClient"},
|
||||
"Policy": {sessionPolicy},
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("GetFederationToken response: status=%d body=%s", resp.StatusCode, string(body))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skipf("GetFederationToken failed (may need IAM policy config): %s", string(body))
|
||||
}
|
||||
|
||||
var stsResp GetFederationTokenTestResponse
|
||||
require.NoError(t, xml.Unmarshal(body, &stsResp))
|
||||
|
||||
fedCreds := stsResp.Result.Credentials
|
||||
require.NotEmpty(t, fedCreds.AccessKeyId)
|
||||
require.NotEmpty(t, fedCreds.SessionToken)
|
||||
|
||||
// Create S3 client with the federated credentials
|
||||
fedSess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String("us-east-1"),
|
||||
Endpoint: aws.String(TestSTSEndpoint),
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
Credentials: credentials.NewStaticCredentials(
|
||||
fedCreds.AccessKeyId, fedCreds.SecretAccessKey, fedCreds.SessionToken),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
fedS3 := s3.New(fedSess)
|
||||
|
||||
// GetObject should succeed (allowed by session policy)
|
||||
getResp, err := fedS3.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String("test.txt"),
|
||||
})
|
||||
if err == nil {
|
||||
defer getResp.Body.Close()
|
||||
t.Log("GetObject with federated credentials succeeded (as expected)")
|
||||
} else {
|
||||
t.Logf("GetObject with federated credentials: %v (session policy enforcement may vary)", err)
|
||||
}
|
||||
|
||||
// PutObject should be denied (not allowed by session policy)
|
||||
_, err = fedS3.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String("denied.txt"),
|
||||
Body: strings.NewReader("should fail"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Log("PutObject correctly denied with federated credentials")
|
||||
assert.Contains(t, err.Error(), "AccessDenied",
|
||||
"PutObject should be denied by session policy")
|
||||
} else {
|
||||
// Clean up if unexpectedly succeeded
|
||||
adminS3.DeleteObject(&s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: aws.String("denied.txt")})
|
||||
t.Log("PutObject unexpectedly succeeded — session policy enforcement may not be active")
|
||||
}
|
||||
}
|
||||
291
test/s3/iam/s3_sts_ldap_test.go
Normal file
291
test/s3/iam/s3_sts_ldap_test.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// AssumeRoleWithLDAPIdentityResponse represents the STS response for LDAP identity
|
||||
type AssumeRoleWithLDAPIdentityTestResponse struct {
|
||||
XMLName xml.Name `xml:"AssumeRoleWithLDAPIdentityResponse"`
|
||||
Result struct {
|
||||
Credentials struct {
|
||||
AccessKeyId string `xml:"AccessKeyId"`
|
||||
SecretAccessKey string `xml:"SecretAccessKey"`
|
||||
SessionToken string `xml:"SessionToken"`
|
||||
Expiration string `xml:"Expiration"`
|
||||
} `xml:"Credentials"`
|
||||
} `xml:"AssumeRoleWithLDAPIdentityResult"`
|
||||
}
|
||||
|
||||
// TestSTSLDAPValidation tests input validation for AssumeRoleWithLDAPIdentity
|
||||
func TestSTSLDAPValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Fatal("SeaweedFS STS endpoint is not running at", TestSTSEndpoint, "- please run 'make setup-all-tests' first")
|
||||
}
|
||||
|
||||
// Check if AssumeRoleWithLDAPIdentity is implemented
|
||||
if !isLDAPIdentityActionImplemented(t) {
|
||||
t.Fatal("AssumeRoleWithLDAPIdentity action is not implemented in the running server - please rebuild weed binary with new code and restart the server")
|
||||
}
|
||||
|
||||
t.Run("missing_ldap_username", func(t *testing.T) {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
"LDAPPassword": {"testpass"},
|
||||
// LDAPUsername is missing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail without LDAPUsername")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
// Expect either MissingParameter or InvalidAction (if not implemented)
|
||||
assert.Contains(t, []string{"MissingParameter", "InvalidAction"}, errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("missing_ldap_password", func(t *testing.T) {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
"LDAPUsername": {"testuser"},
|
||||
// LDAPPassword is missing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail without LDAPPassword")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.Contains(t, []string{"MissingParameter", "InvalidAction"}, errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("missing_role_arn", func(t *testing.T) {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
"LDAPUsername": {"testuser"},
|
||||
"LDAPPassword": {"testpass"},
|
||||
// RoleArn is missing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail without RoleArn")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.Contains(t, []string{"MissingParameter", "InvalidAction"}, errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("invalid_duration_too_short", func(t *testing.T) {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
"LDAPUsername": {"testuser"},
|
||||
"LDAPPassword": {"testpass"},
|
||||
"DurationSeconds": {"100"}, // Less than 900 seconds minimum
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// If the action is implemented, it should reject invalid duration
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response for invalid duration: status=%d, body=%s", resp.StatusCode, string(body))
|
||||
})
|
||||
}
|
||||
|
||||
// TestSTSLDAPWithValidCredentials tests LDAP authentication
|
||||
// This test requires an LDAP server to be configured
|
||||
func TestSTSLDAPWithValidCredentials(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
||||
}
|
||||
|
||||
// Check if LDAP is configured (skip if not)
|
||||
if !isLDAPConfigured() {
|
||||
t.Skip("LDAP is not configured - skipping LDAP integration tests")
|
||||
}
|
||||
|
||||
t.Run("successful_ldap_auth", func(t *testing.T) {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/ldap-user"},
|
||||
"RoleSessionName": {"ldap-test-session"},
|
||||
"LDAPUsername": {"testuser"},
|
||||
"LDAPPassword": {"testpass"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body))
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var stsResp AssumeRoleWithLDAPIdentityTestResponse
|
||||
err = xml.Unmarshal(body, &stsResp)
|
||||
require.NoError(t, err, "Failed to parse response: %s", string(body))
|
||||
|
||||
creds := stsResp.Result.Credentials
|
||||
assert.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should not be empty")
|
||||
assert.NotEmpty(t, creds.SecretAccessKey, "SecretAccessKey should not be empty")
|
||||
assert.NotEmpty(t, creds.SessionToken, "SessionToken should not be empty")
|
||||
assert.NotEmpty(t, creds.Expiration, "Expiration should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSTSLDAPWithInvalidCredentials tests LDAP rejection with bad credentials
|
||||
func TestSTSLDAPWithInvalidCredentials(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
||||
}
|
||||
|
||||
t.Run("invalid_ldap_password", func(t *testing.T) {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/ldap-user"},
|
||||
"RoleSessionName": {"ldap-test-session"},
|
||||
"LDAPUsername": {"testuser"},
|
||||
"LDAPPassword": {"wrong-password"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response for invalid LDAP credentials: status=%d, body=%s", resp.StatusCode, string(body))
|
||||
|
||||
// Should fail (either AccessDenied or InvalidAction if not implemented)
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail with invalid LDAP password")
|
||||
})
|
||||
|
||||
t.Run("nonexistent_ldap_user", func(t *testing.T) {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/ldap-user"},
|
||||
"RoleSessionName": {"ldap-test-session"},
|
||||
"LDAPUsername": {"nonexistent-user-12345"},
|
||||
"LDAPPassword": {"somepassword"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response for nonexistent user: status=%d, body=%s", resp.StatusCode, string(body))
|
||||
|
||||
// Should fail
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail with nonexistent LDAP user")
|
||||
})
|
||||
}
|
||||
|
||||
// callSTSAPIForLDAP makes an STS API call for LDAP operation
|
||||
func callSTSAPIForLDAP(t *testing.T, params url.Values) (*http.Response, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, TestSTSEndpoint+"/",
|
||||
strings.NewReader(params.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
// isLDAPConfigured checks if LDAP server is configured and available
|
||||
func isLDAPConfigured() bool {
|
||||
// Check environment variable for LDAP URL
|
||||
ldapURL := os.Getenv("LDAP_URL")
|
||||
return ldapURL != ""
|
||||
}
|
||||
|
||||
// isLDAPIdentityActionImplemented checks if the running server supports AssumeRoleWithLDAPIdentity
|
||||
func isLDAPIdentityActionImplemented(t *testing.T) bool {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test"},
|
||||
"RoleSessionName": {"test"},
|
||||
"LDAPUsername": {"test"},
|
||||
"LDAPPassword": {"test"},
|
||||
})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// If we get "NotImplemented" or empty response, the action isn't supported
|
||||
if len(body) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
var errResp STSErrorTestResponse
|
||||
if xml.Unmarshal(body, &errResp) == nil && errResp.Error.Code == "NotImplemented" {
|
||||
return false
|
||||
}
|
||||
|
||||
// If we get InvalidAction, the action isn't routed
|
||||
if errResp.Error.Code == "InvalidAction" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
268
test/s3/iam/s3_sts_test.go
Normal file
268
test/s3/iam/s3_sts_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// STS API test constants
|
||||
const (
|
||||
TestSTSEndpoint = "http://localhost:8333"
|
||||
)
|
||||
|
||||
// AssumeRoleWithWebIdentityResponse represents the STS response
|
||||
type AssumeRoleWithWebIdentityTestResponse struct {
|
||||
XMLName xml.Name `xml:"AssumeRoleWithWebIdentityResponse"`
|
||||
Result struct {
|
||||
Credentials struct {
|
||||
AccessKeyId string `xml:"AccessKeyId"`
|
||||
SecretAccessKey string `xml:"SecretAccessKey"`
|
||||
SessionToken string `xml:"SessionToken"`
|
||||
Expiration string `xml:"Expiration"`
|
||||
} `xml:"Credentials"`
|
||||
SubjectFromWebIdentityToken string `xml:"SubjectFromWebIdentityToken,omitempty"`
|
||||
} `xml:"AssumeRoleWithWebIdentityResult"`
|
||||
}
|
||||
|
||||
// STSErrorResponse represents an STS error response
|
||||
type STSErrorTestResponse struct {
|
||||
XMLName xml.Name `xml:"ErrorResponse"`
|
||||
Error struct {
|
||||
Type string `xml:"Type"`
|
||||
Code string `xml:"Code"`
|
||||
Message string `xml:"Message"`
|
||||
} `xml:"Error"`
|
||||
RequestId string `xml:"RequestId"`
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithWebIdentityValidation tests input validation for the STS endpoint
|
||||
func TestAssumeRoleWithWebIdentityValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
||||
}
|
||||
|
||||
t.Run("missing_web_identity_token", func(t *testing.T) {
|
||||
resp, err := callSTSAPI(t, url.Values{
|
||||
"Action": {"AssumeRoleWithWebIdentity"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
// WebIdentityToken is missing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail without WebIdentityToken")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.Equal(t, "MissingParameter", errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("missing_role_arn_invalid_jwt_still_rejected", func(t *testing.T) {
|
||||
// Missing RoleArn is no longer a fast-fail at the HTTP layer:
|
||||
// claim-based policy mode (Phase 3b) advertises RoleArn as
|
||||
// optional so the STS service can derive the assumed-role ARN
|
||||
// from the configured policy claim. Validation now happens at
|
||||
// the STS layer once the JWT is parsed. With a bogus token the
|
||||
// JWT parse fails first, so the request is still rejected —
|
||||
// just with the JWT-parse error code instead of MissingParameter.
|
||||
resp, err := callSTSAPI(t, url.Values{
|
||||
"Action": {"AssumeRoleWithWebIdentity"},
|
||||
"WebIdentityToken": {"fake-jwt-token"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
// RoleArn omitted on purpose.
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should still fail when RoleArn is missing and the JWT is invalid")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.NotEqual(t, "MissingParameter", errResp.Error.Code,
|
||||
"missing RoleArn alone must no longer surface as MissingParameter")
|
||||
})
|
||||
|
||||
t.Run("missing_role_session_name", func(t *testing.T) {
|
||||
resp, err := callSTSAPI(t, url.Values{
|
||||
"Action": {"AssumeRoleWithWebIdentity"},
|
||||
"WebIdentityToken": {"fake-jwt-token"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
// RoleSessionName is missing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail without RoleSessionName")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.Equal(t, "MissingParameter", errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("invalid_jwt_token", func(t *testing.T) {
|
||||
resp, err := callSTSAPI(t, url.Values{
|
||||
"Action": {"AssumeRoleWithWebIdentity"},
|
||||
"WebIdentityToken": {"not-a-valid-jwt-token"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should fail with AccessDenied since the JWT is invalid
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail with invalid JWT token")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.Contains(t, []string{"AccessDenied", "InvalidParameterValue"}, errResp.Error.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithWebIdentityWithMockJWT tests the STS endpoint with mock JWTs
|
||||
// This test requires the mock OIDC provider to be configured
|
||||
func TestAssumeRoleWithWebIdentityWithMockJWT(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
||||
}
|
||||
|
||||
// Create a test framework to get valid JWT tokens
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
// Generate a test JWT using the framework
|
||||
testUsername := "sts-test-user"
|
||||
testRole := "readonly"
|
||||
|
||||
// Try to get a token - use Keycloak if available, otherwise generate a mock JWT
|
||||
var token string
|
||||
var err error
|
||||
if framework.useKeycloak {
|
||||
token, err = framework.getKeycloakToken(testUsername)
|
||||
} else {
|
||||
// Generate a mock JWT token with 1 hour validity
|
||||
token, err = framework.generateJWTToken(testUsername, testRole, time.Hour)
|
||||
}
|
||||
if err != nil {
|
||||
t.Skipf("Unable to generate test JWT (requires mock OIDC or Keycloak): %v", err)
|
||||
}
|
||||
|
||||
t.Run("valid_jwt_token", func(t *testing.T) {
|
||||
resp, err := callSTSAPI(t, url.Values{
|
||||
"Action": {"AssumeRoleWithWebIdentity"},
|
||||
"WebIdentityToken": {token},
|
||||
"RoleArn": {"arn:aws:iam::role/" + testRole},
|
||||
"RoleSessionName": {"integration-test-session"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body))
|
||||
|
||||
// Note: This may still fail if the role/trust policy is not configured
|
||||
// In that case, we just verify the error is about trust policy, not token validation
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.NotEqual(t, "InvalidParameterValue", errResp.Error.Code,
|
||||
"Token validation should not fail - error should be about trust policy")
|
||||
} else {
|
||||
var stsResp AssumeRoleWithWebIdentityTestResponse
|
||||
err = xml.Unmarshal(body, &stsResp)
|
||||
require.NoError(t, err, "Failed to parse response: %s", string(body))
|
||||
|
||||
creds := stsResp.Result.Credentials
|
||||
assert.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should not be empty")
|
||||
assert.NotEmpty(t, creds.SecretAccessKey, "SecretAccessKey should not be empty")
|
||||
assert.NotEmpty(t, creds.SessionToken, "SessionToken should not be empty")
|
||||
assert.NotEmpty(t, creds.Expiration, "Expiration should not be empty")
|
||||
|
||||
t.Logf("Successfully obtained temporary credentials: AccessKeyId=%s", creds.AccessKeyId)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with_duration_seconds", func(t *testing.T) {
|
||||
resp, err := callSTSAPI(t, url.Values{
|
||||
"Action": {"AssumeRoleWithWebIdentity"},
|
||||
"WebIdentityToken": {token},
|
||||
"RoleArn": {"arn:aws:iam::role/" + testRole},
|
||||
"RoleSessionName": {"integration-test-session"},
|
||||
"DurationSeconds": {"3600"}, // 1 hour
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Verify the request is accepted (even if trust policy causes rejection)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should not fail with InvalidParameterValue for DurationSeconds
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.NotContains(t, errResp.Error.Message, "DurationSeconds",
|
||||
"DurationSeconds parameter should be accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// callSTSAPI is a helper to make STS API calls
|
||||
func callSTSAPI(t *testing.T, params url.Values) (*http.Response, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, TestSTSEndpoint+"/",
|
||||
strings.NewReader(params.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
// isSTSEndpointRunning checks if SeaweedFS STS endpoint is running
|
||||
func isSTSEndpointRunning(t *testing.T) bool {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(TestSTSEndpoint + "/status")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
107
test/s3/iam/seaweedfs-test-realm.json
Normal file
107
test/s3/iam/seaweedfs-test-realm.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"realm": "seaweedfs-test",
|
||||
"enabled": true,
|
||||
"roles": {
|
||||
"realm": [
|
||||
{ "name": "s3-admin" },
|
||||
{ "name": "s3-read-only" },
|
||||
{ "name": "s3-write-only" },
|
||||
{ "name": "s3-read-write" }
|
||||
]
|
||||
},
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "seaweedfs-s3",
|
||||
"enabled": true,
|
||||
"secret": "seaweedfs-s3-secret",
|
||||
"publicClient": false,
|
||||
"serviceAccountsEnabled": true,
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"fullScopeAllowed": true,
|
||||
"redirectUris": ["*"],
|
||||
"webOrigins": ["*"],
|
||||
"protocol": "openid-connect",
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "realm-roles",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-realm-role-mapper",
|
||||
"config": {
|
||||
"claim.name": "roles",
|
||||
"jsonType.label": "String",
|
||||
"multivalued": "true",
|
||||
"usermodel.realmRoleMapping.rolePrefix": "",
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "true",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "audience-mapper",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"config": {
|
||||
"included.client.audience": "seaweedfs-s3",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"username": "admin-user",
|
||||
"enabled": true,
|
||||
"email": "admin-user@test.com",
|
||||
"firstName": "Test",
|
||||
"lastName": "User",
|
||||
"emailVerified": true,
|
||||
"requiredActions": [],
|
||||
"realmRoles": ["s3-admin"],
|
||||
"credentials": [
|
||||
{ "type": "password", "value": "adminuser123", "temporary": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"username": "read-user",
|
||||
"enabled": true,
|
||||
"email": "read-user@test.com",
|
||||
"firstName": "Test",
|
||||
"lastName": "User",
|
||||
"emailVerified": true,
|
||||
"requiredActions": [],
|
||||
"realmRoles": ["s3-read-only"],
|
||||
"credentials": [
|
||||
{ "type": "password", "value": "readuser123", "temporary": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"username": "write-user",
|
||||
"enabled": true,
|
||||
"email": "write-user@test.com",
|
||||
"firstName": "Test",
|
||||
"lastName": "User",
|
||||
"emailVerified": true,
|
||||
"requiredActions": [],
|
||||
"realmRoles": ["s3-read-write"],
|
||||
"credentials": [
|
||||
{ "type": "password", "value": "writeuser123", "temporary": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"username": "write-only-user",
|
||||
"enabled": true,
|
||||
"email": "write-only-user@test.com",
|
||||
"firstName": "Test",
|
||||
"lastName": "User",
|
||||
"emailVerified": true,
|
||||
"requiredActions": [],
|
||||
"realmRoles": ["s3-write-only"],
|
||||
"credentials": [
|
||||
{ "type": "password", "value": "writeonlyuser123", "temporary": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
294
test/s3/iam/setup_all_tests.sh
Executable file
294
test/s3/iam/setup_all_tests.sh
Executable file
@@ -0,0 +1,294 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Complete Test Environment Setup Script
|
||||
# This script sets up all required services and configurations for S3 IAM integration tests
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo -e "${BLUE}🚀 Setting up complete test environment for SeaweedFS S3 IAM...${NC}"
|
||||
echo -e "${BLUE}==========================================================${NC}"
|
||||
|
||||
# Check prerequisites
|
||||
check_prerequisites() {
|
||||
echo -e "${YELLOW}🔍 Checking prerequisites...${NC}"
|
||||
|
||||
local missing_tools=()
|
||||
|
||||
for tool in docker jq curl; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
missing_tools+=("$tool")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing_tools[@]} -gt 0 ]; then
|
||||
echo -e "${RED}[FAIL] Missing required tools: ${missing_tools[*]}${NC}"
|
||||
echo -e "${YELLOW}Please install the missing tools and try again${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[OK] All prerequisites met${NC}"
|
||||
}
|
||||
|
||||
# Set up Keycloak for OIDC testing
|
||||
setup_keycloak() {
|
||||
echo -e "\n${BLUE}1. Setting up Keycloak for OIDC testing...${NC}"
|
||||
|
||||
if ! "${SCRIPT_DIR}/setup_keycloak.sh"; then
|
||||
echo -e "${RED}[FAIL] Failed to set up Keycloak${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[OK] Keycloak setup completed${NC}"
|
||||
}
|
||||
|
||||
# Set up OpenLDAP for LDAP-based STS testing
|
||||
setup_ldap() {
|
||||
echo -e "\n${BLUE}1a. Setting up OpenLDAP for STS LDAP testing...${NC}"
|
||||
|
||||
# Check if LDAP container is already running
|
||||
if docker ps --format '{{.Names}}' | grep -q '^openldap-iam-test$'; then
|
||||
echo -e "${YELLOW}OpenLDAP container already running${NC}"
|
||||
echo -e "${GREEN}[OK] LDAP setup completed (using existing container)${NC}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Remove any stopped container with the same name
|
||||
docker rm -f openldap-iam-test 2>/dev/null || true
|
||||
|
||||
# Start OpenLDAP container
|
||||
echo -e "${YELLOW}🔧 Starting OpenLDAP container...${NC}"
|
||||
docker run -d \
|
||||
--name openldap-iam-test \
|
||||
-p 389:389 \
|
||||
-p 636:636 \
|
||||
-e LDAP_ADMIN_PASSWORD=adminpassword \
|
||||
-e LDAP_ORGANISATION="SeaweedFS" \
|
||||
-e LDAP_DOMAIN="seaweedfs.test" \
|
||||
osixia/openldap:latest || {
|
||||
echo -e "${YELLOW}⚠️ OpenLDAP setup failed (optional for basic STS tests)${NC}"
|
||||
return 0 # Don't fail - LDAP is optional
|
||||
}
|
||||
|
||||
# Wait for LDAP to be ready
|
||||
echo -e "${YELLOW}⏳ Waiting for OpenLDAP to be ready...${NC}"
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec openldap-iam-test ldapsearch -x -H ldap://localhost -b "dc=seaweedfs,dc=test" -D "cn=admin,dc=seaweedfs,dc=test" -w adminpassword "(objectClass=*)" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Add test users for LDAP STS testing
|
||||
echo -e "${YELLOW}📝 Adding test users for LDAP STS...${NC}"
|
||||
docker exec -i openldap-iam-test ldapadd -x -D "cn=admin,dc=seaweedfs,dc=test" -w adminpassword <<EOF 2>/dev/null || true
|
||||
dn: ou=users,dc=seaweedfs,dc=test
|
||||
objectClass: organizationalUnit
|
||||
ou: users
|
||||
|
||||
dn: cn=testuser,ou=users,dc=seaweedfs,dc=test
|
||||
objectClass: inetOrgPerson
|
||||
cn: testuser
|
||||
sn: Test User
|
||||
uid: testuser
|
||||
userPassword: testpass
|
||||
|
||||
dn: cn=ldapadmin,ou=users,dc=seaweedfs,dc=test
|
||||
objectClass: inetOrgPerson
|
||||
cn: ldapadmin
|
||||
sn: LDAP Admin
|
||||
uid: ldapadmin
|
||||
userPassword: ldapadminpass
|
||||
EOF
|
||||
|
||||
# Verify test users were created successfully
|
||||
echo -e "${YELLOW}🔍 Verifying LDAP test users...${NC}"
|
||||
if docker exec openldap-iam-test ldapsearch -x -D "cn=admin,dc=seaweedfs,dc=test" -w adminpassword -b "ou=users,dc=seaweedfs,dc=test" "(cn=testuser)" cn 2>/dev/null | grep -q "cn: testuser"; then
|
||||
echo -e "${GREEN}[OK] Test user 'testuser' verified${NC}"
|
||||
else
|
||||
echo -e "${RED}[WARN] Could not verify test user 'testuser' - LDAP tests may fail${NC}"
|
||||
fi
|
||||
|
||||
# Set environment for LDAP tests
|
||||
export LDAP_URL="ldap://localhost:389"
|
||||
export LDAP_BASE_DN="dc=seaweedfs,dc=test"
|
||||
export LDAP_BIND_DN="cn=admin,dc=seaweedfs,dc=test"
|
||||
export LDAP_BIND_PASSWORD="adminpassword"
|
||||
|
||||
echo -e "${GREEN}[OK] LDAP setup completed${NC}"
|
||||
}
|
||||
|
||||
# Set up SeaweedFS test cluster
|
||||
setup_seaweedfs_cluster() {
|
||||
echo -e "\n${BLUE}2. Setting up SeaweedFS test cluster...${NC}"
|
||||
|
||||
# Build SeaweedFS binary if needed
|
||||
echo -e "${YELLOW}🔧 Building SeaweedFS binary...${NC}"
|
||||
cd "${SCRIPT_DIR}/../../../" # Go to seaweedfs root
|
||||
if ! make > /dev/null 2>&1; then
|
||||
echo -e "${RED}[FAIL] Failed to build SeaweedFS binary${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
cd "${SCRIPT_DIR}" # Return to test directory
|
||||
|
||||
# Clean up any existing test data
|
||||
echo -e "${YELLOW}🧹 Cleaning up existing test data...${NC}"
|
||||
rm -rf test-volume-data/* 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}[OK] SeaweedFS cluster setup completed${NC}"
|
||||
}
|
||||
|
||||
# Set up test data and configurations
|
||||
setup_test_configurations() {
|
||||
echo -e "\n${BLUE}3. Setting up test configurations...${NC}"
|
||||
|
||||
# Ensure IAM configuration is properly set up
|
||||
if [ ! -f "${SCRIPT_DIR}/iam_config.json" ]; then
|
||||
echo -e "${YELLOW}⚠️ IAM configuration not found, using default config${NC}"
|
||||
cp "${SCRIPT_DIR}/iam_config.local.json" "${SCRIPT_DIR}/iam_config.json" 2>/dev/null || {
|
||||
echo -e "${RED}[FAIL] No IAM configuration files found${NC}"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Validate configuration
|
||||
if ! jq . "${SCRIPT_DIR}/iam_config.json" >/dev/null; then
|
||||
echo -e "${RED}[FAIL] Invalid IAM configuration JSON${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[OK] Test configurations set up${NC}"
|
||||
}
|
||||
|
||||
# Verify services are ready
|
||||
verify_services() {
|
||||
echo -e "\n${BLUE}4. Verifying services are ready...${NC}"
|
||||
|
||||
# Check if Keycloak is responding
|
||||
echo -e "${YELLOW}🔍 Checking Keycloak availability...${NC}"
|
||||
local keycloak_ready=false
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "http://localhost:8080/health/ready" >/dev/null 2>&1; then
|
||||
keycloak_ready=true
|
||||
break
|
||||
fi
|
||||
if curl -sf "http://localhost:8080/realms/master" >/dev/null 2>&1; then
|
||||
keycloak_ready=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$keycloak_ready" = true ]; then
|
||||
echo -e "${GREEN}[OK] Keycloak is ready${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ Keycloak may not be fully ready yet${NC}"
|
||||
echo -e "${YELLOW}This is okay - tests will wait for Keycloak when needed${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[OK] Service verification completed${NC}"
|
||||
}
|
||||
|
||||
# Set up environment variables
|
||||
setup_environment() {
|
||||
echo -e "\n${BLUE}5. Setting up environment variables...${NC}"
|
||||
|
||||
export ENABLE_DISTRIBUTED_TESTS=true
|
||||
export ENABLE_PERFORMANCE_TESTS=true
|
||||
export ENABLE_STRESS_TESTS=true
|
||||
export KEYCLOAK_URL="http://localhost:8080"
|
||||
export S3_ENDPOINT="http://localhost:8333"
|
||||
export TEST_TIMEOUT=60m
|
||||
export CGO_ENABLED=0
|
||||
|
||||
# Write environment to a file for other scripts to source
|
||||
cat > "${SCRIPT_DIR}/.test_env" << EOF
|
||||
export ENABLE_DISTRIBUTED_TESTS=true
|
||||
export ENABLE_PERFORMANCE_TESTS=true
|
||||
export ENABLE_STRESS_TESTS=true
|
||||
export KEYCLOAK_URL="http://localhost:8080"
|
||||
export S3_ENDPOINT="http://localhost:8333"
|
||||
export TEST_TIMEOUT=60m
|
||||
export CGO_ENABLED=0
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}[OK] Environment variables set${NC}"
|
||||
}
|
||||
|
||||
# Display setup summary
|
||||
display_summary() {
|
||||
echo -e "\n${BLUE}📊 Setup Summary${NC}"
|
||||
echo -e "${BLUE}=================${NC}"
|
||||
echo -e "Keycloak URL: ${KEYCLOAK_URL:-http://localhost:8080}"
|
||||
echo -e "LDAP URL: ${LDAP_URL:-ldap://localhost:389}"
|
||||
echo -e "S3 Endpoint: ${S3_ENDPOINT:-http://localhost:8333}"
|
||||
echo -e "Test Timeout: ${TEST_TIMEOUT:-60m}"
|
||||
echo -e "IAM Config: ${SCRIPT_DIR}/iam_config.json"
|
||||
echo -e ""
|
||||
echo -e "${GREEN}[OK] Complete test environment setup finished!${NC}"
|
||||
echo -e "${YELLOW}💡 You can now run tests with: make run-all-tests${NC}"
|
||||
echo -e "${YELLOW}💡 Or run specific tests with: go test -v -timeout=60m -run TestName${NC}"
|
||||
echo -e "${YELLOW}💡 To stop Keycloak: docker stop keycloak-iam-test${NC}"
|
||||
echo -e "${YELLOW}💡 To stop LDAP: docker stop openldap-iam-test${NC}"
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
check_prerequisites
|
||||
|
||||
# Track what was set up for cleanup on failure
|
||||
local setup_steps=()
|
||||
|
||||
if setup_keycloak; then
|
||||
setup_steps+=("keycloak")
|
||||
else
|
||||
echo -e "${RED}[FAIL] Failed to set up Keycloak${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# LDAP is optional but we try to set it up
|
||||
setup_ldap
|
||||
setup_steps+=("ldap")
|
||||
|
||||
if setup_seaweedfs_cluster; then
|
||||
setup_steps+=("seaweedfs")
|
||||
else
|
||||
echo -e "${RED}[FAIL] Failed to set up SeaweedFS cluster${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if setup_test_configurations; then
|
||||
setup_steps+=("config")
|
||||
else
|
||||
echo -e "${RED}[FAIL] Failed to set up test configurations${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
setup_environment
|
||||
verify_services
|
||||
display_summary
|
||||
|
||||
echo -e "${GREEN}🎉 All setup completed successfully!${NC}"
|
||||
}
|
||||
|
||||
# Cleanup on script interruption
|
||||
cleanup() {
|
||||
echo -e "\n${YELLOW}🧹 Cleaning up on script interruption...${NC}"
|
||||
# Note: We don't automatically stop Keycloak as it might be shared
|
||||
echo -e "${YELLOW}💡 If you want to stop Keycloak: docker stop keycloak-iam-test${NC}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
trap cleanup INT TERM
|
||||
|
||||
# Execute main function
|
||||
main "$@"
|
||||
416
test/s3/iam/setup_keycloak.sh
Executable file
416
test/s3/iam/setup_keycloak.sh
Executable file
@@ -0,0 +1,416 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
KEYCLOAK_IMAGE="quay.io/keycloak/keycloak:26.0.7"
|
||||
CONTAINER_NAME="keycloak-iam-test"
|
||||
KEYCLOAK_PORT="8080" # Default external port
|
||||
KEYCLOAK_INTERNAL_PORT="8080" # Internal container port (always 8080)
|
||||
KEYCLOAK_URL="http://localhost:${KEYCLOAK_PORT}"
|
||||
|
||||
# Realm and test fixtures expected by tests
|
||||
REALM_NAME="seaweedfs-test"
|
||||
CLIENT_ID="seaweedfs-s3"
|
||||
CLIENT_SECRET="seaweedfs-s3-secret"
|
||||
ROLE_ADMIN="s3-admin"
|
||||
ROLE_READONLY="s3-read-only"
|
||||
ROLE_WRITEONLY="s3-write-only"
|
||||
ROLE_READWRITE="s3-read-write"
|
||||
|
||||
# User credentials (matches Docker setup script logic: removes non-alphabetic chars + "123")
|
||||
get_user_password() {
|
||||
case "$1" in
|
||||
"admin-user") echo "adminuser123" ;; # "admin-user" -> "adminuser123"
|
||||
"read-user") echo "readuser123" ;; # "read-user" -> "readuser123"
|
||||
"write-user") echo "writeuser123" ;; # "write-user" -> "writeuser123"
|
||||
"write-only-user") echo "writeonlyuser123" ;; # "write-only-user" -> "writeonlyuser123"
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# List of users to create
|
||||
USERS="admin-user read-user write-user write-only-user"
|
||||
|
||||
echo -e "${BLUE}🔧 Setting up Keycloak realm and users for SeaweedFS S3 IAM testing...${NC}"
|
||||
|
||||
ensure_container() {
|
||||
# Check for any existing Keycloak container and detect its port
|
||||
local keycloak_containers=$(docker ps --format '{{.Names}}\t{{.Ports}}' | grep -E "(keycloak|quay.io/keycloak)")
|
||||
|
||||
if [[ -n "$keycloak_containers" ]]; then
|
||||
# Parse the first available Keycloak container
|
||||
CONTAINER_NAME=$(echo "$keycloak_containers" | head -1 | awk '{print $1}')
|
||||
|
||||
# Extract the external port from the port mapping using sed (compatible with older bash)
|
||||
local port_mapping=$(echo "$keycloak_containers" | head -1 | awk '{print $2}')
|
||||
local extracted_port=$(echo "$port_mapping" | sed -n 's/.*:\([0-9]*\)->8080.*/\1/p')
|
||||
if [[ -n "$extracted_port" ]]; then
|
||||
KEYCLOAK_PORT="$extracted_port"
|
||||
KEYCLOAK_URL="http://localhost:${KEYCLOAK_PORT}"
|
||||
echo -e "${GREEN}[OK] Using existing container '${CONTAINER_NAME}' on port ${KEYCLOAK_PORT}${NC}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback: check for specific container names
|
||||
if docker ps --format '{{.Names}}' | grep -q '^keycloak$'; then
|
||||
CONTAINER_NAME="keycloak"
|
||||
# Try to detect port for 'keycloak' container using docker port command
|
||||
local ports=$(docker port keycloak 8080 2>/dev/null | head -1)
|
||||
if [[ -n "$ports" ]]; then
|
||||
local extracted_port=$(echo "$ports" | sed -n 's/.*:\([0-9]*\)$/\1/p')
|
||||
if [[ -n "$extracted_port" ]]; then
|
||||
KEYCLOAK_PORT="$extracted_port"
|
||||
KEYCLOAK_URL="http://localhost:${KEYCLOAK_PORT}"
|
||||
fi
|
||||
fi
|
||||
echo -e "${GREEN}[OK] Using existing container '${CONTAINER_NAME}' on port ${KEYCLOAK_PORT}${NC}"
|
||||
return 0
|
||||
fi
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo -e "${GREEN}[OK] Using existing container '${CONTAINER_NAME}'${NC}"
|
||||
return 0
|
||||
fi
|
||||
echo -e "${YELLOW}🐳 Starting Keycloak container (${KEYCLOAK_IMAGE})...${NC}"
|
||||
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
|
||||
docker run -d --name "${CONTAINER_NAME}" -p "${KEYCLOAK_PORT}:8080" \
|
||||
-e KEYCLOAK_ADMIN=admin \
|
||||
-e KEYCLOAK_ADMIN_PASSWORD=admin \
|
||||
-e KC_HTTP_ENABLED=true \
|
||||
-e KC_HOSTNAME_STRICT=false \
|
||||
-e KC_HOSTNAME_STRICT_HTTPS=false \
|
||||
-e KC_HEALTH_ENABLED=true \
|
||||
"${KEYCLOAK_IMAGE}" start-dev >/dev/null
|
||||
}
|
||||
|
||||
wait_ready() {
|
||||
echo -e "${YELLOW}⏳ Waiting for Keycloak to be ready...${NC}"
|
||||
for i in $(seq 1 120); do
|
||||
if curl -sf "${KEYCLOAK_URL}/health/ready" >/dev/null; then
|
||||
echo -e "${GREEN}[OK] Keycloak health check passed${NC}"
|
||||
return 0
|
||||
fi
|
||||
if curl -sf "${KEYCLOAK_URL}/realms/master" >/dev/null; then
|
||||
echo -e "${GREEN}[OK] Keycloak master realm accessible${NC}"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo -e "${RED}[FAIL] Keycloak did not become ready in time${NC}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
kcadm() {
|
||||
# Always authenticate before each command to ensure context
|
||||
# Try different admin passwords that might be used in different environments
|
||||
# GitHub Actions uses "admin", local testing might use "admin123"
|
||||
local admin_passwords=("admin" "admin123" "password")
|
||||
local auth_success=false
|
||||
|
||||
for pwd in "${admin_passwords[@]}"; do
|
||||
if docker exec -i "${CONTAINER_NAME}" /opt/keycloak/bin/kcadm.sh config credentials --server "http://localhost:${KEYCLOAK_INTERNAL_PORT}" --realm master --user admin --password "$pwd" >/dev/null 2>&1; then
|
||||
auth_success=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$auth_success" == false ]]; then
|
||||
echo -e "${RED}[FAIL] Failed to authenticate with any known admin password${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
docker exec -i "${CONTAINER_NAME}" /opt/keycloak/bin/kcadm.sh "$@"
|
||||
}
|
||||
|
||||
admin_login() {
|
||||
# This is now handled by each kcadm() call
|
||||
echo "Logging into http://localhost:${KEYCLOAK_INTERNAL_PORT} as user admin of realm master"
|
||||
}
|
||||
|
||||
ensure_realm() {
|
||||
if kcadm get realms | grep -q "${REALM_NAME}"; then
|
||||
echo -e "${GREEN}[OK] Realm '${REALM_NAME}' already exists${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}📝 Creating realm '${REALM_NAME}'...${NC}"
|
||||
if kcadm create realms -s realm="${REALM_NAME}" -s enabled=true; then
|
||||
echo -e "${GREEN}[OK] Realm created${NC}"
|
||||
else
|
||||
# Check if it exists now (might have been created by another process)
|
||||
if kcadm get realms | grep -q "${REALM_NAME}"; then
|
||||
echo -e "${GREEN}[OK] Realm '${REALM_NAME}' already exists (created concurrently)${NC}"
|
||||
else
|
||||
echo -e "${RED}[FAIL] Failed to create realm '${REALM_NAME}'${NC}"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_client() {
|
||||
local id
|
||||
id=$(kcadm get clients -r "${REALM_NAME}" -q clientId="${CLIENT_ID}" | jq -r '.[0].id // empty')
|
||||
if [[ -n "${id}" ]]; then
|
||||
echo -e "${GREEN}[OK] Client '${CLIENT_ID}' already exists${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}📝 Creating client '${CLIENT_ID}'...${NC}"
|
||||
kcadm create clients -r "${REALM_NAME}" \
|
||||
-s clientId="${CLIENT_ID}" \
|
||||
-s protocol=openid-connect \
|
||||
-s publicClient=false \
|
||||
-s serviceAccountsEnabled=true \
|
||||
-s directAccessGrantsEnabled=true \
|
||||
-s standardFlowEnabled=true \
|
||||
-s implicitFlowEnabled=false \
|
||||
-s secret="${CLIENT_SECRET}" >/dev/null
|
||||
echo -e "${GREEN}[OK] Client created${NC}"
|
||||
fi
|
||||
|
||||
# Create and configure role mapper for the client
|
||||
configure_role_mapper "${CLIENT_ID}"
|
||||
}
|
||||
|
||||
ensure_role() {
|
||||
local role="$1"
|
||||
if kcadm get roles -r "${REALM_NAME}" | jq -r '.[].name' | grep -qx "${role}"; then
|
||||
echo -e "${GREEN}[OK] Role '${role}' exists${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}📝 Creating role '${role}'...${NC}"
|
||||
kcadm create roles -r "${REALM_NAME}" -s name="${role}" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_user() {
|
||||
local username="$1" password="$2"
|
||||
local uid
|
||||
uid=$(kcadm get users -r "${REALM_NAME}" -q username="${username}" | jq -r '.[0].id // empty')
|
||||
if [[ -z "${uid}" ]]; then
|
||||
echo -e "${YELLOW}📝 Creating user '${username}'...${NC}"
|
||||
uid=$(kcadm create users -r "${REALM_NAME}" \
|
||||
-s username="${username}" \
|
||||
-s enabled=true \
|
||||
-s email="${username}@seaweedfs.test" \
|
||||
-s emailVerified=true \
|
||||
-s firstName="${username}" \
|
||||
-s lastName="User" \
|
||||
-i)
|
||||
else
|
||||
echo -e "${GREEN}[OK] User '${username}' exists${NC}"
|
||||
fi
|
||||
echo -e "${YELLOW}🔑 Setting password for '${username}'...${NC}"
|
||||
kcadm set-password -r "${REALM_NAME}" --userid "${uid}" --new-password "${password}" --temporary=false >/dev/null
|
||||
}
|
||||
|
||||
assign_role() {
|
||||
local username="$1" role="$2"
|
||||
local uid rid
|
||||
uid=$(kcadm get users -r "${REALM_NAME}" -q username="${username}" | jq -r '.[0].id')
|
||||
rid=$(kcadm get roles -r "${REALM_NAME}" | jq -r ".[] | select(.name==\"${role}\") | .id")
|
||||
# Check if role already assigned
|
||||
if kcadm get "users/${uid}/role-mappings/realm" -r "${REALM_NAME}" | jq -r '.[].name' | grep -qx "${role}"; then
|
||||
echo -e "${GREEN}[OK] User '${username}' already has role '${role}'${NC}"
|
||||
return 0
|
||||
fi
|
||||
echo -e "${YELLOW}➕ Assigning role '${role}' to '${username}'...${NC}"
|
||||
kcadm add-roles -r "${REALM_NAME}" --uid "${uid}" --rolename "${role}" >/dev/null
|
||||
}
|
||||
|
||||
configure_role_mapper() {
|
||||
echo -e "${YELLOW}🔧 Configuring role mapper for client '${CLIENT_ID}'...${NC}"
|
||||
|
||||
# Get client's internal ID
|
||||
local internal_id
|
||||
internal_id=$(kcadm get clients -r "${REALM_NAME}" -q clientId="${CLIENT_ID}" | jq -r '.[0].id // empty')
|
||||
|
||||
if [[ -z "${internal_id}" ]]; then
|
||||
echo -e "${RED}[FAIL] Could not find client ${client_id} to configure role mapper${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if a realm roles mapper already exists for this client
|
||||
local existing_mapper
|
||||
existing_mapper=$(kcadm get "clients/${internal_id}/protocol-mappers/models" -r "${REALM_NAME}" | jq -r '.[] | select(.name=="realm roles" and .protocolMapper=="oidc-usermodel-realm-role-mapper") | .id // empty')
|
||||
|
||||
if [[ -n "${existing_mapper}" ]]; then
|
||||
echo -e "${GREEN}[OK] Realm roles mapper already exists${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}📝 Creating realm roles mapper...${NC}"
|
||||
|
||||
# Create protocol mapper for realm roles
|
||||
kcadm create "clients/${internal_id}/protocol-mappers/models" -r "${REALM_NAME}" \
|
||||
-s name="realm roles" \
|
||||
-s protocol="openid-connect" \
|
||||
-s protocolMapper="oidc-usermodel-realm-role-mapper" \
|
||||
-s consentRequired=false \
|
||||
-s 'config."multivalued"=true' \
|
||||
-s 'config."userinfo.token.claim"=true' \
|
||||
-s 'config."id.token.claim"=true' \
|
||||
-s 'config."access.token.claim"=true' \
|
||||
-s 'config."claim.name"=roles' \
|
||||
-s 'config."jsonType.label"=String' >/dev/null || {
|
||||
echo -e "${RED}[FAIL] Failed to create realm roles mapper${NC}"
|
||||
return 1
|
||||
}
|
||||
|
||||
echo -e "${GREEN}[OK] Realm roles mapper created${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
configure_audience_mapper() {
|
||||
echo -e "${YELLOW}🔧 Configuring audience mapper for client '${CLIENT_ID}'...${NC}"
|
||||
|
||||
# Get client's internal ID
|
||||
local internal_id
|
||||
internal_id=$(kcadm get clients -r "${REALM_NAME}" -q clientId="${CLIENT_ID}" | jq -r '.[0].id // empty')
|
||||
|
||||
if [[ -z "${internal_id}" ]]; then
|
||||
echo -e "${RED}[FAIL] Could not find client ${CLIENT_ID} to configure audience mapper${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if an audience mapper already exists for this client
|
||||
local existing_mapper
|
||||
existing_mapper=$(kcadm get "clients/${internal_id}/protocol-mappers/models" -r "${REALM_NAME}" | jq -r '.[] | select(.name=="audience-mapper" and .protocolMapper=="oidc-audience-mapper") | .id // empty')
|
||||
|
||||
if [[ -n "${existing_mapper}" ]]; then
|
||||
echo -e "${GREEN}[OK] Audience mapper already exists${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}📝 Creating audience mapper...${NC}"
|
||||
|
||||
# Create protocol mapper for audience
|
||||
kcadm create "clients/${internal_id}/protocol-mappers/models" -r "${REALM_NAME}" \
|
||||
-s name="audience-mapper" \
|
||||
-s protocol="openid-connect" \
|
||||
-s protocolMapper="oidc-audience-mapper" \
|
||||
-s consentRequired=false \
|
||||
-s 'config."included.client.audience"='"${CLIENT_ID}" \
|
||||
-s 'config."id.token.claim"=false' \
|
||||
-s 'config."access.token.claim"=true' >/dev/null || {
|
||||
echo -e "${RED}[FAIL] Failed to create audience mapper${NC}"
|
||||
return 1
|
||||
}
|
||||
|
||||
echo -e "${GREEN}[OK] Audience mapper created${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
command -v docker >/dev/null || { echo -e "${RED}[FAIL] Docker is required${NC}"; exit 1; }
|
||||
command -v jq >/dev/null || { echo -e "${RED}[FAIL] jq is required${NC}"; exit 1; }
|
||||
|
||||
ensure_container
|
||||
echo "Keycloak URL: ${KEYCLOAK_URL}"
|
||||
wait_ready
|
||||
admin_login
|
||||
ensure_realm
|
||||
ensure_client
|
||||
configure_role_mapper
|
||||
configure_audience_mapper
|
||||
ensure_role "${ROLE_ADMIN}"
|
||||
ensure_role "${ROLE_READONLY}"
|
||||
ensure_role "${ROLE_WRITEONLY}"
|
||||
ensure_role "${ROLE_READWRITE}"
|
||||
|
||||
for u in $USERS; do
|
||||
ensure_user "$u" "$(get_user_password "$u")"
|
||||
done
|
||||
|
||||
assign_role admin-user "${ROLE_ADMIN}"
|
||||
assign_role read-user "${ROLE_READONLY}"
|
||||
assign_role write-user "${ROLE_READWRITE}"
|
||||
|
||||
# Also create a dedicated write-only user for testing
|
||||
ensure_user write-only-user "$(get_user_password write-only-user)"
|
||||
assign_role write-only-user "${ROLE_WRITEONLY}"
|
||||
|
||||
# Copy the appropriate IAM configuration for this environment
|
||||
setup_iam_config
|
||||
|
||||
# Validate the setup by testing authentication and role inclusion
|
||||
echo -e "${YELLOW}🔍 Validating setup by testing admin-user authentication and role mapping...${NC}"
|
||||
sleep 2
|
||||
|
||||
local validation_result=$(curl -s -w "%{http_code}" -X POST "http://localhost:${KEYCLOAK_PORT}/realms/${REALM_NAME}/protocol/openid-connect/token" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=password" \
|
||||
-d "client_id=${CLIENT_ID}" \
|
||||
-d "client_secret=${CLIENT_SECRET}" \
|
||||
-d "username=admin-user" \
|
||||
-d "password=adminuser123" \
|
||||
-d "scope=openid profile email" \
|
||||
-o /tmp/auth_test_response.json)
|
||||
|
||||
if [[ "${validation_result: -3}" == "200" ]]; then
|
||||
echo -e "${GREEN}[OK] Authentication validation successful${NC}"
|
||||
|
||||
# Extract and decode JWT token to check for roles
|
||||
local access_token=$(cat /tmp/auth_test_response.json | jq -r '.access_token // empty')
|
||||
if [[ -n "${access_token}" ]]; then
|
||||
# Decode JWT payload (second part) and check for roles
|
||||
local payload=$(echo "${access_token}" | cut -d'.' -f2)
|
||||
# Add padding if needed for base64 decode
|
||||
while [[ $((${#payload} % 4)) -ne 0 ]]; do
|
||||
payload="${payload}="
|
||||
done
|
||||
|
||||
local decoded=$(echo "${payload}" | base64 -d 2>/dev/null || echo "{}")
|
||||
local roles=$(echo "${decoded}" | jq -r '.roles // empty' 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "${roles}" && "${roles}" != "null" ]]; then
|
||||
echo -e "${GREEN}[OK] JWT token includes roles: ${roles}${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ JWT token does not include 'roles' claim${NC}"
|
||||
echo -e "${YELLOW}Decoded payload sample:${NC}"
|
||||
echo "${decoded}" | jq '.' 2>/dev/null || echo "${decoded}"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}[FAIL] Authentication validation failed with HTTP ${validation_result: -3}${NC}"
|
||||
echo -e "${YELLOW}Response body:${NC}"
|
||||
cat /tmp/auth_test_response.json 2>/dev/null || echo "No response body"
|
||||
echo -e "${YELLOW}This may indicate a setup issue that needs to be resolved${NC}"
|
||||
fi
|
||||
rm -f /tmp/auth_test_response.json
|
||||
|
||||
echo -e "${GREEN}[OK] Keycloak test realm '${REALM_NAME}' configured${NC}"
|
||||
}
|
||||
|
||||
setup_iam_config() {
|
||||
echo -e "${BLUE}🔧 Setting up IAM configuration for detected environment${NC}"
|
||||
|
||||
# Change to script directory to ensure config files are found
|
||||
local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$script_dir"
|
||||
|
||||
# Choose the appropriate config based on detected port
|
||||
local config_source
|
||||
if [[ "${KEYCLOAK_PORT}" == "8080" ]]; then
|
||||
config_source="iam_config.github.json"
|
||||
echo " Using GitHub Actions configuration (port 8080)"
|
||||
else
|
||||
config_source="iam_config.local.json"
|
||||
echo " Using local development configuration (port ${KEYCLOAK_PORT})"
|
||||
fi
|
||||
|
||||
# Verify source config exists
|
||||
if [[ ! -f "$config_source" ]]; then
|
||||
echo -e "${RED}[FAIL] Config file $config_source not found in $script_dir${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy the appropriate config
|
||||
cp "$config_source" "iam_config.json"
|
||||
|
||||
local detected_issuer=$(cat iam_config.json | jq -r '.providers[] | select(.name=="keycloak") | .config.issuer')
|
||||
echo -e "${GREEN}[OK] IAM configuration set successfully${NC}"
|
||||
echo " - Using config: $config_source"
|
||||
echo " - Keycloak issuer: $detected_issuer"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
419
test/s3/iam/setup_keycloak_docker.sh
Executable file
419
test/s3/iam/setup_keycloak_docker.sh
Executable file
@@ -0,0 +1,419 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Keycloak configuration for Docker environment
|
||||
KEYCLOAK_URL="http://keycloak:8080"
|
||||
KEYCLOAK_ADMIN_USER="admin"
|
||||
KEYCLOAK_ADMIN_PASSWORD="admin"
|
||||
REALM_NAME="seaweedfs-test"
|
||||
CLIENT_ID="seaweedfs-s3"
|
||||
CLIENT_SECRET="seaweedfs-s3-secret"
|
||||
|
||||
echo "🔧 Setting up Keycloak realm and users for SeaweedFS S3 IAM testing..."
|
||||
echo "Keycloak URL: $KEYCLOAK_URL"
|
||||
|
||||
# Wait for Keycloak to be ready
|
||||
echo "⏳ Waiting for Keycloak to be ready..."
|
||||
timeout 120 bash -c '
|
||||
until curl -f "$0/health/ready" > /dev/null 2>&1; do
|
||||
echo "Waiting for Keycloak..."
|
||||
sleep 5
|
||||
done
|
||||
echo "[OK] Keycloak health check passed"
|
||||
' "$KEYCLOAK_URL"
|
||||
|
||||
# Download kcadm.sh if not available
|
||||
if ! command -v kcadm.sh &> /dev/null; then
|
||||
echo "📥 Downloading Keycloak admin CLI..."
|
||||
wget -q https://github.com/keycloak/keycloak/releases/download/26.0.7/keycloak-26.0.7.tar.gz
|
||||
tar -xzf keycloak-26.0.7.tar.gz
|
||||
export PATH="$PWD/keycloak-26.0.7/bin:$PATH"
|
||||
fi
|
||||
|
||||
# Wait a bit more for admin user initialization
|
||||
echo "⏳ Waiting for admin user to be fully initialized..."
|
||||
sleep 10
|
||||
|
||||
# Function to execute kcadm commands with retry and multiple password attempts
|
||||
kcadm() {
|
||||
local max_retries=3
|
||||
local retry_count=0
|
||||
local passwords=("admin" "admin123" "password")
|
||||
|
||||
while [ $retry_count -lt $max_retries ]; do
|
||||
for password in "${passwords[@]}"; do
|
||||
if kcadm.sh "$@" --server "$KEYCLOAK_URL" --realm master --user "$KEYCLOAK_ADMIN_USER" --password "$password" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
retry_count=$((retry_count + 1))
|
||||
echo "🔄 Retry $retry_count of $max_retries..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "[FAIL] Failed to execute kcadm command after $max_retries retries"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Create realm
|
||||
echo "📝 Creating realm '$REALM_NAME'..."
|
||||
kcadm create realms -s realm="$REALM_NAME" -s enabled=true || echo "Realm may already exist"
|
||||
echo "[OK] Realm created"
|
||||
|
||||
# Create OIDC client
|
||||
echo "📝 Creating client '$CLIENT_ID'..."
|
||||
CLIENT_UUID=$(kcadm create clients -r "$REALM_NAME" \
|
||||
-s clientId="$CLIENT_ID" \
|
||||
-s secret="$CLIENT_SECRET" \
|
||||
-s enabled=true \
|
||||
-s serviceAccountsEnabled=true \
|
||||
-s standardFlowEnabled=true \
|
||||
-s directAccessGrantsEnabled=true \
|
||||
-s 'redirectUris=["*"]' \
|
||||
-s 'webOrigins=["*"]' \
|
||||
-i 2>/dev/null || echo "existing-client")
|
||||
|
||||
if [ "$CLIENT_UUID" != "existing-client" ]; then
|
||||
echo "[OK] Client created with ID: $CLIENT_UUID"
|
||||
else
|
||||
echo "[OK] Using existing client"
|
||||
CLIENT_UUID=$(kcadm get clients -r "$REALM_NAME" -q clientId="$CLIENT_ID" --fields id --format csv --noquotes | tail -n +2)
|
||||
fi
|
||||
|
||||
# Configure protocol mapper for roles
|
||||
echo "🔧 Configuring role mapper for client '$CLIENT_ID'..."
|
||||
MAPPER_CONFIG='{
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-realm-role-mapper",
|
||||
"name": "realm-roles",
|
||||
"config": {
|
||||
"claim.name": "roles",
|
||||
"jsonType.label": "String",
|
||||
"multivalued": "true",
|
||||
"usermodel.realmRoleMapping.rolePrefix": ""
|
||||
}
|
||||
}'
|
||||
|
||||
kcadm create clients/"$CLIENT_UUID"/protocol-mappers/models -r "$REALM_NAME" -b "$MAPPER_CONFIG" 2>/dev/null || echo "[OK] Role mapper already exists"
|
||||
echo "[OK] Realm roles mapper configured"
|
||||
|
||||
# Configure audience mapper to ensure JWT tokens have correct audience claim
|
||||
echo "🔧 Configuring audience mapper for client '$CLIENT_ID'..."
|
||||
AUDIENCE_MAPPER_CONFIG='{
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"name": "audience-mapper",
|
||||
"config": {
|
||||
"included.client.audience": "'$CLIENT_ID'",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
}'
|
||||
|
||||
kcadm create clients/"$CLIENT_UUID"/protocol-mappers/models -r "$REALM_NAME" -b "$AUDIENCE_MAPPER_CONFIG" 2>/dev/null || echo "[OK] Audience mapper already exists"
|
||||
echo "[OK] Audience mapper configured"
|
||||
|
||||
# Create realm roles
|
||||
echo "📝 Creating realm roles..."
|
||||
for role in "s3-admin" "s3-read-only" "s3-write-only" "s3-read-write"; do
|
||||
kcadm create roles -r "$REALM_NAME" -s name="$role" 2>/dev/null || echo "Role $role may already exist"
|
||||
done
|
||||
|
||||
# Create users with roles
|
||||
declare -A USERS=(
|
||||
["admin-user"]="s3-admin"
|
||||
["read-user"]="s3-read-only"
|
||||
["write-user"]="s3-read-write"
|
||||
["write-only-user"]="s3-write-only"
|
||||
)
|
||||
|
||||
for username in "${!USERS[@]}"; do
|
||||
role="${USERS[$username]}"
|
||||
password="${username//[^a-zA-Z]/}123" # e.g., "admin-user" -> "adminuser123"
|
||||
|
||||
echo "📝 Creating user '$username'..."
|
||||
kcadm create users -r "$REALM_NAME" \
|
||||
-s username="$username" \
|
||||
-s enabled=true \
|
||||
-s firstName="Test" \
|
||||
-s lastName="User" \
|
||||
-s email="$username@test.com" 2>/dev/null || echo "User $username may already exist"
|
||||
|
||||
echo "🔑 Setting password for '$username'..."
|
||||
kcadm set-password -r "$REALM_NAME" --username "$username" --new-password "$password"
|
||||
|
||||
echo "➕ Assigning role '$role' to '$username'..."
|
||||
kcadm add-roles -r "$REALM_NAME" --uusername "$username" --rolename "$role"
|
||||
done
|
||||
|
||||
# Create IAM configuration for Docker environment
|
||||
echo "🔧 Setting up IAM configuration for Docker environment..."
|
||||
cat > iam_config.json << 'EOF'
|
||||
{
|
||||
"sts": {
|
||||
"tokenDuration": "1h",
|
||||
"maxSessionLength": "12h",
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "dGVzdC1zaWduaW5nLWtleS0zMi1jaGFyYWN0ZXJzLWxvbmc="
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
"name": "keycloak",
|
||||
"type": "oidc",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"issuer": "http://keycloak:8080/realms/seaweedfs-test",
|
||||
"clientId": "seaweedfs-s3",
|
||||
"clientSecret": "seaweedfs-s3-secret",
|
||||
"jwksUri": "http://keycloak:8080/realms/seaweedfs-test/protocol/openid-connect/certs",
|
||||
"userInfoUri": "http://keycloak:8080/realms/seaweedfs-test/protocol/openid-connect/userinfo",
|
||||
"scopes": ["openid", "profile", "email"],
|
||||
"claimsMapping": {
|
||||
"username": "preferred_username",
|
||||
"email": "email",
|
||||
"name": "name"
|
||||
},
|
||||
"roleMapping": {
|
||||
"rules": [
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-admin",
|
||||
"role": "arn:aws:iam::role/KeycloakAdminRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-read-only",
|
||||
"role": "arn:aws:iam::role/KeycloakReadOnlyRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-write-only",
|
||||
"role": "arn:aws:iam::role/KeycloakWriteOnlyRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-read-write",
|
||||
"role": "arn:aws:iam::role/KeycloakReadWriteRole"
|
||||
}
|
||||
],
|
||||
"defaultRole": "arn:aws:iam::role/KeycloakReadOnlyRole"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"policy": {
|
||||
"defaultEffect": "Deny"
|
||||
},
|
||||
"roles": [
|
||||
{
|
||||
"roleName": "KeycloakAdminRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakAdminRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3AdminPolicy"],
|
||||
"description": "Admin role for Keycloak users"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakReadOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakReadOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3ReadOnlyPolicy"],
|
||||
"description": "Read-only role for Keycloak users"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakWriteOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakWriteOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3WriteOnlyPolicy"],
|
||||
"description": "Write-only role for Keycloak users"
|
||||
},
|
||||
{
|
||||
"roleName": "KeycloakReadWriteRole",
|
||||
"roleArn": "arn:aws:iam::role/KeycloakReadWriteRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["S3ReadWritePolicy"],
|
||||
"description": "Read-write role for Keycloak users"
|
||||
}
|
||||
],
|
||||
"policies": [
|
||||
{
|
||||
"name": "S3AdminPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": ["*"]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["sts:ValidateSession"],
|
||||
"Resource": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3ReadOnlyPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:ListBucket"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["sts:ValidateSession"],
|
||||
"Resource": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3WriteOnlyPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:ListBucket"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["sts:ValidateSession"],
|
||||
"Resource": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "S3ReadWritePolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["sts:ValidateSession"],
|
||||
"Resource": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
# Validate setup by testing authentication
|
||||
echo "🔍 Validating setup by testing admin-user authentication and role mapping..."
|
||||
KEYCLOAK_TOKEN_URL="http://keycloak:8080/realms/$REALM_NAME/protocol/openid-connect/token"
|
||||
|
||||
# Get access token for admin-user
|
||||
ACCESS_TOKEN=$(curl -s -X POST "$KEYCLOAK_TOKEN_URL" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=password" \
|
||||
-d "client_id=$CLIENT_ID" \
|
||||
-d "client_secret=$CLIENT_SECRET" \
|
||||
-d "username=admin-user" \
|
||||
-d "password=adminuser123" \
|
||||
-d "scope=openid profile email" | jq -r '.access_token')
|
||||
|
||||
if [ "$ACCESS_TOKEN" = "null" ] || [ -z "$ACCESS_TOKEN" ]; then
|
||||
echo "[FAIL] Failed to obtain access token"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[OK] Authentication validation successful"
|
||||
|
||||
# Decode and check JWT claims
|
||||
PAYLOAD=$(echo "$ACCESS_TOKEN" | cut -d'.' -f2)
|
||||
# Add padding for base64 decode
|
||||
while [ $((${#PAYLOAD} % 4)) -ne 0 ]; do
|
||||
PAYLOAD="${PAYLOAD}="
|
||||
done
|
||||
|
||||
CLAIMS=$(echo "$PAYLOAD" | base64 -d 2>/dev/null | jq .)
|
||||
ROLES=$(echo "$CLAIMS" | jq -r '.roles[]?')
|
||||
|
||||
if [ -n "$ROLES" ]; then
|
||||
echo "[OK] JWT token includes roles: [$(echo "$ROLES" | tr '\n' ',' | sed 's/,$//' | sed 's/,/, /g')]"
|
||||
else
|
||||
echo "⚠️ No roles found in JWT token"
|
||||
fi
|
||||
|
||||
echo "[OK] Keycloak test realm '$REALM_NAME' configured for Docker environment"
|
||||
echo "🐳 Setup complete! You can now run: docker-compose up -d"
|
||||
94
test/s3/iam/test_config.json
Normal file
94
test/s3/iam/test_config.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"identities": [
|
||||
{
|
||||
"name": "testuser",
|
||||
"credentials": [
|
||||
{
|
||||
"accessKey": "test-access-key",
|
||||
"secretKey": "test-secret-key"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
"Admin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "readonlyuser",
|
||||
"credentials": [
|
||||
{
|
||||
"accessKey": "readonly-access-key",
|
||||
"secretKey": "readonly-secret-key"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
"Read"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "writeonlyuser",
|
||||
"credentials": [
|
||||
{
|
||||
"accessKey": "writeonly-access-key",
|
||||
"secretKey": "writeonly-secret-key"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
"Write"
|
||||
]
|
||||
}
|
||||
],
|
||||
"bucketPolicyExamples": {
|
||||
"PublicReadPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "PublicReadGetObject",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::example-bucket/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
"DenyDeletePolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "DenyDeleteOperations",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": [
|
||||
"s3:DeleteObject",
|
||||
"s3:DeleteBucket"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::example-bucket",
|
||||
"arn:aws:s3:::example-bucket/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"IPRestrictedAccessPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "IPRestrictedAccess",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject"
|
||||
],
|
||||
"Resource": "arn:aws:s3:::example-bucket/*",
|
||||
"Condition": {
|
||||
"IpAddress": {
|
||||
"aws:SourceIp": [
|
||||
"203.0.113.0/24"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
76
test/s3/iam/test_iam_config.json
Normal file
76
test/s3/iam/test_iam_config.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"sts": {
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "dGVzdC1zaWduaW5nLWtleS0zMi1jaGFyYWN0ZXJzLWxvbmc=",
|
||||
"tokenDuration": "1h",
|
||||
"maxSessionLength": "12h"
|
||||
},
|
||||
"policy": {
|
||||
"defaultEffect": "Deny",
|
||||
"storeType": "memory"
|
||||
},
|
||||
"roles": [
|
||||
{
|
||||
"roleName": "TestAdminRole",
|
||||
"roleArn": "arn:aws:iam::123456789012:role/TestAdminRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "*"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"AllowAll"
|
||||
]
|
||||
},
|
||||
{
|
||||
"roleName": "TestReadOnlyRole",
|
||||
"roleArn": "arn:aws:iam::123456789012:role/TestReadOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "*"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"AllowAll"
|
||||
]
|
||||
}
|
||||
],
|
||||
"policies": [
|
||||
{
|
||||
"name": "AllowAll",
|
||||
"document": {
|
||||
"version": "2012-10-17",
|
||||
"statement": [
|
||||
{
|
||||
"effect": "Allow",
|
||||
"action": [
|
||||
"s3:*"
|
||||
],
|
||||
"resource": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"providers": []
|
||||
}
|
||||
Reference in New Issue
Block a user