Skip to content
Merged
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ failure replays from its seed.
| Package | What it does |
| --- | --- |
| [`fault`](https://pkg.go.dev/github.com/parallelworks/squall/fault) | Decides which calls fail and how: rejected, applied with the response lost, applied with an error, an operation that ends in an error, or slow. Scripted rules and a seeded random rate. |
| [`access`](https://pkg.go.dev/github.com/parallelworks/squall/access) | Grants a set of permissions (Google Cloud IAM permissions, AWS IAM actions or Azure RBAC actions) and refuses calls that need one it lacks, with the error the cloud answers, including those a request's fields add and Azure's linked checks. Reports which permissions were needed but missing and which were granted but never used. |
| [`aws/ec2`](https://pkg.go.dev/github.com/parallelworks/squall/aws/ec2) | Amazon EC2: instances (run with client tokens, describe with filters and pages, start, stop, terminate, change type), images (describe, register from snapshots, deregister), instance types, EBS volumes (create, describe, attach, detach, delete), EBS snapshots (create, describe with filters and pages, delete), placement groups, tags, Elastic IP addresses (allocate, describe, release, associate with an interface or instance, disassociate) and network interfaces (create with client tokens, describe, delete). |
| [`aws/s3`](https://pkg.go.dev/github.com/parallelworks/squall/aws/s3) | Amazon S3: general purpose buckets (create, head, list with pages and filters, delete), their tags, versioning, policy and CORS rules, and objects (put, delete one or many, list versions and delete markers). |
| [`azure/arm`](https://pkg.go.dev/github.com/parallelworks/squall/azure/arm) | Azure Resource Manager: resource groups (get, create or update, list with tag filters, delete with everything in them that nothing outside uses), virtual machines (create or update, get, list, instance view, deallocate, start, delete), Uniform scale sets (create or update, update, get, list, delete, delete instances) and their VMs, network security groups, network interfaces and public IP addresses, the IP configurations of scale set VMs, private DNS zones, virtual network links and A record sets, and long-running operations to poll. |
Expand Down Expand Up @@ -63,6 +64,21 @@ for seed := range uint64(100) {
}
```

To check that a list of required permissions is exact, grant only it and see
what the code under test needed:

```go
policy := access.Allow(requiredPermissions...)
s := compute.New(compute.WithPolicy(policy))
// ... run the code under test against s
for _, d := range policy.Denials() {
t.Errorf("%s on %s needs %v", d.Method, d.Resource, d.Missing)
}
if unused := policy.Unused(); len(unused) > 0 {
t.Logf("granted but never used: %v", unused)
}
```

## Faithfulness

Each stand-in follows the real API's requests, response shapes and error codes for the
Expand Down
157 changes: 157 additions & 0 deletions access/access.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Package access decides which API calls a stand-in server allows, as the
// cloud's access control would, and records the permissions each call needed.
//
// A [Policy] holds the permissions a test grants: Google Cloud IAM permissions
// such as "compute.instances.create", AWS IAM actions such as
// "ec2:RunInstances", or Azure RBAC actions such as
// "Microsoft.Compute/virtualMachines/write". A server asks it about every call
// with [Policy.Check] and refuses a call that needs a permission the policy
// lacks, with the error the real cloud returns. Running code under test with
// exactly the permissions it claims to need shows which it needs but lacks
// ([Policy.Denials]) and which it holds but never uses ([Policy.Unused]).
//
// A nil *Policy allows every call and records nothing.
package access

import (
"maps"
"slices"
"strings"
"sync"
)

// Call is one API request as the policy sees it.
type Call struct {
// Method is the API method ID, as in fault.Call.
Method string
// Resource names what the call acts on, as in fault.Call.
Resource string
// Needs is every permission the call needs, including those a field of
// the request adds, such as a disk an instance insert creates. A need
// may list alternatives separated by |, any one of which suffices, where
// the cloud documents "one or more of" without saying which applies.
Needs []string
}

// Denial is a call the policy refused.
type Denial struct {
Call
// Missing is the permissions the call needs that the policy lacks.
Missing []string
}

// Policy grants a set of permissions. It is safe for concurrent use.
type Policy struct {
grants []string

mu sync.Mutex
used map[string]bool
denials []Denial
}

// Allow returns a policy that grants exactly perms. A grant may use * to match
// any run of characters, as AWS and Azure grants do, such as "ec2:Describe*".
// Grants match without regard to case.
func Allow(perms ...string) *Policy {
return &Policy{grants: slices.Clone(perms), used: map[string]bool{}}
}

// AllowAll returns a policy that grants every permission and records each
// one a call needs.
func AllowAll() *Policy {
return Allow("*")
}

// Check records c and returns the permissions it needs that the policy lacks,
// or nil if the call is allowed. A server refuses a call for which Check
// returns any.
func (p *Policy) Check(c Call) []string {
if p == nil {
return nil
}
var missing, satisfied []string
for _, need := range c.Needs {
alternatives := strings.Split(need, "|")
i := slices.IndexFunc(alternatives, p.granted)
if i < 0 {
missing = append(missing, need)
continue
}
satisfied = append(satisfied, alternatives[i])
}
p.mu.Lock()
defer p.mu.Unlock()
if len(missing) > 0 {
c.Needs = slices.Clone(c.Needs)
p.denials = append(p.denials, Denial{Call: c, Missing: missing})
return missing
}
for _, need := range satisfied {
p.used[need] = true
}
return nil
}

// Used returns, sorted, every permission an allowed call needed.
func (p *Policy) Used() []string {
if p == nil {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
return slices.Sorted(maps.Keys(p.used))
}

// Unused returns the grants that no allowed call needed, in the order they
// were granted. A wildcard grant counts as used when it matched any need.
func (p *Policy) Unused() []string {
if p == nil {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
var unused []string
for _, grant := range p.grants {
usedBy := func(need string) bool { return match(grant, need) }
if !slices.ContainsFunc(slices.Collect(maps.Keys(p.used)), usedBy) {
unused = append(unused, grant)
}
}
return unused
}

// Denials returns the calls the policy refused, in order.
func (p *Policy) Denials() []Denial {
if p == nil {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
return slices.Clone(p.denials)
}

func (p *Policy) granted(need string) bool {
return slices.ContainsFunc(p.grants, func(grant string) bool { return match(grant, need) })
}

// match reports whether grant covers need, where * in grant matches any run
// of characters and case is ignored.
func match(grant, need string) bool {
grant, need = strings.ToLower(grant), strings.ToLower(need)
parts := strings.Split(grant, "*")
if len(parts) == 1 {
return grant == need
}
if !strings.HasPrefix(need, parts[0]) {
return false
}
need = need[len(parts[0]):]
for _, part := range parts[1 : len(parts)-1] {
i := strings.Index(need, part)
if i < 0 {
return false
}
need = need[i+len(part):]
}
return strings.HasSuffix(need, parts[len(parts)-1])
}
117 changes: 117 additions & 0 deletions access/access_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package access

import (
"sync"
"testing"
)

func TestMatch(t *testing.T) {
cases := []struct {
grant, need string
want bool
}{
{"compute.instances.create", "compute.instances.create", true},
{"compute.instances.create", "compute.instances.delete", false},
{"ec2:Describe*", "ec2:DescribeInstances", true},
{"ec2:describe*", "EC2:DescribeInstances", true},
{"ec2:Describe*", "ec2:RunInstances", false},
{"Microsoft.Compute/*/read", "Microsoft.Compute/virtualMachines/read", true},
{"Microsoft.Compute/*/read", "Microsoft.Compute/virtualMachines/write", false},
{"*", "s3:PutObject", true},
{"a*b*c", "abc", true},
{"a*b*c", "ac", false},
{"*x*x", "x", false},
{"*x*x", "xx", true},
}
for _, c := range cases {
if got := match(c.grant, c.need); got != c.want {
t.Errorf("match(%q, %q) = %v, want %v", c.grant, c.need, got, c.want)
}
}
}

func TestPolicyRecordsUsesAndDenials(t *testing.T) {
p := Allow("compute.instances.create", "compute.disks.create", "compute.routes.delete")

if missing := p.Check(Call{Method: "compute.instances.insert", Resource: "i", Needs: []string{"compute.instances.create", "compute.disks.create"}}); missing != nil {
t.Fatalf("allowed call reported missing %v", missing)
}
missing := p.Check(Call{Method: "compute.instances.insert", Resource: "j", Needs: []string{"compute.instances.create", "compute.instances.setMetadata"}})
if len(missing) != 1 || missing[0] != "compute.instances.setMetadata" {
t.Fatalf("missing = %v, want [compute.instances.setMetadata]", missing)
}

if got := p.Used(); len(got) != 2 || got[0] != "compute.disks.create" || got[1] != "compute.instances.create" {
t.Errorf("Used() = %v", got)
}
if got := p.Unused(); len(got) != 1 || got[0] != "compute.routes.delete" {
t.Errorf("Unused() = %v, want [compute.routes.delete]", got)
}
denials := p.Denials()
if len(denials) != 1 || denials[0].Resource != "j" || denials[0].Missing[0] != "compute.instances.setMetadata" {
t.Errorf("Denials() = %+v", denials)
}
}

func TestDeniedCallRecordsNoUse(t *testing.T) {
p := Allow("s3:PutObject")
p.Check(Call{Method: "s3.PutObject", Needs: []string{"s3:PutObject", "s3:PutObjectTagging"}})
if got := p.Used(); len(got) != 0 {
t.Errorf("Used() = %v, want none: the call was refused", got)
}
}

func TestWildcardGrantCountsAsUsed(t *testing.T) {
p := Allow("ec2:Describe*", "ec2:RunInstances")
p.Check(Call{Method: "ec2.DescribeInstances", Needs: []string{"ec2:DescribeInstances"}})
if got := p.Unused(); len(got) != 1 || got[0] != "ec2:RunInstances" {
t.Errorf("Unused() = %v, want [ec2:RunInstances]", got)
}
}

func TestAlternativeNeedIsMetByAnyOne(t *testing.T) {
p := Allow("compute.addresses.createInternal")
if missing := p.Check(Call{Needs: []string{"compute.addresses.create|compute.addresses.createInternal"}}); missing != nil {
t.Fatalf("missing = %v, want none", missing)
}
if got := p.Used(); len(got) != 1 || got[0] != "compute.addresses.createInternal" {
t.Errorf("Used() = %v, want the alternative that was granted", got)
}
missing := Allow("x").Check(Call{Needs: []string{"a|b"}})
if len(missing) != 1 || missing[0] != "a|b" {
t.Errorf("missing = %v, want [a|b]", missing)
}
}

func TestNilPolicyAllowsEverything(t *testing.T) {
var p *Policy
if missing := p.Check(Call{Needs: []string{"anything"}}); missing != nil {
t.Errorf("nil policy refused a call: %v", missing)
}
if p.Used() != nil || p.Unused() != nil || p.Denials() != nil {
t.Error("nil policy recorded something")
}
}

func TestAllowAllRecordsEveryNeed(t *testing.T) {
p := AllowAll()
p.Check(Call{Needs: []string{"b", "a"}})
if got := p.Used(); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Errorf("Used() = %v", got)
}
}

func TestPolicyIsSafeForConcurrentUse(t *testing.T) {
p := Allow("x")
var wg sync.WaitGroup
for range 50 {
wg.Go(func() {
p.Check(Call{Needs: []string{"x"}})
p.Check(Call{Needs: []string{"y"}})
})
}
wg.Wait()
if got := len(p.Denials()); got != 50 {
t.Errorf("%d denials, want 50", got)
}
}
34 changes: 34 additions & 0 deletions aws/ec2/permissions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package ec2

import (
"fmt"
"net/http"

"github.com/parallelworks/squall/access"
)

// WithPolicy makes the server refuse calls that need an IAM action p lacks,
// as EC2 refuses them, and records what each call needed. Without it every
// call is allowed.
func WithPolicy(p *access.Policy) Option {
return func(s *Server) { s.policy = p }
}

// needs returns the IAM actions a call to the EC2 action name needs: the
// action itself, and ec2:CreateTags when it tags what it creates.
func needs(name string, p *params) []string {
perms := []string{"ec2:" + name}
if name != "CreateTags" && len(p.indexes("TagSpecification")) > 0 {
perms = append(perms, "ec2:CreateTags")
}
return perms
}

func unauthorized(missing []string) *apiError {
return &apiError{
Status: http.StatusForbidden,
Code: "UnauthorizedOperation",
Message: fmt.Sprintf("You are not authorized to perform this operation. User is not authorized to perform: %s because no identity-based policy allows the %s action",
missing[0], missing[0]),
}
}
17 changes: 16 additions & 1 deletion aws/ec2/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@
// images, placement groups (by group ID), network interfaces and Elastic
// IP addresses, and change nothing unless every resource exists.
//
// [WithPolicy] grants the caller a set of IAM actions and refuses a call that
// needs one it lacks with 403 UnauthorizedOperation, before any fault. A call
// needs ec2: and its action, and ec2:CreateTags when it has a
// TagSpecification. Actions are not checked against the resources they act
// on or against condition keys: a grant covers every resource.
//
// Fault method names are "ec2." and the action, such as "ec2.AllocateAddress".
// A call's resource is the region and the ID it acts on, such as
// "us-east-1/eipalloc-00000000000000001", or the first of them for a call
Expand Down Expand Up @@ -150,6 +156,7 @@ import (
"sync"
"sync/atomic"

"github.com/parallelworks/squall/access"
"github.com/parallelworks/squall/fault"
"github.com/parallelworks/squall/internal/stub"
)
Expand All @@ -166,6 +173,7 @@ const (
// safe for concurrent use.
type Server struct {
faults *fault.Injector
policy *access.Policy
addressLimit int
settleAfter int
actions map[string]action
Expand Down Expand Up @@ -316,7 +324,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
writeError(w, requestID, err)
return
}
f, _ := s.faults.Decide(fault.Call{Method: "ec2." + name, Resource: a.target(c)})
resource := a.target(c)
if s.policy != nil {
if missing := s.policy.Check(access.Call{Method: "ec2." + name, Resource: resource, Needs: needs(name, c.p)}); missing != nil {
writeError(w, requestID, unauthorized(missing))
return
}
}
f, _ := s.faults.Decide(fault.Call{Method: "ec2." + name, Resource: resource})
reject := f.Mode == fault.ModeReject || f.Mode == fault.ModeFailOperation
stub.Serve(w, r, f, reject, "text/xml;charset=UTF-8", func() { writeError(w, requestID, faultError(f)) }, func() (func(), bool) {
body, err := a.handle(c)
Expand Down
Loading
Loading