mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-11 09:00:45 +02:00
* sts: enforce session-policy explicit deny during role chaining A chained AssumeRole caller authenticates with an STS session token whose inline session policy can explicitly deny sts:AssumeRole. The deny check only evaluated the caller's named policies, so such a session could still chain into any role its trust policy admits. Validate the session token in the deny check and honor an explicit Deny in the inline session policy too. * test(sts): integration coverage for AssumeRole authorization Add an end-to-end AssumeRole authorization test (real weed mini + boto3): a non-admin caller assumes a role its trust policy admits, an explicit identity-side deny is blocked, and a session policy's explicit deny blocks role chaining. * sts: skip OIDC tokens and reject revoked sessions in the chaining deny check Review follow-ups on the session-policy deny check: - Guard session validation with !isOIDCToken so a bearer token our STS service cannot validate does not error into a false deny. - Reject a revoked session before evaluating its policy, restoring the revocation enforcement the AssumeRole path lost when it stopped routing through IsActionAllowed.
588 lines
17 KiB
Go
588 lines
17 KiB
Go
package sts_integration
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/test/testutil"
|
|
)
|
|
|
|
// TestEnvironment mirrors the one in trino_catalog_test.go but simplified
|
|
type TestEnvironment struct {
|
|
seaweedDir string
|
|
weedBinary string
|
|
dataDir string
|
|
bindIP string
|
|
s3Port int
|
|
s3GrpcPort int
|
|
masterPort int
|
|
masterGrpcPort int
|
|
filerPort int
|
|
filerGrpcPort int
|
|
volumePort int
|
|
volumeGrpcPort int
|
|
weedProcess *exec.Cmd
|
|
weedCancel context.CancelFunc
|
|
dockerAvailable bool
|
|
accessKey string
|
|
secretKey string
|
|
}
|
|
|
|
const testSTSIntegrationSigningKey = "dGVzdC1zaWduaW5nLWtleS1mb3Itc3RzLWludGVncmF0aW9uLXRlc3Rz" // gitleaks:allow - test-signing-key-for-sts-integration-tests
|
|
|
|
func TestSTSIntegration(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
env := NewTestEnvironment(t)
|
|
defer env.Cleanup(t)
|
|
|
|
if !env.dockerAvailable {
|
|
t.Skip("Docker not available, skipping STS integration test")
|
|
}
|
|
|
|
fmt.Printf(">>> Starting SeaweedFS...\n")
|
|
env.StartSeaweedFS(t)
|
|
fmt.Printf(">>> SeaweedFS started.\n")
|
|
|
|
// Run python script in docker to test STS
|
|
runPythonSTSClient(t, env)
|
|
}
|
|
|
|
// TestSTSAssumeRoleAuthorization covers the AssumeRole authorization model:
|
|
// a non-admin caller may assume a role its trust policy admits, an explicit
|
|
// identity-side deny blocks it, and a session policy's explicit deny blocks
|
|
// role chaining.
|
|
func TestSTSAssumeRoleAuthorization(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
env := NewTestEnvironment(t)
|
|
defer env.Cleanup(t)
|
|
|
|
if !env.dockerAvailable {
|
|
t.Skip("Docker not available, skipping STS integration test")
|
|
}
|
|
|
|
fmt.Printf(">>> Starting SeaweedFS...\n")
|
|
env.StartSeaweedFS(t)
|
|
fmt.Printf(">>> SeaweedFS started.\n")
|
|
|
|
runPythonAuthzClient(t, env)
|
|
}
|
|
|
|
func NewTestEnvironment(t *testing.T) *TestEnvironment {
|
|
t.Helper()
|
|
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get working directory: %v", err)
|
|
}
|
|
|
|
seaweedDir := wd
|
|
for i := 0; i < 6; i++ {
|
|
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
|
|
break
|
|
}
|
|
seaweedDir = filepath.Dir(seaweedDir)
|
|
}
|
|
|
|
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
|
|
info, err := os.Stat(weedBinary)
|
|
if err != nil || info.IsDir() {
|
|
weedBinary = "weed"
|
|
if _, err := exec.LookPath(weedBinary); err != nil {
|
|
t.Skip("weed binary not found, skipping integration test")
|
|
}
|
|
}
|
|
|
|
if !testutil.HasDocker() {
|
|
t.Skip("Docker not available, skipping integration test")
|
|
}
|
|
|
|
// Create a unique temporary directory for this test run
|
|
dataDir, err := os.MkdirTemp("", "seaweed-sts-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
// The Cleanup method will remove this directory, so no need for defer here.
|
|
|
|
bindIP := testutil.FindBindIP()
|
|
|
|
// Allocate all ports in a single batch to prevent the OS from recycling
|
|
// a released port, which can cause two services to get the same port.
|
|
ports := testutil.MustAllocatePorts(t, 8)
|
|
masterPort, masterGrpcPort := ports[0], ports[1]
|
|
volumePort, volumeGrpcPort := ports[2], ports[3]
|
|
filerPort, filerGrpcPort := ports[4], ports[5]
|
|
s3Port, s3GrpcPort := ports[6], ports[7]
|
|
|
|
return &TestEnvironment{
|
|
seaweedDir: seaweedDir,
|
|
weedBinary: weedBinary,
|
|
dataDir: dataDir,
|
|
bindIP: bindIP,
|
|
s3Port: s3Port,
|
|
s3GrpcPort: s3GrpcPort,
|
|
masterPort: masterPort,
|
|
masterGrpcPort: masterGrpcPort,
|
|
filerPort: filerPort,
|
|
filerGrpcPort: filerGrpcPort,
|
|
volumePort: volumePort,
|
|
volumeGrpcPort: volumeGrpcPort,
|
|
dockerAvailable: testutil.HasDocker(),
|
|
accessKey: "admin",
|
|
secretKey: "adminadmin",
|
|
}
|
|
}
|
|
|
|
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
|
|
t.Helper()
|
|
|
|
iamConfigPath := filepath.Join(env.dataDir, "iam.json")
|
|
// Note: signingKey must be base64 encoded for []byte JSON unmarshaling
|
|
iamConfig := fmt.Sprintf(`{
|
|
"identities": [
|
|
{
|
|
"name": "admin",
|
|
"credentials": [
|
|
{ "accessKey": "%s", "secretKey": "%s" }
|
|
],
|
|
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
|
|
},
|
|
{
|
|
"name": "nonadmin",
|
|
"credentials": [
|
|
{ "accessKey": "nonadmin_key", "secretKey": "nonadmin_secret" }
|
|
],
|
|
"actions": ["Read", "Write", "List", "Tagging"]
|
|
},
|
|
{
|
|
"name": "denied",
|
|
"credentials": [
|
|
{ "accessKey": "denied_key", "secretKey": "denied_secret" }
|
|
],
|
|
"actions": ["Read", "Write", "List", "Tagging"],
|
|
"policyNames": ["DenyAssumeRole"]
|
|
}
|
|
],
|
|
"sts": {
|
|
"tokenDuration": "1h",
|
|
"maxSessionLength": "12h",
|
|
"issuer": "seaweedfs-sts",
|
|
"signingKey": "%s"
|
|
},
|
|
"policy": {
|
|
"defaultEffect": "Deny",
|
|
"storeType": "memory"
|
|
},
|
|
"policies": [
|
|
{
|
|
"name": "S3FullAccessPolicy",
|
|
"document": {
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{
|
|
"Effect": "Allow",
|
|
"Action": ["s3:*"],
|
|
"Resource": ["*"]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"name": "DenyAssumeRole",
|
|
"document": {
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{
|
|
"Effect": "Deny",
|
|
"Action": ["sts:AssumeRole"],
|
|
"Resource": ["*"]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
],
|
|
"roles": [
|
|
{
|
|
"roleName": "TestRole",
|
|
"roleArn": "arn:aws:iam::role/TestRole",
|
|
"attachedPolicies": ["S3FullAccessPolicy"],
|
|
"trustPolicy": {
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{
|
|
"Effect": "Allow",
|
|
"Principal": "*",
|
|
"Action": ["sts:AssumeRole"]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}`, env.accessKey, env.secretKey, testSTSIntegrationSigningKey)
|
|
if err := os.WriteFile(iamConfigPath, []byte(iamConfig), 0644); err != nil {
|
|
t.Fatalf("Failed to create IAM config: %v", err)
|
|
}
|
|
|
|
// Create empty security.toml
|
|
securityToml := filepath.Join(env.dataDir, "security.toml")
|
|
if err := os.WriteFile(securityToml, []byte("# Empty security config for testing\n"), 0644); err != nil {
|
|
t.Fatalf("Failed to create security.toml: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
env.weedCancel = cancel
|
|
|
|
cmd := exec.CommandContext(ctx, env.weedBinary, "mini",
|
|
"-master.port", fmt.Sprintf("%d", env.masterPort),
|
|
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
|
|
"-volume.port", fmt.Sprintf("%d", env.volumePort),
|
|
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
|
|
"-filer.port", fmt.Sprintf("%d", env.filerPort),
|
|
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
|
|
"-s3.port", fmt.Sprintf("%d", env.s3Port),
|
|
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
|
|
"-s3.config", iamConfigPath,
|
|
"-s3.iam.config", iamConfigPath,
|
|
"-s3.iam.readOnly", "false",
|
|
"-ip", env.bindIP,
|
|
"-ip.bind", "0.0.0.0",
|
|
"-dir", env.dataDir,
|
|
)
|
|
cmd.Dir = env.dataDir
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("Failed to start SeaweedFS: %v", err)
|
|
}
|
|
env.weedProcess = cmd
|
|
|
|
// Wait for S3 API to be ready
|
|
if !testutil.WaitForService(fmt.Sprintf("http://localhost:%d/status", env.s3Port), 30*time.Second) {
|
|
t.Fatalf("S3 API failed to become ready")
|
|
}
|
|
}
|
|
|
|
func (env *TestEnvironment) Start(t *testing.T) {
|
|
if !testutil.HasDocker() {
|
|
t.Skip("Docker not available")
|
|
}
|
|
}
|
|
|
|
func (env *TestEnvironment) Cleanup(t *testing.T) {
|
|
t.Helper()
|
|
if env.weedCancel != nil {
|
|
env.weedCancel()
|
|
}
|
|
if env.weedProcess != nil {
|
|
time.Sleep(1 * time.Second)
|
|
_ = env.weedProcess.Wait()
|
|
}
|
|
if env.dataDir != "" {
|
|
_ = os.RemoveAll(env.dataDir)
|
|
}
|
|
}
|
|
|
|
func runPythonSTSClient(t *testing.T, env *TestEnvironment) {
|
|
t.Helper()
|
|
|
|
// Write python script to temp dir
|
|
scriptContent := fmt.Sprintf(`
|
|
import boto3
|
|
import botocore.config
|
|
from botocore.exceptions import ClientError
|
|
import json
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
print("Starting STS inline session policy test...")
|
|
|
|
primary_endpoint = "http://host.docker.internal:%d"
|
|
fallback_endpoint = "http://%s:%d"
|
|
access_key = "%s"
|
|
secret_key = "%s"
|
|
region = "us-east-1"
|
|
|
|
try:
|
|
def wait_for_endpoint(url, timeout=30):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=2):
|
|
return True
|
|
except urllib.error.HTTPError:
|
|
return True
|
|
except Exception:
|
|
time.sleep(1)
|
|
return False
|
|
|
|
def select_endpoint(urls):
|
|
for url in urls:
|
|
if wait_for_endpoint(url):
|
|
return url
|
|
raise Exception("No reachable S3 endpoint from container")
|
|
|
|
endpoint_url = select_endpoint([primary_endpoint, fallback_endpoint])
|
|
print(f"Using endpoint {endpoint_url}")
|
|
|
|
config = botocore.config.Config(
|
|
retries={'max_attempts': 0},
|
|
s3={'addressing_style': 'path'}
|
|
)
|
|
admin_s3 = boto3.client(
|
|
's3',
|
|
endpoint_url=endpoint_url,
|
|
aws_access_key_id=access_key,
|
|
aws_secret_access_key=secret_key,
|
|
region_name=region,
|
|
config=config
|
|
)
|
|
|
|
bucket = f"sts-inline-policy-{int(time.time() * 1000)}"
|
|
key = "allowed.txt"
|
|
|
|
print(f"Creating bucket {bucket} with admin credentials")
|
|
admin_s3.create_bucket(Bucket=bucket)
|
|
admin_s3.put_object(Bucket=bucket, Key=key, Body=b"ok")
|
|
|
|
sts = boto3.client(
|
|
'sts',
|
|
endpoint_url=endpoint_url,
|
|
aws_access_key_id=access_key,
|
|
aws_secret_access_key=secret_key,
|
|
region_name=region,
|
|
config=config
|
|
)
|
|
|
|
role_arn = "arn:aws:iam::role/TestRole"
|
|
session_name = "test-session"
|
|
session_policy = json.dumps({
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{
|
|
"Effect": "Allow",
|
|
"Action": ["s3:ListBucket"],
|
|
"Resource": [f"arn:aws:s3:::{bucket}"]
|
|
},
|
|
{
|
|
"Effect": "Allow",
|
|
"Action": ["s3:GetObject"],
|
|
"Resource": [f"arn:aws:s3:::{bucket}/*"]
|
|
}
|
|
]
|
|
})
|
|
|
|
print(f"Calling AssumeRole on {role_arn} with inline session policy")
|
|
response = sts.assume_role(
|
|
RoleArn=role_arn,
|
|
RoleSessionName=session_name,
|
|
Policy=session_policy
|
|
)
|
|
|
|
creds = response['Credentials']
|
|
vended_s3 = boto3.client(
|
|
's3',
|
|
endpoint_url=endpoint_url,
|
|
aws_access_key_id=creds['AccessKeyId'],
|
|
aws_secret_access_key=creds['SecretAccessKey'],
|
|
aws_session_token=creds['SessionToken'],
|
|
region_name=region,
|
|
config=config
|
|
)
|
|
|
|
print("Listing objects (allowed)")
|
|
list_resp = vended_s3.list_objects_v2(Bucket=bucket)
|
|
keys = [obj.get('Key') for obj in list_resp.get('Contents', [])]
|
|
if key not in keys:
|
|
print(f"FAILED: Expected to see {key} in list_objects_v2 results")
|
|
sys.exit(1)
|
|
|
|
print("Getting object (allowed)")
|
|
body = vended_s3.get_object(Bucket=bucket, Key=key)['Body'].read()
|
|
if body != b"ok":
|
|
print("FAILED: Unexpected object content")
|
|
sys.exit(1)
|
|
|
|
print("Putting object (expected to be denied)")
|
|
try:
|
|
vended_s3.put_object(Bucket=bucket, Key="denied.txt", Body=b"no")
|
|
print("FAILED: PutObject unexpectedly succeeded")
|
|
sys.exit(1)
|
|
except ClientError as e:
|
|
error_code = e.response.get('Error', {}).get('Code', '')
|
|
if error_code != 'AccessDenied':
|
|
print(f"FAILED: Expected AccessDenied, got {error_code}")
|
|
sys.exit(1)
|
|
print("PutObject correctly denied by inline session policy")
|
|
|
|
print("SUCCESS: Inline session policy downscoping verified")
|
|
sys.exit(0)
|
|
except Exception as e:
|
|
print(f"FAILED: {e}")
|
|
if hasattr(e, 'response'):
|
|
print(f"Response: {e.response}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
sys.exit(1)
|
|
`, env.s3Port, env.bindIP, env.s3Port, env.accessKey, env.secretKey)
|
|
|
|
scriptPath := filepath.Join(env.dataDir, "sts_test.py")
|
|
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil {
|
|
t.Fatalf("Failed to write python script: %v", err)
|
|
}
|
|
|
|
containerName := "seaweed-sts-client-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
|
|
|
cmd := exec.Command("docker", "run", "--rm",
|
|
"--name", containerName,
|
|
"--add-host", "host.docker.internal:host-gateway",
|
|
"-v", fmt.Sprintf("%s:/work", env.dataDir),
|
|
"python:3",
|
|
"/bin/bash", "-c", "pip install boto3 && python /work/sts_test.py",
|
|
)
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("Python STS client failed: %v\nOutput:\n%s", err, string(output))
|
|
}
|
|
t.Logf("Python STS client output:\n%s", string(output))
|
|
}
|
|
|
|
func runPythonAuthzClient(t *testing.T, env *TestEnvironment) {
|
|
t.Helper()
|
|
|
|
scriptContent := fmt.Sprintf(`
|
|
import boto3
|
|
import botocore.config
|
|
from botocore.exceptions import ClientError
|
|
import json
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
print("Starting STS AssumeRole authorization test...")
|
|
|
|
primary_endpoint = "http://host.docker.internal:%d"
|
|
fallback_endpoint = "http://%s:%d"
|
|
region = "us-east-1"
|
|
role_arn = "arn:aws:iam::role/TestRole"
|
|
|
|
def wait_for_endpoint(url, timeout=30):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=2):
|
|
return True
|
|
except urllib.error.HTTPError:
|
|
return True
|
|
except Exception:
|
|
time.sleep(1)
|
|
return False
|
|
|
|
def select_endpoint(urls):
|
|
for url in urls:
|
|
if wait_for_endpoint(url):
|
|
return url
|
|
raise Exception("No reachable S3 endpoint from container")
|
|
|
|
def fail(msg):
|
|
print("FAILED: " + msg)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
endpoint_url = select_endpoint([primary_endpoint, fallback_endpoint])
|
|
print("Using endpoint " + endpoint_url)
|
|
config = botocore.config.Config(retries={'max_attempts': 0}, s3={'addressing_style': 'path'})
|
|
|
|
def sts_client(ak, sk, token=None):
|
|
return boto3.client('sts', endpoint_url=endpoint_url, aws_access_key_id=ak,
|
|
aws_secret_access_key=sk, aws_session_token=token,
|
|
region_name=region, config=config)
|
|
|
|
# 1) Non-admin caller may assume a role its trust policy admits, without the
|
|
# Admin action or an identity-side sts:AssumeRole grant.
|
|
print("1) non-admin AssumeRole (expect success)")
|
|
resp = sts_client("nonadmin_key", "nonadmin_secret").assume_role(
|
|
RoleArn=role_arn, RoleSessionName="nonadmin")
|
|
if not resp.get("Credentials", {}).get("AccessKeyId"):
|
|
fail("non-admin AssumeRole did not return credentials")
|
|
print(" ok")
|
|
|
|
# 2) A caller whose attached policy explicitly denies sts:AssumeRole is blocked,
|
|
# even though the trust policy admits it.
|
|
print("2) explicit identity deny (expect AccessDenied)")
|
|
try:
|
|
sts_client("denied_key", "denied_secret").assume_role(
|
|
RoleArn=role_arn, RoleSessionName="denied")
|
|
fail("explicit-deny caller unexpectedly assumed the role")
|
|
except ClientError as e:
|
|
code = e.response.get("Error", {}).get("Code", "")
|
|
if code != "AccessDenied":
|
|
fail("expected AccessDenied for identity deny, got " + code)
|
|
print(" ok")
|
|
|
|
# 3) A session whose inline policy denies sts:AssumeRole cannot chain into
|
|
# another role, even though the trust policy admits the session.
|
|
print("3) session-policy deny blocks role chaining (expect AccessDenied)")
|
|
deny_session = json.dumps({"Version": "2012-10-17", "Statement": [
|
|
{"Effect": "Deny", "Action": "sts:AssumeRole", "Resource": "*"}]})
|
|
hop1 = sts_client("nonadmin_key", "nonadmin_secret").assume_role(
|
|
RoleArn=role_arn, RoleSessionName="hop1", Policy=deny_session)["Credentials"]
|
|
chained = sts_client(hop1["AccessKeyId"], hop1["SecretAccessKey"], hop1["SessionToken"])
|
|
try:
|
|
chained.assume_role(RoleArn=role_arn, RoleSessionName="hop2")
|
|
fail("chained session unexpectedly assumed the role despite session-policy deny")
|
|
except ClientError as e:
|
|
code = e.response.get("Error", {}).get("Code", "")
|
|
if code != "AccessDenied":
|
|
fail("expected AccessDenied for session-policy deny, got " + code)
|
|
print(" ok")
|
|
|
|
print("SUCCESS: AssumeRole authorization scenarios verified")
|
|
sys.exit(0)
|
|
except Exception as e:
|
|
print("FAILED: " + str(e))
|
|
if hasattr(e, "response"):
|
|
print("Response: " + str(e.response))
|
|
import traceback
|
|
traceback.print_exc()
|
|
sys.exit(1)
|
|
`, env.s3Port, env.bindIP, env.s3Port)
|
|
|
|
scriptPath := filepath.Join(env.dataDir, "sts_authz_test.py")
|
|
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil {
|
|
t.Fatalf("Failed to write python script: %v", err)
|
|
}
|
|
|
|
containerName := "seaweed-sts-authz-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
|
|
|
cmd := exec.Command("docker", "run", "--rm",
|
|
"--name", containerName,
|
|
"--add-host", "host.docker.internal:host-gateway",
|
|
"-v", fmt.Sprintf("%s:/work", env.dataDir),
|
|
"python:3",
|
|
"/bin/bash", "-c", "pip install boto3 && python /work/sts_authz_test.py",
|
|
)
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("Python STS authz client failed: %v\nOutput:\n%s", err, string(output))
|
|
}
|
|
t.Logf("Python STS authz client output:\n%s", string(output))
|
|
}
|
|
|
|
// Helpers copied from trino_catalog_test.go
|