diff --git a/test/s3tables/sts_integration/sts_integration_test.go b/test/s3tables/sts_integration/sts_integration_test.go index 6b31eb53f..672043cab 100644 --- a/test/s3tables/sts_integration/sts_integration_test.go +++ b/test/s3tables/sts_integration/sts_integration_test.go @@ -55,6 +55,29 @@ func TestSTSIntegration(t *testing.T) { runPythonSTSClient(t, env) } +// TestSTSAssumeRoleAuthorization covers the AssumeRole authorization model: +// a non-admin caller may assume a role its trust policy admits, an explicit +// identity-side deny blocks it, and a session policy's explicit deny blocks +// role chaining. +func TestSTSAssumeRoleAuthorization(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + env := NewTestEnvironment(t) + defer env.Cleanup(t) + + if !env.dockerAvailable { + t.Skip("Docker not available, skipping STS integration test") + } + + fmt.Printf(">>> Starting SeaweedFS...\n") + env.StartSeaweedFS(t) + fmt.Printf(">>> SeaweedFS started.\n") + + runPythonAuthzClient(t, env) +} + func NewTestEnvironment(t *testing.T) *TestEnvironment { t.Helper() @@ -133,6 +156,21 @@ func (env *TestEnvironment) StartSeaweedFS(t *testing.T) { { "accessKey": "%s", "secretKey": "%s" } ], "actions": ["Admin", "Read", "Write", "List", "Tagging"] + }, + { + "name": "nonadmin", + "credentials": [ + { "accessKey": "nonadmin_key", "secretKey": "nonadmin_secret" } + ], + "actions": ["Read", "Write", "List", "Tagging"] + }, + { + "name": "denied", + "credentials": [ + { "accessKey": "denied_key", "secretKey": "denied_secret" } + ], + "actions": ["Read", "Write", "List", "Tagging"], + "policyNames": ["DenyAssumeRole"] } ], "sts": { @@ -158,6 +196,19 @@ func (env *TestEnvironment) StartSeaweedFS(t *testing.T) { } ] } + }, + { + "name": "DenyAssumeRole", + "document": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Deny", + "Action": ["sts:AssumeRole"], + "Resource": ["*"] + } + ] + } } ], "roles": [ @@ -409,4 +460,128 @@ except Exception as e: t.Logf("Python STS client output:\n%s", string(output)) } +func runPythonAuthzClient(t *testing.T, env *TestEnvironment) { + t.Helper() + + scriptContent := fmt.Sprintf(` +import boto3 +import botocore.config +from botocore.exceptions import ClientError +import json +import sys +import time +import urllib.error +import urllib.request + +print("Starting STS AssumeRole authorization test...") + +primary_endpoint = "http://host.docker.internal:%d" +fallback_endpoint = "http://%s:%d" +region = "us-east-1" +role_arn = "arn:aws:iam::role/TestRole" + +def wait_for_endpoint(url, timeout=30): + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=2): + return True + except urllib.error.HTTPError: + return True + except Exception: + time.sleep(1) + return False + +def select_endpoint(urls): + for url in urls: + if wait_for_endpoint(url): + return url + raise Exception("No reachable S3 endpoint from container") + +def fail(msg): + print("FAILED: " + msg) + sys.exit(1) + +try: + endpoint_url = select_endpoint([primary_endpoint, fallback_endpoint]) + print("Using endpoint " + endpoint_url) + config = botocore.config.Config(retries={'max_attempts': 0}, s3={'addressing_style': 'path'}) + + def sts_client(ak, sk, token=None): + return boto3.client('sts', endpoint_url=endpoint_url, aws_access_key_id=ak, + aws_secret_access_key=sk, aws_session_token=token, + region_name=region, config=config) + + # 1) Non-admin caller may assume a role its trust policy admits, without the + # Admin action or an identity-side sts:AssumeRole grant. + print("1) non-admin AssumeRole (expect success)") + resp = sts_client("nonadmin_key", "nonadmin_secret").assume_role( + RoleArn=role_arn, RoleSessionName="nonadmin") + if not resp.get("Credentials", {}).get("AccessKeyId"): + fail("non-admin AssumeRole did not return credentials") + print(" ok") + + # 2) A caller whose attached policy explicitly denies sts:AssumeRole is blocked, + # even though the trust policy admits it. + print("2) explicit identity deny (expect AccessDenied)") + try: + sts_client("denied_key", "denied_secret").assume_role( + RoleArn=role_arn, RoleSessionName="denied") + fail("explicit-deny caller unexpectedly assumed the role") + except ClientError as e: + code = e.response.get("Error", {}).get("Code", "") + if code != "AccessDenied": + fail("expected AccessDenied for identity deny, got " + code) + print(" ok") + + # 3) A session whose inline policy denies sts:AssumeRole cannot chain into + # another role, even though the trust policy admits the session. + print("3) session-policy deny blocks role chaining (expect AccessDenied)") + deny_session = json.dumps({"Version": "2012-10-17", "Statement": [ + {"Effect": "Deny", "Action": "sts:AssumeRole", "Resource": "*"}]}) + hop1 = sts_client("nonadmin_key", "nonadmin_secret").assume_role( + RoleArn=role_arn, RoleSessionName="hop1", Policy=deny_session)["Credentials"] + chained = sts_client(hop1["AccessKeyId"], hop1["SecretAccessKey"], hop1["SessionToken"]) + try: + chained.assume_role(RoleArn=role_arn, RoleSessionName="hop2") + fail("chained session unexpectedly assumed the role despite session-policy deny") + except ClientError as e: + code = e.response.get("Error", {}).get("Code", "") + if code != "AccessDenied": + fail("expected AccessDenied for session-policy deny, got " + code) + print(" ok") + + print("SUCCESS: AssumeRole authorization scenarios verified") + sys.exit(0) +except Exception as e: + print("FAILED: " + str(e)) + if hasattr(e, "response"): + print("Response: " + str(e.response)) + import traceback + traceback.print_exc() + sys.exit(1) +`, env.s3Port, env.bindIP, env.s3Port) + + scriptPath := filepath.Join(env.dataDir, "sts_authz_test.py") + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil { + t.Fatalf("Failed to write python script: %v", err) + } + + containerName := "seaweed-sts-authz-" + fmt.Sprintf("%d", time.Now().UnixNano()) + + cmd := exec.Command("docker", "run", "--rm", + "--name", containerName, + "--add-host", "host.docker.internal:host-gateway", + "-v", fmt.Sprintf("%s:/work", env.dataDir), + "python:3", + "/bin/bash", "-c", "pip install boto3 && python /work/sts_authz_test.py", + ) + + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Python STS authz client failed: %v\nOutput:\n%s", err, string(output)) + } + t.Logf("Python STS authz client output:\n%s", string(output)) +} + // Helpers copied from trino_catalog_test.go diff --git a/weed/iam/integration/iam_manager.go b/weed/iam/integration/iam_manager.go index ed73df5b9..228d53be7 100644 --- a/weed/iam/integration/iam_manager.go +++ b/weed/iam/integration/iam_manager.go @@ -1206,18 +1206,20 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest return true, nil } -// IsPrincipalActionExplicitlyDenied reports whether any of the named policies -// contains a statement that explicitly denies the action on the resource. Unlike +// IsPrincipalActionExplicitlyDenied reports whether the action on the resource is +// explicitly denied for the principal by either the named policies or, for a +// chained STS caller, the inline session policy carried by sessionToken. Unlike // IsActionAllowed it does not require an allow — the absence of a matching // statement is not a denial. Used to enforce AWS deny-always-wins when the allow // is granted elsewhere (e.g. a role trust policy for sts:AssumeRole). -func (m *IAMManager) IsPrincipalActionExplicitlyDenied(ctx context.Context, principal, action, resource string, policyNames []string, requestContext map[string]interface{}) (bool, error) { +// +// A chained session that fails validation or has been revoked yields an error so +// callers fail closed. Raw OIDC tokens are skipped here — they are validated on +// the JWT path, not by the STS service. +func (m *IAMManager) IsPrincipalActionExplicitlyDenied(ctx context.Context, principal, action, resource string, policyNames []string, sessionToken string, requestContext map[string]interface{}) (bool, error) { if !m.initialized { return false, fmt.Errorf("IAM manager not initialized") } - if len(policyNames) == 0 { - return false, nil - } if requestContext == nil { requestContext = make(map[string]interface{}) @@ -1232,18 +1234,62 @@ func (m *IAMManager) IsPrincipalActionExplicitlyDenied(ctx context.Context, prin RequestContext: requestContext, } - result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policyNames) - if err != nil { - return false, fmt.Errorf("policy evaluation failed: %w", err) - } - for _, stmt := range result.MatchingStatements { - if stmt.Effect == policy.EffectDeny { + // Base policies: the caller's attached identity policies, or for a chained + // caller the assumed role's attached policies. + if len(policyNames) > 0 { + result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policyNames) + if err != nil { + return false, fmt.Errorf("policy evaluation failed: %w", err) + } + if hasExplicitDeny(result.MatchingStatements) { return true, nil } } + + // A chained STS caller's session restricts what it may do. Skip raw OIDC + // tokens (validated on the JWT path); for our own session tokens, reject a + // revoked session and honor an explicit Deny in the inline session policy. + if sessionToken != "" && m.stsService != nil && !isOIDCToken(sessionToken) { + sessionInfo, err := m.stsService.ValidateSessionToken(ctx, sessionToken) + if err != nil { + return false, fmt.Errorf("session validation failed: %w", err) + } + if sessionInfo != nil && sessionInfo.SessionId != "" { + revoked, rerr := m.IsSessionRevoked(ctx, sessionInfo.SessionId) + if rerr != nil { + return false, fmt.Errorf("revocation check failed: %w", rerr) + } + if revoked { + return false, fmt.Errorf("session has been revoked") + } + } + 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) + } + result, 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 hasExplicitDeny(result.MatchingStatements) { + return true, nil + } + } + } return false, nil } +// hasExplicitDeny reports whether any matched statement is a Deny. +func hasExplicitDeny(matches []policy.StatementMatch) bool { + for _, stmt := range matches { + if stmt.Effect == policy.EffectDeny { + return true + } + } + return false +} + // ValidateTrustPolicy validates if a principal can assume a role (for testing) func (m *IAMManager) ValidateTrustPolicy(ctx context.Context, roleArn, provider, userID string) bool { roleName := utils.ExtractRoleNameFromArn(roleArn) diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index 252f11f97..235f1335b 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -2329,7 +2329,18 @@ func (iam *IdentityAccessManagement) isActionExplicitlyDeniedByIAM(r *http.Reque } } iam.m.RUnlock() - if len(policyNames) == 0 { + + // A chained caller authenticates with an STS session token whose inline + // session policy can also carry an explicit deny. + sessionToken := r.Header.Get(s3_constants.SeaweedFSSessionTokenHeader) + if sessionToken == "" { + sessionToken = r.Header.Get("X-Amz-Security-Token") + if sessionToken == "" { + sessionToken = r.URL.Query().Get("X-Amz-Security-Token") + } + } + + if len(policyNames) == 0 && sessionToken == "" { return false } @@ -2337,7 +2348,7 @@ func (iam *IdentityAccessManagement) isActionExplicitlyDeniedByIAM(r *http.Reque if manager == nil { return false } - denied, err := manager.IsPrincipalActionExplicitlyDenied(r.Context(), principal, action, resource, policyNames, extractRequestContext(r)) + denied, err := manager.IsPrincipalActionExplicitlyDenied(r.Context(), principal, action, resource, policyNames, sessionToken, extractRequestContext(r)) if err != nil { glog.Warningf("AssumeRole explicit-deny check failed for %s, denying: %v", identity.Name, err) return true diff --git a/weed/s3api/s3api_sts_assume_role_authz_test.go b/weed/s3api/s3api_sts_assume_role_authz_test.go index d83315653..c293f4d48 100644 --- a/weed/s3api/s3api_sts_assume_role_authz_test.go +++ b/weed/s3api/s3api_sts_assume_role_authz_test.go @@ -23,6 +23,7 @@ import ( func TestAssumeRole_NonAdminCallerAuthorizedByTrustPolicy(t *testing.T) { ctx := context.Background() manager := newTestSTSIntegrationManager(t) + manager.SetSessionRevocationStore(integration.NewMemorySessionRevocationStore()) require.NoError(t, manager.CreatePolicy(ctx, "", "WarehouseAccess", &policy.PolicyDocument{ Version: "2012-10-17", @@ -78,6 +79,26 @@ func TestAssumeRole_NonAdminCallerAuthorizedByTrustPolicy(t *testing.T) { return rec } + // assumeWithSessionCreds chains: it signs an AssumeRole request with temporary + // session credentials and forwards the session token (role chaining). + assumeWithSessionCreds := func(t *testing.T, creds STSCredentials, roleName string) *httptest.ResponseRecorder { + t.Helper() + body := url.Values{ + "Action": {"AssumeRole"}, + "Version": {"2011-06-15"}, + "RoleArn": {"arn:aws:iam::" + defaultAccountID + ":role/" + roleName}, + "RoleSessionName": {"chained"}, + }.Encode() + req, err := newTestRequest(http.MethodPost, "http://sts.seaweedfs.test/", int64(len(body)), strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("X-Amz-Security-Token", creds.SessionToken) + require.NoError(t, signRequestV4(req, creds.AccessKeyId, creds.SecretAccessKey)) + rec := httptest.NewRecorder() + stsHandlers.handleAssumeRole(rec, req) + return rec + } + t.Run("trust policy admits the caller", func(t *testing.T) { require.NoError(t, manager.CreateRole(ctx, "", "OpenWarehouse", &integration.RoleDefinition{ RoleName: "OpenWarehouse", @@ -151,6 +172,68 @@ func TestAssumeRole_NonAdminCallerAuthorizedByTrustPolicy(t *testing.T) { rec := assume(t, denyAccessKey, denySecretKey, "DenyTestWarehouse") assert.Equal(t, http.StatusForbidden, rec.Code, "explicit identity-side deny must block AssumeRole even when the trust policy admits the caller") }) + + t.Run("session policy explicit deny blocks role chaining", func(t *testing.T) { + require.NoError(t, manager.CreateRole(ctx, "", "ChainWarehouse", &integration.RoleDefinition{ + RoleName: "ChainWarehouse", + TrustPolicy: &policy.PolicyDocument{ + Version: "2012-10-17", + Statement: []policy.Statement{{Effect: "Allow", Principal: "*", Action: []string{"sts:AssumeRole"}}}, + }, + AttachedPolicies: []string{"WarehouseAccess"}, + })) + chainArn := "arn:aws:iam::" + defaultAccountID + ":role/ChainWarehouse" + + // First hop succeeds, with a session policy that denies sts:AssumeRole. + denySession := `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"sts:AssumeRole","Resource":"*"}]}` + body := url.Values{ + "Action": {"AssumeRole"}, + "Version": {"2011-06-15"}, + "RoleArn": {chainArn}, + "RoleSessionName": {"hop1"}, + "Policy": {denySession}, + }.Encode() + req, err := newTestRequest(http.MethodPost, "http://sts.seaweedfs.test/", int64(len(body)), strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + require.NoError(t, signRequestV4(req, accessKey, secretKey)) + rec := httptest.NewRecorder() + stsHandlers.handleAssumeRole(rec, req) + require.Equal(t, http.StatusOK, rec.Code, "first hop should succeed: %s", rec.Body.String()) + var hop1 AssumeRoleResponse + require.NoError(t, xml.Unmarshal(rec.Body.Bytes(), &hop1)) + require.NotEmpty(t, hop1.Result.Credentials.SessionToken) + + // Second hop reuses the session credentials to chain-assume; the session + // policy's explicit deny must block it even though the trust policy admits. + rec2 := assumeWithSessionCreds(t, hop1.Result.Credentials, "ChainWarehouse") + assert.Equal(t, http.StatusForbidden, rec2.Code, "session policy explicit deny must block role chaining: %s", rec2.Body.String()) + }) + + t.Run("revoked chained session cannot assume", func(t *testing.T) { + require.NoError(t, manager.CreateRole(ctx, "", "RevokeWarehouse", &integration.RoleDefinition{ + RoleName: "RevokeWarehouse", + TrustPolicy: &policy.PolicyDocument{ + Version: "2012-10-17", + Statement: []policy.Statement{{Effect: "Allow", Principal: "*", Action: []string{"sts:AssumeRole"}}}, + }, + AttachedPolicies: []string{"WarehouseAccess"}, + })) + + rec := assume(t, accessKey, secretKey, "RevokeWarehouse") + require.Equal(t, http.StatusOK, rec.Code, "first hop should succeed: %s", rec.Body.String()) + var hop1 AssumeRoleResponse + require.NoError(t, xml.Unmarshal(rec.Body.Bytes(), &hop1)) + + // Revoke the session, then chaining with it must be blocked. + session, err := manager.GetSTSService().ValidateSessionToken(ctx, hop1.Result.Credentials.SessionToken) + require.NoError(t, err) + require.NotEmpty(t, session.SessionId) + require.NoError(t, manager.RevokeSession(ctx, session.SessionId, session.ExpiresAt, "test")) + + rec2 := assumeWithSessionCreds(t, hop1.Result.Credentials, "RevokeWarehouse") + assert.Equal(t, http.StatusForbidden, rec2.Code, "a revoked session must not be able to chain-assume") + }) } func TestCallerPrincipalArn(t *testing.T) {