Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions contrib/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
94 changes: 94 additions & 0 deletions contrib/server/enforce_authz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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, "</"+name+">")

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) {
Expand Down
8 changes: 8 additions & 0 deletions docs/coverage/aws/iam.md
Original file line number Diff line number Diff line change
Expand Up @@ -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._
9 changes: 9 additions & 0 deletions docs/coverage/coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -9100,6 +9100,15 @@
"name": "PrincipalHasPolicies"
}
]
},
{
"name": "TrustEvaluator",
"doc": "TrustEvaluator is an optional capability: an IAM implementation that",
"operations": [
{
"name": "EvaluateTrust"
}
]
}
],
"providers": {
Expand Down
50 changes: 49 additions & 1 deletion providers/aws/iam/condition.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down
148 changes: 148 additions & 0 deletions providers/aws/iam/evaluate_trust_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading