sts: enforce session-policy explicit deny during role chaining (#10103)

* sts: enforce session-policy explicit deny during role chaining

A chained AssumeRole caller authenticates with an STS session token whose
inline session policy can explicitly deny sts:AssumeRole. The deny check only
evaluated the caller's named policies, so such a session could still chain into
any role its trust policy admits. Validate the session token in the deny check
and honor an explicit Deny in the inline session policy too.

* test(sts): integration coverage for AssumeRole authorization

Add an end-to-end AssumeRole authorization test (real weed mini + boto3):
a non-admin caller assumes a role its trust policy admits, an explicit
identity-side deny is blocked, and a session policy's explicit deny blocks
role chaining.

* sts: skip OIDC tokens and reject revoked sessions in the chaining deny check

Review follow-ups on the session-policy deny check:
- Guard session validation with !isOIDCToken so a bearer token our STS service
  cannot validate does not error into a false deny.
- Reject a revoked session before evaluating its policy, restoring the
  revocation enforcement the AssumeRole path lost when it stopped routing
  through IsActionAllowed.
This commit is contained in:
Chris Lu
2026-06-24 21:38:21 -07:00
committed by GitHub
parent 88a4a939aa
commit 3b9e196e5f
4 changed files with 329 additions and 14 deletions
@@ -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
+58 -12
View File
@@ -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)
+13 -2
View File
@@ -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
@@ -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) {