Add session policy support to IAM (#8338)
* Add session policy support to IAM - Implement policy evaluation for session tokens in policy_engine.go - Add session_policy field to session claims for tracking applied policies - Update STS service to include session policies in token generation - Add IAM integration tests for session policy validation - Update IAM manager to support policy attachment to sessions - Extend S3 API STS endpoint to handle session policy restrictions * fix: optimize session policy evaluation and add documentation * sts: add NormalizeSessionPolicy helper for inline session policies * sts: support inline session policies for AssumeRoleWithWebIdentity and credential-based flows * s3api: parse and normalize Policy parameter for STS HTTP handlers * tests: add session policy unit tests and integration tests for inline policy downscoping * tests: add s3tables STS inline policy integration * iam: handle user principals and validate tokens * sts: enforce inline session policy size limit * tests: harden s3tables STS integration config * iam: clarify principal policy resolution errors * tests: improve STS integration endpoint selection
This commit is contained in:
@@ -33,6 +33,8 @@ type TestEnvironment struct {
|
||||
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")
|
||||
@@ -110,17 +112,70 @@ func NewTestEnvironment(t *testing.T) *TestEnvironment {
|
||||
volumePort: volumePort,
|
||||
volumeGrpcPort: volumeGrpcPort,
|
||||
dockerAvailable: testutil.HasDocker(),
|
||||
accessKey: "admin", // Matching default in testutil.WriteIAMConfig
|
||||
secretKey: "admin",
|
||||
accessKey: "admin",
|
||||
secretKey: "adminadmin",
|
||||
}
|
||||
}
|
||||
|
||||
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
// Create IAM config file
|
||||
iamConfigPath, err := testutil.WriteIAMConfig(env.dataDir, env.accessKey, env.secretKey)
|
||||
if err != nil {
|
||||
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"]
|
||||
}
|
||||
],
|
||||
"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": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -143,6 +198,8 @@ func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
|
||||
"-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,
|
||||
@@ -190,22 +247,62 @@ func runPythonSTSClient(t *testing.T, env *TestEnvironment) {
|
||||
import boto3
|
||||
import botocore.config
|
||||
from botocore.exceptions import ClientError
|
||||
import os
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
print("Starting STS test...")
|
||||
print("Starting STS inline session policy test...")
|
||||
|
||||
endpoint_url = "http://host.docker.internal:%d"
|
||||
primary_endpoint = "http://host.docker.internal:%d"
|
||||
fallback_endpoint = "http://%s:%d"
|
||||
access_key = "%s"
|
||||
secret_key = "%s"
|
||||
region = "us-east-1"
|
||||
|
||||
print(f"Connecting to {endpoint_url} with key {access_key}")
|
||||
|
||||
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}
|
||||
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,
|
||||
@@ -215,45 +312,77 @@ try:
|
||||
config=config
|
||||
)
|
||||
|
||||
role_arn = "arn:aws:iam::000000000000:role/test-role"
|
||||
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}")
|
||||
|
||||
# This call typically sends parameters in POST body by default in boto3
|
||||
print(f"Calling AssumeRole on {role_arn} with inline session policy")
|
||||
response = sts.assume_role(
|
||||
RoleArn=role_arn,
|
||||
RoleSessionName=session_name
|
||||
RoleSessionName=session_name,
|
||||
Policy=session_policy
|
||||
)
|
||||
|
||||
print("Success! Got credentials:")
|
||||
print(response['Credentials'])
|
||||
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
|
||||
)
|
||||
|
||||
except ClientError as e:
|
||||
# Print available keys for debugging if needed
|
||||
# print(e.response.keys())
|
||||
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)
|
||||
|
||||
response_meta = e.response.get('ResponseMetadata', {})
|
||||
http_code = response_meta.get('HTTPStatusCode')
|
||||
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)
|
||||
|
||||
error_data = e.response.get('Error', {})
|
||||
error_code = error_data.get('Code', 'Unknown')
|
||||
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(f"Got error: {http_code} {error_code}")
|
||||
|
||||
# We expect 503 ServiceUnavailable because stsHandlers is nil in weed mini
|
||||
# This confirms the request was routed to STS handler logic (UnifiedPostHandler)
|
||||
# instead of IAM handler (which would return 403 AccessDenied or 501 NotImplemented)
|
||||
if http_code == 503:
|
||||
print("SUCCESS: Got expected 503 Service Unavailable (STS not configured)")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"FAILED: Unexpected error {e}")
|
||||
sys.exit(1)
|
||||
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.accessKey, env.secretKey)
|
||||
`, 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 {
|
||||
|
||||
@@ -251,6 +251,132 @@ func TestPolicyEnforcement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionPolicyBoundary verifies that inline session policies restrict permissions.
|
||||
func TestSessionPolicyBoundary(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
stsService := iamManager.GetSTSService()
|
||||
require.NotNil(t, stsService)
|
||||
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::test-bucket/allowed/*"]}]}`
|
||||
|
||||
sessionId, err := sts.GenerateSessionId()
|
||||
require.NoError(t, err)
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
principal := "arn:aws:sts::000000000000:assumed-role/S3ReadOnlyRole/policy-session"
|
||||
|
||||
claims := sts.NewSTSSessionClaims(sessionId, stsService.Config.Issuer, expiresAt).
|
||||
WithSessionName("policy-session").
|
||||
WithRoleInfo("arn:aws:iam::role/S3ReadOnlyRole", principal, principal).
|
||||
WithSessionPolicy(sessionPolicy)
|
||||
|
||||
sessionToken, err := stsService.GetTokenGenerator().GenerateJWTWithClaims(claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: principal,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/allowed/file.txt",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed, "Session policy should allow GetObject within allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: principal,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/other/file.txt",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny GetObject outside allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: principal,
|
||||
Action: "s3:ListBucket",
|
||||
Resource: "arn:aws:s3:::test-bucket",
|
||||
SessionToken: sessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny ListBucket when not explicitly allowed")
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithWebIdentitySessionPolicy verifies Policy downscoping is applied to web identity sessions.
|
||||
func TestAssumeRoleWithWebIdentitySessionPolicy(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
validJWTToken := createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::test-bucket/allowed/*"]}]}`
|
||||
|
||||
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
WebIdentityToken: validJWTToken,
|
||||
RoleSessionName: "policy-web-identity",
|
||||
Policy: &sessionPolicy,
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/allowed/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed, "Session policy should allow GetObject within allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/other/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny GetObject outside allowed prefix")
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithCredentialsSessionPolicy verifies Policy downscoping is applied to credentials sessions.
|
||||
func TestAssumeRoleWithCredentialsSessionPolicy(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["filer:CreateEntry"],"Resource":["arn:aws:filer::path/user-docs/allowed/*"]}]}`
|
||||
assumeRequest := &sts.AssumeRoleWithCredentialsRequest{
|
||||
RoleArn: "arn:aws:iam::role/LDAPUserRole",
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
RoleSessionName: "policy-ldap",
|
||||
ProviderName: "test-ldap",
|
||||
Policy: &sessionPolicy,
|
||||
}
|
||||
|
||||
response, err := iamManager.AssumeRoleWithCredentials(ctx, assumeRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "filer:CreateEntry",
|
||||
Resource: "arn:aws:filer::path/user-docs/allowed/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed, "Session policy should allow CreateEntry within allowed prefix")
|
||||
|
||||
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: response.AssumedRoleUser.Arn,
|
||||
Action: "filer:CreateEntry",
|
||||
Resource: "arn:aws:filer::path/user-docs/other/file.txt",
|
||||
SessionToken: response.Credentials.SessionToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "Session policy should deny CreateEntry outside allowed prefix")
|
||||
}
|
||||
|
||||
// TestSessionExpiration tests session expiration and cleanup
|
||||
func TestSessionExpiration(t *testing.T) {
|
||||
iamManager := setupIntegratedIAMSystem(t)
|
||||
|
||||
@@ -7,10 +7,12 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
)
|
||||
|
||||
// maxPoliciesForEvaluation defines an upper bound on the number of policies that
|
||||
@@ -23,6 +25,7 @@ type IAMManager struct {
|
||||
stsService *sts.STSService
|
||||
policyEngine *policy.PolicyEngine
|
||||
roleStore RoleStore
|
||||
userStore UserStore
|
||||
filerAddressProvider func() string // Function to get current filer address
|
||||
initialized bool
|
||||
}
|
||||
@@ -48,6 +51,11 @@ type RoleStoreConfig struct {
|
||||
StoreConfig map[string]interface{} `json:"storeConfig,omitempty"`
|
||||
}
|
||||
|
||||
// UserStore defines the interface for retrieving IAM user policy attachments.
|
||||
type UserStore interface {
|
||||
GetUser(ctx context.Context, username string) (*iam_pb.Identity, error)
|
||||
}
|
||||
|
||||
// RoleDefinition defines a role with its trust policy and attached policies
|
||||
type RoleDefinition struct {
|
||||
// RoleName is the name of the role
|
||||
@@ -92,6 +100,11 @@ func NewIAMManager() *IAMManager {
|
||||
return &IAMManager{}
|
||||
}
|
||||
|
||||
// SetUserStore assigns the user store used to resolve IAM user policy attachments.
|
||||
func (m *IAMManager) SetUserStore(store UserStore) {
|
||||
m.userStore = store
|
||||
}
|
||||
|
||||
// Initialize initializes the IAM manager with all components
|
||||
func (m *IAMManager) Initialize(config *IAMConfig, filerAddressProvider func() string) error {
|
||||
if config == nil {
|
||||
@@ -312,8 +325,10 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
|
||||
// Validate session token if present (skip for OIDC tokens which are already validated,
|
||||
// and skip for empty tokens which represent static access keys)
|
||||
var sessionInfo *sts.SessionInfo
|
||||
if request.SessionToken != "" && !isOIDCToken(request.SessionToken) {
|
||||
_, err := m.stsService.ValidateSessionToken(ctx, request.SessionToken)
|
||||
var err error
|
||||
sessionInfo, err = m.stsService.ValidateSessionToken(ctx, request.SessionToken)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid session: %w", err)
|
||||
}
|
||||
@@ -349,6 +364,9 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
|
||||
evalCtx.RequestContext["aws:username"] = awsUsername
|
||||
evalCtx.RequestContext["aws:userid"] = arnInfo.RoleName
|
||||
} else if userName := utils.ExtractUserNameFromPrincipal(request.Principal); userName != "" {
|
||||
evalCtx.RequestContext["aws:username"] = userName
|
||||
evalCtx.RequestContext["aws:userid"] = userName
|
||||
}
|
||||
if arnInfo.AccountID != "" {
|
||||
evalCtx.RequestContext["aws:PrincipalAccount"] = arnInfo.AccountID
|
||||
@@ -364,58 +382,75 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
}
|
||||
}
|
||||
|
||||
// If explicit policy names are provided (e.g. from user identity), evaluate them directly
|
||||
if len(request.PolicyNames) > 0 {
|
||||
policies := request.PolicyNames
|
||||
if bucketPolicyName != "" {
|
||||
// Enforce an upper bound on the number of policies to avoid excessive allocations
|
||||
if len(policies) >= maxPoliciesForEvaluation {
|
||||
return false, fmt.Errorf("too many policies for evaluation: %d >= %d", len(policies), maxPoliciesForEvaluation)
|
||||
policies := request.PolicyNames
|
||||
if len(policies) == 0 {
|
||||
// Extract role name from principal ARN
|
||||
roleName := utils.ExtractRoleNameFromPrincipal(request.Principal)
|
||||
if roleName == "" {
|
||||
userName := utils.ExtractUserNameFromPrincipal(request.Principal)
|
||||
if userName == "" {
|
||||
return false, fmt.Errorf("could not extract role from principal: %s", request.Principal)
|
||||
}
|
||||
// Create a new slice to avoid modifying the request and append the bucket policy
|
||||
copied := make([]string, len(policies))
|
||||
copy(copied, policies)
|
||||
policies = append(copied, bucketPolicyName)
|
||||
if m.userStore == nil {
|
||||
return false, fmt.Errorf("user store unavailable for principal: %s", request.Principal)
|
||||
}
|
||||
user, err := m.userStore.GetUser(ctx, userName)
|
||||
if err != nil || user == nil {
|
||||
return false, fmt.Errorf("user not found for principal: %s (user=%s)", request.Principal, userName)
|
||||
}
|
||||
policies = user.GetPolicyNames()
|
||||
} else {
|
||||
// Get role definition
|
||||
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("role not found: %s", roleName)
|
||||
}
|
||||
|
||||
policies = roleDef.AttachedPolicies
|
||||
}
|
||||
|
||||
result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policies)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("policy evaluation failed: %w", err)
|
||||
}
|
||||
return result.Effect == policy.EffectAllow, nil
|
||||
}
|
||||
|
||||
// Extract role name from principal ARN
|
||||
roleName := utils.ExtractRoleNameFromPrincipal(request.Principal)
|
||||
if roleName == "" {
|
||||
return false, fmt.Errorf("could not extract role from principal: %s", request.Principal)
|
||||
}
|
||||
|
||||
// Get role definition
|
||||
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("role not found: %s", roleName)
|
||||
}
|
||||
|
||||
// Evaluate policies attached to the role
|
||||
policies := roleDef.AttachedPolicies
|
||||
if bucketPolicyName != "" {
|
||||
// Enforce an upper bound on the number of policies to avoid excessive allocations
|
||||
if len(policies) >= maxPoliciesForEvaluation {
|
||||
return false, fmt.Errorf("too many policies for evaluation: %d >= %d", len(policies), maxPoliciesForEvaluation)
|
||||
}
|
||||
// Create a new slice to avoid modifying the role definition and append the bucket policy
|
||||
// Create a new slice to avoid modifying the original and append the bucket policy
|
||||
copied := make([]string, len(policies))
|
||||
copy(copied, policies)
|
||||
policies = append(copied, bucketPolicyName)
|
||||
}
|
||||
|
||||
result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policies)
|
||||
baseResult, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policies)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("policy evaluation failed: %w", err)
|
||||
}
|
||||
|
||||
return result.Effect == policy.EffectAllow, nil
|
||||
// Base policy must allow; if it doesn't, deny immediately (session policy can only further restrict)
|
||||
if baseResult.Effect != policy.EffectAllow {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// If there's a session policy, it must also allow the action
|
||||
if sessionInfo != nil && sessionInfo.SessionPolicy != "" {
|
||||
var sessionPolicy policy.PolicyDocument
|
||||
if err := json.Unmarshal([]byte(sessionInfo.SessionPolicy), &sessionPolicy); err != nil {
|
||||
return false, fmt.Errorf("invalid session policy JSON: %w", err)
|
||||
}
|
||||
if err := policy.ValidatePolicyDocument(&sessionPolicy); err != nil {
|
||||
return false, fmt.Errorf("invalid session policy document: %w", err)
|
||||
}
|
||||
sessionResult, err := m.policyEngine.EvaluatePolicyDocument(ctx, evalCtx, "session-policy", &sessionPolicy, policy.EffectDeny)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("session policy evaluation failed: %w", err)
|
||||
}
|
||||
if sessionResult.Effect != policy.EffectAllow {
|
||||
// Session policy does not allow this action
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ValidateTrustPolicy validates if a principal can assume a role (for testing)
|
||||
@@ -643,7 +678,28 @@ func isOIDCToken(token string) bool {
|
||||
}
|
||||
|
||||
// JWT tokens typically start with "eyJ" (base64 encoded JSON starting with "{")
|
||||
return strings.HasPrefix(token, "eyJ")
|
||||
if !strings.HasPrefix(token, "eyJ") {
|
||||
return false
|
||||
}
|
||||
|
||||
parsed, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if typ, ok := claims["typ"].(string); ok && typ == sts.TokenTypeSession {
|
||||
return false
|
||||
}
|
||||
if typ, ok := claims[sts.JWTClaimTokenType].(string); ok && typ == sts.TokenTypeSession {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// TrustPolicyValidator interface implementation
|
||||
|
||||
@@ -474,6 +474,68 @@ func (e *PolicyEngine) EvaluateTrustPolicy(ctx context.Context, trustPolicy *Pol
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// EvaluatePolicyDocument evaluates a single policy document without storing it.
|
||||
// defaultEffect controls the fallback result when no statements match.
|
||||
func (e *PolicyEngine) EvaluatePolicyDocument(ctx context.Context, evalCtx *EvaluationContext, policyName string, policyDoc *PolicyDocument, defaultEffect Effect) (*EvaluationResult, error) {
|
||||
if !e.initialized {
|
||||
return nil, fmt.Errorf("policy engine not initialized")
|
||||
}
|
||||
|
||||
if evalCtx == nil {
|
||||
return nil, fmt.Errorf("evaluation context cannot be nil")
|
||||
}
|
||||
|
||||
if policyDoc == nil {
|
||||
return nil, fmt.Errorf("policy document cannot be nil")
|
||||
}
|
||||
|
||||
if policyName == "" {
|
||||
policyName = "inline-policy"
|
||||
}
|
||||
|
||||
result := &EvaluationResult{
|
||||
Effect: defaultEffect,
|
||||
EvaluationDetails: &EvaluationDetails{
|
||||
Principal: evalCtx.Principal,
|
||||
Action: evalCtx.Action,
|
||||
Resource: evalCtx.Resource,
|
||||
PoliciesEvaluated: []string{policyName},
|
||||
},
|
||||
}
|
||||
|
||||
var matchingStatements []StatementMatch
|
||||
explicitDeny := false
|
||||
hasAllow := false
|
||||
|
||||
for _, statement := range policyDoc.Statement {
|
||||
if e.statementMatches(&statement, evalCtx) {
|
||||
match := StatementMatch{
|
||||
PolicyName: policyName,
|
||||
StatementSid: statement.Sid,
|
||||
Effect: Effect(statement.Effect),
|
||||
Reason: "Action, Resource, and Condition matched",
|
||||
}
|
||||
matchingStatements = append(matchingStatements, match)
|
||||
|
||||
if statement.Effect == "Deny" {
|
||||
explicitDeny = true
|
||||
} else if statement.Effect == "Allow" {
|
||||
hasAllow = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.MatchingStatements = matchingStatements
|
||||
|
||||
if explicitDeny {
|
||||
result.Effect = EffectDeny
|
||||
} else if hasAllow {
|
||||
result.Effect = EffectAllow
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// statementMatches checks if a statement matches the evaluation context
|
||||
func (e *PolicyEngine) statementMatches(statement *Statement, evalCtx *EvaluationContext) bool {
|
||||
// Check principal match (for trust policies)
|
||||
|
||||
@@ -31,6 +31,8 @@ type STSSessionClaims struct {
|
||||
|
||||
// Authorization data
|
||||
Policies []string `json:"pol,omitempty"` // policies (abbreviated)
|
||||
// SessionPolicy contains inline session policy JSON (optional)
|
||||
SessionPolicy string `json:"spol,omitempty"`
|
||||
|
||||
// Identity provider information
|
||||
IdentityProvider string `json:"idp"` // identity_provider
|
||||
@@ -88,6 +90,7 @@ func (c *STSSessionClaims) ToSessionInfo() *SessionInfo {
|
||||
AssumedRoleUser: c.AssumedRole,
|
||||
Principal: c.Principal,
|
||||
Policies: c.Policies,
|
||||
SessionPolicy: c.SessionPolicy,
|
||||
ExpiresAt: expiresAt,
|
||||
IdentityProvider: c.IdentityProvider,
|
||||
ExternalUserId: c.ExternalUserId,
|
||||
@@ -148,6 +151,12 @@ func (c *STSSessionClaims) WithPolicies(policies []string) *STSSessionClaims {
|
||||
return c
|
||||
}
|
||||
|
||||
// WithSessionPolicy sets the inline session policy JSON for this session
|
||||
func (c *STSSessionClaims) WithSessionPolicy(policy string) *STSSessionClaims {
|
||||
c.SessionPolicy = policy
|
||||
return c
|
||||
}
|
||||
|
||||
// WithIdentityProvider sets identity provider information
|
||||
func (c *STSSessionClaims) WithIdentityProvider(providerName, externalUserId, providerIssuer string) *STSSessionClaims {
|
||||
c.IdentityProvider = providerName
|
||||
|
||||
@@ -89,6 +89,7 @@ func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) {
|
||||
expiresAt := time.Now().Add(2 * time.Hour)
|
||||
|
||||
policies := []string{"policy1", "policy2"}
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"]}]}`
|
||||
requestContext := map[string]interface{}{
|
||||
"sourceIp": "192.168.1.1",
|
||||
"userAgent": "test-agent",
|
||||
@@ -99,6 +100,7 @@ func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) {
|
||||
WithRoleInfo("role-arn", "assumed-role", "principal").
|
||||
WithIdentityProvider("provider", "external-id", "issuer").
|
||||
WithPolicies(policies).
|
||||
WithSessionPolicy(sessionPolicy).
|
||||
WithRequestContext(requestContext).
|
||||
WithMaxDuration(2 * time.Hour)
|
||||
|
||||
@@ -114,6 +116,7 @@ func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) {
|
||||
assert.Equal(t, "external-id", sessionInfo.ExternalUserId)
|
||||
assert.Equal(t, "issuer", sessionInfo.ProviderIssuer)
|
||||
assert.Equal(t, policies, sessionInfo.Policies)
|
||||
assert.Equal(t, sessionPolicy, sessionInfo.SessionPolicy)
|
||||
assert.Equal(t, requestContext, sessionInfo.RequestContext)
|
||||
assert.WithinDuration(t, expiresAt, sessionInfo.ExpiresAt, 1*time.Second)
|
||||
}
|
||||
|
||||
35
weed/iam/sts/session_policy.go
Normal file
35
weed/iam/sts/session_policy.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
)
|
||||
|
||||
// NormalizeSessionPolicy validates and normalizes inline session policy JSON.
|
||||
// It returns an empty string if the input is empty or whitespace.
|
||||
func NormalizeSessionPolicy(policyJSON string) (string, error) {
|
||||
trimmed := strings.TrimSpace(policyJSON)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
const maxSessionPolicySize = 2048
|
||||
if len(trimmed) > maxSessionPolicySize {
|
||||
return "", fmt.Errorf("session policy exceeds maximum size of %d characters", maxSessionPolicySize)
|
||||
}
|
||||
|
||||
var policyDoc policy.PolicyDocument
|
||||
if err := json.Unmarshal([]byte(trimmed), &policyDoc); err != nil {
|
||||
return "", fmt.Errorf("invalid session policy JSON: %w", err)
|
||||
}
|
||||
if err := policy.ValidatePolicyDocument(&policyDoc); err != nil {
|
||||
return "", fmt.Errorf("invalid session policy document: %w", err)
|
||||
}
|
||||
normalized, err := json.Marshal(&policyDoc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to normalize session policy: %w", err)
|
||||
}
|
||||
return string(normalized), nil
|
||||
}
|
||||
@@ -25,174 +25,55 @@ func createSessionPolicyTestJWT(t *testing.T, issuer, subject string) string {
|
||||
return tokenString
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithWebIdentity_SessionPolicy tests the handling of the Policy field
|
||||
// in AssumeRoleWithWebIdentityRequest to ensure users are properly informed that
|
||||
// session policies are not currently supported
|
||||
// TestAssumeRoleWithWebIdentity_SessionPolicy verifies inline session policies are preserved in tokens.
|
||||
func TestAssumeRoleWithWebIdentity_SessionPolicy(t *testing.T) {
|
||||
service := setupTestSTSService(t)
|
||||
|
||||
t.Run("should_reject_request_with_session_policy", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a request with a session policy
|
||||
sessionPolicy := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::example-bucket/*"
|
||||
}]
|
||||
}`
|
||||
|
||||
testToken := createSessionPolicyTestJWT(t, "test-issuer", "test-user")
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
WebIdentityToken: testToken,
|
||||
RoleSessionName: "test-session",
|
||||
DurationSeconds: nil, // Use default
|
||||
Policy: &sessionPolicy, // ← Session policy provided
|
||||
}
|
||||
|
||||
// Should return an error indicating session policies are not supported
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
|
||||
// Verify the error
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "session policies are not currently supported")
|
||||
assert.Contains(t, err.Error(), "Policy parameter must be omitted")
|
||||
})
|
||||
|
||||
t.Run("should_succeed_without_session_policy", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
testToken := createSessionPolicyTestJWT(t, "test-issuer", "test-user")
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
WebIdentityToken: testToken,
|
||||
RoleSessionName: "test-session",
|
||||
DurationSeconds: nil, // Use default
|
||||
Policy: nil, // ← No session policy
|
||||
}
|
||||
|
||||
// Should succeed without session policy
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
|
||||
// Verify success
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
assert.NotNil(t, response.Credentials)
|
||||
assert.NotEmpty(t, response.Credentials.AccessKeyId)
|
||||
assert.NotEmpty(t, response.Credentials.SecretAccessKey)
|
||||
assert.NotEmpty(t, response.Credentials.SessionToken)
|
||||
})
|
||||
|
||||
t.Run("should_succeed_with_empty_policy_pointer", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
testToken := createSessionPolicyTestJWT(t, "test-issuer", "test-user")
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
WebIdentityToken: testToken,
|
||||
RoleSessionName: "test-session",
|
||||
Policy: nil, // ← Explicitly nil
|
||||
}
|
||||
|
||||
// Should succeed with nil policy pointer
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
assert.NotNil(t, response.Credentials)
|
||||
})
|
||||
|
||||
t.Run("should_reject_empty_string_policy", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
emptyPolicy := "" // Empty string, but still a non-nil pointer
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
WebIdentityToken: createSessionPolicyTestJWT(t, "test-issuer", "test-user"),
|
||||
RoleSessionName: "test-session",
|
||||
Policy: &emptyPolicy, // ← Non-nil pointer to empty string
|
||||
}
|
||||
|
||||
// Should still reject because pointer is not nil
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "session policies are not currently supported")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithWebIdentity_SessionPolicy_ErrorMessage tests that the error message
|
||||
// is clear and helps users understand what they need to do
|
||||
func TestAssumeRoleWithWebIdentity_SessionPolicy_ErrorMessage(t *testing.T) {
|
||||
service := setupTestSTSService(t)
|
||||
|
||||
ctx := context.Background()
|
||||
complexPolicy := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowS3Access",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::my-bucket/*",
|
||||
"arn:aws:s3:::my-bucket"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:prefix": ["documents/", "images/"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::example-bucket/*"}]}`
|
||||
testToken := createSessionPolicyTestJWT(t, "test-issuer", "test-user")
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
WebIdentityToken: testToken,
|
||||
RoleSessionName: "test-session-with-complex-policy",
|
||||
Policy: &complexPolicy,
|
||||
RoleSessionName: "test-session",
|
||||
Policy: &sessionPolicy,
|
||||
}
|
||||
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
// Verify error details
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
sessionInfo, err := service.ValidateSessionToken(ctx, response.Credentials.SessionToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
errorMsg := err.Error()
|
||||
normalized, err := NormalizeSessionPolicy(sessionPolicy)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, normalized, sessionInfo.SessionPolicy)
|
||||
|
||||
// The error should be clear and actionable
|
||||
assert.Contains(t, errorMsg, "session policies are not currently supported",
|
||||
"Error should explain that session policies aren't supported")
|
||||
assert.Contains(t, errorMsg, "Policy parameter must be omitted",
|
||||
"Error should specify what action the user needs to take")
|
||||
t.Run("should_succeed_without_session_policy", func(t *testing.T) {
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
WebIdentityToken: createSessionPolicyTestJWT(t, "test-issuer", "test-user"),
|
||||
RoleSessionName: "test-session",
|
||||
}
|
||||
|
||||
// Should NOT contain internal implementation details
|
||||
assert.NotContains(t, errorMsg, "nil pointer",
|
||||
"Error should not expose internal implementation details")
|
||||
assert.NotContains(t, errorMsg, "struct field",
|
||||
"Error should not expose internal struct details")
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
sessionInfo, err := service.ValidateSessionToken(ctx, response.Credentials.SessionToken)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, sessionInfo.SessionPolicy)
|
||||
})
|
||||
}
|
||||
|
||||
// Test edge case scenarios for the Policy field handling
|
||||
func TestAssumeRoleWithWebIdentity_SessionPolicy_EdgeCases(t *testing.T) {
|
||||
service := setupTestSTSService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("malformed_json_policy_still_rejected", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
t.Run("malformed_json_policy_rejected", func(t *testing.T) {
|
||||
malformedPolicy := `{"Version": "2012-10-17", "Statement": [` // Incomplete JSON
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
@@ -202,17 +83,30 @@ func TestAssumeRoleWithWebIdentity_SessionPolicy_EdgeCases(t *testing.T) {
|
||||
Policy: &malformedPolicy,
|
||||
}
|
||||
|
||||
// Should reject before even parsing the policy JSON
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "session policies are not currently supported")
|
||||
assert.Contains(t, err.Error(), "invalid session policy JSON")
|
||||
})
|
||||
|
||||
t.Run("policy_with_whitespace_still_rejected", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
whitespacePolicy := " \t\n " // Only whitespace
|
||||
t.Run("invalid_policy_document_rejected", func(t *testing.T) {
|
||||
invalidPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow"}]}`
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
WebIdentityToken: createSessionPolicyTestJWT(t, "test-issuer", "test-user"),
|
||||
RoleSessionName: "test-session",
|
||||
Policy: &invalidPolicy,
|
||||
}
|
||||
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "invalid session policy document")
|
||||
})
|
||||
|
||||
t.Run("whitespace_policy_ignored", func(t *testing.T) {
|
||||
whitespacePolicy := " \t\n "
|
||||
|
||||
request := &AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
@@ -221,58 +115,54 @@ func TestAssumeRoleWithWebIdentity_SessionPolicy_EdgeCases(t *testing.T) {
|
||||
Policy: &whitespacePolicy,
|
||||
}
|
||||
|
||||
// Should reject any non-nil policy, even whitespace
|
||||
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, response)
|
||||
assert.Contains(t, err.Error(), "session policies are not currently supported")
|
||||
sessionInfo, err := service.ValidateSessionToken(ctx, response.Credentials.SessionToken)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, sessionInfo.SessionPolicy)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithWebIdentity_PolicyFieldDocumentation verifies that the struct
|
||||
// field is properly documented to help developers understand the limitation
|
||||
// TestAssumeRoleWithWebIdentity_PolicyFieldDocumentation verifies that the struct field exists and is optional.
|
||||
func TestAssumeRoleWithWebIdentity_PolicyFieldDocumentation(t *testing.T) {
|
||||
// This test documents the current behavior and ensures the struct field
|
||||
// exists with proper typing
|
||||
request := &AssumeRoleWithWebIdentityRequest{}
|
||||
|
||||
// Verify the Policy field exists and has the correct type
|
||||
assert.IsType(t, (*string)(nil), request.Policy,
|
||||
"Policy field should be *string type for optional JSON policy")
|
||||
|
||||
// Verify initial value is nil (no policy by default)
|
||||
assert.Nil(t, request.Policy,
|
||||
"Policy field should default to nil (no session policy)")
|
||||
|
||||
// Test that we can set it to a string pointer (even though it will be rejected)
|
||||
policyValue := `{"Version": "2012-10-17"}`
|
||||
request.Policy = &policyValue
|
||||
assert.NotNil(t, request.Policy, "Should be able to assign policy value")
|
||||
assert.Equal(t, policyValue, *request.Policy, "Policy value should be preserved")
|
||||
}
|
||||
|
||||
// TestAssumeRoleWithCredentials_NoSessionPolicySupport verifies that
|
||||
// AssumeRoleWithCredentialsRequest doesn't have a Policy field, which is correct
|
||||
// since credential-based role assumption typically doesn't support session policies
|
||||
func TestAssumeRoleWithCredentials_NoSessionPolicySupport(t *testing.T) {
|
||||
// Verify that AssumeRoleWithCredentialsRequest doesn't have a Policy field
|
||||
// This is the expected behavior since session policies are typically only
|
||||
// supported with web identity (OIDC/SAML) flows in AWS STS
|
||||
// TestAssumeRoleWithCredentials_SessionPolicy verifies session policy support for credentials-based flow.
|
||||
func TestAssumeRoleWithCredentials_SessionPolicy(t *testing.T) {
|
||||
service := setupTestSTSService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"filer:CreateEntry","Resource":"arn:aws:filer::path/user-docs/*"}]}`
|
||||
request := &AssumeRoleWithCredentialsRequest{
|
||||
RoleArn: "arn:aws:iam::role/TestRole",
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
RoleSessionName: "test-session",
|
||||
ProviderName: "ldap",
|
||||
ProviderName: "test-ldap",
|
||||
Policy: &sessionPolicy,
|
||||
}
|
||||
|
||||
// The struct should compile and work without a Policy field
|
||||
assert.NotNil(t, request)
|
||||
assert.Equal(t, "arn:aws:iam::role/TestRole", request.RoleArn)
|
||||
assert.Equal(t, "testuser", request.Username)
|
||||
response, err := service.AssumeRoleWithCredentials(ctx, request)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
// This documents that credential-based assume role does NOT support session policies
|
||||
// which matches AWS STS behavior where session policies are primarily for
|
||||
// web identity (OIDC/SAML) and federation scenarios
|
||||
sessionInfo, err := service.ValidateSessionToken(ctx, response.Credentials.SessionToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
normalized, err := NormalizeSessionPolicy(sessionPolicy)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, normalized, sessionInfo.SessionPolicy)
|
||||
}
|
||||
|
||||
@@ -161,6 +161,9 @@ type AssumeRoleWithCredentialsRequest struct {
|
||||
|
||||
// DurationSeconds is the duration of the role session (optional)
|
||||
DurationSeconds *int64 `json:"DurationSeconds,omitempty"`
|
||||
|
||||
// Policy is an optional session policy (optional)
|
||||
Policy *string `json:"Policy,omitempty"`
|
||||
}
|
||||
|
||||
// AssumeRoleResponse represents the response from assume role operations
|
||||
@@ -237,6 +240,9 @@ type SessionInfo struct {
|
||||
// Policies are the policies associated with this session
|
||||
Policies []string `json:"policies"`
|
||||
|
||||
// SessionPolicy is the inline session policy JSON (optional)
|
||||
SessionPolicy string `json:"sessionPolicy,omitempty"`
|
||||
|
||||
// RequestContext contains additional request context for policy evaluation
|
||||
RequestContext map[string]interface{} `json:"requestContext,omitempty"`
|
||||
|
||||
@@ -418,9 +424,13 @@ func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *Ass
|
||||
return nil, fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
|
||||
// Check for unsupported session policy
|
||||
sessionPolicy := ""
|
||||
if request.Policy != nil {
|
||||
return nil, fmt.Errorf("session policies are not currently supported - Policy parameter must be omitted")
|
||||
normalized, err := NormalizeSessionPolicy(*request.Policy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid session policy: %w", err)
|
||||
}
|
||||
sessionPolicy = normalized
|
||||
}
|
||||
|
||||
// 1. Validate the web identity token with appropriate provider
|
||||
@@ -485,6 +495,9 @@ func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *Ass
|
||||
WithIdentityProvider(provider.Name(), externalIdentity.UserID, "").
|
||||
WithMaxDuration(sessionDuration).
|
||||
WithRequestContext(requestContext)
|
||||
if sessionPolicy != "" {
|
||||
sessionClaims.WithSessionPolicy(sessionPolicy)
|
||||
}
|
||||
|
||||
// Generate self-contained JWT token with all session information
|
||||
jwtToken, err := s.tokenGenerator.GenerateJWTWithClaims(sessionClaims)
|
||||
@@ -517,6 +530,15 @@ func (s *STSService) AssumeRoleWithCredentials(ctx context.Context, request *Ass
|
||||
return nil, fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
|
||||
sessionPolicy := ""
|
||||
if request.Policy != nil {
|
||||
normalized, err := NormalizeSessionPolicy(*request.Policy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid session policy: %w", err)
|
||||
}
|
||||
sessionPolicy = normalized
|
||||
}
|
||||
|
||||
// 1. Get the specified provider
|
||||
provider, exists := s.providers[request.ProviderName]
|
||||
if !exists {
|
||||
@@ -565,6 +587,9 @@ func (s *STSService) AssumeRoleWithCredentials(ctx context.Context, request *Ass
|
||||
WithRoleInfo(request.RoleArn, assumedRoleUser.Arn, assumedRoleUser.Arn).
|
||||
WithIdentityProvider(provider.Name(), externalIdentity.UserID, "").
|
||||
WithMaxDuration(sessionDuration)
|
||||
if sessionPolicy != "" {
|
||||
sessionClaims.WithSessionPolicy(sessionPolicy)
|
||||
}
|
||||
|
||||
// Generate self-contained JWT token with all session information
|
||||
jwtToken, err := s.tokenGenerator.GenerateJWTWithClaims(sessionClaims)
|
||||
|
||||
@@ -16,6 +16,9 @@ const (
|
||||
|
||||
// iamRoleMarker is the marker that identifies IAM role ARNs
|
||||
iamRoleMarker = "role/"
|
||||
|
||||
// iamUserMarker is the marker that identifies IAM user ARNs
|
||||
iamUserMarker = "user/"
|
||||
)
|
||||
|
||||
// ARNInfo contains structured information about a parsed AWS ARN.
|
||||
@@ -88,6 +91,36 @@ func ExtractRoleNameFromPrincipal(principal string) string {
|
||||
return ExtractRoleNameFromArn(principal)
|
||||
}
|
||||
|
||||
// ExtractUserNameFromPrincipal extracts the user name from an AWS IAM principal ARN.
|
||||
//
|
||||
// It handles both legacy and standard AWS IAM user ARN formats:
|
||||
// - arn:aws:iam::user/UserName (legacy format without account ID)
|
||||
// - arn:aws:iam::ACCOUNT:user/UserName (standard AWS format with account ID)
|
||||
//
|
||||
// Returns an empty string if the principal does not represent an IAM user.
|
||||
func ExtractUserNameFromPrincipal(principal string) string {
|
||||
if !strings.HasPrefix(principal, iamPrefix) {
|
||||
return ""
|
||||
}
|
||||
|
||||
remainder := principal[len(iamPrefix):]
|
||||
resourcePart := remainder
|
||||
if colonIdx := strings.Index(remainder, ":"); colonIdx != -1 {
|
||||
resourcePart = remainder[colonIdx+1:]
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(resourcePart, iamUserMarker) {
|
||||
return ""
|
||||
}
|
||||
|
||||
userName := resourcePart[len(iamUserMarker):]
|
||||
if userName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return userName
|
||||
}
|
||||
|
||||
// ExtractRoleNameFromArn extracts the role name from an AWS IAM role ARN.
|
||||
//
|
||||
// It handles both legacy and standard AWS IAM role ARN formats:
|
||||
|
||||
@@ -189,6 +189,9 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to load IAM configuration: %v", err)
|
||||
} else {
|
||||
if iam.credentialManager != nil {
|
||||
iamManager.SetUserStore(iam.credentialManager)
|
||||
}
|
||||
glog.V(1).Infof("IAM Manager loaded, creating integration")
|
||||
// Create S3 IAM integration with the loaded IAM manager
|
||||
// filerAddress not actually used, just for backward compatibility
|
||||
|
||||
@@ -165,12 +165,25 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *
|
||||
return
|
||||
}
|
||||
|
||||
sessionPolicyJSON, err := sts.NormalizeSessionPolicy(r.FormValue("Policy"))
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMalformedPolicyDocument,
|
||||
fmt.Errorf("invalid Policy document: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
var sessionPolicyPtr *string
|
||||
if sessionPolicyJSON != "" {
|
||||
sessionPolicyPtr = &sessionPolicyJSON
|
||||
}
|
||||
|
||||
// Build request for STS service
|
||||
request := &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: roleArn,
|
||||
WebIdentityToken: webIdentityToken,
|
||||
RoleSessionName: roleSessionName,
|
||||
DurationSeconds: durationSeconds,
|
||||
Policy: sessionPolicyPtr,
|
||||
}
|
||||
|
||||
// Call STS service
|
||||
@@ -216,6 +229,8 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *
|
||||
|
||||
// handleAssumeRole handles the AssumeRole API action
|
||||
// This requires AWS Signature V4 authentication
|
||||
// Inline session policies (Policy parameter) are supported for AssumeRole,
|
||||
// AssumeRoleWithWebIdentity, and AssumeRoleWithLDAPIdentity.
|
||||
func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract parameters from form
|
||||
roleArn := r.FormValue("RoleArn")
|
||||
@@ -290,8 +305,16 @@ func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse optional inline session policy for downscoping
|
||||
sessionPolicyJSON, err := sts.NormalizeSessionPolicy(r.FormValue("Policy"))
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMalformedPolicyDocument,
|
||||
fmt.Errorf("invalid Policy document: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Generate common STS components
|
||||
stsCreds, assumedUser, err := h.prepareSTSCredentials(roleArn, roleSessionName, durationSeconds, nil)
|
||||
stsCreds, assumedUser, err := h.prepareSTSCredentials(roleArn, roleSessionName, durationSeconds, sessionPolicyJSON, nil)
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, STSErrInternalError, err)
|
||||
return
|
||||
@@ -420,12 +443,19 @@ func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
|
||||
sessionPolicyJSON, err := sts.NormalizeSessionPolicy(r.FormValue("Policy"))
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMalformedPolicyDocument,
|
||||
fmt.Errorf("invalid Policy document: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Generate common STS components with LDAP-specific claims
|
||||
modifyClaims := func(claims *sts.STSSessionClaims) {
|
||||
claims.WithIdentityProvider("ldap", identity.UserID, identity.Provider)
|
||||
}
|
||||
|
||||
stsCreds, assumedUser, err := h.prepareSTSCredentials(roleArn, roleSessionName, durationSeconds, modifyClaims)
|
||||
stsCreds, assumedUser, err := h.prepareSTSCredentials(roleArn, roleSessionName, durationSeconds, sessionPolicyJSON, modifyClaims)
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, STSErrInternalError, err)
|
||||
return
|
||||
@@ -445,7 +475,7 @@ func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r
|
||||
|
||||
// prepareSTSCredentials extracts common shared logic for credential generation
|
||||
func (h *STSHandlers) prepareSTSCredentials(roleArn, roleSessionName string,
|
||||
durationSeconds *int64, modifyClaims func(*sts.STSSessionClaims)) (STSCredentials, *AssumedRoleUser, error) {
|
||||
durationSeconds *int64, sessionPolicy string, modifyClaims func(*sts.STSSessionClaims)) (STSCredentials, *AssumedRoleUser, error) {
|
||||
|
||||
// Calculate duration
|
||||
duration := time.Hour // Default 1 hour
|
||||
@@ -479,6 +509,10 @@ func (h *STSHandlers) prepareSTSCredentials(roleArn, roleSessionName string,
|
||||
WithSessionName(roleSessionName).
|
||||
WithRoleInfo(roleArn, fmt.Sprintf("%s:%s", roleName, roleSessionName), assumedRoleArn)
|
||||
|
||||
if sessionPolicy != "" {
|
||||
claims.WithSessionPolicy(sessionPolicy)
|
||||
}
|
||||
|
||||
// Apply custom claims if provided (e.g., LDAP identity)
|
||||
if modifyClaims != nil {
|
||||
modifyClaims(claims)
|
||||
@@ -582,13 +616,14 @@ type LDAPIdentityResult struct {
|
||||
type STSErrorCode string
|
||||
|
||||
const (
|
||||
STSErrAccessDenied STSErrorCode = "AccessDenied"
|
||||
STSErrExpiredToken STSErrorCode = "ExpiredTokenException"
|
||||
STSErrInvalidAction STSErrorCode = "InvalidAction"
|
||||
STSErrInvalidParameterValue STSErrorCode = "InvalidParameterValue"
|
||||
STSErrMissingParameter STSErrorCode = "MissingParameter"
|
||||
STSErrSTSNotReady STSErrorCode = "ServiceUnavailable"
|
||||
STSErrInternalError STSErrorCode = "InternalError"
|
||||
STSErrAccessDenied STSErrorCode = "AccessDenied"
|
||||
STSErrExpiredToken STSErrorCode = "ExpiredTokenException"
|
||||
STSErrInvalidAction STSErrorCode = "InvalidAction"
|
||||
STSErrInvalidParameterValue STSErrorCode = "InvalidParameterValue"
|
||||
STSErrMalformedPolicyDocument STSErrorCode = "MalformedPolicyDocument"
|
||||
STSErrMissingParameter STSErrorCode = "MissingParameter"
|
||||
STSErrSTSNotReady STSErrorCode = "ServiceUnavailable"
|
||||
STSErrInternalError STSErrorCode = "InternalError"
|
||||
)
|
||||
|
||||
// stsErrorResponses maps error codes to HTTP status and messages
|
||||
@@ -596,13 +631,14 @@ var stsErrorResponses = map[STSErrorCode]struct {
|
||||
HTTPStatusCode int
|
||||
Message string
|
||||
}{
|
||||
STSErrAccessDenied: {http.StatusForbidden, "Access Denied"},
|
||||
STSErrExpiredToken: {http.StatusBadRequest, "Token has expired"},
|
||||
STSErrInvalidAction: {http.StatusBadRequest, "Invalid action"},
|
||||
STSErrInvalidParameterValue: {http.StatusBadRequest, "Invalid parameter value"},
|
||||
STSErrMissingParameter: {http.StatusBadRequest, "Missing required parameter"},
|
||||
STSErrSTSNotReady: {http.StatusServiceUnavailable, "STS service not ready"},
|
||||
STSErrInternalError: {http.StatusInternalServerError, "Internal error"},
|
||||
STSErrAccessDenied: {http.StatusForbidden, "Access Denied"},
|
||||
STSErrExpiredToken: {http.StatusBadRequest, "Token has expired"},
|
||||
STSErrInvalidAction: {http.StatusBadRequest, "Invalid action"},
|
||||
STSErrInvalidParameterValue: {http.StatusBadRequest, "Invalid parameter value"},
|
||||
STSErrMalformedPolicyDocument: {http.StatusBadRequest, "Malformed policy document"},
|
||||
STSErrMissingParameter: {http.StatusBadRequest, "Missing required parameter"},
|
||||
STSErrSTSNotReady: {http.StatusServiceUnavailable, "STS service not ready"},
|
||||
STSErrInternalError: {http.StatusInternalServerError, "Internal error"},
|
||||
}
|
||||
|
||||
// STSErrorResponse is the XML error response format
|
||||
|
||||
Reference in New Issue
Block a user