mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-21 22:56:55 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user