Browse Source

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
pull/8340/head
Chris Lu 7 days ago
committed by GitHub
parent
commit
49a64f50f1
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 213
      test/s3tables/sts_integration/sts_integration_test.go
  2. 126
      weed/iam/integration/iam_integration_test.go
  3. 126
      weed/iam/integration/iam_manager.go
  4. 62
      weed/iam/policy/policy_engine.go
  5. 9
      weed/iam/sts/session_claims.go
  6. 3
      weed/iam/sts/session_claims_test.go
  7. 35
      weed/iam/sts/session_policy.go
  8. 228
      weed/iam/sts/session_policy_test.go
  9. 29
      weed/iam/sts/sts_service.go
  10. 33
      weed/iam/utils/arn_utils.go
  11. 3
      weed/s3api/s3api_server.go
  12. 70
      weed/s3api/s3api_sts.go

213
test/s3tables/sts_integration/sts_integration_test.go

@ -33,6 +33,8 @@ type TestEnvironment struct {
secretKey string secretKey string
} }
const testSTSIntegrationSigningKey = "dGVzdC1zaWduaW5nLWtleS1mb3Itc3RzLWludGVncmF0aW9uLXRlc3Rz" // gitleaks:allow - test-signing-key-for-sts-integration-tests
func TestSTSIntegration(t *testing.T) { func TestSTSIntegration(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("Skipping integration test in short mode") t.Skip("Skipping integration test in short mode")
@ -110,17 +112,70 @@ func NewTestEnvironment(t *testing.T) *TestEnvironment {
volumePort: volumePort, volumePort: volumePort,
volumeGrpcPort: volumeGrpcPort, volumeGrpcPort: volumeGrpcPort,
dockerAvailable: testutil.HasDocker(), dockerAvailable: testutil.HasDocker(),
accessKey: "admin", // Matching default in testutil.WriteIAMConfig
secretKey: "admin",
accessKey: "admin",
secretKey: "adminadmin",
} }
} }
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) { func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
t.Helper() 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) 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", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort), "-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.config", iamConfigPath, "-s3.config", iamConfigPath,
"-s3.iam.config", iamConfigPath,
"-s3.iam.readOnly", "false",
"-ip", env.bindIP, "-ip", env.bindIP,
"-ip.bind", "0.0.0.0", "-ip.bind", "0.0.0.0",
"-dir", env.dataDir, "-dir", env.dataDir,
@ -190,22 +247,62 @@ func runPythonSTSClient(t *testing.T, env *TestEnvironment) {
import boto3 import boto3
import botocore.config import botocore.config
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
import os
import json
import sys 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" access_key = "%s"
secret_key = "%s" secret_key = "%s"
region = "us-east-1" region = "us-east-1"
print(f"Connecting to {endpoint_url} with key {access_key}")
try: 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( 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 = boto3.client(
'sts', 'sts',
endpoint_url=endpoint_url, endpoint_url=endpoint_url,
@ -215,45 +312,77 @@ try:
config=config config=config
) )
role_arn = "arn:aws:iam::000000000000:role/test-role"
role_arn = "arn:aws:iam::role/TestRole"
session_name = "test-session" session_name = "test-session"
print(f"Calling AssumeRole on {role_arn}")
# This call typically sends parameters in POST body by default in boto3
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( response = sts.assume_role(
RoleArn=role_arn, RoleArn=role_arn,
RoleSessionName=session_name
RoleSessionName=session_name,
Policy=session_policy
) )
print("Success! Got credentials:")
print(response['Credentials'])
except ClientError as e:
# Print available keys for debugging if needed
# print(e.response.keys())
response_meta = e.response.get('ResponseMetadata', {})
http_code = response_meta.get('HTTPStatusCode')
error_data = e.response.get('Error', {})
error_code = error_data.get('Code', 'Unknown')
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)
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: except Exception as e:
print(f"FAILED: {e}") print(f"FAILED: {e}")
if hasattr(e, 'response'):
print(f"Response: {e.response}")
import traceback
traceback.print_exc()
sys.exit(1) 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") scriptPath := filepath.Join(env.dataDir, "sts_test.py")
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil { if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil {

126
weed/iam/integration/iam_integration_test.go

@ -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 // TestSessionExpiration tests session expiration and cleanup
func TestSessionExpiration(t *testing.T) { func TestSessionExpiration(t *testing.T) {
iamManager := setupIntegratedIAMSystem(t) iamManager := setupIntegratedIAMSystem(t)

126
weed/iam/integration/iam_manager.go

@ -7,10 +7,12 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/iam/policy" "github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/providers" "github.com/seaweedfs/seaweedfs/weed/iam/providers"
"github.com/seaweedfs/seaweedfs/weed/iam/sts" "github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/iam/utils" "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 // maxPoliciesForEvaluation defines an upper bound on the number of policies that
@ -23,6 +25,7 @@ type IAMManager struct {
stsService *sts.STSService stsService *sts.STSService
policyEngine *policy.PolicyEngine policyEngine *policy.PolicyEngine
roleStore RoleStore roleStore RoleStore
userStore UserStore
filerAddressProvider func() string // Function to get current filer address filerAddressProvider func() string // Function to get current filer address
initialized bool initialized bool
} }
@ -48,6 +51,11 @@ type RoleStoreConfig struct {
StoreConfig map[string]interface{} `json:"storeConfig,omitempty"` 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 // RoleDefinition defines a role with its trust policy and attached policies
type RoleDefinition struct { type RoleDefinition struct {
// RoleName is the name of the role // RoleName is the name of the role
@ -92,6 +100,11 @@ func NewIAMManager() *IAMManager {
return &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 // Initialize initializes the IAM manager with all components
func (m *IAMManager) Initialize(config *IAMConfig, filerAddressProvider func() string) error { func (m *IAMManager) Initialize(config *IAMConfig, filerAddressProvider func() string) error {
if config == nil { 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, // Validate session token if present (skip for OIDC tokens which are already validated,
// and skip for empty tokens which represent static access keys) // and skip for empty tokens which represent static access keys)
var sessionInfo *sts.SessionInfo
if request.SessionToken != "" && !isOIDCToken(request.SessionToken) { 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 { if err != nil {
return false, fmt.Errorf("invalid session: %w", err) 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:username"] = awsUsername
evalCtx.RequestContext["aws:userid"] = arnInfo.RoleName 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 != "" { if arnInfo.AccountID != "" {
evalCtx.RequestContext["aws:PrincipalAccount"] = 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)
}
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)
} }
// 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)
}
result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policies)
if err != nil {
return false, fmt.Errorf("policy evaluation failed: %w", err)
policies = roleDef.AttachedPolicies
} }
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 != "" { if bucketPolicyName != "" {
// Enforce an upper bound on the number of policies to avoid excessive allocations // Enforce an upper bound on the number of policies to avoid excessive allocations
if len(policies) >= maxPoliciesForEvaluation { if len(policies) >= maxPoliciesForEvaluation {
return false, fmt.Errorf("too many policies for evaluation: %d >= %d", 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)) copied := make([]string, len(policies))
copy(copied, policies) copy(copied, policies)
policies = append(copied, bucketPolicyName) policies = append(copied, bucketPolicyName)
} }
result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policies)
baseResult, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policies)
if err != nil { if err != nil {
return false, fmt.Errorf("policy evaluation failed: %w", err) 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) // 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 "{") // 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 // TrustPolicyValidator interface implementation

62
weed/iam/policy/policy_engine.go

@ -474,6 +474,68 @@ func (e *PolicyEngine) EvaluateTrustPolicy(ctx context.Context, trustPolicy *Pol
return result, nil 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 // statementMatches checks if a statement matches the evaluation context
func (e *PolicyEngine) statementMatches(statement *Statement, evalCtx *EvaluationContext) bool { func (e *PolicyEngine) statementMatches(statement *Statement, evalCtx *EvaluationContext) bool {
// Check principal match (for trust policies) // Check principal match (for trust policies)

9
weed/iam/sts/session_claims.go

@ -31,6 +31,8 @@ type STSSessionClaims struct {
// Authorization data // Authorization data
Policies []string `json:"pol,omitempty"` // policies (abbreviated) Policies []string `json:"pol,omitempty"` // policies (abbreviated)
// SessionPolicy contains inline session policy JSON (optional)
SessionPolicy string `json:"spol,omitempty"`
// Identity provider information // Identity provider information
IdentityProvider string `json:"idp"` // identity_provider IdentityProvider string `json:"idp"` // identity_provider
@ -88,6 +90,7 @@ func (c *STSSessionClaims) ToSessionInfo() *SessionInfo {
AssumedRoleUser: c.AssumedRole, AssumedRoleUser: c.AssumedRole,
Principal: c.Principal, Principal: c.Principal,
Policies: c.Policies, Policies: c.Policies,
SessionPolicy: c.SessionPolicy,
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
IdentityProvider: c.IdentityProvider, IdentityProvider: c.IdentityProvider,
ExternalUserId: c.ExternalUserId, ExternalUserId: c.ExternalUserId,
@ -148,6 +151,12 @@ func (c *STSSessionClaims) WithPolicies(policies []string) *STSSessionClaims {
return c 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 // WithIdentityProvider sets identity provider information
func (c *STSSessionClaims) WithIdentityProvider(providerName, externalUserId, providerIssuer string) *STSSessionClaims { func (c *STSSessionClaims) WithIdentityProvider(providerName, externalUserId, providerIssuer string) *STSSessionClaims {
c.IdentityProvider = providerName c.IdentityProvider = providerName

3
weed/iam/sts/session_claims_test.go

@ -89,6 +89,7 @@ func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) {
expiresAt := time.Now().Add(2 * time.Hour) expiresAt := time.Now().Add(2 * time.Hour)
policies := []string{"policy1", "policy2"} policies := []string{"policy1", "policy2"}
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"]}]}`
requestContext := map[string]interface{}{ requestContext := map[string]interface{}{
"sourceIp": "192.168.1.1", "sourceIp": "192.168.1.1",
"userAgent": "test-agent", "userAgent": "test-agent",
@ -99,6 +100,7 @@ func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) {
WithRoleInfo("role-arn", "assumed-role", "principal"). WithRoleInfo("role-arn", "assumed-role", "principal").
WithIdentityProvider("provider", "external-id", "issuer"). WithIdentityProvider("provider", "external-id", "issuer").
WithPolicies(policies). WithPolicies(policies).
WithSessionPolicy(sessionPolicy).
WithRequestContext(requestContext). WithRequestContext(requestContext).
WithMaxDuration(2 * time.Hour) WithMaxDuration(2 * time.Hour)
@ -114,6 +116,7 @@ func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) {
assert.Equal(t, "external-id", sessionInfo.ExternalUserId) assert.Equal(t, "external-id", sessionInfo.ExternalUserId)
assert.Equal(t, "issuer", sessionInfo.ProviderIssuer) assert.Equal(t, "issuer", sessionInfo.ProviderIssuer)
assert.Equal(t, policies, sessionInfo.Policies) assert.Equal(t, policies, sessionInfo.Policies)
assert.Equal(t, sessionPolicy, sessionInfo.SessionPolicy)
assert.Equal(t, requestContext, sessionInfo.RequestContext) assert.Equal(t, requestContext, sessionInfo.RequestContext)
assert.WithinDuration(t, expiresAt, sessionInfo.ExpiresAt, 1*time.Second) assert.WithinDuration(t, expiresAt, sessionInfo.ExpiresAt, 1*time.Second)
} }

35
weed/iam/sts/session_policy.go

@ -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
}

228
weed/iam/sts/session_policy_test.go

@ -25,194 +25,88 @@ func createSessionPolicyTestJWT(t *testing.T, issuer, subject string) string {
return tokenString 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) { func TestAssumeRoleWithWebIdentity_SessionPolicy(t *testing.T) {
service := setupTestSTSService(t) service := setupTestSTSService(t)
ctx := context.Background()
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/*"
}]
}`
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::example-bucket/*"}]}`
testToken := createSessionPolicyTestJWT(t, "test-issuer", "test-user")
testToken := createSessionPolicyTestJWT(t, "test-issuer", "test-user")
request := &AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::role/TestRole",
WebIdentityToken: testToken,
RoleSessionName: "test-session",
Policy: &sessionPolicy,
}
request := &AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::role/TestRole",
WebIdentityToken: testToken,
RoleSessionName: "test-session",
DurationSeconds: nil, // Use default
Policy: &sessionPolicy, // ← Session policy provided
}
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
require.NoError(t, err)
require.NotNil(t, response)
// Should return an error indicating session policies are not supported
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
sessionInfo, err := service.ValidateSessionToken(ctx, response.Credentials.SessionToken)
require.NoError(t, err)
// 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")
})
normalized, err := NormalizeSessionPolicy(sessionPolicy)
require.NoError(t, err)
assert.Equal(t, normalized, sessionInfo.SessionPolicy)
t.Run("should_succeed_without_session_policy", func(t *testing.T) { t.Run("should_succeed_without_session_policy", func(t *testing.T) {
ctx := context.Background()
testToken := createSessionPolicyTestJWT(t, "test-issuer", "test-user")
request := &AssumeRoleWithWebIdentityRequest{ request := &AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::role/TestRole", RoleArn: "arn:aws:iam::role/TestRole",
WebIdentityToken: testToken,
WebIdentityToken: createSessionPolicyTestJWT(t, "test-issuer", "test-user"),
RoleSessionName: "test-session", RoleSessionName: "test-session",
DurationSeconds: nil, // Use default
Policy: nil, // ← No session policy
} }
// Should succeed without session policy
response, err := service.AssumeRoleWithWebIdentity(ctx, request) response, err := service.AssumeRoleWithWebIdentity(ctx, request)
// Verify success
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, response) 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)
sessionInfo, err := service.ValidateSessionToken(ctx, response.Credentials.SessionToken)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, response)
assert.NotNil(t, response.Credentials)
assert.Empty(t, sessionInfo.SessionPolicy)
}) })
}
t.Run("should_reject_empty_string_policy", func(t *testing.T) {
ctx := context.Background()
// Test edge case scenarios for the Policy field handling
func TestAssumeRoleWithWebIdentity_SessionPolicy_EdgeCases(t *testing.T) {
service := setupTestSTSService(t)
ctx := context.Background()
emptyPolicy := "" // Empty string, but still a non-nil pointer
t.Run("malformed_json_policy_rejected", func(t *testing.T) {
malformedPolicy := `{"Version": "2012-10-17", "Statement": [` // Incomplete JSON
request := &AssumeRoleWithWebIdentityRequest{ request := &AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::role/TestRole", RoleArn: "arn:aws:iam::role/TestRole",
WebIdentityToken: createSessionPolicyTestJWT(t, "test-issuer", "test-user"), WebIdentityToken: createSessionPolicyTestJWT(t, "test-issuer", "test-user"),
RoleSessionName: "test-session", RoleSessionName: "test-session",
Policy: &emptyPolicy, // ← Non-nil pointer to empty string
Policy: &malformedPolicy,
} }
// Should still reject because pointer is not nil
response, err := service.AssumeRoleWithWebIdentity(ctx, request) response, err := service.AssumeRoleWithWebIdentity(ctx, request)
assert.Error(t, err) assert.Error(t, err)
assert.Nil(t, response) assert.Nil(t, response)
assert.Contains(t, err.Error(), "session policies are not currently supported")
assert.Contains(t, err.Error(), "invalid session policy JSON")
}) })
}
// 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/"]
}
}
}
]
}`
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,
}
response, err := service.AssumeRoleWithWebIdentity(ctx, request)
// Verify error details
require.Error(t, err)
assert.Nil(t, response)
errorMsg := err.Error()
// 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")
// 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")
}
// Test edge case scenarios for the Policy field handling
func TestAssumeRoleWithWebIdentity_SessionPolicy_EdgeCases(t *testing.T) {
service := setupTestSTSService(t)
t.Run("malformed_json_policy_still_rejected", func(t *testing.T) {
ctx := context.Background()
malformedPolicy := `{"Version": "2012-10-17", "Statement": [` // Incomplete JSON
t.Run("invalid_policy_document_rejected", func(t *testing.T) {
invalidPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow"}]}`
request := &AssumeRoleWithWebIdentityRequest{ request := &AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::role/TestRole", RoleArn: "arn:aws:iam::role/TestRole",
WebIdentityToken: createSessionPolicyTestJWT(t, "test-issuer", "test-user"), WebIdentityToken: createSessionPolicyTestJWT(t, "test-issuer", "test-user"),
RoleSessionName: "test-session", RoleSessionName: "test-session",
Policy: &malformedPolicy,
Policy: &invalidPolicy,
} }
// Should reject before even parsing the policy JSON
response, err := service.AssumeRoleWithWebIdentity(ctx, request) response, err := service.AssumeRoleWithWebIdentity(ctx, request)
assert.Error(t, err) assert.Error(t, err)
assert.Nil(t, response) assert.Nil(t, response)
assert.Contains(t, err.Error(), "session policies are not currently supported")
assert.Contains(t, err.Error(), "invalid session policy document")
}) })
t.Run("policy_with_whitespace_still_rejected", func(t *testing.T) {
ctx := context.Background()
whitespacePolicy := " \t\n " // Only whitespace
t.Run("whitespace_policy_ignored", func(t *testing.T) {
whitespacePolicy := " \t\n "
request := &AssumeRoleWithWebIdentityRequest{ request := &AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::role/TestRole", RoleArn: "arn:aws:iam::role/TestRole",
@ -221,58 +115,54 @@ func TestAssumeRoleWithWebIdentity_SessionPolicy_EdgeCases(t *testing.T) {
Policy: &whitespacePolicy, Policy: &whitespacePolicy,
} }
// Should reject any non-nil policy, even whitespace
response, err := service.AssumeRoleWithWebIdentity(ctx, request) 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) { func TestAssumeRoleWithWebIdentity_PolicyFieldDocumentation(t *testing.T) {
// This test documents the current behavior and ensures the struct field
// exists with proper typing
request := &AssumeRoleWithWebIdentityRequest{} request := &AssumeRoleWithWebIdentityRequest{}
// Verify the Policy field exists and has the correct type
assert.IsType(t, (*string)(nil), request.Policy, assert.IsType(t, (*string)(nil), request.Policy,
"Policy field should be *string type for optional JSON 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, assert.Nil(t, request.Policy,
"Policy field should default to nil (no session 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"}` policyValue := `{"Version": "2012-10-17"}`
request.Policy = &policyValue request.Policy = &policyValue
assert.NotNil(t, request.Policy, "Should be able to assign policy value") assert.NotNil(t, request.Policy, "Should be able to assign policy value")
assert.Equal(t, policyValue, *request.Policy, "Policy value should be preserved") 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{ request := &AssumeRoleWithCredentialsRequest{
RoleArn: "arn:aws:iam::role/TestRole", RoleArn: "arn:aws:iam::role/TestRole",
Username: "testuser", Username: "testuser",
Password: "testpass", Password: "testpass",
RoleSessionName: "test-session", 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)
sessionInfo, err := service.ValidateSessionToken(ctx, response.Credentials.SessionToken)
require.NoError(t, err)
// 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
normalized, err := NormalizeSessionPolicy(sessionPolicy)
require.NoError(t, err)
assert.Equal(t, normalized, sessionInfo.SessionPolicy)
} }

29
weed/iam/sts/sts_service.go

@ -161,6 +161,9 @@ type AssumeRoleWithCredentialsRequest struct {
// DurationSeconds is the duration of the role session (optional) // DurationSeconds is the duration of the role session (optional)
DurationSeconds *int64 `json:"DurationSeconds,omitempty"` 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 // AssumeRoleResponse represents the response from assume role operations
@ -237,6 +240,9 @@ type SessionInfo struct {
// Policies are the policies associated with this session // Policies are the policies associated with this session
Policies []string `json:"policies"` 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 contains additional request context for policy evaluation
RequestContext map[string]interface{} `json:"requestContext,omitempty"` 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) return nil, fmt.Errorf("invalid request: %w", err)
} }
// Check for unsupported session policy
sessionPolicy := ""
if request.Policy != nil { 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 // 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, ""). WithIdentityProvider(provider.Name(), externalIdentity.UserID, "").
WithMaxDuration(sessionDuration). WithMaxDuration(sessionDuration).
WithRequestContext(requestContext) WithRequestContext(requestContext)
if sessionPolicy != "" {
sessionClaims.WithSessionPolicy(sessionPolicy)
}
// Generate self-contained JWT token with all session information // Generate self-contained JWT token with all session information
jwtToken, err := s.tokenGenerator.GenerateJWTWithClaims(sessionClaims) 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) 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 // 1. Get the specified provider
provider, exists := s.providers[request.ProviderName] provider, exists := s.providers[request.ProviderName]
if !exists { if !exists {
@ -565,6 +587,9 @@ func (s *STSService) AssumeRoleWithCredentials(ctx context.Context, request *Ass
WithRoleInfo(request.RoleArn, assumedRoleUser.Arn, assumedRoleUser.Arn). WithRoleInfo(request.RoleArn, assumedRoleUser.Arn, assumedRoleUser.Arn).
WithIdentityProvider(provider.Name(), externalIdentity.UserID, ""). WithIdentityProvider(provider.Name(), externalIdentity.UserID, "").
WithMaxDuration(sessionDuration) WithMaxDuration(sessionDuration)
if sessionPolicy != "" {
sessionClaims.WithSessionPolicy(sessionPolicy)
}
// Generate self-contained JWT token with all session information // Generate self-contained JWT token with all session information
jwtToken, err := s.tokenGenerator.GenerateJWTWithClaims(sessionClaims) jwtToken, err := s.tokenGenerator.GenerateJWTWithClaims(sessionClaims)

33
weed/iam/utils/arn_utils.go

@ -16,6 +16,9 @@ const (
// iamRoleMarker is the marker that identifies IAM role ARNs // iamRoleMarker is the marker that identifies IAM role ARNs
iamRoleMarker = "role/" iamRoleMarker = "role/"
// iamUserMarker is the marker that identifies IAM user ARNs
iamUserMarker = "user/"
) )
// ARNInfo contains structured information about a parsed AWS ARN. // ARNInfo contains structured information about a parsed AWS ARN.
@ -88,6 +91,36 @@ func ExtractRoleNameFromPrincipal(principal string) string {
return ExtractRoleNameFromArn(principal) 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. // ExtractRoleNameFromArn extracts the role name from an AWS IAM role ARN.
// //
// It handles both legacy and standard AWS IAM role ARN formats: // It handles both legacy and standard AWS IAM role ARN formats:

3
weed/s3api/s3api_server.go

@ -189,6 +189,9 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
if err != nil { if err != nil {
glog.Errorf("Failed to load IAM configuration: %v", err) glog.Errorf("Failed to load IAM configuration: %v", err)
} else { } else {
if iam.credentialManager != nil {
iamManager.SetUserStore(iam.credentialManager)
}
glog.V(1).Infof("IAM Manager loaded, creating integration") glog.V(1).Infof("IAM Manager loaded, creating integration")
// Create S3 IAM integration with the loaded IAM manager // Create S3 IAM integration with the loaded IAM manager
// filerAddress not actually used, just for backward compatibility // filerAddress not actually used, just for backward compatibility

70
weed/s3api/s3api_sts.go

@ -165,12 +165,25 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *
return 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 // Build request for STS service
request := &sts.AssumeRoleWithWebIdentityRequest{ request := &sts.AssumeRoleWithWebIdentityRequest{
RoleArn: roleArn, RoleArn: roleArn,
WebIdentityToken: webIdentityToken, WebIdentityToken: webIdentityToken,
RoleSessionName: roleSessionName, RoleSessionName: roleSessionName,
DurationSeconds: durationSeconds, DurationSeconds: durationSeconds,
Policy: sessionPolicyPtr,
} }
// Call STS service // Call STS service
@ -216,6 +229,8 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *
// handleAssumeRole handles the AssumeRole API action // handleAssumeRole handles the AssumeRole API action
// This requires AWS Signature V4 authentication // 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) { func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) {
// Extract parameters from form // Extract parameters from form
roleArn := r.FormValue("RoleArn") roleArn := r.FormValue("RoleArn")
@ -290,8 +305,16 @@ func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) {
return 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 // 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 { if err != nil {
h.writeSTSErrorResponse(w, r, STSErrInternalError, err) h.writeSTSErrorResponse(w, r, STSErrInternalError, err)
return return
@ -420,12 +443,19 @@ func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r
return 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 // Generate common STS components with LDAP-specific claims
modifyClaims := func(claims *sts.STSSessionClaims) { modifyClaims := func(claims *sts.STSSessionClaims) {
claims.WithIdentityProvider("ldap", identity.UserID, identity.Provider) 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 { if err != nil {
h.writeSTSErrorResponse(w, r, STSErrInternalError, err) h.writeSTSErrorResponse(w, r, STSErrInternalError, err)
return return
@ -445,7 +475,7 @@ func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r
// prepareSTSCredentials extracts common shared logic for credential generation // prepareSTSCredentials extracts common shared logic for credential generation
func (h *STSHandlers) prepareSTSCredentials(roleArn, roleSessionName string, 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 // Calculate duration
duration := time.Hour // Default 1 hour duration := time.Hour // Default 1 hour
@ -479,6 +509,10 @@ func (h *STSHandlers) prepareSTSCredentials(roleArn, roleSessionName string,
WithSessionName(roleSessionName). WithSessionName(roleSessionName).
WithRoleInfo(roleArn, fmt.Sprintf("%s:%s", roleName, roleSessionName), assumedRoleArn) WithRoleInfo(roleArn, fmt.Sprintf("%s:%s", roleName, roleSessionName), assumedRoleArn)
if sessionPolicy != "" {
claims.WithSessionPolicy(sessionPolicy)
}
// Apply custom claims if provided (e.g., LDAP identity) // Apply custom claims if provided (e.g., LDAP identity)
if modifyClaims != nil { if modifyClaims != nil {
modifyClaims(claims) modifyClaims(claims)
@ -582,13 +616,14 @@ type LDAPIdentityResult struct {
type STSErrorCode string type STSErrorCode string
const ( 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 // stsErrorResponses maps error codes to HTTP status and messages
@ -596,13 +631,14 @@ var stsErrorResponses = map[STSErrorCode]struct {
HTTPStatusCode int HTTPStatusCode int
Message string 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 // STSErrorResponse is the XML error response format

Loading…
Cancel
Save