From d42d9cfd93c5145bc2364fa9944b6440ecf5400d Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Sun, 4 Oct 2026 19:21:47 +0530 Subject: [PATCH] fix(sts): enforce AssumeRole trust with the real caller Under --enforce-auth the role trust policy is now evaluated for the signed caller, with principal types, NotPrincipal and conditions. Session credentials are limited to the STS and IAM calls AWS allows each kind, signed web identity and SAML calls are refused, and a RoleArn with another account or path no longer assumes the local role. --- contrib/server/README.md | 14 + contrib/server/enforce_authz_test.go | 94 +++++ docs/coverage/aws/iam.md | 8 + docs/coverage/coverage.json | 9 + providers/aws/iam/condition.go | 50 ++- providers/aws/iam/evaluate_trust_test.go | 148 +++++++ providers/aws/iam/trust_policy.go | 169 +++++++- server/aws/authgate.go | 20 +- server/aws/authz_matrix_test.go | 152 ++++++- server/aws/authzgate.go | 39 +- server/aws/aws.go | 6 + server/aws/publicauth_test.go | 56 --- server/aws/sts/authz.go | 45 +- server/aws/sts/authz_test.go | 22 +- server/aws/sts/handler.go | 7 + server/aws/sts/operations.go | 69 ++- server/aws/sts/sessions.go | 62 ++- server/aws/sts/trust.go | 245 +++++++++++ server/aws/sts/trust_enforced_test.go | 513 +++++++++++++++++++++++ server/serveflags/serveflags.go | 3 +- services/iam/driver/driver.go | 35 ++ 21 files changed, 1645 insertions(+), 121 deletions(-) create mode 100644 providers/aws/iam/evaluate_trust_test.go create mode 100644 server/aws/sts/trust.go create mode 100644 server/aws/sts/trust_enforced_test.go diff --git a/contrib/server/README.md b/contrib/server/README.md index 26e932801..0d749e60a 100644 --- a/contrib/server/README.md +++ b/contrib/server/README.md @@ -188,6 +188,20 @@ actually run the request, so neither the SigV4 signing scope nor a forged - The account root and IAM users with no policies are unrestricted, so a freshly created user can bootstrap others. Role sessions are always evaluated on the role's policies. +- `sts:AssumeRole` is decided by the role's trust policy for the real caller. + A trust that names the caller's ARN is enough on its own; a trust that names + the account (`arn:aws:iam::ACCOUNT:root`) also needs an identity policy that + allows `sts:AssumeRole`. Trust conditions such as `sts:ExternalId` are + checked, passing tags needs `sts:TagSession` and passing a source identity + needs `sts:SetSourceIdentity`. The `RoleArn` must match the role's ARN, + including its account and path. +- Temporary credentials are limited like in AWS: `GetFederationToken` + credentials cannot call IAM or STS (except `GetCallerIdentity`), + `GetSessionToken` credentials cannot call IAM or STS (except `AssumeRole` + and `GetCallerIdentity`), and role sessions cannot call `GetSessionToken` or + `GetFederationToken`. +- Signed `AssumeRoleWithWebIdentity` and `AssumeRoleWithSAML` calls are + refused, because the token or assertion is not validated yet. - Operations AWS serves without credentials (Cognito sign-in, API Gateway invoke) and the Kubernetes data plane are not IAM-authorized. The `/_cloudemu/*` admin endpoints use the admin token instead (next section). diff --git a/contrib/server/enforce_authz_test.go b/contrib/server/enforce_authz_test.go index 93717ca8f..bff36d5c7 100644 --- a/contrib/server/enforce_authz_test.go +++ b/contrib/server/enforce_authz_test.go @@ -19,6 +19,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/autoscaling" "github.com/aws/aws-sdk-go-v2/service/dynamodb" + ddbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/aws/aws-sdk-go-v2/service/ec2" ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/aws-sdk-go-v2/service/iam" @@ -323,6 +324,99 @@ func TestEnforceAuthAuthorizesQueryAndREST(t *testing.T) { }) } +// TestEnforceAuthAssumeRoleTrust checks AssumeRole under --enforce-auth is +// decided by the role's trust policy for the real caller: ExternalId is +// enforced, a role trusting someone else is refused, and the session gets the +// role's policies. +func TestEnforceAuthAssumeRoleTrust(t *testing.T) { + endpoint, stop := enforceAuthServer(t) + defer stop() + + ctx := context.Background() + boot := clientsFor(t, endpoint, seedBootUser(t, endpoint)) + caller := boot.newUser(t, "caller", allowDoc("dynamodb:ListTables")) + + got, err := boot.iam.GetUser(ctx, &iam.GetUserInput{UserName: aws.String("caller")}) + wantOK(t, "GetUser", err) + + callerARN := aws.ToString(got.User.Arn) + otherARN := strings.TrimSuffix(callerARN, "caller") + "other" + trusts := map[string]string{ + "withext": `{"Effect":"Allow","Principal":{"AWS":"` + callerARN + `"},"Action":"sts:AssumeRole",` + + `"Condition":{"StringEquals":{"sts:ExternalId":"ext-1"}}}`, + "someoneelse": `{"Effect":"Allow","Principal":{"AWS":"` + otherARN + `"},"Action":"sts:AssumeRole"}`, + } + + arns := map[string]string{} + + for name, stmt := range trusts { + out, err := boot.iam.CreateRole(ctx, &iam.CreateRoleInput{ + RoleName: aws.String(name), AssumeRolePolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[` + stmt + `]}`), + }) + wantOK(t, "CreateRole "+name, err) + + arns[name] = aws.ToString(out.Role.Arn) + } + + _, err = boot.iam.PutRolePolicy(ctx, &iam.PutRolePolicyInput{ + RoleName: aws.String("withext"), PolicyName: aws.String("ddb"), PolicyDocument: aws.String(allowDoc("dynamodb:*")), + }) + wantOK(t, "PutRolePolicy", err) + + assume := func(role, externalID string) (int, string) { + form := url.Values{"Action": {"AssumeRole"}, "Version": {"2011-06-15"}, "RoleArn": {arns[role]}, "RoleSessionName": {"s"}} + if externalID != "" { + form.Set("ExternalId", externalID) + } + + return signedForm(t, endpoint, caller, "sts", form) + } + + if status, body := assume("withext", ""); status != http.StatusForbidden || !strings.Contains(body, "AccessDenied") { + t.Fatalf("AssumeRole without ExternalId: %d %s", status, body) + } + + if status, body := assume("someoneelse", ""); status != http.StatusForbidden { + t.Fatalf("AssumeRole of a role trusting another user: %d %s", status, body) + } + + status, body := assume("withext", "ext-1") + if status != http.StatusOK { + t.Fatalf("AssumeRole with ExternalId: %d %s", status, body) + } + + field := func(name string) string { + _, rest, _ := strings.Cut(body, "<"+name+">") + v, _, _ := strings.Cut(rest, "") + + return v + } + + session := aws.Credentials{ + AccessKeyID: field("AccessKeyId"), SecretAccessKey: field("SecretAccessKey"), SessionToken: field("SessionToken"), + } + + cfg, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithRegion("us-east-1"), awsconfig.WithRetryMaxAttempts(1), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + session.AccessKeyID, session.SecretAccessKey, session.SessionToken)), + ) + wantOK(t, "session config", err) + + ddb := dynamodb.NewFromConfig(cfg, func(o *dynamodb.Options) { o.BaseEndpoint = aws.String(endpoint) }) + _, err = ddb.CreateTable(ctx, &dynamodb.CreateTableInput{ + TableName: aws.String("t1"), + AttributeDefinitions: []ddbtypes.AttributeDefinition{{AttributeName: aws.String("pk"), AttributeType: ddbtypes.ScalarAttributeTypeS}}, + KeySchema: []ddbtypes.KeySchemaElement{{AttributeName: aws.String("pk"), KeyType: ddbtypes.KeyTypeHash}}, + BillingMode: ddbtypes.BillingModePayPerRequest, + }) + wantOK(t, "CreateTable with the role session", err) + + roleIAM := iam.NewFromConfig(cfg, func(o *iam.Options) { o.BaseEndpoint = aws.String(endpoint) }) + _, err = roleIAM.ListUsers(ctx, &iam.ListUsersInput{}) + wantCode(t, "ListUsers with the role session", err, "AccessDenied") +} + // signedForm sends a SigV4-signed query-protocol POST and returns the status // and body. func signedForm(t *testing.T, endpoint string, c aws.Credentials, service string, form url.Values) (int, string) { diff --git a/docs/coverage/aws/iam.md b/docs/coverage/aws/iam.md index 08be62176..c82ea09b2 100644 --- a/docs/coverage/aws/iam.md +++ b/docs/coverage/aws/iam.md @@ -93,6 +93,14 @@ PolicyInspector is an optional capability: an IAM implementation that can | --- | --- | | `PrincipalHasPolicies` | | +### TrustEvaluator + +TrustEvaluator is an optional capability: an IAM implementation that + +| Operation | Description | +| --- | --- | +| `EvaluateTrust` | | + ## Not in scope _Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index 4265e9981..c7c2dbd3f 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -9100,6 +9100,15 @@ "name": "PrincipalHasPolicies" } ] + }, + { + "name": "TrustEvaluator", + "doc": "TrustEvaluator is an optional capability: an IAM implementation that", + "operations": [ + { + "name": "EvaluateTrust" + } + ] } ], "providers": { diff --git a/providers/aws/iam/condition.go b/providers/aws/iam/condition.go index efc73ea2d..d7f1d2061 100644 --- a/providers/aws/iam/condition.go +++ b/providers/aws/iam/condition.go @@ -5,6 +5,8 @@ import ( "strconv" "strings" "time" + + "github.com/stackshy/cloudemu/v2/services/iam/driver" ) // ConditionContext carries the request condition keys available for policy @@ -81,7 +83,8 @@ func evaluateConditionsWith(conds map[string]map[string]any, cctx ConditionConte // key presence, not the key's value. A non-IAM absent rule overrides all of // that for a missing key. func evaluateConditionKey(rawOp, key string, values []string, cctx ConditionContext, absent absentKey) bool { - base, ifExists := splitIfExists(rawOp) + set, op := splitSetOperator(rawOp) + base, ifExists := splitIfExists(op) ctxVal, present := cctx.get(key) @@ -94,14 +97,59 @@ func evaluateConditionKey(rawOp, key string, values []string, cctx ConditionCont } if !present { + // ForAllValues is vacuously true for a missing key, ForAnyValue is false. + if set != "" { + return set == setForAll + } + // A missing key never matches a plain condition; the ...IfExists variant // passes so the statement is gated only when the key is actually supplied. return ifExists } + if set != "" { + return evalSetOperator(set, base, ctxVal, values) + } + return evalPresentOperator(base, ctxVal, values) } +// The set-operator qualifiers for multivalued condition keys. +const ( + setForAll = "ForAllValues" + setForAny = "ForAnyValue" +) + +// splitSetOperator strips a "ForAllValues:" or "ForAnyValue:" qualifier from +// an operator, returning the qualifier ("" when there is none) and the rest. +func splitSetOperator(op string) (set, rest string) { + for _, q := range []string{setForAll, setForAny} { + if after, ok := strings.CutPrefix(op, q+":"); ok { + return q, after + } + } + + return "", op +} + +// evalSetOperator applies op to each value of a multivalued key, whose values +// are joined by driver.ConditionValueSeparator. ForAllValues needs every +// request value to satisfy op, ForAnyValue at least one. +func evalSetOperator(set, op, ctxVal string, values []string) bool { + for _, v := range strings.Split(ctxVal, driver.ConditionValueSeparator) { + ok := evalPresentOperator(op, v, values) + if set == setForAny && ok { + return true + } + + if set == setForAll && !ok { + return false + } + } + + return set == setForAll +} + // evalPresentOperator evaluates an operator whose key is present. String-shaped // operators (String*, Arn*, Ip*, Bool) share the value-comparator path; the // numeric and date families parse their operands. An unrecognized operator diff --git a/providers/aws/iam/evaluate_trust_test.go b/providers/aws/iam/evaluate_trust_test.go new file mode 100644 index 000000000..6020a619b --- /dev/null +++ b/providers/aws/iam/evaluate_trust_test.go @@ -0,0 +1,148 @@ +package iam + +import ( + "context" + "testing" + + "github.com/stackshy/cloudemu/v2/services/iam/driver" +) + +const ( + trustAcct = "123456789012" + trustUserARN = "arn:aws:iam::" + trustAcct + ":user/alice" + trustRoleARN = "arn:aws:iam::" + trustAcct + ":role/team/chain" + trustSessARN = "arn:aws:sts::" + trustAcct + ":assumed-role/chain/s1" +) + +func trustDoc(statements string) string { + return `{"Version":"2012-10-17","Statement":[` + statements + `]}` +} + +func allowStmt(principal string) string { + return `{"Effect":"Allow","Principal":` + principal + `,"Action":"sts:AssumeRole"}` +} + +func TestEvaluateTrust(t *testing.T) { + user := []string{trustUserARN} + session := []string{trustRoleARN, trustSessARN} + + cases := []struct { + name string + doc string + callers []string + account string + action string + cctx map[string]string + want driver.TrustResult + }{ + {"exact user ARN is named directly", trustDoc(allowStmt(`{"AWS":"` + trustUserARN + `"}`)), + user, trustAcct, "", nil, driver.TrustResult{RoleExists: true, Allow: true, NamedDirectly: true}}, + {"account root ARN matches without naming", trustDoc(allowStmt(`{"AWS":"arn:aws:iam::` + trustAcct + `:root"}`)), + user, trustAcct, "", nil, driver.TrustResult{RoleExists: true, Allow: true}}, + {"bare account id matches without naming", trustDoc(allowStmt(`{"AWS":"` + trustAcct + `"}`)), + user, trustAcct, "", nil, driver.TrustResult{RoleExists: true, Allow: true}}, + {"another account's root does not match", trustDoc(allowStmt(`{"AWS":"arn:aws:iam::999999999999:root"}`)), + user, trustAcct, "", nil, driver.TrustResult{RoleExists: true}}, + {"another user does not match", trustDoc(allowStmt(`{"AWS":"arn:aws:iam::` + trustAcct + `:user/bob"}`)), + user, trustAcct, "", nil, driver.TrustResult{RoleExists: true}}, + {"AWS wildcard matches without naming", trustDoc(allowStmt(`{"AWS":"*"}`)), + user, trustAcct, "", nil, driver.TrustResult{RoleExists: true, Allow: true}}, + {"string wildcard matches without naming", trustDoc(allowStmt(`"*"`)), + user, trustAcct, "", nil, driver.TrustResult{RoleExists: true, Allow: true}}, + {"no ARN wildcarding", trustDoc(allowStmt(`{"AWS":"arn:aws:iam::` + trustAcct + `:user/*"}`)), + user, trustAcct, "", nil, driver.TrustResult{RoleExists: true}}, + {"Federated star never matches a signed caller", trustDoc(allowStmt(`{"Federated":"*"}`)), + user, trustAcct, "", nil, driver.TrustResult{RoleExists: true}}, + {"Service star never matches a signed caller", trustDoc(allowStmt(`{"Service":"*"}`)), + user, trustAcct, "", nil, driver.TrustResult{RoleExists: true}}, + {"CanonicalUser never matches", trustDoc(allowStmt(`{"CanonicalUser":"*"}`)), + user, trustAcct, "", nil, driver.TrustResult{RoleExists: true}}, + {"role ARN names a role session", trustDoc(allowStmt(`{"AWS":"` + trustRoleARN + `"}`)), + session, trustAcct, "", nil, driver.TrustResult{RoleExists: true, Allow: true, NamedDirectly: true}}, + {"explicit deny", trustDoc(allowStmt(`"*"`) + `,{"Effect":"Deny","Principal":{"AWS":"` + trustUserARN + + `"},"Action":"sts:AssumeRole"}`), user, trustAcct, "", nil, + driver.TrustResult{RoleExists: true, Allow: true, ExplicitDeny: true}}, + {"NotPrincipal deny hits a caller it does not list", trustDoc(allowStmt(`"*"`) + + `,{"Effect":"Deny","NotPrincipal":{"AWS":["arn:aws:iam::` + trustAcct + `:user/bob","` + trustAcct + `"]},` + + `"Action":"sts:AssumeRole"}`), user, trustAcct, "", nil, + driver.TrustResult{RoleExists: true, Allow: true, ExplicitDeny: true}}, + {"NotPrincipal deny spares a caller it lists with the account", trustDoc(allowStmt(`"*"`) + + `,{"Effect":"Deny","NotPrincipal":{"AWS":["` + trustUserARN + `","arn:aws:iam::` + trustAcct + `:root"]},` + + `"Action":"sts:AssumeRole"}`), user, trustAcct, "", nil, + driver.TrustResult{RoleExists: true, Allow: true}}, + {"NotPrincipal must list both the role and the session", trustDoc(allowStmt(`"*"`) + + `,{"Effect":"Deny","NotPrincipal":{"AWS":["` + trustRoleARN + `","` + trustAcct + `"]},` + + `"Action":"sts:AssumeRole"}`), session, trustAcct, "", nil, + driver.TrustResult{RoleExists: true, Allow: true, ExplicitDeny: true}}, + {"NotPrincipal never grants", trustDoc(`{"Effect":"Allow","NotPrincipal":{"AWS":"arn:aws:iam::1:user/x"},` + + `"Action":"sts:AssumeRole"}`), user, trustAcct, "", nil, driver.TrustResult{RoleExists: true}}, + {"ExternalId condition met", trustDoc(`{"Effect":"Allow","Principal":{"AWS":"` + trustUserARN + `"},` + + `"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"x-1"}}}`), user, trustAcct, "", + map[string]string{"sts:ExternalId": "x-1"}, driver.TrustResult{RoleExists: true, Allow: true, NamedDirectly: true}}, + {"ExternalId condition missing", trustDoc(`{"Effect":"Allow","Principal":{"AWS":"` + trustUserARN + `"},` + + `"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"x-1"}}}`), user, trustAcct, "", + nil, driver.TrustResult{RoleExists: true}}, + {"tag keys any value", trustDoc(`{"Effect":"Allow","Principal":"*","Action":"sts:TagSession",` + + `"Condition":{"ForAnyValue:StringEquals":{"aws:TagKeys":"team"}}}`), user, trustAcct, "sts:TagSession", + map[string]string{"aws:TagKeys": "env" + driver.ConditionValueSeparator + "team"}, + driver.TrustResult{RoleExists: true, Allow: true}}, + {"tag keys all values", trustDoc(`{"Effect":"Allow","Principal":"*","Action":"sts:TagSession",` + + `"Condition":{"ForAllValues:StringEquals":{"aws:TagKeys":["team"]}}}`), user, trustAcct, "sts:TagSession", + map[string]string{"aws:TagKeys": "env" + driver.ConditionValueSeparator + "team"}, + driver.TrustResult{RoleExists: true}}, + {"the statement must cover the action", trustDoc(allowStmt(`"*"`)), user, trustAcct, "sts:TagSession", nil, + driver.TrustResult{RoleExists: true}}, + {"role tags are resource tags", trustDoc(`{"Effect":"Allow","Principal":"*","Action":"sts:AssumeRole",` + + `"Condition":{"StringEquals":{"aws:ResourceTag/tier":"gold"}}}`), user, trustAcct, "", nil, + driver.TrustResult{RoleExists: true, Allow: true}}, + {"malformed document allows nothing", "{", user, trustAcct, "", nil, driver.TrustResult{RoleExists: true}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + _, err := m.CreateRole(ctx, driver.RoleConfig{ + Name: "target", AssumeRolePolicyDoc: tc.doc, Tags: map[string]string{"tier": "gold"}, + }) + requireNoError(t, err) + + action := tc.action + if action == "" { + action = "sts:AssumeRole" + } + + got := m.EvaluateTrust(ctx, &driver.TrustRequest{ + RoleName: "target", Action: action, CallerARNs: tc.callers, CallerAccount: tc.account, Context: tc.cctx, + }) + assertEqual(t, tc.want, got) + }) + } +} + +func TestEvaluateTrustMissingRole(t *testing.T) { + m := newTestMock() + + got := m.EvaluateTrust(context.Background(), &driver.TrustRequest{ + RoleName: "ghost", Action: "sts:AssumeRole", CallerARNs: []string{trustUserARN}, CallerAccount: trustAcct, + }) + assertEqual(t, driver.TrustResult{}, got) +} + +// TestEvaluateAssumeRoleTrustLegacyUnchanged pins the auth-off evaluation: it +// ignores conditions and principal types, as it always has. +func TestEvaluateAssumeRoleTrustLegacyUnchanged(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + _, err := m.CreateRole(ctx, driver.RoleConfig{ + Name: "legacy", + AssumeRolePolicyDoc: trustDoc(`{"Effect":"Allow","Principal":{"AWS":"` + rootCaller + `"},` + + `"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"x"}}}`), + }) + requireNoError(t, err) + + _, allowed := m.EvaluateAssumeRoleTrust(ctx, "legacy", rootCaller) + assertEqual(t, true, allowed) +} diff --git a/providers/aws/iam/trust_policy.go b/providers/aws/iam/trust_policy.go index 64f97db9e..1f52716ee 100644 --- a/providers/aws/iam/trust_policy.go +++ b/providers/aws/iam/trust_policy.go @@ -3,7 +3,10 @@ package iam import ( "context" "encoding/json" + "slices" "strings" + + "github.com/stackshy/cloudemu/v2/services/iam/driver" ) // assumeRoleAction is the action a trust policy must allow for a principal to @@ -19,9 +22,169 @@ type trustPolicyDoc struct { } type trustStatement struct { - Effect string `json:"Effect"` - Action any `json:"Action"` - Principal any `json:"Principal"` + Effect string `json:"Effect"` + Action any `json:"Action"` + Principal any `json:"Principal"` + NotPrincipal any `json:"NotPrincipal"` + Condition map[string]map[string]any `json:"Condition"` +} + +// EvaluateTrust evaluates the trust policy of req.RoleName for a real caller. +// It implements driver.TrustEvaluator. +// +// Only an "AWS" principal (or the string "*") can match a caller that signed +// with SigV4; "Service", "Federated" and "CanonicalUser" never do. Within +// "AWS", "*" matches anyone, the account root ARN or a bare account id +// matches any caller in that account, and an exact caller ARN names the +// caller directly. ARNs are not wildcard-matched. A Deny with NotPrincipal +// applies to every caller it does not list (see notPrincipalExcludes). +// Conditions are evaluated against req.Context plus the role's tags as +// aws:ResourceTag/. +// +// The decision between trust and identity policies is left to the caller, +// which needs NamedDirectly for it: within one account a trust policy that +// names the caller's ARN is enough on its own, while one that names the +// account still needs an identity-based allow. +func (m *Mock) EvaluateTrust(_ context.Context, req *driver.TrustRequest) driver.TrustResult { + r, ok := m.roles.Get(req.RoleName) + if !ok { + return driver.TrustResult{} + } + + res := driver.TrustResult{RoleExists: true} + + var pd trustPolicyDoc + if err := json.Unmarshal([]byte(r.AssumeRolePolicyDoc), &pd); err != nil { + return res + } + + cctx := trustConditionContext(req.Context, r.Tags) + + for i := range pd.Statement { + stmt := &pd.Statement[i] + if !matchesAction(toStringSlice(stmt.Action), req.Action) { + continue + } + + deny := strings.EqualFold(stmt.Effect, "Deny") + + matched, named := stmt.callerMatches(req, deny) + if !matched || !evaluateConditions(stmt.Condition, cctx) { + continue + } + + switch { + case deny: + res.ExplicitDeny = true + case strings.EqualFold(stmt.Effect, "Allow"): + res.Allow = true + res.NamedDirectly = res.NamedDirectly || named + } + } + + return res +} + +// trustConditionContext is the request context plus the role's tags. +func trustConditionContext(reqCtx, roleTags map[string]string) ConditionContext { + cctx := make(ConditionContext, len(reqCtx)+len(roleTags)) + + for k, v := range roleTags { + cctx["aws:ResourceTag/"+k] = v + } + + for k, v := range reqCtx { + cctx[k] = v + } + + return cctx +} + +// callerMatches reports whether the statement applies to the caller, and +// whether it names one of the caller's ARNs exactly. NotPrincipal is only +// honored on a Deny; an Allow with NotPrincipal grants nothing. +func (s *trustStatement) callerMatches(req *driver.TrustRequest, deny bool) (matched, named bool) { + if s.Principal != nil { + return awsPrincipalMatches(s.Principal, req) + } + + if deny && s.NotPrincipal != nil { + return !notPrincipalExcludes(s.NotPrincipal, req), false + } + + return false, false +} + +// awsPrincipalMatches matches a Principal element against a SigV4 caller. +func awsPrincipalMatches(principal any, req *driver.TrustRequest) (matched, named bool) { + switch p := principal.(type) { + case string: + return p == "*", false + case map[string]any: + for _, entry := range toStringSlice(p["AWS"]) { + switch { + case slices.Contains(req.CallerARNs, entry): + return true, true + case entry == "*" || namesAccount(entry, req.CallerAccount): + matched = true + } + } + } + + return matched, false +} + +// namesAccount reports whether entry is account, or its root ARN in any +// partition. +func namesAccount(entry, account string) bool { + if account == "" { + return false + } + + if entry == account { + return true + } + + rest, ok := strings.CutPrefix(entry, "arn:") + if !ok { + return false + } + + _, tail, ok := strings.Cut(rest, ":") + + return ok && tail == "iam::"+account+":root" +} + +// notPrincipalExcludes reports whether a NotPrincipal element spares the +// caller. As in AWS, it must list every principal in the caller's chain: each +// of the caller's ARNs (a role session's role and session) and the account. +// Listing only some of them leaves the caller subject to the Deny. +func notPrincipalExcludes(notPrincipal any, req *driver.TrustRequest) bool { + var listed []string + + switch p := notPrincipal.(type) { + case string: + return p == "*" + case map[string]any: + listed = toStringSlice(p["AWS"]) + } + + if slices.Contains(listed, "*") { + return true + } + + accountListed := slices.ContainsFunc(listed, func(e string) bool { return namesAccount(e, req.CallerAccount) }) + if !accountListed || len(req.CallerARNs) == 0 { + return false + } + + for _, arn := range req.CallerARNs { + if !slices.Contains(listed, arn) && !namesAccount(arn, req.CallerAccount) { + return false + } + } + + return true } // EvaluateAssumeRoleTrust reports whether callerPrincipal may assume the role diff --git a/server/aws/authgate.go b/server/aws/authgate.go index 1c8929490..2bcb228f3 100644 --- a/server/aws/authgate.go +++ b/server/aws/authgate.go @@ -93,12 +93,12 @@ func newAuthGate(g *gateConfig) func(http.ResponseWriter, *http.Request) (*http. // role's policies, a GetSessionToken session as the user that minted it. var ( principal authctx.Principal - roleSession bool + kind stssrv.SessionKind aerr *sigv4.AuthError ) if strings.HasPrefix(akid, tempCredentialPrefix) { - principal, roleSession, aerr = verifyTempCredential(r, body, akid, g.scope.AccountID, g.sessions, g.clock) + principal, kind, aerr = verifyTempCredential(r, body, akid, g.scope.AccountID, g.sessions, g.clock) } else { principal, aerr = sigv4.Verify(r, body, resolverLookup(r, resolver), g.clock) } @@ -112,7 +112,7 @@ func newAuthGate(g *gateConfig) func(http.ResponseWriter, *http.Request) (*http. plan := g.resolvePlan(probe, h, probed, body) - return g.authorize(w, r, h, plan, &principal, roleSession) + return g.authorize(w, r, h, plan, &principal, kind) } } @@ -122,11 +122,11 @@ func newAuthGate(g *gateConfig) func(http.ResponseWriter, *http.Request) (*http. // expired session (ExpiredToken), then // SigV4-verifies the signature against that secret. When no session store is // wired the credential is unverifiable, so it fails closed. The principal is -// the session's owner (see stssrv.SessionOwner), and roleSession reports -// whether it is a role session. +// the session's owner (see stssrv.SessionOwner), and kind is the +// kind of session. func verifyTempCredential( r *http.Request, body []byte, akid, accountID string, sessions *stssrv.SessionStore, clock config.Clock, -) (principal authctx.Principal, roleSession bool, aerr *sigv4.AuthError) { +) (principal authctx.Principal, kind stssrv.SessionKind, aerr *sigv4.AuthError) { invalid := &sigv4.AuthError{ Code: "InvalidClientTokenId", Message: "The security token included in the request is invalid.", @@ -134,16 +134,16 @@ func verifyTempCredential( } if sessions == nil { - return authctx.Principal{}, false, invalid + return authctx.Principal{}, stssrv.KindNone, invalid } sess, ok := sessions.Lookup(akid) if !ok || !sessionTokenMatches(r, sess.SessionToken) { - return authctx.Principal{}, false, invalid + return authctx.Principal{}, stssrv.KindNone, invalid } if clock.Now().UTC().After(sess.Expiration) { - return authctx.Principal{}, false, &sigv4.AuthError{ + return authctx.Principal{}, stssrv.KindNone, &sigv4.AuthError{ Code: "ExpiredToken", Message: "The security token included in the request is expired.", HTTPStatus: http.StatusForbidden, @@ -162,7 +162,7 @@ func verifyTempCredential( principal, aerr = sigv4.Verify(r, body, lookup, clock) - return principal, sess.Owner.Role, aerr + return principal, sess.Owner.Kind, aerr } // sessionTokenMatches reports whether the request carries the session token diff --git a/server/aws/authz_matrix_test.go b/server/aws/authz_matrix_test.go index 8ca5770e0..f8cf9ebc9 100644 --- a/server/aws/authz_matrix_test.go +++ b/server/aws/authz_matrix_test.go @@ -488,7 +488,7 @@ func TestAuthzMatrixSTS(t *testing.T) { t.Fatalf("messages differ:\n existing: %s\n missing: %s", existing, missing) } - if !strings.Contains(existing, "on resource: arn:aws:iam::"+defaultTestAccount+":role/pathed because") { + if !strings.HasSuffix(existing, "on resource: arn:aws:iam::"+defaultTestAccount+":role/pathed") { t.Fatalf("the message must name the RoleArn as sent: %s", existing) } }) @@ -513,6 +513,156 @@ func TestAuthzMatrixSTS(t *testing.T) { }) } +// TestAuthzMatrixSTSTrust covers the AssumeRole trust rows: the trust policy +// is evaluated for the signed caller, and each kind of temporary credential +// is limited to the STS and IAM calls AWS allows it. +func TestAuthzMatrixSTSTrust(t *testing.T) { + ts, cloud := matrixServer(t, nil) + ctx := context.Background() + acct := "arn:aws:iam::" + defaultTestAccount + assumer := userWithPolicy(t, cloud, "assumer", allow("sts:AssumeRole")) + dyn := userWithPolicy(t, cloud, "dyn", allowDynamo) + boot := userWithPolicy(t, cloud, "boot", "") + + roles := map[string]string{ + "rootonly": `{"AWS":"` + acct + `:root"}`, + "dynnamed": `{"AWS":"` + acct + `:user/dyn"}`, + "otheruser": `{"AWS":"` + acct + `:user/someone"}`, + "federated": `{"Federated":"*"}`, + } + for name, principal := range roles { + trust := `{"Statement":[{"Effect":"Allow","Principal":` + principal + `,"Action":"sts:AssumeRole"}]}` + if _, err := cloud.IAM.CreateRole(ctx, iamdriver.RoleConfig{Name: name, AssumeRolePolicyDoc: trust}); err != nil { + t.Fatalf("CreateRole: %v", err) + } + } + + ext := `{"Statement":[{"Effect":"Allow","Principal":{"AWS":"` + acct + `:user/dyn"},"Action":"sts:AssumeRole",` + + `"Condition":{"StringEquals":{"sts:ExternalId":"e1"}}}]}` + if _, err := cloud.IAM.CreateRole(ctx, iamdriver.RoleConfig{Name: "external", AssumeRolePolicyDoc: ext}); err != nil { + t.Fatalf("CreateRole: %v", err) + } + + assume := func(creds aws.Credentials, role, extra string) (int, string) { + return doSigned(t, ts, creds, form("sts", "Action=AssumeRole&Version=2011-06-15&RoleSessionName=s&RoleArn="+ + acct+":role/"+role+extra)) + } + + cases := []struct { + name string + creds aws.Credentials + role string + extra string + allowed bool + }{ + {"permission but trust mismatch", assumer, "otheruser", "", false}, + {"trust names the user, no identity policy", dyn, "dynnamed", "", true}, + {"trust names root, no identity policy", dyn, "rootonly", "", false}, + {"trust names root, identity allow", assumer, "rootonly", "", true}, + {"ExternalId matches", dyn, "external", "&ExternalId=e1", true}, + {"ExternalId differs", dyn, "external", "&ExternalId=e2", false}, + {"Federated star does not match an IAM user", assumer, "federated", "", false}, + {"unrestricted caller, trust names root", boot, "rootonly", "", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + status, body := assume(tc.creds, tc.role, tc.extra) + if tc.allowed { + if status != http.StatusOK { + t.Fatalf("AssumeRole: %d %s", status, body) + } + + return + } + + wantDenied(t, status, body, xmlAccessDenied) + }) + } + + t.Run("foreign account and wrong path are refused", func(t *testing.T) { + for _, arn := range []string{"arn:aws:iam::999999999999:role/rootonly", acct + ":role/x/rootonly"} { + status, body := doSigned(t, ts, boot, form("sts", "Action=AssumeRole&Version=2011-06-15&RoleSessionName=s&RoleArn="+arn)) + wantDenied(t, status, body, xmlAccessDenied) + } + }) + + session := func(action string) aws.Credentials { + status, body := doSigned(t, ts, boot, form("sts", "Action="+action+"&Version=2011-06-15&Name=fed")) + if status != http.StatusOK { + t.Fatalf("%s: %d %s", action, status, body) + } + + return aws.Credentials{ + AccessKeyID: between(body, "", ""), + SecretAccessKey: between(body, "", ""), + SessionToken: between(body, "", ""), + } + } + + federation, sessionToken := session("GetFederationToken"), session("GetSessionToken") + + t.Run("federation credentials cannot AssumeRole", func(t *testing.T) { + status, body := assume(federation, "rootonly", "") + wantDenied(t, status, body, xmlAccessDenied) + }) + + t.Run("session-token credentials cannot call IAM", func(t *testing.T) { + status, body := doSigned(t, ts, sessionToken, form("iam", "Action=ListUsers&Version=2010-05-08")) + wantDenied(t, status, body, xmlAccessDenied) + }) + + t.Run("role chaining when the trust allows it", func(t *testing.T) { + status, body := assume(boot, "rootonly", "") + if status != http.StatusOK { + t.Fatalf("AssumeRole: %d %s", status, body) + } + + chain := `{"Statement":[{"Effect":"Allow","Principal":{"AWS":"` + acct + `:role/rootonly"},"Action":"sts:AssumeRole"}]}` + if _, err := cloud.IAM.CreateRole(ctx, iamdriver.RoleConfig{Name: "next", AssumeRolePolicyDoc: chain}); err != nil { + t.Fatalf("CreateRole: %v", err) + } + + roleCreds := aws.Credentials{ + AccessKeyID: between(body, "", ""), + SecretAccessKey: between(body, "", ""), + SessionToken: between(body, "", ""), + } + + if status, body := assume(roleCreds, "next", ""); status != http.StatusOK { + t.Fatalf("chained AssumeRole: %d %s", status, body) + } + }) + + t.Run("signed web identity and SAML are refused", func(t *testing.T) { + for _, q := range []string{ + "Action=AssumeRoleWithWebIdentity&Version=2011-06-15&RoleSessionName=s&WebIdentityToken=junk&RoleArn=" + acct + ":role/federated", + "Action=AssumeRoleWithSAML&Version=2011-06-15&PrincipalArn=p&SAMLAssertion=eA%3D%3D&RoleArn=" + acct + ":role/federated", + } { + status, body := doSigned(t, ts, boot, form("sts", q)) + wantDenied(t, status, body, "not available under --enforce-auth") + } + }) + + t.Run("GetCallerIdentity works for every principal", func(t *testing.T) { + for name, creds := range map[string]aws.Credentials{ + "user": dyn, "boot": boot, "federation": federation, "session token": sessionToken, + } { + if status, body := doSigned(t, ts, creds, form("sts", "Action=GetCallerIdentity&Version=2011-06-15")); status != http.StatusOK { + t.Fatalf("%s: %d %s", name, status, body) + } + } + }) +} + +// between returns the text of s between the first open and the next close. +func between(s, open, closing string) string { + _, rest, _ := strings.Cut(s, open) + v, _, _ := strings.Cut(rest, closing) + + return v +} + // TestAuthzMatrixBootstrap checks the shortcut principals are unrestricted on // every plan except an unmapped JSON-RPC target. func TestAuthzMatrixBootstrap(t *testing.T) { diff --git a/server/aws/authzgate.go b/server/aws/authzgate.go index d09ed6dbb..036115659 100644 --- a/server/aws/authzgate.go +++ b/server/aws/authzgate.go @@ -8,6 +8,7 @@ import ( "github.com/stackshy/cloudemu/v2/server" "github.com/stackshy/cloudemu/v2/server/authctx" + stssrv "github.com/stackshy/cloudemu/v2/server/aws/sts" "github.com/stackshy/cloudemu/v2/server/wire" "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" @@ -151,6 +152,26 @@ func (g *gateConfig) jsonRPCPlan(probe *http.Request, h server.Handler, body []b return authzPlan{kind: planChecks, req: probe, checks: awsauthz.Single(service+":"+op, deriveResource(service, body, g.scope))} } +// forbiddenFor returns the first action of the plan that a credential of kind +// may never perform, or "". +func (p authzPlan) forbiddenFor(kind stssrv.SessionKind) string { + if kind == stssrv.KindNone { + return "" + } + + for _, c := range p.checks { + if kind.Forbids(c.Action) { + return c.Action + } + } + + if p.action != "" && kind.Forbids(p.action) { + return p.action + } + + return "" +} + // denyTarget is the request a deny is rendered from: the probe when there is // one, else the original request. func (p authzPlan) denyTarget(r *http.Request) *http.Request { @@ -186,11 +207,14 @@ func rawOperation(probe *http.Request) string { // the request carrying the principal and the gate's evaluation; on deny it // has written the 403. // -// strict is set for an STS role session. Its principal is the role, which is -// evaluated on its policies alone: the root and no-policies bootstrap -// shortcuts that apply to IAM users do not apply. +// kind is the STS session kind of a temporary credential. A role session is +// strict: its principal is the role, which is evaluated on its policies +// alone, so the root and no-policies bootstrap shortcuts that apply to IAM +// users do not apply. Every session kind is also barred from the STS and IAM +// operations AWS never lets it call (stssrv.SessionKind.Forbids), whatever +// its policies say. func (g *gateConfig) authorize( - w http.ResponseWriter, r *http.Request, h server.Handler, plan authzPlan, p *authctx.Principal, strict bool, + w http.ResponseWriter, r *http.Request, h server.Handler, plan authzPlan, p *authctx.Principal, kind stssrv.SessionKind, ) (*http.Request, bool) { if plan.kind == planNoHandler || plan.kind == planAuthnOnly { return withPrincipal(r, *p), true @@ -201,7 +225,14 @@ func (g *gateConfig) authorize( return r, false } + if action := plan.forbiddenFor(kind); action != "" { + writeAccessDenied(w, plan.denyTarget(r), h, "User: "+principalARN(p)+" is not authorized to perform: "+action) + + return r, false + } + ev := awsauthz.Evaluation{Principal: *p, CondCtx: awsauthz.ConditionContext(r, p, g.scope)} + strict := kind == stssrv.KindRole shortcut := !strict && (isAdminPrincipal(*p) || !principalHasPolicies(r, *p, g.iam)) if msg := g.decide(r, p, plan, &ev, shortcut); msg != "" { diff --git a/server/aws/aws.go b/server/aws/aws.go index d7bda7a0b..e624b7bd5 100644 --- a/server/aws/aws.go +++ b/server/aws/aws.go @@ -450,6 +450,12 @@ type Drivers struct { // IAM users with no policies are unrestricted (bootstrap); role sessions are // always evaluated on the role's policies. Operations AWS serves without // credentials, and the Kubernetes data plane, are not IAM-authorized. + // + // AssumeRole is decided by the role's trust policy for the real caller, + // together with the caller's identity policies, and STS temporary + // credentials are limited to the STS and IAM calls AWS allows each kind. + // Signed AssumeRoleWithWebIdentity and AssumeRoleWithSAML are refused, + // since their token or assertion is not validated. EnforceAuth bool // Clock drives SigV4 timestamp-expiry evaluation and STS temporary-credential // expiry when EnforceAuth is on. Nil uses the real clock; tests inject a diff --git a/server/aws/publicauth_test.go b/server/aws/publicauth_test.go index 91495e762..d4323b99e 100644 --- a/server/aws/publicauth_test.go +++ b/server/aws/publicauth_test.go @@ -5,11 +5,9 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "encoding/xml" "io" "net/http" "net/http/httptest" - "net/url" "strings" "testing" "time" @@ -248,55 +246,6 @@ func sessionCreds(c *ststypes.Credentials) aws.Credentials { } } -// signedAssumeWebIdentity sends a SigV4-signed AssumeRoleWithWebIdentity for -// roleArn and returns the session credentials from the XML response. -func signedAssumeWebIdentity(t *testing.T, ts *httptest.Server, creds aws.Credentials, roleArn string) aws.Credentials { - t.Helper() - - ctx := context.Background() - body := url.Values{ - "Action": {"AssumeRoleWithWebIdentity"}, "Version": {"2011-06-15"}, "RoleArn": {roleArn}, - "RoleSessionName": {"s"}, "WebIdentityToken": {"junk"}, - }.Encode() - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, ts.URL+"/", strings.NewReader(body)) - if err != nil { - t.Fatalf("new request: %v", err) - } - - req.Header.Set("Content-Type", formCT) - - sum := sha256.Sum256([]byte(body)) - if err := v4.NewSigner().SignHTTP(ctx, creds, req, hex.EncodeToString(sum[:]), "sts", "us-east-1", time.Now()); err != nil { - t.Fatalf("sign: %v", err) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("do: %v", err) - } - defer resp.Body.Close() - - var out struct { - Result struct { - Credentials struct { - AccessKeyID string `xml:"AccessKeyId"` - SecretAccessKey string `xml:"SecretAccessKey"` - SessionToken string `xml:"SessionToken"` - } `xml:"Credentials"` - } `xml:"AssumeRoleWithWebIdentityResult"` - } - - raw, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK || xml.Unmarshal(raw, &out) != nil { - t.Fatalf("signed AssumeRoleWithWebIdentity: %d %s", resp.StatusCode, raw) - } - - c := out.Result.Credentials - - return aws.Credentials{AccessKeyID: c.AccessKeyID, SecretAccessKey: c.SecretAccessKey, SessionToken: c.SessionToken} -} - // TestSessionCredentialsAreAuthorized proves an STS session is authorized as // its owner. A role session gets exactly its role's policies: a role that does // not exist, or has no allowing policy, is denied. A GetSessionToken session @@ -338,10 +287,6 @@ func TestSessionCredentialsAreAuthorized(t *testing.T) { return sessionCreds(out.Credentials) } - // The SDK always sends AssumeRoleWithWebIdentity unsigned (noAuth), so sign - // it by hand: an authenticated caller asking for a role that does not exist. - web := signedAssumeWebIdentity(t, ts, bootCreds, "arn:aws:iam::"+defaultTestAccount+":role/nonexistent") - sessionFor := func(user string, doc string) aws.Credentials { out, err := stsClient(ts, userWithPolicy(t, cloud, user, doc)).GetSessionToken(ctx, &awssts.GetSessionTokenInput{}) if err != nil { @@ -358,7 +303,6 @@ func TestSessionCredentialsAreAuthorized(t *testing.T) { target string denied bool }{ - {"web identity session for a missing role", web, "dynamodb", listTables, true}, {"role with no policies", assume("noperm"), "dynamodb", listTables, true}, {"role allowed its action", assume("dynrole"), "dynamodb", listTables, false}, {"role outside its policy", assume("dynrole"), "sqs", listQueues, true}, diff --git a/server/aws/sts/authz.go b/server/aws/sts/authz.go index aa3371289..3ddc8d73a 100644 --- a/server/aws/sts/authz.go +++ b/server/aws/sts/authz.go @@ -20,11 +20,14 @@ type roleGetter interface { // - GetSessionToken needs no permission, but an explicit Deny on // sts:GetSessionToken still blocks it (DenyOnly). // - The AssumeRole family needs sts: on the role that will be -// assumed. The role is resolved the way the operation resolves it (by the -// last path segment of RoleArn), and its stored ARN is the resource, so a -// RoleArn with a different path or account cannot borrow another role's -// grant. An unknown role leaves the resource unknown. A deny names the -// RoleArn as sent, like AWS, so it does not reveal whether the role exists. +// assumed. The resource is the role's ARN when RoleArn names an existing +// role exactly; otherwise it is unknown (the operation refuses such a +// RoleArn anyway). A deny names the RoleArn as sent, like AWS, so it does +// not reveal whether the role exists. +// - AssumeRole's checks are ResourcePolicy checks: the handler finishes the +// decision with the role's trust policy, which can grant the call on its +// own when it names the caller. Passing session tags adds sts:TagSession, +// and passing a source identity adds sts:SetSourceIdentity, on the role. // - GetFederationToken needs sts:GetFederationToken on the federated user. // - GetAccessKeyInfo and DecodeAuthorizationMessage take no resource. func (h *Handler) IAMChecks(r *http.Request, _ awsauthz.Scope) ([]awsauthz.Check, bool) { @@ -40,7 +43,9 @@ func (h *Handler) IAMChecks(r *http.Request, _ awsauthz.Scope) ([]awsauthz.Check return []awsauthz.Check{}, true case actionGetSessionToken: return []awsauthz.Check{{Action: action, Resource: "*", Mode: awsauthz.DenyOnly}}, true - case actionAssumeRole, actionAssumeRoleWithWebIdentity, actionAssumeRoleWithSAML: + case actionAssumeRole: + return h.assumeRoleChecks(r), true + case actionAssumeRoleWithWebIdentity, actionAssumeRoleWithSAML: return []awsauthz.Check{{Action: action, Resource: h.assumedRoleARN(r), MessageResource: r.Form.Get("RoleArn")}}, true case actionGetFederationToken: return awsauthz.Single(action, "arn:aws:sts::"+h.accountID+":federated-user/"+r.Form.Get("Name")), true @@ -63,15 +68,29 @@ const ( actionDecodeAuthorizationMessage = "DecodeAuthorizationMessage" ) -// assumedRoleARN is the stored ARN of the role named by the request's -// RoleArn, or "" when that role does not exist. -func (h *Handler) assumedRoleARN(r *http.Request) string { - if h.roles == nil { - return "" +// assumeRoleChecks are the ResourcePolicy checks of an AssumeRole request, +// one per trust action it needs. +func (h *Handler) assumeRoleChecks(r *http.Request) []awsauthz.Check { + resource, roleArn := h.assumedRoleARN(r), r.Form.Get("RoleArn") + actions := trustActions(r) + checks := make([]awsauthz.Check, 0, len(actions)) + + for _, action := range actions { + checks = append(checks, awsauthz.Check{ + Action: action, Resource: resource, Mode: awsauthz.ResourcePolicy, MessageResource: roleArn, + }) } - role, err := h.roles.GetRole(r.Context(), roleNameFromArn(r.Form.Get("RoleArn"))) - if err != nil || role == nil { + return checks +} + +// assumedRoleARN is the ARN of the role the request's RoleArn names, or "" +// when no role has exactly that ARN. +func (h *Handler) assumedRoleARN(r *http.Request) string { + roleArn := r.Form.Get("RoleArn") + + role, ok := h.requestedRole(r, roleArn, roleNameFromArn(roleArn)) + if !ok { return "" } diff --git a/server/aws/sts/authz_test.go b/server/aws/sts/authz_test.go index 6a8cedb70..7bc57fd4e 100644 --- a/server/aws/sts/authz_test.go +++ b/server/aws/sts/authz_test.go @@ -29,6 +29,15 @@ func TestIAMChecks(t *testing.T) { return []awsauthz.Check{{Action: action, Resource: resource, MessageResource: requested}} } + trusted := func(resource, requested string, actions ...string) []awsauthz.Check { + checks := make([]awsauthz.Check, 0, len(actions)) + for _, a := range actions { + checks = append(checks, awsauthz.Check{Action: a, Resource: resource, Mode: awsauthz.ResourcePolicy, MessageResource: requested}) + } + + return checks + } + cases := []struct { body string want []awsauthz.Check @@ -36,12 +45,13 @@ func TestIAMChecks(t *testing.T) { }{ {"Action=GetCallerIdentity", []awsauthz.Check{}, true}, {"Action=GetSessionToken", []awsauthz.Check{{Action: "sts:GetSessionToken", Resource: "*", Mode: awsauthz.DenyOnly}}, true}, - {"Action=AssumeRole&RoleArn=" + role, assume("sts:AssumeRole", role, role), true}, - // The operation assumes the role by its last path segment, so the - // resource is the stored ARN, whatever path or account was sent. - // A deny still names the RoleArn as sent. - {"Action=AssumeRole&RoleArn=" + other, assume("sts:AssumeRole", role, other), true}, - {"Action=AssumeRole&RoleArn=" + missing, assume("sts:AssumeRole", "", missing), true}, + {"Action=AssumeRole&RoleArn=" + role, trusted(role, role, "sts:AssumeRole"), true}, + // A RoleArn with another account or path names no role here, so the + // resource is unknown. A deny still names the RoleArn as sent. + {"Action=AssumeRole&RoleArn=" + other, trusted("", other, "sts:AssumeRole"), true}, + {"Action=AssumeRole&RoleArn=" + missing, trusted("", missing, "sts:AssumeRole"), true}, + {"Action=AssumeRole&Tags.member.1.Key=k&Tags.member.1.Value=v&SourceIdentity=me&RoleArn=" + role, + trusted(role, role, "sts:AssumeRole", "sts:TagSession", "sts:SetSourceIdentity"), true}, {"Action=AssumeRoleWithWebIdentity&RoleArn=" + role, assume("sts:AssumeRoleWithWebIdentity", role, role), true}, {"Action=AssumeRoleWithSAML&RoleArn=" + role, assume("sts:AssumeRoleWithSAML", role, role), true}, {"Action=GetFederationToken&Name=bob", awsauthz.Single("sts:GetFederationToken", diff --git a/server/aws/sts/handler.go b/server/aws/sts/handler.go index d9d53a1d2..425adbaf3 100644 --- a/server/aws/sts/handler.go +++ b/server/aws/sts/handler.go @@ -65,6 +65,9 @@ type Handler struct { accountID string region string trust roleTrustEvaluator + // trustEval evaluates trust policies for the real caller under + // EnforceAuth. Without it an enforced AssumeRole is refused. + trustEval iamdriver.TrustEvaluator // roles resolves the role an AssumeRole-family call will assume, so its // stored ARN is the resource the gate authorizes. Nil without IAM. roles roleGetter @@ -107,6 +110,10 @@ func New(accountID, region string, iam iamdriver.IAM) *Handler { h.trust = te } + if te, ok := iam.(iamdriver.TrustEvaluator); ok { + h.trustEval = te + } + if iam != nil { h.roles = iam } diff --git a/server/aws/sts/operations.go b/server/aws/sts/operations.go index 98fd781fc..adceb5504 100644 --- a/server/aws/sts/operations.go +++ b/server/aws/sts/operations.go @@ -7,6 +7,7 @@ import ( "time" "github.com/stackshy/cloudemu/v2/server/authctx" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" "github.com/stackshy/cloudemu/v2/server/wire/awsidentity" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" ) @@ -47,9 +48,9 @@ func (h *Handler) resolveCallerIdentity(r *http.Request) awsidentity.Identity { } // assumeRole returns synthetic temporary credentials and an AssumedRoleUser -// derived from the requested RoleArn and RoleSessionName. When an IAM trust -// evaluator is wired, the target role must exist and its trust policy must allow -// the caller to sts:AssumeRole; otherwise AWS returns AccessDenied (403). +// derived from the requested RoleArn and RoleSessionName. When IAM is wired, +// RoleArn must be the ARN of an existing role and its trust policy must allow +// the caller (see trustAllows); otherwise AWS returns AccessDenied (403). func (h *Handler) assumeRole(w http.ResponseWriter, r *http.Request) { roleArn := r.Form.Get("RoleArn") sessionName := r.Form.Get("RoleSessionName") @@ -63,11 +64,10 @@ func (h *Handler) assumeRole(w http.ResponseWriter, r *http.Request) { // where role-name is the last path segment of the requested RoleArn. roleName := roleNameFromArn(roleArn) - if !h.trustAllows(r, roleName) { + if !h.trustAllows(r, roleArn, roleName) { // Real STS returns AccessDenied (403) both when the trust policy denies // the caller and when the role does not exist (it does not disclose which). - awsquery.WriteXMLError(w, http.StatusForbidden, "AccessDenied", - "User is not authorized to perform sts:AssumeRole on "+roleArn) + awsquery.WriteXMLError(w, http.StatusForbidden, "AccessDenied", assumeDeniedMessage(r, roleArn)) return } @@ -100,16 +100,28 @@ func (h *Handler) assumeRole(w http.ResponseWriter, r *http.Request) { // under for GetCallerIdentity. const assumedRoleIDPrefix = "AROACLOUDEMU0000000000" -// trustAllows reports whether the caller may assume roleName. With no trust -// evaluator wired it stays permissive (standalone init-creds behavior). With one -// wired, a missing role or a trust policy that does not allow the caller both -// deny. The caller principal is the account-root identity because cloudemu does not -// verify SigV4, so it evaluates trust against a consistent same-account root. -func (h *Handler) trustAllows(r *http.Request, roleName string) bool { +// trustAllows reports whether the caller may assume the role RoleArn names. +// +// Under EnforceAuth the gate has authenticated the caller and recorded its +// identity decisions, and callerTrusted decides with the real caller. +// +// Otherwise the caller is unknown. With no trust evaluator wired AssumeRole +// stays permissive (standalone init-creds behavior). With one wired, RoleArn +// must be an existing role's ARN, and the trust policy is evaluated for the +// account root, as it always has been. +func (h *Handler) trustAllows(r *http.Request, roleArn, roleName string) bool { + if ev, enforced := awsauthz.EvaluationFrom(r.Context()); enforced { + return h.callerTrusted(r, &ev, roleArn, roleName) + } + if h.trust == nil { return true } + if _, ok := h.requestedRole(r, roleArn, roleName); !ok { + return false + } + callerPrincipal := "arn:aws:iam::" + h.accountID + ":root" _, allowed := h.trust.EvaluateAssumeRoleTrust(r.Context(), roleName, callerPrincipal) @@ -120,6 +132,11 @@ func (h *Handler) trustAllows(r *http.Request, roleName string) bool { // token (the flow EKS IRSA uses). cloudemu does not validate the token; it // echoes a synthetic subject/provider derived from the request. func (h *Handler) assumeRoleWithWebIdentity(w http.ResponseWriter, r *http.Request) { + if refusedUnderEnforceAuth(w, r, actionAssumeRoleWithWebIdentity, + "web identity tokens are validated against a registered OIDC provider") { + return + } + sessionName := r.Form.Get("RoleSessionName") if sessionName == "" { sessionName = defaultSessionName @@ -160,6 +177,11 @@ func (h *Handler) assumeRoleWithWebIdentity(w http.ResponseWriter, r *http.Reque // assumeRoleWithSAML mirrors AssumeRole but is fed by a SAML assertion. // cloudemu does not validate the assertion; it echoes a synthetic subject. func (h *Handler) assumeRoleWithSAML(w http.ResponseWriter, r *http.Request) { + if refusedUnderEnforceAuth(w, r, actionAssumeRoleWithSAML, + "SAML assertions are validated against a registered SAML provider") { + return + } + roleName := roleNameFromArn(r.Form.Get("RoleArn")) sessionName := "cloudemu-saml-session" assumedArn := "arn:aws:sts::" + h.accountID + ":assumed-role/" + roleName + "/" + sessionName @@ -201,7 +223,7 @@ func (h *Handler) getFederationToken(w http.ResponseWriter, r *http.Request) { fedUserID := h.accountID + ":" + name creds, ok := h.mintCredentials(w, durationFromForm(r), awsidentity.Identity{ARN: fedArn, UserID: fedUserID}, - h.callerOwner(r)) + h.callerOwner(r, KindFederation)) if !ok { return } @@ -259,7 +281,7 @@ func durationFromForm(r *http.Request) time.Duration { // or a federated user, so the minted credentials are recorded under the // identity resolveCallerIdentity resolves for the request that asked for them. func (h *Handler) getSessionToken(w http.ResponseWriter, r *http.Request) { - creds, ok := h.mintCredentials(w, durationFromForm(r), h.resolveCallerIdentity(r), h.callerOwner(r)) + creds, ok := h.mintCredentials(w, durationFromForm(r), h.resolveCallerIdentity(r), h.callerOwner(r, KindSessionToken)) if !ok { return } @@ -332,23 +354,26 @@ func (h *Handler) mintCredentials( // roleOwner is the policy owner of a session for the assumed role roleName. func roleOwner(roleName string) SessionOwner { - return SessionOwner{PolicyEntity: roleName, Role: true} + return SessionOwner{PolicyEntity: roleName, Kind: KindRole} } -// callerOwner is the policy owner of a session the caller mints for itself -// (GetSessionToken, GetFederationToken): the calling IAM user. A caller that is -// itself signing with a session passes that session's owner on, so a session -// can never widen its own permissions. -func (h *Handler) callerOwner(r *http.Request) SessionOwner { +// callerOwner is the policy owner of a session of kind the caller mints for +// itself (GetSessionToken, GetFederationToken): the calling IAM user. A caller +// that is itself signing with a session passes that session's policy owner +// on, so a session can never widen its own permissions. +func (h *Handler) callerOwner(r *http.Request, kind SessionKind) SessionOwner { p, _ := authctx.PrincipalFrom(r.Context()) if h.sessions != nil { if sess, ok := h.sessions.Lookup(p.AccessKeyID); ok { - return sess.Owner + owner := sess.Owner + owner.Kind = kind + + return owner } } - return SessionOwner{PolicyEntity: p.UserName} + return SessionOwner{PolicyEntity: p.UserName, Kind: kind} } // roleNameFromArn extracts the role name (last path segment) from a role ARN diff --git a/server/aws/sts/sessions.go b/server/aws/sts/sessions.go index 21a5d4982..d5b7eebc7 100644 --- a/server/aws/sts/sessions.go +++ b/server/aws/sts/sessions.go @@ -2,6 +2,8 @@ package sts import ( "errors" + "slices" + "strings" "sync" "time" @@ -32,10 +34,62 @@ type SessionOwner struct { // the session: the assumed role, or the user that called GetSessionToken or // GetFederationToken. PolicyEntity string - // Role marks a role session. Its role's policies are evaluated strictly: a - // role with no allowing policy (or no such role) is denied, with none of the - // no-policy bootstrap leniency a long-term user key gets. - Role bool + // Kind is the kind of credential. A role session's role policies are + // evaluated strictly: a role with no allowing policy (or no such role) is + // denied, with none of the no-policy bootstrap leniency a long-term user key + // gets. + Kind SessionKind +} + +// SessionKind is the STS operation a temporary credential came from. It +// decides which STS and IAM operations the credential may call. +type SessionKind int + +const ( + // KindNone is not a session: a long-term access key. + KindNone SessionKind = iota + // KindRole is an AssumeRole-family session. + KindRole + // KindSessionToken is a GetSessionToken session. + KindSessionToken + // KindFederation is a GetFederationToken session. + KindFederation +) + +// Forbids reports whether a credential of kind k may never perform action, +// whatever its policies say. From the IAM User Guide, "Compare AWS STS +// credentials": +// +// - AssumeRole-family credentials cannot call GetFederationToken or +// GetSessionToken. +// - GetSessionToken credentials cannot call IAM (cloudemu does not verify +// MFA, so the MFA exception never applies) and cannot call STS except +// AssumeRole and GetCallerIdentity. +// - GetFederationToken credentials cannot call IAM, nor STS except +// GetCallerIdentity. +// +// sts:TagSession and sts:SetSourceIdentity are permissions of an AssumeRole +// call, not operations, so they follow AssumeRole. +func (k SessionKind) Forbids(action string) bool { + svc, op, _ := strings.Cut(action, ":") + + isOp := func(names ...string) bool { + return slices.ContainsFunc(names, func(n string) bool { return strings.EqualFold(op, n) }) + } + + iam, sts := strings.EqualFold(svc, "iam"), strings.EqualFold(svc, "sts") + + switch k { + case KindRole: + return sts && isOp(actionGetSessionToken, actionGetFederationToken) + case KindSessionToken: + return iam || sts && !isOp(actionAssumeRole, actionGetCallerIdentity, "TagSession", "SetSourceIdentity") + case KindFederation: + return iam || sts && !isOp(actionGetCallerIdentity) + case KindNone: + } + + return false } // SessionStore records the temporary credentials STS issues so their signatures diff --git a/server/aws/sts/trust.go b/server/aws/sts/trust.go new file mode 100644 index 000000000..acd72e20a --- /dev/null +++ b/server/aws/sts/trust.go @@ -0,0 +1,245 @@ +package sts + +import ( + "context" + "net/http" + "strconv" + "strings" + + "github.com/stackshy/cloudemu/v2/server/authctx" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" + iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" +) + +// The trust actions an AssumeRole call can need. +const ( + trustAssumeRole = "sts:AssumeRole" + trustTagSession = "sts:TagSession" + trustSetSourceIdentity = "sts:SetSourceIdentity" +) + +// maxSessionTags is the most session tags AssumeRole accepts. Reading stops +// there, so a long form cannot make the handler loop. +const maxSessionTags = 50 + +// callerTrusted decides an AssumeRole call under EnforceAuth for the caller +// the gate authenticated, from the role's trust policy and the identity +// decisions the gate recorded in ev. +// +// Each trust action the call needs (sts:AssumeRole, plus sts:TagSession when +// it passes tags and sts:SetSourceIdentity when it passes a source identity) +// must be allowed by the trust policy, must not be explicitly denied by the +// trust or identity policies, and must be allowed by an identity policy unless +// the trust policy names the caller directly. Within one account a trust +// policy that names the caller's ARN is enough on its own, while one that +// names the account still needs an identity-based allow (IAM User Guide, +// "Policy evaluation logic", and "How AWS enforcement code logic evaluates +// requests to allow or deny access": role trust policies must explicitly allow +// the principal). +// +// RoleArn must be the ARN of an existing role in this account (STS-X2). +func (h *Handler) callerTrusted(r *http.Request, ev *awsauthz.Evaluation, roleArn, roleName string) bool { + if h.trustEval == nil { + return false + } + + role, ok := h.requestedRole(r, roleArn, roleName) + if !ok { + return false + } + + ctx := r.Context() + callers, account := h.trustCallers(ctx, &ev.Principal) + cctx := trustContext(r, ev, callers) + + for _, action := range trustActions(r) { + res := h.trustEval.EvaluateTrust(ctx, &iamdriver.TrustRequest{ + RoleName: role.Name, Action: action, CallerARNs: callers, CallerAccount: account, Context: cctx, + }) + + identity := identityDecision(ev, action) + + switch { + case !res.RoleExists, !res.Allow, res.ExplicitDeny, identity == awsauthz.ExplicitDeny: + return false + case identity != awsauthz.Allowed && !res.NamedDirectly: + return false + } + } + + return true +} + +// requestedRole returns the role RoleArn names. It must be a role of this +// IAM, with RoleArn equal to its ARN: a RoleArn for another account, or with +// a different path, names a different role, which does not exist here. +func (h *Handler) requestedRole(r *http.Request, roleArn, roleName string) (*iamdriver.RoleInfo, bool) { + if h.roles == nil { + return nil, false + } + + role, err := h.roles.GetRole(r.Context(), roleName) + if err != nil || role == nil || role.ARN != roleArn { + return nil, false + } + + return role, true +} + +// trustCallers returns the ARNs the caller is known by in a trust policy, +// and its account. A role session is known by its role's ARN and by its +// assumed-role ARN; an IAM user, a GetSessionToken session (which acts as its +// user) and a federated user by their own ARN; the account root by the root +// ARN. +func (h *Handler) trustCallers(ctx context.Context, p *authctx.Principal) (arns []string, account string) { + account = p.AccountID + if account == "" { + account = h.accountID + } + + if p.ARN == "" || strings.HasSuffix(p.ARN, ":root") { + return []string{"arn:aws:iam::" + account + ":root"}, account + } + + if h.sessions != nil { + if sess, ok := h.sessions.Lookup(p.AccessKeyID); ok && sess.Owner.Kind == KindRole && h.roles != nil { + if role, err := h.roles.GetRole(ctx, sess.Owner.PolicyEntity); err == nil && role != nil { + return []string{role.ARN, p.ARN}, account + } + } + } + + return []string{p.ARN}, account +} + +// trustActions lists the trust actions an AssumeRole request needs. +func trustActions(r *http.Request) []string { + actions := []string{trustAssumeRole} + + keys, _ := requestTags(r) + if len(keys) > 0 || r.Form.Get("TransitiveTagKeys.member.1") != "" { + actions = append(actions, trustTagSession) + } + + if r.Form.Get("SourceIdentity") != "" { + actions = append(actions, trustSetSourceIdentity) + } + + return actions +} + +// requestTags reads the session tags of an AssumeRole request +// (Tags.member.N.Key / .Value), in order. +func requestTags(r *http.Request) (keys []string, tags map[string]string) { + tags = map[string]string{} + + for i := 1; i <= maxSessionTags; i++ { + prefix := "Tags.member." + strconv.Itoa(i) + + key := r.Form.Get(prefix + ".Key") + if key == "" { + break + } + + keys = append(keys, key) + tags[key] = r.Form.Get(prefix + ".Value") + } + + return keys, tags +} + +// trustContext is the condition context a trust policy is evaluated with: the +// gate's context, plus the STS request keys. A role session's aws:PrincipalArn +// is its role's ARN, as in AWS. +func trustContext(r *http.Request, ev *awsauthz.Evaluation, callers []string) map[string]string { + cctx := make(map[string]string, len(ev.CondCtx)) + for k, v := range ev.CondCtx { + cctx[k] = v + } + + set := func(key, value string) { + if value != "" { + cctx[key] = value + } + } + + if len(callers) > 1 { + cctx["aws:PrincipalArn"] = callers[0] + } + + set("sts:RoleSessionName", r.Form.Get("RoleSessionName")) + set("sts:ExternalId", r.Form.Get("ExternalId")) + set("sts:SourceIdentity", r.Form.Get("SourceIdentity")) + + keys, tags := requestTags(r) + for k, v := range tags { + cctx["aws:RequestTag/"+k] = v + } + + set("aws:TagKeys", strings.Join(keys, iamdriver.ConditionValueSeparator)) + set("sts:TransitiveTagKeys", strings.Join(memberList(r, "TransitiveTagKeys"), iamdriver.ConditionValueSeparator)) + + return cctx +} + +// memberList reads a query-protocol list parameter (.member.N). +func memberList(r *http.Request, name string) []string { + var out []string + + for i := 1; i <= maxSessionTags; i++ { + v := r.Form.Get(name + ".member." + strconv.Itoa(i)) + if v == "" { + break + } + + out = append(out, v) + } + + return out +} + +// identityDecision is the identity decision the gate recorded for action. A +// missing decision is an implicit deny. +func identityDecision(ev *awsauthz.Evaluation, action string) awsauthz.Decision { + for c, d := range ev.Decisions { + if c.Action == action { + return d + } + } + + return awsauthz.ImplicitDeny +} + +// assumeDeniedMessage is the AccessDenied message of a refused AssumeRole. It +// names the RoleArn as sent and reads the same whatever the reason, so it does +// not reveal whether the role exists. Without EnforceAuth the caller is +// unknown and the message keeps its old form. +func assumeDeniedMessage(r *http.Request, roleArn string) string { + ev, enforced := awsauthz.EvaluationFrom(r.Context()) + if !enforced { + return "User is not authorized to perform sts:AssumeRole on " + roleArn + } + + caller := ev.Principal.ARN + if caller == "" { + caller = ev.Principal.UserName + } + + return "User: " + caller + " is not authorized to perform: " + trustAssumeRole + " on resource: " + roleArn +} + +// refusedUnderEnforceAuth refuses a signed AssumeRoleWithWebIdentity or +// AssumeRoleWithSAML under EnforceAuth: the token or assertion is not +// validated, so no caller may use it to get a role's credentials. It writes +// the 403 and reports true when it refused. +func refusedUnderEnforceAuth(w http.ResponseWriter, r *http.Request, action, until string) bool { + if _, enforced := awsauthz.EvaluationFrom(r.Context()); !enforced { + return false + } + + awsquery.WriteXMLError(w, http.StatusForbidden, "AccessDenied", + action+" is not available under --enforce-auth until "+until) + + return true +} diff --git a/server/aws/sts/trust_enforced_test.go b/server/aws/sts/trust_enforced_test.go new file mode 100644 index 000000000..633aa6468 --- /dev/null +++ b/server/aws/sts/trust_enforced_test.go @@ -0,0 +1,513 @@ +package sts_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + awsiam "github.com/aws/aws-sdk-go-v2/service/iam" + awssts "github.com/aws/aws-sdk-go-v2/service/sts" + ststypes "github.com/aws/aws-sdk-go-v2/service/sts/types" + smithy "github.com/aws/smithy-go" + + cloudemu "github.com/stackshy/cloudemu/v2" + awsprovider "github.com/stackshy/cloudemu/v2/providers/aws" + awsserver "github.com/stackshy/cloudemu/v2/server/aws" + iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" +) + +const ( + acctRoot = "arn:aws:iam::" + testAccountID + ":root" + callerARN = "arn:aws:iam::" + testAccountID + ":user/caller" + allowDDB = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}]}` + allowStsAR = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:*","Resource":"*"}]}` +) + +// enforcedSTS is an --enforce-auth server with IAM and STS wired. +type enforcedSTS struct { + t *testing.T + url string + cloud *awsprovider.Provider +} + +func newEnforcedSTS(t *testing.T) *enforcedSTS { + t.Helper() + + cloud := cloudemu.NewAWS() + ts := newServer(t, awsserver.Drivers{ + IAM: cloud.IAM, STS: true, AccountID: testAccountID, Region: testRegion, EnforceAuth: true, + }) + + return &enforcedSTS{t: t, url: ts.URL, cloud: cloud} +} + +// user creates an IAM user with an optional inline policy and returns its key. +func (e *enforcedSTS) user(name, doc string) aws.Credentials { + e.t.Helper() + + ctx := context.Background() + + if _, err := e.cloud.IAM.CreateUser(ctx, iamdriver.UserConfig{Name: name}); err != nil { + e.t.Fatalf("CreateUser: %v", err) + } + + if doc != "" { + if err := e.cloud.IAM.AttachUserPolicy(ctx, name, e.policy(name+"-user", doc)); err != nil { + e.t.Fatalf("AttachUserPolicy: %v", err) + } + } + + ak, err := e.cloud.IAM.CreateAccessKey(ctx, iamdriver.AccessKeyConfig{UserName: name}) + if err != nil { + e.t.Fatalf("CreateAccessKey: %v", err) + } + + return aws.Credentials{AccessKeyID: ak.AccessKeyID, SecretAccessKey: ak.SecretAccessKey} +} + +// role creates a role with the given trust document and optional inline policy. +func (e *enforcedSTS) role(name, path, trust, doc string) string { + e.t.Helper() + + ctx := context.Background() + + info, err := e.cloud.IAM.CreateRole(ctx, iamdriver.RoleConfig{Name: name, Path: path, AssumeRolePolicyDoc: trust}) + if err != nil { + e.t.Fatalf("CreateRole: %v", err) + } + + if doc != "" { + e.attachRole(name, name+"-role", doc) + } + + return info.ARN +} + +// policy creates a managed policy and returns its ARN. +func (e *enforcedSTS) policy(name, doc string) string { + e.t.Helper() + + pol, err := e.cloud.IAM.CreatePolicy(context.Background(), iamdriver.PolicyConfig{Name: name, PolicyDocument: doc}) + if err != nil { + e.t.Fatalf("CreatePolicy: %v", err) + } + + return pol.ARN +} + +func (e *enforcedSTS) attachRole(role, name, doc string) { + e.t.Helper() + + if err := e.cloud.IAM.AttachRolePolicy(context.Background(), role, e.policy(name, doc)); err != nil { + e.t.Fatalf("AttachRolePolicy: %v", err) + } +} + +func (e *enforcedSTS) config(c aws.Credentials) aws.Config { + e.t.Helper() + + cfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion(testRegion), + awsconfig.WithRetryMaxAttempts(1), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(c.AccessKeyID, c.SecretAccessKey, c.SessionToken)), + ) + if err != nil { + e.t.Fatalf("aws config: %v", err) + } + + return cfg +} + +func (e *enforcedSTS) sts(c aws.Credentials) *awssts.Client { + return awssts.NewFromConfig(e.config(c), func(o *awssts.Options) { o.BaseEndpoint = aws.String(e.url) }) +} + +func (e *enforcedSTS) iam(c aws.Credentials) *awsiam.Client { + return awsiam.NewFromConfig(e.config(c), func(o *awsiam.Options) { o.BaseEndpoint = aws.String(e.url) }) +} + +func (e *enforcedSTS) assume(c aws.Credentials, in *awssts.AssumeRoleInput) (aws.Credentials, error) { + if in.RoleSessionName == nil { + in.RoleSessionName = aws.String("s1") + } + + out, err := e.sts(c).AssumeRole(context.Background(), in) + if err != nil { + return aws.Credentials{}, err + } + + return toCreds(out.Credentials), nil +} + +func toCreds(c *ststypes.Credentials) aws.Credentials { + return aws.Credentials{ + AccessKeyID: aws.ToString(c.AccessKeyId), + SecretAccessKey: aws.ToString(c.SecretAccessKey), + SessionToken: aws.ToString(c.SessionToken), + } +} + +func trustOf(statements ...string) string { + return `{"Version":"2012-10-17","Statement":[` + strings.Join(statements, ",") + `]}` +} + +func trustStmt(action, principal, condition string) string { + s := `{"Effect":"Allow","Principal":` + principal + `,"Action":` + action + if condition != "" { + s += `,"Condition":` + condition + } + + return s + "}" +} + +func awsPrincipal(arn string) string { return `{"AWS":"` + arn + `"}` } + +func errCode(err error) string { + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + return apiErr.ErrorCode() + } + + return "" +} + +func wantAssume(t *testing.T, err error, allowed bool) { + t.Helper() + + switch { + case allowed && err != nil: + t.Fatalf("AssumeRole: want success, got %v", err) + case !allowed && errCode(err) != "AccessDenied": + t.Fatalf("AssumeRole: want AccessDenied, got %v", err) + } +} + +// TestEnforcedTrustEvaluatesTheRealCaller covers the trust decision: the trust +// policy is evaluated for the signed caller, a directly named principal needs +// no identity allow, a trusted account still does, and principal types, +// conditions, session tags and source identity are honored. +func TestEnforcedTrustEvaluatesTheRealCaller(t *testing.T) { + assumeAll := `["sts:AssumeRole","sts:TagSession","sts:SetSourceIdentity"]` + tags := []ststypes.Tag{{Key: aws.String("team"), Value: aws.String("blue")}} + + cases := []struct { + name string + trust string + identity string + in awssts.AssumeRoleInput + allowed bool + }{ + {"user named in the trust, no identity allow", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(callerARN), "")), + allowDDB, awssts.AssumeRoleInput{}, true}, + {"account root in the trust, no identity allow", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(acctRoot), "")), + allowDDB, awssts.AssumeRoleInput{}, false}, + {"account id in the trust, identity allow", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(testAccountID), "")), + allowStsAR, awssts.AssumeRoleInput{}, true}, + {"another user in the trust, identity allow", + trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal("arn:aws:iam::"+testAccountID+":user/other"), "")), + allowStsAR, awssts.AssumeRoleInput{}, false}, + {"Federated star never trusts an IAM user", trustOf(trustStmt(`"sts:AssumeRole"`, `{"Federated":"*"}`, "")), + allowStsAR, awssts.AssumeRoleInput{}, false}, + {"Service star never trusts an IAM user", trustOf(trustStmt(`"sts:AssumeRole"`, `{"Service":"*"}`, "")), + allowStsAR, awssts.AssumeRoleInput{}, false}, + {"explicit identity deny beats a named trust", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(callerARN), "")), + `{"Statement":[{"Effect":"Deny","Action":"sts:AssumeRole","Resource":"*"}]}`, awssts.AssumeRoleInput{}, false}, + {"trust deny beats an identity allow", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(acctRoot), ""), + `{"Effect":"Deny","Principal":{"AWS":"`+callerARN+`"},"Action":"sts:AssumeRole"}`), + allowStsAR, awssts.AssumeRoleInput{}, false}, + {"NotPrincipal deny hits an unlisted caller", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(acctRoot), ""), + `{"Effect":"Deny","NotPrincipal":{"AWS":["arn:aws:iam::`+testAccountID+`:user/other","`+acctRoot+`"]},`+ + `"Action":"sts:AssumeRole"}`), allowStsAR, awssts.AssumeRoleInput{}, false}, + {"ExternalId matches", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(callerARN), + `{"StringEquals":{"sts:ExternalId":"ext-1"}}`)), allowDDB, + awssts.AssumeRoleInput{ExternalId: aws.String("ext-1")}, true}, + {"ExternalId differs", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(callerARN), + `{"StringEquals":{"sts:ExternalId":"ext-1"}}`)), allowDDB, + awssts.AssumeRoleInput{ExternalId: aws.String("ext-2")}, false}, + {"ExternalId missing", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(callerARN), + `{"StringEquals":{"sts:ExternalId":"ext-1"}}`)), allowDDB, awssts.AssumeRoleInput{}, false}, + {"RoleSessionName condition met", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(callerARN), + `{"StringLike":{"sts:RoleSessionName":"ci-*"}}`)), allowDDB, + awssts.AssumeRoleInput{RoleSessionName: aws.String("ci-42")}, true}, + {"RoleSessionName condition not met", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(callerARN), + `{"StringLike":{"sts:RoleSessionName":"ci-*"}}`)), allowDDB, + awssts.AssumeRoleInput{RoleSessionName: aws.String("dev")}, false}, + {"tags need sts:TagSession in the trust", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(callerARN), "")), + allowDDB, awssts.AssumeRoleInput{Tags: tags}, false}, + {"tags with sts:TagSession", trustOf(trustStmt(assumeAll, awsPrincipal(callerARN), "")), + allowDDB, awssts.AssumeRoleInput{Tags: tags}, true}, + {"request tag condition", trustOf(trustStmt(assumeAll, awsPrincipal(callerARN), + `{"StringEquals":{"aws:RequestTag/team":"red"}}`)), allowDDB, awssts.AssumeRoleInput{Tags: tags}, false}, + {"tag keys condition", trustOf(trustStmt(assumeAll, awsPrincipal(callerARN), + `{"ForAllValues:StringEquals":{"aws:TagKeys":["team","env"]}}`)), allowDDB, awssts.AssumeRoleInput{Tags: tags}, true}, + {"source identity needs sts:SetSourceIdentity", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(callerARN), "")), + allowDDB, awssts.AssumeRoleInput{SourceIdentity: aws.String("alice")}, false}, + {"source identity allowed and matched", trustOf(trustStmt(assumeAll, awsPrincipal(callerARN), + `{"StringEquals":{"sts:SourceIdentity":"alice"}}`)), allowDDB, + awssts.AssumeRoleInput{SourceIdentity: aws.String("alice")}, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + e := newEnforcedSTS(t) + caller := e.user("caller", tc.identity) + roleArn := e.role("target", "", tc.trust, "") + + in := tc.in + in.RoleArn = aws.String(roleArn) + + _, err := e.assume(caller, &in) + wantAssume(t, err, tc.allowed) + }) + } +} + +// TestEnforcedTrustRoleArnMustMatch covers STS-X2: a RoleArn in another account +// or with a different path names a different role, so it never assumes the +// local one. +func TestEnforcedTrustRoleArnMustMatch(t *testing.T) { + e := newEnforcedSTS(t) + caller := e.user("caller", "") + e.role("app", "/team/", trustOf(trustStmt(`"sts:AssumeRole"`, `"*"`, "")), "") + + for _, arn := range []string{ + "arn:aws:iam::999999999999:role/team/app", + "arn:aws:iam::" + testAccountID + ":role/app", + "arn:aws:iam::" + testAccountID + ":role/other/app", + } { + _, err := e.assume(caller, &awssts.AssumeRoleInput{RoleArn: aws.String(arn)}) + wantAssume(t, err, false) + + if !strings.Contains(err.Error(), "on resource: "+arn) { + t.Fatalf("the deny must name the RoleArn as sent: %v", err) + } + } + + _, err := e.assume(caller, &awssts.AssumeRoleInput{RoleArn: aws.String("arn:aws:iam::" + testAccountID + ":role/team/app")}) + wantAssume(t, err, true) +} + +// TestEnforcedTrustDenyIsNeutral checks a refused AssumeRole names the caller +// and the RoleArn as sent, and reads the same whether the role exists or not. +func TestEnforcedTrustDenyIsNeutral(t *testing.T) { + e := newEnforcedSTS(t) + caller := e.user("caller", allowDDB) + e.role("exists", "", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(acctRoot), "")), "") + + message := func(name string) string { + _, err := e.assume(caller, &awssts.AssumeRoleInput{RoleArn: aws.String("arn:aws:iam::" + testAccountID + ":role/" + name)}) + + var apiErr smithy.APIError + if !errors.As(err, &apiErr) || apiErr.ErrorCode() != "AccessDenied" { + t.Fatalf("AssumeRole %s: want AccessDenied, got %v", name, err) + } + + return apiErr.ErrorMessage() + } + + existing, missing := message("exists"), message("absent") + + want := "User: " + callerARN + " is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::" + + testAccountID + ":role/exists" + if existing != want { + t.Fatalf("message = %q, want %q", existing, want) + } + + if strings.ReplaceAll(missing, "role/absent", "role/exists") != existing { + t.Fatalf("messages differ:\n existing: %s\n missing: %s", existing, missing) + } +} + +// TestEnforcedSessionRestrictions covers what each kind of temporary +// credential may call (IAM User Guide, "Compare AWS STS credentials"). +func TestEnforcedSessionRestrictions(t *testing.T) { + e := newEnforcedSTS(t) + ctx := context.Background() + boot := e.user("boot", "") + e.role("target", "", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(acctRoot), "")), "") + roleArn := aws.String("arn:aws:iam::" + testAccountID + ":role/target") + + fed, err := e.sts(boot).GetFederationToken(ctx, &awssts.GetFederationTokenInput{Name: aws.String("fed")}) + if err != nil { + t.Fatalf("GetFederationToken: %v", err) + } + + tok, err := e.sts(boot).GetSessionToken(ctx, &awssts.GetSessionTokenInput{}) + if err != nil { + t.Fatalf("GetSessionToken: %v", err) + } + + role, err := e.assume(boot, &awssts.AssumeRoleInput{RoleArn: roleArn}) + if err != nil { + t.Fatalf("AssumeRole: %v", err) + } + + federation, session := toCreds(fed.Credentials), toCreds(tok.Credentials) + + t.Run("federation", func(t *testing.T) { + _, err := e.assume(federation, &awssts.AssumeRoleInput{RoleArn: roleArn}) + wantAssume(t, err, false) + + _, err = e.iam(federation).ListUsers(ctx, &awsiam.ListUsersInput{}) + if errCode(err) != "AccessDenied" { + t.Fatalf("ListUsers: want AccessDenied, got %v", err) + } + + if _, err := e.sts(federation).GetCallerIdentity(ctx, &awssts.GetCallerIdentityInput{}); err != nil { + t.Fatalf("GetCallerIdentity: %v", err) + } + }) + + t.Run("session token", func(t *testing.T) { + _, err := e.iam(session).ListUsers(ctx, &awsiam.ListUsersInput{}) + if errCode(err) != "AccessDenied" { + t.Fatalf("ListUsers: want AccessDenied, got %v", err) + } + + _, err = e.sts(session).GetSessionToken(ctx, &awssts.GetSessionTokenInput{}) + if errCode(err) != "AccessDenied" { + t.Fatalf("GetSessionToken: want AccessDenied, got %v", err) + } + + _, err = e.assume(session, &awssts.AssumeRoleInput{RoleArn: roleArn}) + wantAssume(t, err, true) + }) + + t.Run("role session", func(t *testing.T) { + _, err := e.sts(role).GetSessionToken(ctx, &awssts.GetSessionTokenInput{}) + if errCode(err) != "AccessDenied" { + t.Fatalf("GetSessionToken: want AccessDenied, got %v", err) + } + + _, err = e.sts(role).GetFederationToken(ctx, &awssts.GetFederationTokenInput{Name: aws.String("x")}) + if errCode(err) != "AccessDenied" { + t.Fatalf("GetFederationToken: want AccessDenied, got %v", err) + } + + if _, err := e.sts(role).GetCallerIdentity(ctx, &awssts.GetCallerIdentityInput{}); err != nil { + t.Fatalf("GetCallerIdentity: %v", err) + } + }) +} + +// TestEnforcedRoleChaining checks a role session assumes the next role when +// the next role trusts the first role by ARN, or trusts the account and the +// first role's policies allow it. +func TestEnforcedRoleChaining(t *testing.T) { + e := newEnforcedSTS(t) + boot := e.user("boot", "") + + firstArn := e.role("first", "/ops/", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(acctRoot), "")), allowDDB) + byArn := e.role("byarn", "", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(firstArn), "")), "") + byAccount := e.role("byaccount", "", trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal(acctRoot), "")), "") + other := e.role("other", "", trustOf(trustStmt(`"sts:AssumeRole"`, + awsPrincipal("arn:aws:iam::"+testAccountID+":role/elsewhere"), "")), "") + + first, err := e.assume(boot, &awssts.AssumeRoleInput{RoleArn: aws.String(firstArn)}) + if err != nil { + t.Fatalf("AssumeRole first: %v", err) + } + + _, err = e.assume(first, &awssts.AssumeRoleInput{RoleArn: aws.String(byArn)}) + wantAssume(t, err, true) + + _, err = e.assume(first, &awssts.AssumeRoleInput{RoleArn: aws.String(byAccount)}) + wantAssume(t, err, false) + + _, err = e.assume(first, &awssts.AssumeRoleInput{RoleArn: aws.String(other)}) + wantAssume(t, err, false) + + e.attachRole("first", "chain", allowStsAR) + + _, err = e.assume(first, &awssts.AssumeRoleInput{RoleArn: aws.String(byAccount)}) + wantAssume(t, err, true) +} + +// TestEnforcedWebIdentityAndSAMLRefused checks signed AssumeRoleWithWebIdentity +// and AssumeRoleWithSAML are refused under --enforce-auth: the token and the +// assertion are not validated, so even an unrestricted caller gets a 403. +func TestEnforcedWebIdentityAndSAMLRefused(t *testing.T) { + e := newEnforcedSTS(t) + boot := e.user("boot", "") + roleArn := e.role("fed", "", trustOf(trustStmt(`"sts:AssumeRoleWithWebIdentity"`, `{"Federated":"*"}`, "")), "") + + for action, form := range map[string]string{ + "AssumeRoleWithWebIdentity": "&RoleSessionName=s&WebIdentityToken=junk", + "AssumeRoleWithSAML": "&PrincipalArn=arn:aws:iam::" + testAccountID + ":saml-provider/p&SAMLAssertion=eA%3D%3D", + } { + status, body := signedSTSForm(t, e.url, boot, "Action="+action+"&Version=2011-06-15&RoleArn="+roleArn+form) + if status != http.StatusForbidden || !strings.Contains(body, "AccessDenied") || + !strings.Contains(body, action+" is not available under --enforce-auth") { + t.Fatalf("signed %s: %d %s", action, status, body) + } + } +} + +// signedSTSForm sends a SigV4-signed STS query request and returns the status +// and body. +func signedSTSForm(t *testing.T, endpoint string, c aws.Credentials, body string) (int, string) { + t.Helper() + + ctx := context.Background() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/", strings.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + sum := sha256.Sum256([]byte(body)) + if err := v4.NewSigner().SignHTTP(ctx, c, req, hex.EncodeToString(sum[:]), "sts", testRegion, time.Now()); err != nil { + t.Fatalf("sign: %v", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + + return resp.StatusCode, string(raw) +} + +// TestAuthOffTrustUnchanged checks the auth-off path keeps evaluating the +// trust policy for the account root and ignores conditions, while a RoleArn +// for another account or path is still refused (STS-X2). +func TestAuthOffTrustUnchanged(t *testing.T) { + ts, iamClient := newTrustServer(t) + ctx := context.Background() + + trust := trustOf(trustStmt(`"sts:AssumeRole"`, awsPrincipal("arn:aws:iam::123456789012:root"), + `{"StringEquals":{"sts:ExternalId":"ext-1"}}`)) + if _, err := iamClient.CreateRole(ctx, &awsiam.CreateRoleInput{ + RoleName: aws.String("legacy"), Path: aws.String("/p/"), AssumeRolePolicyDocument: aws.String(trust), + }); err != nil { + t.Fatalf("CreateRole: %v", err) + } + + client := stsClient(t, ts.URL) + + if _, err := client.AssumeRole(ctx, &awssts.AssumeRoleInput{ + RoleArn: aws.String("arn:aws:iam::" + testAccountID + ":role/p/legacy"), RoleSessionName: aws.String("s"), + }); err != nil { + t.Fatalf("AssumeRole: %v", err) + } + + for _, arn := range []string{"arn:aws:iam::999999999999:role/p/legacy", "arn:aws:iam::" + testAccountID + ":role/legacy"} { + _, err := client.AssumeRole(ctx, &awssts.AssumeRoleInput{RoleArn: aws.String(arn), RoleSessionName: aws.String("s")}) + assertAccessDenied(t, err) + } +} diff --git a/server/serveflags/serveflags.go b/server/serveflags/serveflags.go index 6bac061f5..509e976b7 100644 --- a/server/serveflags/serveflags.go +++ b/server/serveflags/serveflags.go @@ -243,7 +243,8 @@ func registerEnforceAuthFlag(fs *flag.FlagSet, c *CommonConfig) { "recorded when it issued them, and unknown or expired sessions are rejected. Each request is then authorized against "+ "the caller's IAM policies: per operation for query and JSON-RPC services, and at service level for REST services "+ "(only a service-wide grant such as s3:* passes, so fine-grained REST policies are denied until per-operation "+ - "checks land). Root and users with no policies are unrestricted. Azure: "+ + "checks land). Root and users with no policies are unrestricted. AssumeRole is decided by the role's trust policy "+ + "for the signed caller, and signed AssumeRoleWithWebIdentity/SAML calls are refused. Azure: "+ "validate each request's Bearer token claims (accepted audience, expiry, a principal claim) and reject "+ "missing/malformed/expired/wrong-audience tokens with 401. The token signature is not verified (no Azure AD signing "+ "key), so this is claims-based authentication only; RBAC authorization is a follow-up. The /_cloudemu admin endpoints "+ diff --git a/services/iam/driver/driver.go b/services/iam/driver/driver.go index f01cbdc11..2141d603e 100644 --- a/services/iam/driver/driver.go +++ b/services/iam/driver/driver.go @@ -287,6 +287,41 @@ type PermissionEvaluator interface { EvaluateServiceWide(ctx context.Context, principal, service string, condCtx map[string]string) Decision } +// ConditionValueSeparator joins the values of a multivalued condition key +// (aws:TagKeys, sts:TransitiveTagKeys) in a condition context. Tag keys +// cannot contain it. +const ConditionValueSeparator = "\x1f" + +// TrustRequest asks whether a role's trust policy lets a caller perform +// Action (sts:AssumeRole, sts:TagSession, sts:SetSourceIdentity) on it. +// CallerARNs are every ARN the caller is known by: an IAM user's ARN, or a +// role session's role ARN and assumed-role ARN. CallerAccount is the +// caller's account, matched by a trust that names the account. +type TrustRequest struct { + RoleName string + Action string + CallerARNs []string + CallerAccount string + Context map[string]string +} + +// TrustResult is the trust policy's answer. NamedDirectly is set when an +// allowing statement names one of the caller's ARNs exactly, rather than the +// caller's account or "*". +type TrustResult struct { + RoleExists bool + Allow bool + ExplicitDeny bool + NamedDirectly bool +} + +// TrustEvaluator is an optional capability: an IAM implementation that +// evaluates a role trust policy for a real caller, with principal types, +// NotPrincipal and conditions. The STS handler type-asserts for it. AWS-only. +type TrustEvaluator interface { + EvaluateTrust(ctx context.Context, req *TrustRequest) TrustResult +} + // IAM is the interface that IAM provider implementations must satisfy. type IAM interface { CreateUser(ctx context.Context, config UserConfig) (*UserInfo, error)