diff options
Diffstat (limited to 'test')
28 files changed, 7178 insertions, 0 deletions
diff --git a/test/s3/iam/Dockerfile.s3 b/test/s3/iam/Dockerfile.s3 new file mode 100644 index 000000000..36f0ead1f --- /dev/null +++ b/test/s3/iam/Dockerfile.s3 @@ -0,0 +1,33 @@ +# Multi-stage build for SeaweedFS S3 with IAM +FROM golang:1.23-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"] diff --git a/test/s3/iam/Makefile b/test/s3/iam/Makefile new file mode 100644 index 000000000..57d0ca9df --- /dev/null +++ b/test/s3/iam/Makefile @@ -0,0 +1,306 @@ +# 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 + +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 + +start-services: ## Start SeaweedFS services for testing + @echo "๐ Starting SeaweedFS services..." + @echo "Starting master server..." + @$(WEED_BINARY) master -port=$(MASTER_PORT) \ + -mdir=test-volume-data/m9333 > weed-master.log 2>&1 & \ + echo $$! > $(MASTER_PID_FILE) + + @echo "Waiting for master server to be ready..." + @timeout 60 bash -c 'until curl -s http://localhost:$(MASTER_PORT)/cluster/status > /dev/null 2>&1; do echo "Waiting for master server..."; sleep 2; done' || (echo "โ Master failed to start, checking logs..." && tail -20 weed-master.log && exit 1) + @echo "โ
Master server is ready" + + @echo "Starting volume server..." + @$(WEED_BINARY) volume -port=$(VOLUME_PORT) \ + -ip=localhost \ + -dataCenter=dc1 -rack=rack1 \ + -dir=test-volume-data \ + -max=100 \ + -mserver=localhost:$(MASTER_PORT) > weed-volume.log 2>&1 & \ + echo $$! > $(VOLUME_PID_FILE) + + @echo "Waiting for volume server to be ready..." + @timeout 60 bash -c 'until curl -s http://localhost:$(VOLUME_PORT)/status > /dev/null 2>&1; do echo "Waiting for volume server..."; sleep 2; done' || (echo "โ Volume server failed to start, checking logs..." && tail -20 weed-volume.log && exit 1) + @echo "โ
Volume server is ready" + + @echo "Starting filer server..." + @$(WEED_BINARY) filer -port=$(FILER_PORT) \ + -defaultStoreDir=test-volume-data/filerldb2 \ + -master=localhost:$(MASTER_PORT) > weed-filer.log 2>&1 & \ + echo $$! > $(FILER_PID_FILE) + + @echo "Waiting for filer server to be ready..." + @timeout 60 bash -c 'until curl -s http://localhost:$(FILER_PORT)/status > /dev/null 2>&1; do echo "Waiting for filer server..."; sleep 2; done' || (echo "โ Filer failed to start, checking logs..." && tail -20 weed-filer.log && exit 1) + @echo "โ
Filer server is ready" + + @echo "Starting S3 API server with IAM..." + @$(WEED_BINARY) -v=3 s3 -port=$(S3_PORT) \ + -filer=localhost:$(FILER_PORT) \ + -config=test_config.json \ + -iam.config=$(CURDIR)/iam_config.json > weed-s3.log 2>&1 & \ + echo $$! > $(S3_PID_FILE) + + @echo "Waiting for S3 API server to be ready..." + @timeout 60 bash -c 'until curl -s http://localhost:$(S3_PORT) > /dev/null 2>&1; do echo "Waiting for S3 API server..."; sleep 2; done' || (echo "โ S3 API failed to start, checking logs..." && tail -20 weed-s3.log && exit 1) + @echo "โ
S3 API server is ready" + + @echo "โ
All services started and ready" + +wait-for-services: ## Wait for all services to be ready + @echo "โณ Waiting for services to be ready..." + @echo "Checking master server..." + @timeout 30 bash -c 'until curl -s http://localhost:$(MASTER_PORT)/cluster/status > /dev/null; do sleep 1; done' || (echo "โ Master failed to start" && exit 1) + + @echo "Checking filer server..." + @timeout 30 bash -c 'until curl -s http://localhost:$(FILER_PORT)/status > /dev/null; do sleep 1; done' || (echo "โ Filer failed to start" && exit 1) + + @echo "Checking S3 API server..." + @timeout 30 bash -c 'until curl -s http://localhost:$(S3_PORT) > /dev/null 2>&1; do sleep 1; done' || (echo "โ 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 "โ ๏ธ Volume pre-allocation failed, but continuing..." + @sleep 3 + @echo "โ
All services are ready" + +stop-services: ## Stop all SeaweedFS services + @echo "๐ Stopping SeaweedFS services..." + @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 "โ
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 + @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 ./... + +# 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 +.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 + +setup-all-tests: ## Setup environment for all tests (including Keycloak) + @echo "๐ Setting up complete test environment..." + @./setup_all_tests.sh + + diff --git a/test/s3/iam/Makefile.docker b/test/s3/iam/Makefile.docker new file mode 100644 index 000000000..0e175a1aa --- /dev/null +++ b/test/s3/iam/Makefile.docker @@ -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" diff --git a/test/s3/iam/README-Docker.md b/test/s3/iam/README-Docker.md new file mode 100644 index 000000000..3759d7fae --- /dev/null +++ b/test/s3/iam/README-Docker.md @@ -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:seaweed: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 +``` diff --git a/test/s3/iam/README.md b/test/s3/iam/README.md new file mode 100644 index 000000000..ba871600c --- /dev/null +++ b/test/s3/iam/README.md @@ -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.19+** 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:seaweed: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: '1.19' + + - 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. diff --git a/test/s3/iam/STS_DISTRIBUTED.md b/test/s3/iam/STS_DISTRIBUTED.md new file mode 100644 index 000000000..b18ec4fdb --- /dev/null +++ b/test/s3/iam/STS_DISTRIBUTED.md @@ -0,0 +1,511 @@ +# 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:seaweed: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 +``` + +## 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**. diff --git a/test/s3/iam/docker-compose-simple.yml b/test/s3/iam/docker-compose-simple.yml new file mode 100644 index 000000000..9e3b91e42 --- /dev/null +++ b/test/s3/iam/docker-compose-simple.yml @@ -0,0 +1,22 @@ +version: '3.8' + +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 diff --git a/test/s3/iam/docker-compose.test.yml b/test/s3/iam/docker-compose.test.yml new file mode 100644 index 000000000..e759f63dc --- /dev/null +++ b/test/s3/iam/docker-compose.test.yml @@ -0,0 +1,162 @@ +# Docker Compose for SeaweedFS S3 IAM Integration Tests +version: '3.8' + +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 -mserver=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 diff --git a/test/s3/iam/docker-compose.yml b/test/s3/iam/docker-compose.yml new file mode 100644 index 000000000..9e9c00f6d --- /dev/null +++ b/test/s3/iam/docker-compose.yml @@ -0,0 +1,162 @@ +version: '3.8' + +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 -mserver=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 diff --git a/test/s3/iam/go.mod b/test/s3/iam/go.mod new file mode 100644 index 000000000..f8a940108 --- /dev/null +++ b/test/s3/iam/go.mod @@ -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 +) diff --git a/test/s3/iam/go.sum b/test/s3/iam/go.sum new file mode 100644 index 000000000..b1bd7cfcf --- /dev/null +++ b/test/s3/iam/go.sum @@ -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= diff --git a/test/s3/iam/iam_config.github.json b/test/s3/iam/iam_config.github.json new file mode 100644 index 000000000..b9a2fface --- /dev/null +++ b/test/s3/iam/iam_config.github.json @@ -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:seaweed:iam::role/KeycloakAdminRole" + }, + { + "claim": "roles", + "value": "s3-read-only", + "role": "arn:seaweed:iam::role/KeycloakReadOnlyRole" + }, + { + "claim": "roles", + "value": "s3-write-only", + "role": "arn:seaweed:iam::role/KeycloakWriteOnlyRole" + }, + { + "claim": "roles", + "value": "s3-read-write", + "role": "arn:seaweed:iam::role/KeycloakReadWriteRole" + } + ], + "defaultRole": "arn:seaweed:iam::role/KeycloakReadOnlyRole" + } + } + } + ], + "policy": { + "defaultEffect": "Deny" + }, + "roles": [ + { + "roleName": "TestAdminRole", + "roleArn": "arn:seaweed: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:seaweed: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:seaweed: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:seaweed: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:seaweed: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:seaweed: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:seaweed: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:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + }, + { + "Effect": "Allow", + "Action": ["sts:ValidateSession"], + "Resource": ["*"] + } + ] + } + }, + { + "name": "S3WriteOnlyPolicy", + "document": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:*" + ], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + }, + { + "Effect": "Deny", + "Action": [ + "s3:GetObject", + "s3:ListBucket" + ], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + }, + { + "Effect": "Allow", + "Action": ["sts:ValidateSession"], + "Resource": ["*"] + } + ] + } + }, + { + "name": "S3ReadWritePolicy", + "document": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:*" + ], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + }, + { + "Effect": "Allow", + "Action": ["sts:ValidateSession"], + "Resource": ["*"] + } + ] + } + } + ] +} diff --git a/test/s3/iam/iam_config.json b/test/s3/iam/iam_config.json new file mode 100644 index 000000000..b9a2fface --- /dev/null +++ b/test/s3/iam/iam_config.json @@ -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:seaweed:iam::role/KeycloakAdminRole" + }, + { + "claim": "roles", + "value": "s3-read-only", + "role": "arn:seaweed:iam::role/KeycloakReadOnlyRole" + }, + { + "claim": "roles", + "value": "s3-write-only", + "role": "arn:seaweed:iam::role/KeycloakWriteOnlyRole" + }, + { + "claim": "roles", + "value": "s3-read-write", + "role": "arn:seaweed:iam::role/KeycloakReadWriteRole" + } + ], + "defaultRole": "arn:seaweed:iam::role/KeycloakReadOnlyRole" + } + } + } + ], + "policy": { + "defaultEffect": "Deny" + }, + "roles": [ + { + "roleName": "TestAdminRole", + "roleArn": "arn:seaweed: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:seaweed: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:seaweed: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:seaweed: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:seaweed: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:seaweed: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:seaweed: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:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + }, + { + "Effect": "Allow", + "Action": ["sts:ValidateSession"], + "Resource": ["*"] + } + ] + } + }, + { + "name": "S3WriteOnlyPolicy", + "document": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:*" + ], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + }, + { + "Effect": "Deny", + "Action": [ + "s3:GetObject", + "s3:ListBucket" + ], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + }, + { + "Effect": "Allow", + "Action": ["sts:ValidateSession"], + "Resource": ["*"] + } + ] + } + }, + { + "name": "S3ReadWritePolicy", + "document": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:*" + ], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + }, + { + "Effect": "Allow", + "Action": ["sts:ValidateSession"], + "Resource": ["*"] + } + ] + } + } + ] +} diff --git a/test/s3/iam/iam_config.local.json b/test/s3/iam/iam_config.local.json new file mode 100644 index 000000000..b2b2ef4e5 --- /dev/null +++ b/test/s3/iam/iam_config.local.json @@ -0,0 +1,345 @@ +{ + "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:8090/realms/seaweedfs-test", + "clientId": "seaweedfs-s3", + "clientSecret": "seaweedfs-s3-secret", + "jwksUri": "http://localhost:8090/realms/seaweedfs-test/protocol/openid-connect/certs", + "userInfoUri": "http://localhost:8090/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:seaweed:iam::role/KeycloakAdminRole" + }, + { + "claim": "roles", + "value": "s3-read-only", + "role": "arn:seaweed:iam::role/KeycloakReadOnlyRole" + }, + { + "claim": "roles", + "value": "s3-write-only", + "role": "arn:seaweed:iam::role/KeycloakWriteOnlyRole" + }, + { + "claim": "roles", + "value": "s3-read-write", + "role": "arn:seaweed:iam::role/KeycloakReadWriteRole" + } + ], + "defaultRole": "arn:seaweed:iam::role/KeycloakReadOnlyRole" + } + } + } + ], + "policy": { + "defaultEffect": "Deny" + }, + "roles": [ + { + "roleName": "TestAdminRole", + "roleArn": "arn:seaweed: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:seaweed: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:seaweed: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:seaweed: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:seaweed: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:seaweed: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:seaweed: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:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "sts:ValidateSession" + ], + "Resource": [ + "*" + ] + } + ] + } + }, + { + "name": "S3WriteOnlyPolicy", + "document": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:*" + ], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + }, + { + "Effect": "Deny", + "Action": [ + "s3:GetObject", + "s3:ListBucket" + ], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "sts:ValidateSession" + ], + "Resource": [ + "*" + ] + } + ] + } + }, + { + "name": "S3ReadWritePolicy", + "document": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:*" + ], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "sts:ValidateSession" + ], + "Resource": [ + "*" + ] + } + ] + } + } + ] +} diff --git a/test/s3/iam/iam_config_distributed.json b/test/s3/iam/iam_config_distributed.json new file mode 100644 index 000000000..c9827c220 --- /dev/null +++ b/test/s3/iam/iam_config_distributed.json @@ -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:seaweed: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:seaweed: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:seaweed: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:seaweed:s3:::*", + "arn:seaweed: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:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + } + ] + } + } + ] +} diff --git a/test/s3/iam/iam_config_docker.json b/test/s3/iam/iam_config_docker.json new file mode 100644 index 000000000..c0fd5ab87 --- /dev/null +++ b/test/s3/iam/iam_config_docker.json @@ -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:seaweed: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:seaweed: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:seaweed: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:seaweed:s3:::*", + "arn:seaweed: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:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + } + ] + } + } + ] +} diff --git a/test/s3/iam/run_all_tests.sh b/test/s3/iam/run_all_tests.sh new file mode 100755 index 000000000..f5c2cea59 --- /dev/null +++ b/test/s3/iam/run_all_tests.sh @@ -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}โ
$description completed successfully${NC}" + return 0 + else + echo -e "${RED}โ $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}โ
Benchmark tests completed successfully${NC}" + PASSED_CATEGORIES=$((PASSED_CATEGORIES + 1)) +else + echo -e "${RED}โ 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}โ
Versioning stress tests completed successfully${NC}" + PASSED_CATEGORIES=$((PASSED_CATEGORIES + 1)) + else + echo -e "${RED}โ 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}โ Some test categories failed${NC}" + exit 1 +fi diff --git a/test/s3/iam/run_performance_tests.sh b/test/s3/iam/run_performance_tests.sh new file mode 100755 index 000000000..293632b2c --- /dev/null +++ b/test/s3/iam/run_performance_tests.sh @@ -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}โ
Performance tests completed${NC}" diff --git a/test/s3/iam/run_stress_tests.sh b/test/s3/iam/run_stress_tests.sh new file mode 100755 index 000000000..a302c4488 --- /dev/null +++ b/test/s3/iam/run_stress_tests.sh @@ -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}โ
All stress test iterations completed successfully${NC}" diff --git a/test/s3/iam/s3_iam_distributed_test.go b/test/s3/iam/s3_iam_distributed_test.go new file mode 100644 index 000000000..545a56bcb --- /dev/null +++ b/test/s3/iam/s3_iam_distributed_test.go @@ -0,0 +1,426 @@ +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("test-user", "TestAdminRole") + require.NoError(t, err) + + client2, err := framework.CreateS3ClientWithJWT("test-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 + listResult, err := client2.ListBuckets(&s3.ListBucketsInput{}) + require.NoError(t, err) + + found := false + for _, bucket := range listResult.Buckets { + if *bucket.Name == bucketName { + found = true + break + } + } + 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("readonly-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, "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(fmt.Sprintf("user-%d", goroutineID), "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++ + } + }) +} diff --git a/test/s3/iam/s3_iam_framework.go b/test/s3/iam/s3_iam_framework.go new file mode 100644 index 000000000..aee70e4a1 --- /dev/null +++ b/test/s3/iam/s3_iam_framework.go @@ -0,0 +1,861 @@ +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/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) (string, error) { + // For now, simulate what the STS service would return by calling AssumeRoleWithWebIdentity + // In a real test, we'd make an actual HTTP call to the STS endpoint + // But for unit testing, we'll create a realistic JWT manually that will pass validation + + 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()) + + // Create session token claims exactly matching STSSessionClaims struct + roleArn := fmt.Sprintf("arn:seaweed:iam::role/%s", roleName) + sessionName := fmt.Sprintf("test-session-%s", username) + principalArn := fmt.Sprintf("arn:seaweed:sts::assumed-role/%s/%s", 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 + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims) + tokenString, err := token.SignedString(signingKey) + if err != nil { + return "", err + } + + // The generated JWT is self-contained and includes all necessary session information. + // The stateless design of the STS service means no external session storage is required. + + 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) { + var token string + var err error + + if f.useKeycloak { + // Use real Keycloak authentication + 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) + token, err = f.generateSTSSessionToken(username, roleName, time.Hour) + 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) + 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, "_", "-") + + // Add random suffix to handle parallel tests + randomSuffix := mathrand.Intn(10000) + + return fmt.Sprintf("%s-%s-%d", prefix, testName, randomSuffix) +} + +// 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" { + f.t.Logf("Bucket %s already exists, cleaning up first", bucketName) + + // Empty the existing bucket + f.emptyBucket(s3Client, bucketName) + + // Don't need to recreate - bucket already exists and is now empty + } 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 +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) + + // Try to list buckets to check if service is available + maxRetries := 30 + for i := 0; i < maxRetries; i++ { + _, err := s3Client.ListBuckets(&s3.ListBucketsInput{}) + if err == nil { + return nil + } + time.Sleep(1 * time.Second) + } + + return fmt.Errorf("S3 service not available 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 +} diff --git a/test/s3/iam/s3_iam_integration_test.go b/test/s3/iam/s3_iam_integration_test.go new file mode 100644 index 000000000..5c89bda6f --- /dev/null +++ b/test/s3/iam/s3_iam_integration_test.go @@ -0,0 +1,596 @@ +package iam + +import ( + "bytes" + "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" + testBucketPrefix = "test-iam-bucket" + testObjectKey = "test-object.txt" + testObjectData = "Hello, SeaweedFS IAM Integration!" +) + +var ( + testBucket = testBucketPrefix +) + +// 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 +func TestS3IAMPolicyEnforcement(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) + + // Put test object with admin client + _, err = adminClient.PutObject(&s3.PutObjectInput{ + Bucket: aws.String(testBucket), + Key: aws.String(testObjectKey), + Body: strings.NewReader(testObjectData), + }) + require.NoError(t, err) + + t.Run("read_only_policy_enforcement", func(t *testing.T) { + // Create S3 client with read-only role + readOnlyClient, err := framework.CreateS3ClientWithJWT("read-user", "TestReadOnlyRole") + require.NoError(t, err) + + // Should be able to read objects + result, err := readOnlyClient.GetObject(&s3.GetObjectInput{ + Bucket: aws.String(testBucket), + 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() + + // Should be able to list objects + listResult, err := readOnlyClient.ListObjects(&s3.ListObjectsInput{ + Bucket: aws.String(testBucket), + }) + require.NoError(t, err) + assert.Len(t, listResult.Contents, 1) + assert.Equal(t, testObjectKey, *listResult.Contents[0].Key) + + // Should NOT be able to put objects + _, err = readOnlyClient.PutObject(&s3.PutObjectInput{ + Bucket: aws.String(testBucket), + Key: aws.String("forbidden-object.txt"), + Body: strings.NewReader("This should fail"), + }) + require.Error(t, err) + if awsErr, ok := err.(awserr.Error); ok { + assert.Equal(t, "AccessDenied", awsErr.Code()) + } + + // Should NOT be able to delete objects + _, err = readOnlyClient.DeleteObject(&s3.DeleteObjectInput{ + Bucket: aws.String(testBucket), + Key: aws.String(testObjectKey), + }) + require.Error(t, err) + if awsErr, ok := err.(awserr.Error); ok { + assert.Equal(t, "AccessDenied", awsErr.Code()) + } + }) + + t.Run("write_only_policy_enforcement", func(t *testing.T) { + // Create S3 client with write-only role + writeOnlyClient, err := framework.CreateS3ClientWithJWT("write-user", "TestWriteOnlyRole") + require.NoError(t, err) + + // Should be able to put objects + testWriteKey := "write-test-object.txt" + testWriteData := "Write-only test data" + + _, err = writeOnlyClient.PutObject(&s3.PutObjectInput{ + Bucket: aws.String(testBucket), + Key: aws.String(testWriteKey), + Body: strings.NewReader(testWriteData), + }) + require.NoError(t, err) + + // Should be able to delete objects + _, err = writeOnlyClient.DeleteObject(&s3.DeleteObjectInput{ + Bucket: aws.String(testBucket), + Key: aws.String(testWriteKey), + }) + require.NoError(t, err) + + // Should NOT be able to read objects + _, err = writeOnlyClient.GetObject(&s3.GetObjectInput{ + Bucket: aws.String(testBucket), + Key: aws.String(testObjectKey), + }) + require.Error(t, err) + if awsErr, ok := err.(awserr.Error); ok { + assert.Equal(t, "AccessDenied", awsErr.Code()) + } + + // Should NOT be able to list objects + _, err = writeOnlyClient.ListObjects(&s3.ListObjectsInput{ + Bucket: aws.String(testBucket), + }) + require.Error(t, err) + if awsErr, ok := err.(awserr.Error); ok { + assert.Equal(t, "AccessDenied", awsErr.Code()) + } + }) + + t.Run("admin_policy_enforcement", func(t *testing.T) { + // Admin client should be able to do everything + testAdminKey := "admin-test-object.txt" + testAdminData := "Admin test data" + + // Should be able to put objects + _, err = adminClient.PutObject(&s3.PutObjectInput{ + Bucket: aws.String(testBucket), + Key: aws.String(testAdminKey), + Body: strings.NewReader(testAdminData), + }) + require.NoError(t, err) + + // Should be able to read objects + result, err := adminClient.GetObject(&s3.GetObjectInput{ + Bucket: aws.String(testBucket), + Key: aws.String(testAdminKey), + }) + require.NoError(t, err) + + data, err := io.ReadAll(result.Body) + require.NoError(t, err) + assert.Equal(t, testAdminData, string(data)) + result.Body.Close() + + // Should be able to list objects + listResult, err := adminClient.ListObjects(&s3.ListObjectsInput{ + Bucket: aws.String(testBucket), + }) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(listResult.Contents), 1) + + // Should be able to delete objects + _, err = adminClient.DeleteObject(&s3.DeleteObjectInput{ + Bucket: aws.String(testBucket), + Key: aws.String(testAdminKey), + }) + require.NoError(t, err) + + // Should be able to delete buckets + // First delete remaining objects + _, err = adminClient.DeleteObject(&s3.DeleteObjectInput{ + Bucket: aws.String(testBucket), + Key: aws.String(testObjectKey), + }) + require.NoError(t, err) + + // Then delete the bucket + _, err = adminClient.DeleteBucket(&s3.DeleteBucketInput{ + Bucket: aws.String(testBucket), + }) + require.NoError(t, err) + }) +} + +// 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) + + 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 + 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) + + err = framework.CreateBucket(adminClient, testBucket) + require.NoError(t, err) + + t.Run("bucket_policy_allows_public_read", func(t *testing.T) { + // 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:seaweed:s3:::%s/*"] + } + ] + }`, testBucket) + + _, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{ + Bucket: aws.String(testBucket), + Policy: aws.String(bucketPolicy), + }) + require.NoError(t, err) + + // Put test object + _, err = adminClient.PutObject(&s3.PutObjectInput{ + Bucket: aws.String(testBucket), + 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(testBucket), + 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() + }) + + 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:seaweed:s3:::%s/*"] + } + ] + }`, testBucket) + + _, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{ + Bucket: aws.String(testBucket), + 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(testBucket), + }) + require.NoError(t, err) + assert.Contains(t, *policyResult.Policy, "s3:DeleteObject") + assert.Contains(t, *policyResult.Policy, "Deny") + + // IMPLEMENTATION NOTE: Bucket policy enforcement in authorization flow + // is planned for a future phase. Currently, this test validates policy + // storage and retrieval. When enforcement is implemented, this test + // should be extended to verify that delete operations are actually denied. + }) + + // Cleanup - delete bucket policy first, then objects and bucket + _, err = adminClient.DeleteBucketPolicy(&s3.DeleteBucketPolicyInput{ + Bucket: aws.String(testBucket), + }) + require.NoError(t, err) + + _, err = adminClient.DeleteObject(&s3.DeleteObjectInput{ + Bucket: aws.String(testBucket), + Key: aws.String(testObjectKey), + }) + require.NoError(t, err) + + _, err = adminClient.DeleteBucket(&s3.DeleteBucketInput{ + Bucket: aws.String(testBucket), + }) + 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") + }) +} + +// Helper function to create test content of specific size +func createTestContent(size int) *bytes.Reader { + content := make([]byte, size) + for i := range content { + content[i] = byte(i % 256) + } + return bytes.NewReader(content) +} + +// 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 static bucket name but with cleanup to handle conflicts + err = framework.CreateBucketWithCleanup(adminClient, testBucketPrefix) + require.NoError(t, err) + + // Put test object + _, err = adminClient.PutObject(&s3.PutObjectInput{ + Bucket: aws.String(testBucketPrefix), + 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(testBucketPrefix), + 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(testBucket), + Key: aws.String(testObjectKey), + }) + require.NoError(t, err) + + _, err = adminClient.DeleteBucket(&s3.DeleteBucketInput{ + Bucket: aws.String(testBucket), + }) + require.NoError(t, err) +} diff --git a/test/s3/iam/s3_keycloak_integration_test.go b/test/s3/iam/s3_keycloak_integration_test.go new file mode 100644 index 000000000..0bb87161d --- /dev/null +++ b/test/s3/iam/s3_keycloak_integration_test.go @@ -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 +} diff --git a/test/s3/iam/setup_all_tests.sh b/test/s3/iam/setup_all_tests.sh new file mode 100755 index 000000000..597d367aa --- /dev/null +++ b/test/s3/iam/setup_all_tests.sh @@ -0,0 +1,212 @@ +#!/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}โ Missing required tools: ${missing_tools[*]}${NC}" + echo -e "${YELLOW}Please install the missing tools and try again${NC}" + exit 1 + fi + + echo -e "${GREEN}โ
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}โ Failed to set up Keycloak${NC}" + return 1 + fi + + echo -e "${GREEN}โ
Keycloak 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}โ 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}โ
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}โ No IAM configuration files found${NC}" + return 1 + } + fi + + # Validate configuration + if ! jq . "${SCRIPT_DIR}/iam_config.json" >/dev/null; then + echo -e "${RED}โ Invalid IAM configuration JSON${NC}" + return 1 + fi + + echo -e "${GREEN}โ
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}โ
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}โ
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}โ
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 "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}โ
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}" +} + +# 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}โ Failed to set up Keycloak${NC}" + exit 1 + fi + + if setup_seaweedfs_cluster; then + setup_steps+=("seaweedfs") + else + echo -e "${RED}โ Failed to set up SeaweedFS cluster${NC}" + exit 1 + fi + + if setup_test_configurations; then + setup_steps+=("config") + else + echo -e "${RED}โ 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 "$@" diff --git a/test/s3/iam/setup_keycloak.sh b/test/s3/iam/setup_keycloak.sh new file mode 100755 index 000000000..5d3cc45d6 --- /dev/null +++ b/test/s3/iam/setup_keycloak.sh @@ -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}โ
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}โ
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}โ
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}โ
Keycloak health check passed${NC}" + return 0 + fi + if curl -sf "${KEYCLOAK_URL}/realms/master" >/dev/null; then + echo -e "${GREEN}โ
Keycloak master realm accessible${NC}" + return 0 + fi + sleep 2 + done + echo -e "${RED}โ 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}โ 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}โ
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 2>/dev/null; then + echo -e "${GREEN}โ
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}โ
Realm '${REALM_NAME}' already exists (created concurrently)${NC}" + else + echo -e "${RED}โ 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}โ
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}โ
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}โ
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}โ
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}โ
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}โ 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}โ
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}โ Failed to create realm roles mapper${NC}" + return 1 + } + + echo -e "${GREEN}โ
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}โ 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}โ
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}โ Failed to create audience mapper${NC}" + return 1 + } + + echo -e "${GREEN}โ
Audience mapper created${NC}" + fi +} + +main() { + command -v docker >/dev/null || { echo -e "${RED}โ Docker is required${NC}"; exit 1; } + command -v jq >/dev/null || { echo -e "${RED}โ 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}โ
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}โ
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}โ 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}โ
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}โ 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}โ
IAM configuration set successfully${NC}" + echo " - Using config: $config_source" + echo " - Keycloak issuer: $detected_issuer" +} + +main "$@" diff --git a/test/s3/iam/setup_keycloak_docker.sh b/test/s3/iam/setup_keycloak_docker.sh new file mode 100755 index 000000000..e648bb7b6 --- /dev/null +++ b/test/s3/iam/setup_keycloak_docker.sh @@ -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 "โ
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 "โ 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 "โ
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 "โ
Client created with ID: $CLIENT_UUID" +else + echo "โ
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 "โ
Role mapper already exists" +echo "โ
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 "โ
Audience mapper already exists" +echo "โ
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:seaweed:iam::role/KeycloakAdminRole" + }, + { + "claim": "roles", + "value": "s3-read-only", + "role": "arn:seaweed:iam::role/KeycloakReadOnlyRole" + }, + { + "claim": "roles", + "value": "s3-write-only", + "role": "arn:seaweed:iam::role/KeycloakWriteOnlyRole" + }, + { + "claim": "roles", + "value": "s3-read-write", + "role": "arn:seaweed:iam::role/KeycloakReadWriteRole" + } + ], + "defaultRole": "arn:seaweed:iam::role/KeycloakReadOnlyRole" + } + } + } + ], + "policy": { + "defaultEffect": "Deny" + }, + "roles": [ + { + "roleName": "KeycloakAdminRole", + "roleArn": "arn:seaweed: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:seaweed: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:seaweed: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:seaweed: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:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + }, + { + "Effect": "Allow", + "Action": ["sts:ValidateSession"], + "Resource": ["*"] + } + ] + } + }, + { + "name": "S3WriteOnlyPolicy", + "document": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:*"], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + }, + { + "Effect": "Deny", + "Action": [ + "s3:GetObject", + "s3:ListBucket" + ], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + }, + { + "Effect": "Allow", + "Action": ["sts:ValidateSession"], + "Resource": ["*"] + } + ] + } + }, + { + "name": "S3ReadWritePolicy", + "document": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:*"], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed: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 "โ Failed to obtain access token" + exit 1 +fi + +echo "โ
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 "โ
JWT token includes roles: [$(echo "$ROLES" | tr '\n' ',' | sed 's/,$//' | sed 's/,/, /g')]" +else + echo "โ ๏ธ No roles found in JWT token" +fi + +echo "โ
Keycloak test realm '$REALM_NAME' configured for Docker environment" +echo "๐ณ Setup complete! You can now run: docker-compose up -d" diff --git a/test/s3/iam/test_config.json b/test/s3/iam/test_config.json new file mode 100644 index 000000000..d2f1fb09e --- /dev/null +++ b/test/s3/iam/test_config.json @@ -0,0 +1,321 @@ +{ + "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"] + } + ], + "iam": { + "enabled": true, + "sts": { + "tokenDuration": "15m", + "issuer": "seaweedfs-sts", + "signingKey": "test-sts-signing-key-for-integration-tests" + }, + "policy": { + "defaultEffect": "Deny" + }, + "providers": { + "oidc": { + "test-oidc": { + "issuer": "http://localhost:8080/.well-known/openid_configuration", + "clientId": "test-client-id", + "jwksUri": "http://localhost:8080/jwks", + "userInfoUri": "http://localhost:8080/userinfo", + "roleMapping": { + "rules": [ + { + "claim": "groups", + "claimValue": "admins", + "roleName": "S3AdminRole" + }, + { + "claim": "groups", + "claimValue": "users", + "roleName": "S3ReadOnlyRole" + }, + { + "claim": "groups", + "claimValue": "writers", + "roleName": "S3WriteOnlyRole" + } + ] + }, + "claimsMapping": { + "email": "email", + "displayName": "name", + "groups": "groups" + } + } + }, + "ldap": { + "test-ldap": { + "server": "ldap://localhost:389", + "baseDN": "dc=example,dc=com", + "bindDN": "cn=admin,dc=example,dc=com", + "bindPassword": "admin-password", + "userFilter": "(uid=%s)", + "groupFilter": "(memberUid=%s)", + "attributes": { + "email": "mail", + "displayName": "cn", + "groups": "memberOf" + }, + "roleMapping": { + "rules": [ + { + "claim": "groups", + "claimValue": "cn=admins,ou=groups,dc=example,dc=com", + "roleName": "S3AdminRole" + }, + { + "claim": "groups", + "claimValue": "cn=users,ou=groups,dc=example,dc=com", + "roleName": "S3ReadOnlyRole" + } + ] + } + } + } + }, + "policyStore": {} + }, + "roles": { + "S3AdminRole": { + "trustPolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": ["test-oidc", "test-ldap"] + }, + "Action": "sts:AssumeRoleWithWebIdentity" + } + ] + }, + "attachedPolicies": ["S3AdminPolicy"], + "description": "Full administrative access to S3 resources" + }, + "S3ReadOnlyRole": { + "trustPolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": ["test-oidc", "test-ldap"] + }, + "Action": "sts:AssumeRoleWithWebIdentity" + } + ] + }, + "attachedPolicies": ["S3ReadOnlyPolicy"], + "description": "Read-only access to S3 resources" + }, + "S3WriteOnlyRole": { + "trustPolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": ["test-oidc", "test-ldap"] + }, + "Action": "sts:AssumeRoleWithWebIdentity" + } + ] + }, + "attachedPolicies": ["S3WriteOnlyPolicy"], + "description": "Write-only access to S3 resources" + } + }, + "policies": { + "S3AdminPolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:*"], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + } + ] + }, + "S3ReadOnlyPolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:GetObjectVersion", + "s3:ListBucket", + "s3:ListBucketVersions", + "s3:GetBucketLocation", + "s3:GetBucketVersioning" + ], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ] + } + ] + }, + "S3WriteOnlyPolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:PutObject", + "s3:PutObjectAcl", + "s3:DeleteObject", + "s3:DeleteObjectVersion", + "s3:InitiateMultipartUpload", + "s3:UploadPart", + "s3:CompleteMultipartUpload", + "s3:AbortMultipartUpload", + "s3:ListMultipartUploadParts" + ], + "Resource": [ + "arn:seaweed:s3:::*/*" + ] + } + ] + }, + "S3BucketManagementPolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:CreateBucket", + "s3:DeleteBucket", + "s3:GetBucketPolicy", + "s3:PutBucketPolicy", + "s3:DeleteBucketPolicy", + "s3:GetBucketVersioning", + "s3:PutBucketVersioning" + ], + "Resource": [ + "arn:seaweed:s3:::*" + ] + } + ] + }, + "S3IPRestrictedPolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:*"], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ], + "Condition": { + "IpAddress": { + "aws:SourceIp": ["192.168.1.0/24", "10.0.0.0/8"] + } + } + } + ] + }, + "S3TimeBasedPolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:ListBucket"], + "Resource": [ + "arn:seaweed:s3:::*", + "arn:seaweed:s3:::*/*" + ], + "Condition": { + "DateGreaterThan": { + "aws:CurrentTime": "2023-01-01T00:00:00Z" + }, + "DateLessThan": { + "aws:CurrentTime": "2025-12-31T23:59:59Z" + } + } + } + ] + } + }, + "bucketPolicyExamples": { + "PublicReadPolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PublicReadGetObject", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:seaweed:s3:::example-bucket/*" + } + ] + }, + "DenyDeletePolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyDeleteOperations", + "Effect": "Deny", + "Principal": "*", + "Action": ["s3:DeleteObject", "s3:DeleteBucket"], + "Resource": [ + "arn:seaweed:s3:::example-bucket", + "arn:seaweed:s3:::example-bucket/*" + ] + } + ] + }, + "IPRestrictedAccessPolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "IPRestrictedAccess", + "Effect": "Allow", + "Principal": "*", + "Action": ["s3:GetObject", "s3:PutObject"], + "Resource": "arn:seaweed:s3:::example-bucket/*", + "Condition": { + "IpAddress": { + "aws:SourceIp": ["203.0.113.0/24"] + } + } + } + ] + } + } +} diff --git a/test/s3/versioning/enable_stress_tests.sh b/test/s3/versioning/enable_stress_tests.sh new file mode 100755 index 000000000..5fa169ee0 --- /dev/null +++ b/test/s3/versioning/enable_stress_tests.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Enable S3 Versioning Stress Tests + +set -e + +# Colors +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${YELLOW}๐ Enabling S3 Versioning Stress Tests${NC}" + +# Disable short mode to enable stress tests +export ENABLE_STRESS_TESTS=true + +# Run versioning stress tests +echo -e "${YELLOW}๐งช Running versioning stress tests...${NC}" +make test-versioning-stress + +echo -e "${GREEN}โ
Versioning stress tests completed${NC}" |
