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)