From 08833e9833c0e9ae0c56e562ff2fb8c7572b0c17 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Sun, 27 Sep 2026 23:34:04 +0530 Subject: [PATCH] feat(iam): tri-state, unknown-resource and service-wide policy evaluation --- docs/coverage/aws/iam.md | 9 + docs/coverage/coverage.json | 12 + providers/aws/iam/condition.go | 33 +- providers/aws/iam/evaluate_modes_test.go | 478 +++++++++++++++++++++++ providers/aws/iam/iam.go | 14 +- providers/aws/iam/simulate.go | 233 ++++++++++- services/iam/driver/driver.go | 35 ++ 7 files changed, 798 insertions(+), 16 deletions(-) create mode 100644 providers/aws/iam/evaluate_modes_test.go diff --git a/docs/coverage/aws/iam.md b/docs/coverage/aws/iam.md index e5f155e81..cb14cb5c7 100644 --- a/docs/coverage/aws/iam.md +++ b/docs/coverage/aws/iam.md @@ -68,6 +68,15 @@ ContextualAuthorizer is an optional capability: an IAM implementation that can | --- | --- | | `CheckPermissionWithContext` | | +### PermissionEvaluator + +PermissionEvaluator is an optional capability: an IAM implementation that + +| Operation | Description | +| --- | --- | +| `EvaluatePermission` | | +| `EvaluateServiceWide` | | + ### PolicyInspector PolicyInspector is an optional capability: an IAM implementation that can diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index 877eb82e7..d6e327d63 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -8546,6 +8546,18 @@ } ] }, + { + "name": "PermissionEvaluator", + "doc": "PermissionEvaluator is an optional capability: an IAM implementation that", + "operations": [ + { + "name": "EvaluatePermission" + }, + { + "name": "EvaluateServiceWide" + } + ] + }, { "name": "PolicyInspector", "doc": "PolicyInspector is an optional capability: an IAM implementation that can", diff --git a/providers/aws/iam/condition.go b/providers/aws/iam/condition.go index 84d718077..af0b82e27 100644 --- a/providers/aws/iam/condition.go +++ b/providers/aws/iam/condition.go @@ -41,9 +41,31 @@ func (c ConditionContext) get(key string) (string, bool) { // single key the listed values combine with OR (a negative operator requires // that none match). An empty condition block is vacuously true. func evaluateConditions(conds map[string]map[string]any, cctx ConditionContext) bool { + return evaluateConditionsWith(conds, cctx, absentKeyIAM) +} + +// absentKey says how a condition key missing from the request context is +// treated. +type absentKey int + +const ( + // absentKeyIAM is real IAM: a plain operator fails, its ...IfExists form + // passes, and Null tests for presence. + absentKeyIAM absentKey = iota + // absentKeyMatches treats every missing key as satisfied, whatever the + // operator. Used for Deny statements when the resource is unknown. + absentKeyMatches + // absentKeyFails treats every missing key as unsatisfied, whatever the + // operator. Used for Allow statements when the resource is unknown. + absentKeyFails +) + +// evaluateConditionsWith is evaluateConditions with an explicit rule for keys +// the request context does not carry. +func evaluateConditionsWith(conds map[string]map[string]any, cctx ConditionContext, absent absentKey) bool { for rawOp, keyVals := range conds { for key, raw := range keyVals { - if !evaluateConditionKey(rawOp, key, toStringSlice(raw), cctx) { + if !evaluateConditionKey(rawOp, key, toStringSlice(raw), cctx, absent) { return false } } @@ -56,12 +78,17 @@ func evaluateConditions(conds map[string]map[string]any, cctx ConditionContext) // policy-supplied values. It resolves the request value from the context, // applies the ...IfExists rule for an absent key, and dispatches to the operator // family. The Null operator is handled first because it is defined in terms of -// key presence, not the key's value. -func evaluateConditionKey(rawOp, key string, values []string, cctx ConditionContext) bool { +// 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) ctxVal, present := cctx.get(key) + if !present && absent != absentKeyIAM { + return absent == absentKeyMatches + } + if strings.EqualFold(base, "Null") { return evalNull(present, values) } diff --git a/providers/aws/iam/evaluate_modes_test.go b/providers/aws/iam/evaluate_modes_test.go new file mode 100644 index 000000000..f722981ca --- /dev/null +++ b/providers/aws/iam/evaluate_modes_test.go @@ -0,0 +1,478 @@ +package iam + +import ( + "context" + "testing" + + "github.com/stackshy/cloudemu/v2/services/iam/driver" +) + +func polStmt(effect string, fields map[string]any) map[string]any { + s := map[string]any{"Effect": effect} + for k, v := range fields { + s[k] = v + } + + return s +} + +func polDoc(stmts ...map[string]any) string { return makePolicyDoc(stmts) } + +func assertDecision(t *testing.T, want, got string) { + t.Helper() + + if want != got { + t.Errorf("decision: want %s, got %s", want, got) + } +} + +// userWithDocs creates a user carrying one inline policy per document. +func userWithDocs(t *testing.T, m *Mock, name string, docs ...string) { + t.Helper() + requireNoError(t, mustUser(t, m, name)) + + for i, d := range docs { + requireNoError(t, m.PutUserPolicy(context.Background(), name, name+string(rune('a'+i)), d)) + } +} + +func unknownRes(action string, cctx ConditionContext) evalRequest { + return evalRequest{action: action, cctx: cctx} +} + +// legacyDecide is the decide loop as it stood before evaluation modes existed, +// kept here as the reference known-resource mode must reproduce. +func legacyDecide(docs []string, action, resource string, cctx ConditionContext) string { + allow, deny := false, false + + for _, d := range docs { + a, dn := evaluatePolicy(d, action, resource, cctx) + deny = deny || dn + allow = allow || a + } + + switch { + case deny: + return decisionExplicitDeny + case allow: + return decisionAllowed + default: + return decisionImplicitDeny + } +} + +func TestDecideWithKnownResourceMatchesLegacy(t *testing.T) { + docs := []string{ + polDoc(polStmt("Allow", map[string]any{"Action": "s3:*", "Resource": "*"})), + polDoc(polStmt("Allow", map[string]any{"Action": "s3:GetObject", "Resource": "arn:aws:s3:::b/*"})), + polDoc(polStmt("Allow", map[string]any{"NotAction": "iam:*", "Resource": "*"})), + polDoc(polStmt("Allow", map[string]any{"Action": "*", "NotResource": "arn:aws:s3:::secret"})), + polDoc( + polStmt("Allow", map[string]any{"Action": "*", "Resource": "*"}), + polStmt("Deny", map[string]any{"Action": "s3:DeleteBucket", "Resource": "arn:aws:s3:::prod"}), + ), + polDoc(polStmt("Deny", map[string]any{ + "Action": "*", "Resource": "*", + "Condition": map[string]any{"StringEquals": map[string]any{"s3:prefix": "home/"}}, + })), + polDoc(polStmt("Allow", map[string]any{ + "Action": "ec2:*", "Resource": "*", + "Condition": map[string]any{"IpAddress": map[string]any{"aws:SourceIp": "10.0.0.0/8"}}, + })), + `not json`, + } + actions := []string{"s3:GetObject", "s3:DeleteBucket", "iam:CreateUser", "ec2:RunInstances"} + resources := []string{"*", "arn:aws:s3:::b/k", "arn:aws:s3:::prod", "arn:aws:s3:::secret"} + contexts := []ConditionContext{nil, {"aws:SourceIp": "10.1.1.1"}, {"s3:prefix": "home/"}} + + for i := range docs { + for j := i; j < len(docs); j++ { + set := []string{docs[i], docs[j]} + for _, a := range actions { + for _, r := range resources { + for _, c := range contexts { + want := legacyDecide(set, a, r, c) + got := decideWith(set, evalRequest{action: a, resource: r, cctx: c}, evalKnownResource) + if want != got { + t.Fatalf("docs %d,%d %s on %s ctx %v: legacy %s, decideWith %s", i, j, a, r, c, want, got) + } + } + } + } + } + } +} + +func TestUnknownResourceAllow(t *testing.T) { + tests := []struct { + name string + doc string + want string + }{ + {"star resource counts", polDoc(polStmt("Allow", map[string]any{"Action": "s3:GetObject", "Resource": "*"})), decisionAllowed}, + {"star in list counts", polDoc(polStmt("Allow", map[string]any{ + "Action": "s3:GetObject", "Resource": []any{"arn:aws:s3:::b/*", "*"}, + })), decisionAllowed}, + {"specific resource does not count", polDoc(polStmt("Allow", map[string]any{ + "Action": "s3:GetObject", "Resource": "arn:aws:s3:::b/*", + })), decisionImplicitDeny}, + {"partial wildcard does not count", polDoc(polStmt("Allow", map[string]any{ + "Action": "s3:GetObject", "Resource": "arn:aws:s3:::*", + })), decisionImplicitDeny}, + {"NotResource never counts", polDoc(polStmt("Allow", map[string]any{ + "Action": "s3:GetObject", "NotResource": "arn:aws:s3:::secret", + })), decisionImplicitDeny}, + {"action mismatch", polDoc(polStmt("Allow", map[string]any{"Action": "s3:PutObject", "Resource": "*"})), decisionImplicitDeny}, + {"NotAction allow", polDoc(polStmt("Allow", map[string]any{"NotAction": "iam:*", "Resource": "*"})), decisionAllowed}, + {"NotAction excludes action", polDoc(polStmt("Allow", map[string]any{"NotAction": "s3:*", "Resource": "*"})), decisionImplicitDeny}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertDecision(t, tc.want, decideWith([]string{tc.doc}, unknownRes("s3:GetObject", nil), evalUnknownResource)) + }) + } +} + +func TestUnknownResourceDenyIgnoresResource(t *testing.T) { + allowAll := polStmt("Allow", map[string]any{"Action": "*", "Resource": "*"}) + + tests := []struct { + name string + deny map[string]any + want string + }{ + {"specific resource deny counts", polStmt("Deny", map[string]any{ + "Action": "s3:DeleteBucket", "Resource": "arn:aws:s3:::prod", + }), decisionExplicitDeny}, + {"NotResource deny counts", polStmt("Deny", map[string]any{ + "Action": "s3:DeleteBucket", "NotResource": "arn:aws:s3:::scratch", + }), decisionExplicitDeny}, + {"NotAction deny counts", polStmt("Deny", map[string]any{ + "NotAction": "s3:Get*", "Resource": "arn:aws:s3:::prod", + }), decisionExplicitDeny}, + {"other action deny ignored", polStmt("Deny", map[string]any{ + "Action": "s3:PutObject", "Resource": "arn:aws:s3:::prod", + }), decisionAllowed}, + {"NotAction covering action ignored", polStmt("Deny", map[string]any{ + "NotAction": "s3:*", "Resource": "*", + }), decisionAllowed}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := decideWith([]string{polDoc(allowAll, tc.deny)}, unknownRes("s3:DeleteBucket", nil), evalUnknownResource) + assertDecision(t, tc.want, got) + }) + } +} + +// TestUnknownResourceMissingConditionKey covers the fail-closed deviation: with +// an unknown resource, a Deny whose condition key is absent still applies, and +// an Allow whose key is absent does not. +func TestUnknownResourceMissingConditionKey(t *testing.T) { + allowAll := polStmt("Allow", map[string]any{"Action": "*", "Resource": "*"}) + prefixCond := map[string]any{"StringEquals": map[string]any{"s3:prefix": "home/"}} + denyPrefix := polStmt("Deny", map[string]any{"Action": "s3:ListBucket", "Resource": "*", "Condition": prefixCond}) + allowPrefix := polStmt("Allow", map[string]any{"Action": "s3:ListBucket", "Resource": "*", "Condition": prefixCond}) + allowIfExists := polStmt("Allow", map[string]any{ + "Action": "s3:ListBucket", "Resource": "*", + "Condition": map[string]any{"StringEqualsIfExists": map[string]any{"s3:prefix": "home/"}}, + }) + denyIPAndPrefix := polStmt("Deny", map[string]any{ + "Action": "s3:ListBucket", "Resource": "*", + "Condition": map[string]any{ + "IpAddress": map[string]any{"aws:SourceIp": "10.0.0.0/8"}, + "StringEquals": map[string]any{"s3:prefix": "home/"}, + }, + }) + + tests := []struct { + name string + docs []string + cctx ConditionContext + mode evalMode + want string + }{ + {"deny with missing key counts", []string{polDoc(allowAll, denyPrefix)}, nil, evalUnknownResource, decisionExplicitDeny}, + {"deny with present non-matching key ignored", []string{polDoc(allowAll, denyPrefix)}, + ConditionContext{"s3:prefix": "other/"}, evalUnknownResource, decisionAllowed}, + {"deny present key still ANDs with missing key", []string{polDoc(allowAll, denyIPAndPrefix)}, + ConditionContext{"aws:SourceIp": "10.1.2.3"}, evalUnknownResource, decisionExplicitDeny}, + {"deny present key failing wins over missing key", []string{polDoc(allowAll, denyIPAndPrefix)}, + ConditionContext{"aws:SourceIp": "8.8.8.8"}, evalUnknownResource, decisionAllowed}, + {"allow with missing key ignored", []string{polDoc(allowPrefix)}, nil, evalUnknownResource, decisionImplicitDeny}, + {"allow IfExists with missing key ignored", []string{polDoc(allowIfExists)}, nil, evalUnknownResource, decisionImplicitDeny}, + {"allow with present matching key counts", []string{polDoc(allowPrefix)}, + ConditionContext{"s3:prefix": "home/"}, evalUnknownResource, decisionAllowed}, + {"known mode keeps real IAM semantics for deny", []string{polDoc(allowAll, denyPrefix)}, nil, evalKnownResource, decisionAllowed}, + {"known mode keeps IfExists allow", []string{polDoc(allowIfExists)}, nil, evalKnownResource, decisionAllowed}, + {"service-wide deny with missing key counts", []string{polDoc(allowAll, denyPrefix)}, nil, evalServiceWide, decisionExplicitDeny}, + {"service-wide allow with missing key ignored", []string{polDoc(allowPrefix)}, nil, evalServiceWide, decisionImplicitDeny}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := evalRequest{action: "s3:ListBucket", resource: "*", service: "s3", cctx: tc.cctx} + assertDecision(t, tc.want, decideWith(tc.docs, req, tc.mode)) + }) + } +} + +func TestServiceWideDecisions(t *testing.T) { + allowAll := polStmt("Allow", map[string]any{"Action": "*", "Resource": "*"}) + allow := func(field string, v any) map[string]any { + return polStmt("Allow", map[string]any{field: v, "Resource": "*"}) + } + deny := func(field string, v any) map[string]any { + return polStmt("Deny", map[string]any{field: v, "Resource": "arn:aws:s3:::prod"}) + } + + tests := []struct { + name string + stmts []map[string]any + svc string + want string + }{ + {"allow star", []map[string]any{allowAll}, "s3", decisionAllowed}, + {"allow svc star", []map[string]any{allow("Action", "s3:*")}, "s3", decisionAllowed}, + {"allow prefix wildcard", []map[string]any{allow("Action", "s*")}, "s3", decisionAllowed}, + {"allow svc star in list", []map[string]any{allow("Action", []any{"s3:GetObject", "s3:*"})}, "s3", decisionAllowed}, + {"allow partial action", []map[string]any{allow("Action", "s3:Get*")}, "s3", decisionImplicitDeny}, + {"allow other service", []map[string]any{allow("Action", "ec2:*")}, "s3", decisionImplicitDeny}, + {"allow svc star on specific resource", []map[string]any{ + polStmt("Allow", map[string]any{"Action": "s3:*", "Resource": "arn:aws:s3:::b"}), + }, "s3", decisionImplicitDeny}, + {"allow NotResource", []map[string]any{ + polStmt("Allow", map[string]any{"Action": "s3:*", "NotResource": "arn:aws:s3:::b"}), + }, "s3", decisionImplicitDeny}, + {"allow NotAction other service", []map[string]any{allow("NotAction", []any{"iam:*", "organizations:*"})}, "s3", decisionAllowed}, + {"allow NotAction same service", []map[string]any{allow("NotAction", []any{"iam:*", "organizations:*"})}, "iam", decisionImplicitDeny}, + {"allow NotAction partial same service", []map[string]any{allow("NotAction", "s3:DeleteBucket")}, "s3", decisionImplicitDeny}, + {"allow NotAction star", []map[string]any{allow("NotAction", "*")}, "s3", decisionImplicitDeny}, + {"allow NotAction service wildcard", []map[string]any{allow("NotAction", "s*:*")}, "s3", decisionImplicitDeny}, + {"deny one action", []map[string]any{allowAll, deny("Action", "s3:DeleteBucket")}, "s3", decisionExplicitDeny}, + {"deny wildcard service", []map[string]any{allowAll, deny("Action", "*:Delete*")}, "s3", decisionExplicitDeny}, + {"deny pattern without colon", []map[string]any{allowAll, deny("Action", "s3*")}, "s3", decisionExplicitDeny}, + {"deny other service", []map[string]any{allowAll, deny("Action", "ec2:*")}, "s3", decisionAllowed}, + {"deny NotResource", []map[string]any{allowAll, polStmt("Deny", map[string]any{ + "Action": "s3:PutObject", "NotResource": "arn:aws:s3:::b/*", + })}, "s3", decisionExplicitDeny}, + {"deny NotAction covers svc", []map[string]any{allowAll, deny("NotAction", "s3:*")}, "s3", decisionAllowed}, + {"deny NotAction star covers svc", []map[string]any{allowAll, deny("NotAction", "*")}, "s3", decisionAllowed}, + {"deny NotAction partial", []map[string]any{allowAll, deny("NotAction", "s3:Get*")}, "s3", decisionExplicitDeny}, + {"deny NotAction other service", []map[string]any{allowAll, deny("NotAction", "iam:*")}, "s3", decisionExplicitDeny}, + {"no statements", nil, "s3", decisionImplicitDeny}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + docs := []string{} + if tc.stmts != nil { + docs = append(docs, polDoc(tc.stmts...)) + } + + assertDecision(t, tc.want, decideWith(docs, evalRequest{service: tc.svc}, evalServiceWide)) + }) + } +} + +func TestEvaluatePermissionTriState(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + userWithDocs(t, m, "ann", polDoc( + polStmt("Allow", map[string]any{"Action": "s3:*", "Resource": "*"}), + polStmt("Deny", map[string]any{"Action": "s3:DeleteBucket", "Resource": "arn:aws:s3:::prod"}), + )) + + tests := []struct { + name string + req driver.EvalRequest + want driver.Decision + }{ + {"known allowed", driver.EvalRequest{ + Principal: "ann", Action: "s3:GetObject", Resource: "arn:aws:s3:::b/k", ResourceKnown: true, + }, driver.DecisionAllowed}, + {"known implicit deny", driver.EvalRequest{ + Principal: "ann", Action: "ec2:RunInstances", Resource: "*", ResourceKnown: true, + }, driver.DecisionImplicitDeny}, + {"known explicit deny", driver.EvalRequest{ + Principal: "ann", Action: "s3:DeleteBucket", Resource: "arn:aws:s3:::prod", ResourceKnown: true, + }, driver.DecisionExplicitDeny}, + {"known other bucket allowed", driver.EvalRequest{ + Principal: "ann", Action: "s3:DeleteBucket", Resource: "arn:aws:s3:::dev", ResourceKnown: true, + }, driver.DecisionAllowed}, + {"unknown resource deny applies", driver.EvalRequest{ + Principal: "ann", Action: "s3:DeleteBucket", + }, driver.DecisionExplicitDeny}, + {"unknown resource allowed", driver.EvalRequest{Principal: "ann", Action: "s3:GetObject"}, driver.DecisionAllowed}, + {"unknown principal", driver.EvalRequest{ + Principal: "ghost", Action: "s3:GetObject", Resource: "*", ResourceKnown: true, + }, driver.DecisionImplicitDeny}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertEqual(t, tc.want, m.EvaluatePermission(ctx, tc.req)) + }) + } + + assertEqual(t, driver.DecisionExplicitDeny, m.EvaluateServiceWide(ctx, "ann", "s3", nil)) + assertEqual(t, driver.DecisionImplicitDeny, m.EvaluateServiceWide(ctx, "ann", "ec2", nil)) + assertEqual(t, driver.DecisionImplicitDeny, m.EvaluateServiceWide(ctx, "ghost", "s3", nil)) +} + +// TestEvaluateExplicitDenyBeatsAllow proves a Deny in one policy source (a +// group) overrides an Allow in another (an inline user policy) in every mode. +func TestEvaluateExplicitDenyBeatsAllow(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + userWithDocs(t, m, "bea", polDoc(polStmt("Allow", map[string]any{"Action": "*", "Resource": "*"}))) + requireNoError(t, mustGroup(t, m, "locked")) + requireNoError(t, m.PutGroupPolicy(ctx, "locked", "deny-iam", polDoc( + polStmt("Deny", map[string]any{"Action": "iam:*", "Resource": "*"}), + ))) + requireNoError(t, m.AddUserToGroup(ctx, "bea", "locked")) + + assertEqual(t, driver.DecisionExplicitDeny, m.EvaluatePermission(ctx, driver.EvalRequest{ + Principal: "bea", Action: "iam:CreateUser", Resource: "*", ResourceKnown: true, + })) + assertEqual(t, driver.DecisionExplicitDeny, m.EvaluatePermission(ctx, driver.EvalRequest{ + Principal: "bea", Action: "iam:CreateUser", + })) + assertEqual(t, driver.DecisionExplicitDeny, m.EvaluateServiceWide(ctx, "bea", "iam", nil)) + assertEqual(t, driver.DecisionAllowed, m.EvaluateServiceWide(ctx, "bea", "s3", nil)) +} + +func TestEvaluatePermissionsBoundaryModes(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + userWithDocs(t, m, "cal", polDoc(polStmt("Allow", map[string]any{"Action": "*", "Resource": "*"}))) + + boundary, err := m.CreatePolicy(ctx, driver.PolicyConfig{ + Name: "boundary", + PolicyDocument: polDoc( + polStmt("Allow", map[string]any{"Action": "s3:*", "Resource": "arn:aws:s3:::data/*"}), + polStmt("Allow", map[string]any{"Action": "sqs:*", "Resource": "*"}), + polStmt("Deny", map[string]any{"Action": "sqs:DeleteQueue", "Resource": "arn:aws:sqs:us-east-1:123456789012:prod"}), + ), + }) + requireNoError(t, err) + requireNoError(t, m.PutUserPermissionsBoundary(ctx, "cal", boundary.ARN)) + + tests := []struct { + name string + req driver.EvalRequest + want driver.Decision + }{ + {"known inside boundary", driver.EvalRequest{ + Principal: "cal", Action: "s3:GetObject", Resource: "arn:aws:s3:::data/x", ResourceKnown: true, + }, driver.DecisionAllowed}, + {"known outside boundary resource", driver.EvalRequest{ + Principal: "cal", Action: "s3:GetObject", Resource: "arn:aws:s3:::other/x", ResourceKnown: true, + }, driver.DecisionImplicitDeny}, + {"unknown resource boundary needs star", driver.EvalRequest{Principal: "cal", Action: "s3:GetObject"}, driver.DecisionImplicitDeny}, + {"unknown resource boundary star", driver.EvalRequest{Principal: "cal", Action: "sqs:SendMessage"}, driver.DecisionAllowed}, + {"unknown resource boundary deny", driver.EvalRequest{Principal: "cal", Action: "sqs:DeleteQueue"}, driver.DecisionExplicitDeny}, + {"outside boundary action", driver.EvalRequest{Principal: "cal", Action: "ec2:RunInstances"}, driver.DecisionImplicitDeny}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertEqual(t, tc.want, m.EvaluatePermission(ctx, tc.req)) + }) + } + + assertEqual(t, driver.DecisionImplicitDeny, m.EvaluateServiceWide(ctx, "cal", "s3", nil)) + assertEqual(t, driver.DecisionExplicitDeny, m.EvaluateServiceWide(ctx, "cal", "sqs", nil)) + assertEqual(t, driver.DecisionImplicitDeny, m.EvaluateServiceWide(ctx, "cal", "ec2", nil)) + + // CheckPermission keeps treating the boundary as it did before. + ok, err := m.CheckPermission(ctx, "cal", "s3:GetObject", "arn:aws:s3:::data/x") + requireNoError(t, err) + assertEqual(t, true, ok) + + ok, err = m.CheckPermission(ctx, "cal", "s3:GetObject", "arn:aws:s3:::other/x") + requireNoError(t, err) + assertEqual(t, false, ok) +} + +// TestCheckPermissionAgreesWithKnownEvaluation checks that CheckPermission and +// CheckPermissionWithContext return exactly what they did before, and that the +// known-resource EvaluatePermission agrees with them on the same corpus. +func TestCheckPermissionAgreesWithKnownEvaluation(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + userWithDocs(t, m, "dee", + polDoc(polStmt("Allow", map[string]any{"Action": "s3:GetObject", "Resource": "arn:aws:s3:::allowed/*"})), + polDoc(polStmt("Allow", map[string]any{ + "Action": "ec2:RunInstances", "Resource": "*", + "Condition": map[string]any{"IpAddress": map[string]any{"aws:SourceIp": "10.0.0.0/8"}}, + })), + polDoc( + polStmt("Allow", map[string]any{"NotAction": "iam:*", "Resource": "*"}), + polStmt("Deny", map[string]any{"Action": "dynamodb:DeleteTable", "Resource": "*"}), + ), + ) + + tests := []struct { + action, resource string + cctx map[string]string + want bool + }{ + {"s3:GetObject", "arn:aws:s3:::allowed/r.txt", nil, true}, + {"s3:GetObject", "arn:aws:s3:::other/r.txt", nil, true}, // NotAction iam:* allows it + {"ec2:RunInstances", "*", map[string]string{"aws:SourceIp": "10.9.9.9"}, true}, + {"ec2:RunInstances", "*", nil, true}, + {"iam:CreateUser", "*", nil, false}, + {"iam:CreateUser", "*", map[string]string{"aws:SourceIp": "10.9.9.9"}, false}, + {"dynamodb:DeleteTable", "arn:aws:dynamodb:us-east-1:123456789012:table/t", nil, false}, + {"dynamodb:PutItem", "arn:aws:dynamodb:us-east-1:123456789012:table/t", nil, true}, + } + + for _, tc := range tests { + got, err := m.CheckPermissionWithContext(ctx, "dee", tc.action, tc.resource, tc.cctx) + requireNoError(t, err) + assertEqual(t, tc.want, got) + + if tc.cctx == nil { + plain, perr := m.CheckPermission(ctx, "dee", tc.action, tc.resource) + requireNoError(t, perr) + assertEqual(t, tc.want, plain) + } + + dec := m.EvaluatePermission(ctx, driver.EvalRequest{ + Principal: "dee", Action: tc.action, Resource: tc.resource, ResourceKnown: true, Context: tc.cctx, + }) + assertEqual(t, tc.want, dec == driver.DecisionAllowed) + } +} + +func TestCouldMatchService(t *testing.T) { + tests := []struct { + pattern, svc string + want bool + }{ + {"*", "s3", true}, + {"s3:*", "s3", true}, + {"s3:GetObject", "s3", true}, + {"*:Get*", "s3", true}, + {"s*:*", "s3", true}, + {"s3*", "s3", true}, + {"s*", "sqs", true}, + {"ec2:*", "s3", false}, + {"s3", "s3", false}, + {"sqs*", "s3", false}, + {"", "s3", false}, + } + + for _, tc := range tests { + if got := couldMatchService(tc.pattern, tc.svc); got != tc.want { + t.Errorf("couldMatchService(%q, %q) = %v, want %v", tc.pattern, tc.svc, got, tc.want) + } + } +} diff --git a/providers/aws/iam/iam.go b/providers/aws/iam/iam.go index 6b27a3e19..a1c0e8cc4 100644 --- a/providers/aws/iam/iam.go +++ b/providers/aws/iam/iam.go @@ -923,19 +923,9 @@ func (m *Mock) CheckPermission(ctx context.Context, principal, action, resource func (m *Mock) CheckPermissionWithContext( _ context.Context, principal, action, resource string, cctx map[string]string, ) (bool, error) { - entityType := m.principalEntityType(principal) - ctxKeys := ConditionContext(cctx) + req := evalRequest{action: action, resource: resource, cctx: ConditionContext(cctx)} - if decide(m.gatherPrincipalDocs(entityType, principal), action, resource, ctxKeys) != decisionAllowed { - return false, nil - } - - if boundary, ok := m.permissionsBoundaryDoc(entityType, principal); ok && - decide([]string{boundary}, action, resource, ctxKeys) != decisionAllowed { - return false, nil - } - - return true, nil + return m.evaluatePrincipal(principal, req, evalKnownResource) == decisionAllowed, nil } // principalEntityType classifies an IAM principal named by its friendly name as diff --git a/providers/aws/iam/simulate.go b/providers/aws/iam/simulate.go index a8324a7bd..f7c25b6d1 100644 --- a/providers/aws/iam/simulate.go +++ b/providers/aws/iam/simulate.go @@ -2,6 +2,7 @@ package iam import ( "context" + "encoding/json" "strings" "github.com/stackshy/cloudemu/v2/services/iam/driver" @@ -64,14 +65,51 @@ func simulate(docs, actions, resourceARNs []string, cctx ConditionContext) []dri return results } +// evalMode selects how much the evaluator knows about the request. +type evalMode int + +const ( + // evalKnownResource evaluates one action against one concrete resource with + // real IAM semantics. + evalKnownResource evalMode = iota + // evalUnknownResource evaluates one action when the caller cannot name the + // resource. The answer is never more permissive than it would be for any + // concrete resource. + evalUnknownResource + // evalServiceWide asks whether every action of one service is allowed on + // every resource. + evalServiceWide +) + +// evalRequest is one question for decideWith. action and resource are used by +// the per-action modes, service by evalServiceWide. +type evalRequest struct { + action string + resource string + service string + cctx ConditionContext +} + // decide reduces a set of policy documents to a single simulation decision for // one action/resource: an explicit Deny wins, then any Allow, else the default // implicit deny. func decide(docs []string, action, resource string, cctx ConditionContext) string { + return decideWith(docs, evalRequest{action: action, resource: resource, cctx: cctx}, evalKnownResource) +} + +// decideWith is decide for any evalMode. Known-resource mode is plain IAM +// evaluation; the other modes use evaluatePolicyConservative. +func decideWith(docs []string, req evalRequest, mode evalMode) string { allow, deny := false, false for _, doc := range docs { - a, d := evaluatePolicy(doc, action, resource, cctx) + var a, d bool + if mode == evalKnownResource { + a, d = evaluatePolicy(doc, req.action, req.resource, req.cctx) + } else { + a, d = evaluatePolicyConservative(doc, req, mode) + } + if d { deny = true } @@ -91,6 +129,199 @@ func decide(docs []string, action, resource string, cctx ConditionContext) strin } } +// evaluatePolicyConservative evaluates one document when the resource is not +// known (evalUnknownResource or evalServiceWide). An Allow counts only when it +// surely covers the request: its Resource list holds "*", and every condition +// key it tests is present and satisfied. A Deny counts whenever it might apply: +// its resource is ignored, and a condition key missing from the context is +// taken as satisfied. +// +// The Deny rule for a missing key deliberately differs from real IAM, where a +// plain operator on a missing key is false for Deny too. With the resource +// unknown we cannot prove the Deny would not apply to the real request, so we +// fail closed (AUTHZ-X1 design, section 1.2). Do not "fix" this toward +// real IAM: it would turn into a Deny bypass. Known-resource mode keeps real +// semantics. +func evaluatePolicyConservative(doc string, req evalRequest, mode evalMode) (allow, deny bool) { + var pd policyDoc + if err := json.Unmarshal([]byte(doc), &pd); err != nil { + return false, false + } + + for i := range pd.Statement { + s := &pd.Statement[i] + + switch { + case strings.EqualFold(s.Effect, "Deny"): + if s.denyMayApply(req, mode) { + deny = true + } + case strings.EqualFold(s.Effect, "Allow"): + if s.allowSurelyApplies(req, mode) { + allow = true + } + } + } + + return allow, deny +} + +// denyMayApply reports whether a Deny statement could match some request the +// query describes. Resource and NotResource are ignored. +func (s *policyStatement) denyMayApply(req evalRequest, mode evalMode) bool { + if mode == evalServiceWide { + switch { + case s.Action != nil: + if !anyCouldMatchService(toStringSlice(s.Action), req.service) { + return false + } + case s.NotAction != nil: + // NotAction applies to everything it does not list, so the Deny + // misses the service only when one entry covers all of svc:*. + if anyCoversService(toStringSlice(s.NotAction), req.service) { + return false + } + default: + return false + } + } else if !s.actionMatches(req.action) { + return false + } + + return evaluateConditionsWith(s.Condition, req.cctx, absentKeyMatches) +} + +// allowSurelyApplies reports whether an Allow statement matches every request +// the query describes. It needs Resource "*" (NotResource never qualifies). +func (s *policyStatement) allowSurelyApplies(req evalRequest, mode evalMode) bool { + if s.Resource == nil || !containsStar(toStringSlice(s.Resource)) { + return false + } + + if mode == evalServiceWide { + switch { + case s.Action != nil: + if !anyCoversService(toStringSlice(s.Action), req.service) { + return false + } + case s.NotAction != nil: + if anyCouldMatchService(toStringSlice(s.NotAction), req.service) { + return false + } + default: + return false + } + } else if !s.actionMatches(req.action) { + return false + } + + return evaluateConditionsWith(s.Condition, req.cctx, absentKeyFails) +} + +func containsStar(resources []string) bool { + for _, r := range resources { + if r == "*" { + return true + } + } + + return false +} + +// anyCoversService reports whether one pattern matches every action of svc. +// Matching the pattern against the literal "svc:*" does that: "*", "s3:*" and +// "s*" cover s3, "s3:Get*" does not. +func anyCoversService(patterns []string, svc string) bool { + for _, p := range patterns { + if wildcardMatch(p, svc+":*") { + return true + } + } + + return false +} + +func anyCouldMatchService(patterns []string, svc string) bool { + for _, p := range patterns { + if couldMatchService(p, svc) { + return true + } + } + + return false +} + +// couldMatchService reports whether pattern might match some "svc:Action". It +// may say yes for a pattern that cannot really match, never the reverse, which +// is the safe direction for both of its callers. +func couldMatchService(pattern, svc string) bool { + if head, _, ok := strings.Cut(pattern, ":"); ok { + // Actions carry exactly one colon, so the pattern's first colon lines up + // with it and the head must match the service name. + return wildcardMatch(head, svc) + } + + // With no colon, only a '*' can span the separator, so the text before the + // first '*' must be a prefix of the service name. + star := strings.IndexByte(pattern, '*') + if star < 0 { + return false + } + + return strings.HasPrefix(svc, pattern[:star]) +} + +// EvaluatePermission reports the tri-state decision for one action. With +// req.ResourceKnown it is exactly CheckPermissionWithContext's evaluation; +// without it the resource is treated as unknown (see evaluatePolicyConservative). +// It implements driver.PermissionEvaluator. +func (m *Mock) EvaluatePermission(_ context.Context, req driver.EvalRequest) driver.Decision { + mode := evalUnknownResource + if req.ResourceKnown { + mode = evalKnownResource + } + + q := evalRequest{action: req.Action, resource: req.Resource, cctx: ConditionContext(req.Context)} + + return driver.Decision(m.evaluatePrincipal(req.Principal, q, mode)) +} + +// EvaluateServiceWide reports whether principal may perform every action of +// service on every resource. It implements driver.PermissionEvaluator. +func (m *Mock) EvaluateServiceWide( + _ context.Context, principal, service string, condCtx map[string]string, +) driver.Decision { + q := evalRequest{service: service, cctx: ConditionContext(condCtx)} + + return driver.Decision(m.evaluatePrincipal(principal, q, evalServiceWide)) +} + +// evaluatePrincipal combines a principal's identity policies with its +// permissions boundary, both evaluated in the same mode. An explicit Deny in +// either wins; otherwise both must allow. +func (m *Mock) evaluatePrincipal(principal string, req evalRequest, mode evalMode) string { + entityType := m.principalEntityType(principal) + + identity := decideWith(m.gatherPrincipalDocs(entityType, principal), req, mode) + if identity == decisionExplicitDeny { + return identity + } + + boundary := decisionAllowed + if doc, ok := m.permissionsBoundaryDoc(entityType, principal); ok { + boundary = decideWith([]string{doc}, req, mode) + } + + switch { + case boundary == decisionExplicitDeny: + return decisionExplicitDeny + case identity == decisionAllowed && boundary == decisionAllowed: + return decisionAllowed + default: + return decisionImplicitDeny + } +} + // parsePrincipalARN extracts the entity type ("user", "role", or "group") and // friendly name from an IAM principal ARN, tolerating an embedded path. func parsePrincipalARN(arn string) (entityType, name string) { diff --git a/services/iam/driver/driver.go b/services/iam/driver/driver.go index fad7b29b9..0a961f418 100644 --- a/services/iam/driver/driver.go +++ b/services/iam/driver/driver.go @@ -243,6 +243,41 @@ type ContextualAuthorizer interface { ) (bool, error) } +// Decision is the outcome of one policy evaluation. The values match the AWS +// SimulatePolicy EvalDecision strings. +type Decision string + +// Policy evaluation outcomes. An explicit Deny always wins; an implicit deny +// means no statement allowed the request. +const ( + DecisionAllowed Decision = "allowed" + DecisionImplicitDeny Decision = "implicitDeny" + DecisionExplicitDeny Decision = "explicitDeny" +) + +// EvalRequest is one permission question for PermissionEvaluator. When +// ResourceKnown is false the caller cannot name the target resource, and the +// evaluator answers conservatively: it never allows more than it would for any +// concrete resource. Resource is ignored in that case. +type EvalRequest struct { + Principal string + Action string + Resource string + ResourceKnown bool + Context map[string]string +} + +// PermissionEvaluator is an optional capability: an IAM implementation that +// reports the full tri-state decision (allowed, implicit deny, explicit deny) +// and can evaluate a request whose resource is unknown, or a whole service +// ("may this principal use any s3 action on anything?"). The AWS authorization +// gate type-asserts for it. Like ContextualAuthorizer it is AWS-only and +// therefore not part of the shared IAM interface. +type PermissionEvaluator interface { + EvaluatePermission(ctx context.Context, req EvalRequest) Decision + EvaluateServiceWide(ctx context.Context, principal, service string, condCtx map[string]string) Decision +} + // IAM is the interface that IAM provider implementations must satisfy. type IAM interface { CreateUser(ctx context.Context, config UserConfig) (*UserInfo, error)