From 5a4d57808b13e7f46d99a154aa4f7eb39e6c3d39 Mon Sep 17 00:00:00 2001 From: Michael McQuade Date: Thu, 22 Oct 2026 20:12:08 -0500 Subject: [PATCH 1/3] feat: access policies that refuse calls missing a permission and record what each call needed --- access/access.go | 157 ++++++++++++++++ access/access_test.go | 117 ++++++++++++ azure/arm/permissions.go | 168 +++++++++++++++++ azure/arm/server.go | 17 ++ gcp/compute/permissions.go | 230 ++++++++++++++++++++++++ gcp/compute/server.go | 21 +++ gcp/storage/permissions.go | 76 ++++++++ gcp/storage/server.go | 23 ++- sdktest/azure/arm/permissions_test.go | 76 ++++++++ sdktest/gcp/compute/permissions_test.go | 95 ++++++++++ sdktest/gcp/storage/permissions_test.go | 56 ++++++ 11 files changed, 1033 insertions(+), 3 deletions(-) create mode 100644 access/access.go create mode 100644 access/access_test.go create mode 100644 azure/arm/permissions.go create mode 100644 gcp/compute/permissions.go create mode 100644 gcp/storage/permissions.go create mode 100644 sdktest/azure/arm/permissions_test.go create mode 100644 sdktest/gcp/compute/permissions_test.go create mode 100644 sdktest/gcp/storage/permissions_test.go diff --git a/access/access.go b/access/access.go new file mode 100644 index 0000000..466bde4 --- /dev/null +++ b/access/access.go @@ -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]) +} diff --git a/access/access_test.go b/access/access_test.go new file mode 100644 index 0000000..447c3ab --- /dev/null +++ b/access/access_test.go @@ -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) + } +} diff --git a/azure/arm/permissions.go b/azure/arm/permissions.go new file mode 100644 index 0000000..cccd0a1 --- /dev/null +++ b/azure/arm/permissions.go @@ -0,0 +1,168 @@ +package arm + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "slices" + "strings" + + "github.com/parallelworks/squall/access" +) + +// WithPolicy makes the server refuse calls that need an action p lacks, as +// Azure RBAC 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 } +} + +// policyActions are the RBAC actions a route checks where they differ from +// its fault method name. Microsoft.Compute documents no +// locations/operationResults/read; its operation results are read with +// locations/operations/read. +var policyActions = map[string]string{ + "Microsoft.Compute/locations/operationResults/read": "Microsoft.Compute/locations/operations/read", +} + +// link is an action a request needs on another resource it references. +type link struct { + // path leads to the ID in the request body; "[]" steps into each element of an array. + path []string + action string +} + +func linkAt(action, path string) link { return link{path: strings.Split(path, "."), action: action} } + +// linkedActions are the linked access checks ARM makes on resources a write references. +var linkedActions = map[string][]link{ + "Microsoft.Network/networkInterfaces/write": { + linkAt("Microsoft.Network/virtualNetworks/subnets/join/action", "properties.ipConfigurations.[].properties.subnet.id"), + linkAt("Microsoft.Network/publicIPAddresses/join/action", "properties.ipConfigurations.[].properties.publicIPAddress.id"), + linkAt("Microsoft.Network/networkSecurityGroups/join/action", "properties.networkSecurityGroup.id"), + }, + "Microsoft.Network/privateDnsZones/virtualNetworkLinks/write": { + linkAt("Microsoft.Network/virtualNetworks/join/action", "properties.virtualNetwork.id"), + }, + "Microsoft.Compute/virtualMachines/write": { + linkAt("Microsoft.Network/networkInterfaces/join/action", "properties.networkProfile.networkInterfaces.[].id"), + linkAt("Microsoft.Compute/disks/read", "properties.storageProfile.osDisk.managedDisk.id"), + linkAt(imageRead, "properties.storageProfile.imageReference.id"), + }, + "Microsoft.Compute/virtualMachineScaleSets/write": { + linkAt("Microsoft.Network/virtualNetworks/subnets/join/action", + "properties.virtualMachineProfile.networkProfile.networkInterfaceConfigurations.[].properties.ipConfigurations.[].properties.subnet.id"), + linkAt("Microsoft.Network/networkSecurityGroups/join/action", + "properties.virtualMachineProfile.networkProfile.networkInterfaceConfigurations.[].properties.networkSecurityGroup.id"), + linkAt(imageRead, "properties.virtualMachineProfile.storageProfile.imageReference.id"), + }, +} + +// imageRead stands for the read action on an image ID, which depends on whether it names a managed image or a gallery image version. +const imageRead = "image read" + +func imageAction(id string) string { + if strings.Contains(strings.ToLower(id), "/providers/microsoft.compute/galleries/") { + return "Microsoft.Compute/galleries/images/versions/read" + } + return "Microsoft.Compute/images/read" +} + +// scoped is an action needed on a scope. +type scoped struct { + action, scope string +} + +// needs returns the action a call to rt checks on its own scope and the +// actions it needs on the resources its body references, leaving the body +// for the handler. +func needs(rt *route, r *http.Request) (string, []scoped) { + action := rt.action + if a, ok := policyActions[action]; ok { + action = a + } + links := linkedActions[action] + if len(links) == 0 || r.Method != http.MethodPut { + return action, nil + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxBody)) + r.Body = io.NopCloser(bytes.NewReader(body)) + var doc any + if err != nil || json.Unmarshal(body, &doc) != nil { + return action, nil + } + var linked []scoped + for _, l := range links { + for _, id := range idsAt(doc, l.path) { + a := l.action + if a == imageRead { + a = imageAction(id) + } + if !slices.Contains(linked, scoped{a, id}) { + linked = append(linked, scoped{a, id}) + } + } + } + return action, linked +} + +// idsAt returns the non-empty strings at path in v, matching keys without regard to case. +func idsAt(v any, path []string) []string { + if len(path) == 0 { + if s, ok := v.(string); ok && s != "" { + return []string{s} + } + return nil + } + if path[0] == "[]" { + var out []string + items, _ := v.([]any) + for _, item := range items { + out = append(out, idsAt(item, path[1:])...) + } + return out + } + m, _ := v.(map[string]any) + for k, child := range m { + if strings.EqualFold(k, path[0]) { + return idsAt(child, path[1:]) + } + } + return nil +} + +// authorize checks a call against the policy and returns the error ARM +// answers when it is refused. +func (s *Server) authorize(rt *route, r *http.Request, scope string) *apiError { + if s.policy == nil { + return nil + } + action, linked := needs(rt, r) + call := access.Call{Method: rt.action, Resource: scope, Needs: []string{action}} + for _, l := range linked { + call.Needs = append(call.Needs, l.action) + } + missing := s.policy.Check(call) + if missing == nil { + return nil + } + if slices.Contains(missing, action) { + return newError(http.StatusForbidden, "AuthorizationFailed", + "The client '%s' with object id '%s' does not have authorization to perform action '%s' over scope '%s' or the scope is invalid. If access was recently granted, please refresh your credentials.", + clientID, clientID, action, scope) + } + var actions, scopes []string + for _, l := range linked { + if slices.Contains(missing, l.action) { + actions = append(actions, l.action) + scopes = append(scopes, l.scope) + } + } + return newError(http.StatusForbidden, "LinkedAuthorizationFailed", + "The client '%s' with object id '%s' has permission to perform action '%s' on scope '%s'; however, it does not have permission to perform action(s) '%s' on the linked scope(s) '%s' (respectively) or the linked scope(s) are invalid.", + clientID, clientID, action, scope, strings.Join(actions, ","), strings.Join(scopes, ",")) +} + +// clientID is the object ID refusals name, as the server does not read the caller's token. +const clientID = "00000000-0000-0000-0000-000000000000" diff --git a/azure/arm/server.go b/azure/arm/server.go index 7dccec1..1b99a56 100644 --- a/azure/arm/server.go +++ b/azure/arm/server.go @@ -121,6 +121,17 @@ // 429 and 5xx answers, and keeps polling a resource group deletion whose // Location answers one, so fail that operation with a status such as 409. // +// [WithPolicy] grants the caller a set of RBAC actions and refuses a call +// that needs one it lacks with 403 AuthorizationFailed, before any fault. A +// write that references another resource also needs the action ARM checks on +// it, such as Microsoft.Network/virtualNetworks/subnets/join/action for a +// network interface's subnet or Microsoft.Network/networkInterfaces/join/action +// for a VM's interface, and fails without it with LinkedAuthorizationFailed, +// naming each action and resource it lacks. Polling a Microsoft.Compute +// operation result needs Microsoft.Compute/locations/operations/read, as +// Microsoft.Compute defines no operationResults action. Actions are not +// checked against the scope they act on: a grant covers every subscription. +// // A lost response aborts the handler with [http.ErrAbortHandler] after // sending the response header, so the [http.Server] serving it closes the // connection. Calling ServeHTTP directly propagates that panic. A HEAD's @@ -141,6 +152,7 @@ import ( "sync" "time" + "github.com/parallelworks/squall/access" "github.com/parallelworks/squall/fault" "github.com/parallelworks/squall/internal/stub" ) @@ -160,6 +172,7 @@ const ( // is safe for concurrent use. type Server struct { faults *fault.Injector + policy *access.Policy now func() time.Time settleAfter int routes []route @@ -379,6 +392,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeError(w, newError(http.StatusNotFound, "NotFound", "squall: %s /%s is not served", r.Method, path)) return } + if err := s.authorize(rt, r, "/"+path); err != nil { + writeError(w, err) + return + } f, _ := s.faults.Decide(fault.Call{Method: rt.action, Resource: "/" + path}) reject := f.Mode == fault.ModeReject || f.Mode == fault.ModeFailOperation && !rt.lro stub.Serve(w, r, f, reject, "application/json; charset=utf-8", func() { writeError(w, faultError(f)) }, func() (func(), bool) { diff --git a/gcp/compute/permissions.go b/gcp/compute/permissions.go new file mode 100644 index 0000000..169d03f --- /dev/null +++ b/gcp/compute/permissions.go @@ -0,0 +1,230 @@ +package compute + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/parallelworks/squall/access" +) + +// WithPolicy makes the server refuse calls that need a permission p lacks, +// as Compute Engine 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 } +} + +// basePermissions is what Compute Engine checks for each method, from each +// method's "IAM Permissions" in the REST reference. +var basePermissions = map[string][]string{ + "compute.addresses.list": {"compute.addresses.list"}, + "compute.addresses.insert": {"compute.addresses.create|compute.addresses.createInternal"}, + "compute.addresses.get": {"compute.addresses.get"}, + "compute.addresses.delete": {"compute.addresses.delete|compute.addresses.deleteInternal"}, + "compute.addresses.setLabels": {"compute.addresses.setLabels"}, + + "compute.zones.list": {"compute.zones.list"}, + "compute.zones.get": {"compute.zones.get"}, + + "compute.instances.bulkInsert": {"compute.instances.create"}, + "compute.instances.insert": {"compute.instances.create"}, + "compute.instances.list": {"compute.instances.list"}, + "compute.instances.aggregatedList": {"compute.instances.list"}, + "compute.instances.get": {"compute.instances.get"}, + "compute.instances.delete": {"compute.instances.delete"}, + "compute.instances.stop": {"compute.instances.stop"}, + "compute.instances.start": {"compute.instances.start"}, + "compute.instances.setLabels": {"compute.instances.setLabels"}, + "compute.instances.setMachineType": {"compute.instances.setMachineType"}, + "compute.instances.addAccessConfig": {"compute.instances.addAccessConfig", "compute.subnetworks.useExternalIp|compute.networks.useExternalIp"}, + "compute.instances.attachDisk": {"compute.instances.attachDisk"}, + "compute.instances.detachDisk": {"compute.instances.detachDisk"}, + + "compute.disks.list": {"compute.disks.list"}, + "compute.disks.aggregatedList": {"compute.disks.list"}, + "compute.disks.insert": {"compute.disks.create"}, + "compute.disks.get": {"compute.disks.get"}, + "compute.disks.delete": {"compute.disks.delete"}, + + "compute.snapshots.list": {"compute.snapshots.list"}, + "compute.snapshots.insert": {"compute.snapshots.create"}, + "compute.snapshots.get": {"compute.snapshots.get"}, + "compute.snapshots.delete": {"compute.snapshots.delete"}, + + "compute.resourcePolicies.list": {"compute.resourcePolicies.list"}, + "compute.resourcePolicies.insert": {"compute.resourcePolicies.create"}, + "compute.resourcePolicies.get": {"compute.resourcePolicies.get"}, + "compute.resourcePolicies.delete": {"compute.resourcePolicies.delete"}, + + "compute.instanceTemplates.list": {"compute.instanceTemplates.list"}, + "compute.instanceTemplates.insert": {"compute.instanceTemplates.create"}, + "compute.instanceTemplates.get": {"compute.instanceTemplates.get"}, + "compute.instanceTemplates.delete": {"compute.instanceTemplates.delete"}, + + "compute.instanceGroupManagers.aggregatedList": {"compute.instanceGroupManagers.list"}, + + "compute.routes.list": {"compute.routes.list"}, + "compute.routes.insert": {"compute.routes.create", "compute.networks.updatePolicy"}, + "compute.routes.get": {"compute.routes.get"}, + "compute.routes.delete": {"compute.routes.delete"}, + + "compute.zoneOperations.get": {"compute.zoneOperations.get"}, + "compute.zoneOperations.wait": {"compute.zoneOperations.get"}, + "compute.regionOperations.get": {"compute.regionOperations.get"}, + "compute.regionOperations.wait": {"compute.regionOperations.get"}, + "compute.globalOperations.get": {"compute.globalOperations.get"}, + "compute.globalOperations.wait": {"compute.globalOperations.get"}, +} + +// requestFields is the part of a request body that adds permissions. +type requestFields struct { + permissionFields + InstanceProperties *permissionFields `json:"instanceProperties"` + SourceInstanceTemplate string `json:"sourceInstanceTemplate"` + Properties *permissionFields `json:"properties"` + + AddressType string `json:"addressType"` + SourceImage string `json:"sourceImage"` + SourceSnapshot string `json:"sourceSnapshot"` + SourceDisk string `json:"sourceDisk"` + Source string `json:"source"` + Mode string `json:"mode"` + NatIP string `json:"natIP"` +} + +type permissionFields struct { + Disks []struct { + Source string `json:"source"` + Mode string `json:"mode"` + InitializeParams *struct { + SourceImage string `json:"sourceImage"` + SourceSnapshot string `json:"sourceSnapshot"` + Labels map[string]string `json:"labels"` + } `json:"initializeParams"` + } `json:"disks"` + NetworkInterfaces []struct { + Network string `json:"network"` + Subnetwork string `json:"subnetwork"` + AccessConfigs []struct { + NatIP string `json:"natIP"` + } `json:"accessConfigs"` + } `json:"networkInterfaces"` + Metadata json.RawMessage `json:"metadata"` + Tags json.RawMessage `json:"tags"` + Labels map[string]string `json:"labels"` + Subnetwork string `json:"subnetwork"` + ServiceAccounts []json.RawMessage `json:"serviceAccounts"` + ResourcePolicies []string `json:"resourcePolicies"` + DeletionProtection bool `json:"deletionProtection"` +} + +// needs returns every permission a call to method needs, reading the fields +// of r's body that add permissions and leaving the body for the handler. +func needs(method string, r *http.Request) []string { + perms := basePermissions[method] + if r.Method != http.MethodPost { + return perms + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxBody)) + r.Body = io.NopCloser(bytes.NewReader(body)) + var f requestFields + if err != nil || json.Unmarshal(body, &f) != nil { + return perms + } + perms = append([]string(nil), perms...) + add := func(cond bool, more ...string) { + if cond { + perms = append(perms, more...) + } + } + switch method { + case "compute.instances.insert": + perms = append(perms, f.permissionFields.needs()...) + add(f.DeletionProtection, "compute.instances.setDeletionProtection") + case "compute.instances.bulkInsert": + add(f.SourceInstanceTemplate != "", "compute.instanceTemplates.useReadOnly") + if f.InstanceProperties != nil { + perms = append(perms, f.InstanceProperties.needs()...) + } + case "compute.instanceTemplates.insert": + if p := f.Properties; p != nil { + for _, ni := range p.NetworkInterfaces { + add(ni.Network != "", "compute.networks.get") + add(ni.Subnetwork != "", "compute.subnetworks.get") + } + for _, d := range p.Disks { + add(d.InitializeParams != nil && d.InitializeParams.SourceImage != "", "compute.images.get") + } + } + case "compute.addresses.insert": + add(len(f.Labels) > 0, "compute.addresses.setLabels") + add(f.Subnetwork != "", "compute.subnetworks.use") + case "compute.disks.insert": + add(f.SourceImage != "", "compute.images.useReadOnly") + add(f.SourceSnapshot != "", "compute.snapshots.useReadOnly") + add(len(f.Labels) > 0, "compute.disks.setLabels") + case "compute.snapshots.insert": + add(f.SourceDisk != "", "compute.disks.createSnapshot") + add(len(f.Labels) > 0, "compute.snapshots.setLabels") + case "compute.instances.attachDisk": + add(f.Source != "", diskUse(f.Mode)) + case "compute.instances.addAccessConfig": + add(f.NatIP != "", "compute.addresses.use") + } + return perms +} + +func (f *permissionFields) needs() []string { + var perms []string + add := func(cond bool, more ...string) { + if cond { + perms = append(perms, more...) + } + } + for _, d := range f.Disks { + if ip := d.InitializeParams; ip != nil { + perms = append(perms, "compute.disks.create") + add(ip.SourceImage != "", "compute.images.useReadOnly") + add(ip.SourceSnapshot != "", "compute.snapshots.useReadOnly") + add(len(ip.Labels) > 0, "compute.disks.setLabels") + } + add(d.Source != "", diskUse(d.Mode)) + } + for _, ni := range f.NetworkInterfaces { + add(ni.Network != "", "compute.networks.use") + add(ni.Subnetwork != "", "compute.subnetworks.use") + for _, ac := range ni.AccessConfigs { + if ni.Subnetwork != "" { + perms = append(perms, "compute.subnetworks.useExternalIp") + } else { + perms = append(perms, "compute.networks.useExternalIp") + } + add(ac.NatIP != "", "compute.addresses.use") + } + } + add(len(f.Metadata) > 0 && string(f.Metadata) != "null", "compute.instances.setMetadata") + add(len(f.Tags) > 0 && string(f.Tags) != "null", "compute.instances.setTags") + add(len(f.Labels) > 0, "compute.instances.setLabels") + add(len(f.ServiceAccounts) > 0, "compute.instances.setServiceAccount", "iam.serviceAccounts.actAs") + add(len(f.ResourcePolicies) > 0, "compute.resourcePolicies.use") + return perms +} + +func diskUse(mode string) string { + if mode == "READ_ONLY" { + return "compute.disks.useReadOnly" + } + return "compute.disks.use" +} + +func permissionDenied(missing []string, resource string) *apiError { + return &apiError{ + Status: http.StatusForbidden, + Reason: "forbidden", + Message: fmt.Sprintf("Required '%s' permission for '%s'", strings.Split(missing[0], "|")[0], resource), + } +} diff --git a/gcp/compute/server.go b/gcp/compute/server.go index b63c6d5..577f742 100644 --- a/gcp/compute/server.go +++ b/gcp/compute/server.go @@ -88,6 +88,19 @@ // first instance it names in perInstanceProperties, or the zone's // instances when it names none. // +// [WithPolicy] grants the caller a set of IAM permissions and refuses a call +// that needs one it lacks with 403 forbidden, "Required '' +// permission for ''", before any fault. A call needs its method's +// permission and those the fields of its request add, as each method's +// reference lists them: an instance insert that creates a boot disk from an +// image needs compute.disks.create and compute.images.useReadOnly, one with +// metadata compute.instances.setMetadata, and one with a service account +// compute.instances.setServiceAccount and iam.serviceAccounts.actAs. Where +// the reference names either of two permissions, as for an address insert +// that may be external or internal, either suffices. Polling an operation +// needs its scope's operations get permission. Permissions are not checked +// against the resource they act on: a grant covers every project. +// // A lost response aborts the handler with [http.ErrAbortHandler] after // sending the response header, so the [http.Server] serving it closes the // connection. Calling ServeHTTP directly propagates that panic. @@ -106,6 +119,7 @@ import ( "sync" "time" + "github.com/parallelworks/squall/access" "github.com/parallelworks/squall/fault" "github.com/parallelworks/squall/internal/stub" ) @@ -124,6 +138,7 @@ const betaSelfLinkBase = "https://www.googleapis.com/compute/beta/" type Server struct { mux *http.ServeMux faults *fault.Injector + policy *access.Policy now func() time.Time settleAfter int @@ -324,6 +339,12 @@ func (s *Server) route(pattern, method, verb string, h handler, target func(*htt if target != nil { resource = target(r, resource) } + if s.policy != nil { + if missing := s.policy.Check(access.Call{Method: method, Resource: resource, Needs: needs(method, r)}); missing != nil { + writeError(w, permissionDenied(missing, resource)) + return + } + } f, _ := s.faults.Decide(fault.Call{Method: method, Resource: resource}) reject := f.Mode == fault.ModeReject || f.Mode == fault.ModeFailOperation && !startsOperation stub.Serve(w, r, f, reject, "", func() { writeError(w, faultError(f)) }, func() (func(), bool) { diff --git a/gcp/storage/permissions.go b/gcp/storage/permissions.go new file mode 100644 index 0000000..fa36ddb --- /dev/null +++ b/gcp/storage/permissions.go @@ -0,0 +1,76 @@ +package storage + +import ( + "fmt" + "net/http" + "strings" + + "github.com/parallelworks/squall/access" +) + +// WithPolicy makes the server refuse calls that need a permission p lacks, +// as Cloud Storage 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 } +} + +// basePermissions is what Cloud Storage checks for each method, from each +// method's "Required permissions" in the JSON API reference. +var basePermissions = map[string][]string{ + "storage.buckets.insert": {"storage.buckets.create"}, + "storage.buckets.list": {"storage.buckets.list"}, + "storage.buckets.get": {"storage.buckets.get"}, + "storage.buckets.patch": {"storage.buckets.update"}, + "storage.buckets.delete": {"storage.buckets.delete"}, + "storage.objects.list": {"storage.objects.list"}, + "storage.objects.delete": {"storage.objects.delete"}, + "storage.objects.insert": {"storage.objects.create"}, +} + +// needs returns every permission a call to method on resource needs. +func (s *Server) needs(method, resource string, r *http.Request) []string { + perms := append([]string(nil), basePermissions[method]...) + q := r.URL.Query() + add := func(cond bool, more ...string) { + if cond { + perms = append(perms, more...) + } + } + full := q.Get("projection") == "full" + switch method { + case "storage.buckets.insert": + add(q.Get("enableObjectRetention") == "true", "storage.buckets.enableObjectRetention") + case "storage.buckets.list", "storage.buckets.get": + add(full, "storage.buckets.getIamPolicy") + case "storage.buckets.patch": + add(q.Get("predefinedAcl") != "" || q.Get("predefinedDefaultObjectAcl") != "", "storage.buckets.setIamPolicy") + add(full, "storage.buckets.getIamPolicy") + case "storage.objects.list": + add(full, "storage.objects.getIamPolicy") + case "storage.objects.insert": + add(s.exists(resource), "storage.objects.delete") + } + return perms +} + +// exists reports whether resource, as "b//o/", names a live object. +func (s *Server) exists(resource string) bool { + bucketName, name, ok := strings.Cut(strings.TrimPrefix(resource, "b/"), "/o/") + if !ok { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + b := s.buckets[bucketName] + return b != nil && b.liveObject(name) != nil +} + +func permissionDenied(missing []string, resource string) *apiError { + perm := strings.Split(missing[0], "|")[0] + return &apiError{ + Status: http.StatusForbidden, + Reason: "forbidden", + Message: fmt.Sprintf("The caller does not have %s access to %s. Permission '%s' denied on resource (or it may not exist).", perm, resource, perm), + } +} diff --git a/gcp/storage/server.go b/gcp/storage/server.go index 3eea10e..841d988 100644 --- a/gcp/storage/server.go +++ b/gcp/storage/server.go @@ -20,7 +20,8 @@ // global, as in Cloud Storage. [Server.PutForeignBucket] holds a name for // someone the caller cannot reach: inserting it fails with 409 conflict // and every other call on it with 403 forbidden. buckets.list lists only -// the project's own. Other denials are a fault rule away, such as +// the project's own. [WithPolicy] denies calls by permission, and +// other denials are a fault rule away, such as // fault.Reject(403, "forbidden") for "storage.buckets.list". // - Inserting a bucket the caller already has fails with 409 conflict, as a // client's retry of a create that took effect does. Bucket names follow @@ -32,8 +33,8 @@ // - Bucket fields other than name, location, storageClass, labels, // versioning, cors, customPlacementConfig, encryption and rpo are // rejected with 400, as are ACLs other than private and projectPrivate. -// cors, encryption and rpo are recorded, not enforced. There is no IAM, -// soft delete, retention, lifecycle, autoclass or hierarchical namespace. +// cors, encryption and rpo are recorded, not enforced. There are no IAM +// policies on buckets or objects, and no soft delete, retention, lifecycle, autoclass or hierarchical namespace. // buckets.patch has patch semantics: it merges labels, deleting one set // to null, and clears labels, versioning, cors or encryption set to null. // - Objects record their size, MD5 and CRC32C, not their data; a crc32c or @@ -61,6 +62,14 @@ // "projects/my-project/b". The JSON API has no long-running operations, so // [fault.ModeFailOperation] fails a call as [fault.ModeReject] does. // +// [WithPolicy] grants the caller a set of IAM permissions and refuses a call +// that needs one it lacks with 403 forbidden, before any fault. A call needs +// its method's permission, and those its parameters add, as each method's +// reference lists them: projection=full needs getIamPolicy, a predefined ACL +// on a patch storage.buckets.setIamPolicy, and an upload that replaces a live +// object storage.objects.delete. Permissions are not checked against the +// bucket they act on: a grant covers every bucket. +// // A lost response aborts the handler with [http.ErrAbortHandler] after // sending the response header, so the [http.Server] serving it closes the // connection. Calling ServeHTTP directly propagates that panic. @@ -82,6 +91,7 @@ import ( "sync" "time" + "github.com/parallelworks/squall/access" "github.com/parallelworks/squall/fault" "github.com/parallelworks/squall/internal/stub" ) @@ -100,6 +110,7 @@ const ( type Server struct { mux *http.ServeMux faults *fault.Injector + policy *access.Policy now func() time.Time mu sync.Mutex @@ -202,6 +213,12 @@ func (s *Server) route(pattern, method string, h handler, target func(*http.Requ if target != nil { resource = target(r) } + if s.policy != nil { + if missing := s.policy.Check(access.Call{Method: method, Resource: resource, Needs: s.needs(method, resource, r)}); missing != nil { + writeError(w, permissionDenied(missing, resource)) + return + } + } f, _ := s.faults.Decide(fault.Call{Method: method, Resource: resource}) reject := f.Mode == fault.ModeReject || f.Mode == fault.ModeFailOperation stub.Serve(w, r, f, reject, "application/json; charset=UTF-8", func() { writeError(w, faultError(f)) }, func() (func(), bool) { diff --git a/sdktest/azure/arm/permissions_test.go b/sdktest/azure/arm/permissions_test.go new file mode 100644 index 0000000..9b7ebcd --- /dev/null +++ b/sdktest/azure/arm/permissions_test.go @@ -0,0 +1,76 @@ +package arm_test + +import ( + "net/http" + "slices" + "strings" + "testing" + + "github.com/parallelworks/squall/access" + "github.com/parallelworks/squall/azure/arm" +) + +func TestPolicyRefusesAnActionItDoesNotGrant(t *testing.T) { + policy := access.Allow("Microsoft.Resources/subscriptions/resourceGroups/write") + c := setup(t, arm.WithPolicy(policy)) + c.createGroup(t, group, nil) + + _, err := c.groups.Get(t.Context(), group, nil) + if code(err) != "AuthorizationFailed" || status(err) != http.StatusForbidden { + t.Fatalf("get = %v, want 403 AuthorizationFailed", err) + } + if !strings.Contains(err.Error(), "'Microsoft.Resources/subscriptions/resourceGroups/read'") { + t.Errorf("error = %v, want it to name the action", err) + } +} + +func TestPolicyChecksResourcesAWriteLinksTo(t *testing.T) { + policy := access.Allow( + "Microsoft.Resources/subscriptions/resourceGroups/write", + "Microsoft.Network/networkInterfaces/write", + "Microsoft.Network/networkInterfaces/read", + "Microsoft.Network/networkSecurityGroups/write", + ) + c := setup(t, arm.WithPolicy(policy)) + c.createGroup(t, group, nil) + + p, err := c.nics.BeginCreateOrUpdate(t.Context(), group, "nic-1", nicBody(nil, nil), nil) + _, err = wait(t, p, err) + if code(err) != "LinkedAuthorizationFailed" || status(err) != http.StatusForbidden { + t.Fatalf("create = %v, want 403 LinkedAuthorizationFailed", err) + } + if !strings.Contains(err.Error(), "'Microsoft.Network/virtualNetworks/subnets/join/action' on the linked scope(s) '"+subnet+"'") { + t.Errorf("error = %v, want it to name the action and the subnet", err) + } + denials := policy.Denials() + if len(denials) != 1 || !slices.Equal(denials[0].Missing, []string{"Microsoft.Network/virtualNetworks/subnets/join/action"}) { + t.Errorf("Denials() = %+v", denials) + } +} + +func TestPolicyRecordsWhatAllowedCallsNeeded(t *testing.T) { + policy := access.Allow( + "Microsoft.Resources/subscriptions/resourceGroups/write", + "Microsoft.Network/networkInterfaces/*", + "Microsoft.Network/virtualNetworks/subnets/join/action", + "Microsoft.Network/publicIPAddresses/join/action", + ) + c := setup(t, arm.WithPolicy(policy)) + c.createGroup(t, group, nil) + + p, err := c.nics.BeginCreateOrUpdate(t.Context(), group, "nic-1", nicBody(nil, nil), nil) + if _, err := wait(t, p, err); err != nil { + t.Fatal(err) + } + if got := policy.Unused(); !slices.Equal(got, []string{"Microsoft.Network/publicIPAddresses/join/action"}) { + t.Errorf("Unused() = %v", got) + } + want := []string{ + "Microsoft.Network/networkInterfaces/write", + "Microsoft.Network/virtualNetworks/subnets/join/action", + "Microsoft.Resources/subscriptions/resourceGroups/write", + } + if got := policy.Used(); !slices.Equal(got, want) { + t.Errorf("Used() = %v, want %v", got, want) + } +} diff --git a/sdktest/gcp/compute/permissions_test.go b/sdktest/gcp/compute/permissions_test.go new file mode 100644 index 0000000..9f18592 --- /dev/null +++ b/sdktest/gcp/compute/permissions_test.go @@ -0,0 +1,95 @@ +package compute_test + +import ( + "errors" + "net/http" + "slices" + "strings" + "testing" + + "cloud.google.com/go/compute/apiv1/computepb" + "google.golang.org/api/googleapi" + + "github.com/parallelworks/squall/access" + "github.com/parallelworks/squall/gcp/compute" +) + +func insertWithMetadata(t *testing.T, c *clients) error { + t.Helper() + op, err := c.instances.Insert(t.Context(), &computepb.InsertInstanceRequest{ + Project: "p", + Zone: zone, + InstanceResource: &computepb.Instance{ + Name: new("vm"), + MachineType: new("zones/" + zone + "/machineTypes/e2-small"), + Disks: []*computepb.AttachedDisk{{ + Boot: new(true), + AutoDelete: new(true), + InitializeParams: &computepb.AttachedDiskInitializeParams{SourceImage: new("projects/debian-cloud/global/images/family/debian-12")}, + }}, + NetworkInterfaces: []*computepb.NetworkInterface{{Subnetwork: new("regions/us-east1/subnetworks/default")}}, + Metadata: &computepb.Metadata{Items: []*computepb.Items{{Key: new("startup-script"), Value: new("true")}}}, + }, + }) + if err != nil { + return err + } + return op.Wait(t.Context()) +} + +func TestPolicyRefusesACallMissingAPermissionAFieldNeeds(t *testing.T) { + policy := access.Allow( + "compute.instances.create", + "compute.disks.create", + "compute.images.useReadOnly", + "compute.subnetworks.use", + "compute.zoneOperations.get", + ) + c := setup(t, compute.WithPolicy(policy)) + + err := insertWithMetadata(t, c) + var gerr *googleapi.Error + if !errors.As(err, &gerr) || gerr.Code != http.StatusForbidden { + t.Fatalf("insert = %v, want 403", err) + } + if !strings.Contains(gerr.Message, "Required 'compute.instances.setMetadata' permission") { + t.Errorf("message = %q, want it to name compute.instances.setMetadata", gerr.Message) + } + if got := c.server.Instances(); len(got) != 0 { + t.Errorf("a refused insert created %d instances", len(got)) + } + denials := policy.Denials() + if len(denials) != 1 || denials[0].Method != "compute.instances.insert" || !slices.Equal(denials[0].Missing, []string{"compute.instances.setMetadata"}) { + t.Errorf("Denials() = %+v", denials) + } +} + +func TestPolicyRecordsWhatAllowedCallsNeeded(t *testing.T) { + policy := access.Allow( + "compute.instances.create", + "compute.instances.setMetadata", + "compute.disks.create", + "compute.images.useReadOnly", + "compute.subnetworks.use", + "compute.zoneOperations.get", + "compute.routes.delete", + ) + c := setup(t, compute.WithPolicy(policy)) + + if err := insertWithMetadata(t, c); err != nil { + t.Fatal(err) + } + if got := policy.Unused(); !slices.Equal(got, []string{"compute.routes.delete"}) { + t.Errorf("Unused() = %v, want [compute.routes.delete]", got) + } + if len(policy.Denials()) != 0 { + t.Errorf("Denials() = %+v, want none", policy.Denials()) + } +} + +func TestNoPolicyAllowsEveryCall(t *testing.T) { + c := setup(t) + if err := insertWithMetadata(t, c); err != nil { + t.Fatal(err) + } +} diff --git a/sdktest/gcp/storage/permissions_test.go b/sdktest/gcp/storage/permissions_test.go new file mode 100644 index 0000000..042b3b4 --- /dev/null +++ b/sdktest/gcp/storage/permissions_test.go @@ -0,0 +1,56 @@ +package storage_test + +import ( + "errors" + "net/http" + "slices" + "strings" + "testing" + + "google.golang.org/api/googleapi" + + "github.com/parallelworks/squall/access" + "github.com/parallelworks/squall/gcp/storage" +) + +func TestPolicyNeedsDeleteToOverwriteAnObject(t *testing.T) { + policy := access.Allow("storage.buckets.create", "storage.objects.create", "storage.objects.list") + c := newClient(t, storage.New(storage.WithPolicy(policy))) + if err := c.Bucket("data").Create(t.Context(), project, nil); err != nil { + t.Fatal(err) + } + write(t, c, "data", "a.txt", "one") + + w := c.Bucket("data").Object("a.txt").NewWriter(t.Context()) + if _, err := w.Write([]byte("two")); err != nil { + t.Fatal(err) + } + err := w.Close() + var gerr *googleapi.Error + if !errors.As(err, &gerr) || gerr.Code != http.StatusForbidden { + t.Fatalf("overwrite = %v, want 403", err) + } + if !strings.Contains(gerr.Message, "Permission 'storage.objects.delete' denied") { + t.Errorf("message = %q, want it to name storage.objects.delete", gerr.Message) + } + if got := policy.Unused(); !slices.Equal(got, []string{"storage.objects.list"}) { + t.Errorf("Unused() = %v, want [storage.objects.list]", got) + } +} + +func TestPolicyRefusesAMethodItDoesNotGrant(t *testing.T) { + policy := access.Allow("storage.buckets.create") + c := newClient(t, storage.New(storage.WithPolicy(policy))) + if err := c.Bucket("data").Create(t.Context(), project, nil); err != nil { + t.Fatal(err) + } + err := c.Bucket("data").Delete(t.Context()) + var gerr *googleapi.Error + if !errors.As(err, &gerr) || gerr.Code != http.StatusForbidden { + t.Fatalf("delete = %v, want 403", err) + } + denials := policy.Denials() + if len(denials) != 1 || denials[0].Method != "storage.buckets.delete" || denials[0].Resource != "b/data" { + t.Errorf("Denials() = %+v", denials) + } +} From 286677249ad13040fd576844eb47f15c5b34cf00 Mon Sep 17 00:00:00 2001 From: Michael McQuade Date: Thu, 22 Oct 2026 20:28:06 -0500 Subject: [PATCH 2/3] feat: EC2 and S3 refuse calls the policy does not allow, as IAM does --- README.md | 16 +++++++ aws/ec2/permissions.go | 34 ++++++++++++++ aws/ec2/server.go | 15 ++++++- aws/s3/objects.go | 6 +++ aws/s3/permissions.go | 70 +++++++++++++++++++++++++++++ aws/s3/server.go | 24 +++++++++- gcp/compute/permissions.go | 5 ++- gcp/storage/permissions.go | 2 +- sdktest/aws/ec2/permissions_test.go | 48 ++++++++++++++++++++ sdktest/aws/s3/permissions_test.go | 65 +++++++++++++++++++++++++++ 10 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 aws/ec2/permissions.go create mode 100644 aws/s3/permissions.go create mode 100644 sdktest/aws/ec2/permissions_test.go create mode 100644 sdktest/aws/s3/permissions_test.go diff --git a/README.md b/README.md index b746baf..bc400ad 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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 diff --git a/aws/ec2/permissions.go b/aws/ec2/permissions.go new file mode 100644 index 0000000..99dde88 --- /dev/null +++ b/aws/ec2/permissions.go @@ -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]), + } +} diff --git a/aws/ec2/server.go b/aws/ec2/server.go index e89326d..4ceb242 100644 --- a/aws/ec2/server.go +++ b/aws/ec2/server.go @@ -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 @@ -150,6 +156,7 @@ import ( "sync" "sync/atomic" + "github.com/parallelworks/squall/access" "github.com/parallelworks/squall/fault" "github.com/parallelworks/squall/internal/stub" ) @@ -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 @@ -316,7 +324,12 @@ 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 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) diff --git a/aws/s3/objects.go b/aws/s3/objects.go index dd111a1..e10c323 100644 --- a/aws/s3/objects.go +++ b/aws/s3/objects.go @@ -13,6 +13,8 @@ import ( "strconv" "strings" "time" + + "github.com/parallelworks/squall/access" ) const ( @@ -245,6 +247,10 @@ func (s *Server) deleteObjects(c *call) (*reply, *apiError) { continue } } + if s.policy.Check(access.Call{Method: "s3.DeleteObjects", Resource: c.bucket + "/" + o.Key, Needs: []string{deleteAction(o.VersionID)}}) != nil { + out.Errors = append(out.Errors, deleteErrorXML{o.Key, o.VersionID, "AccessDenied", "Access Denied"}) + continue + } d := s.remove(b, o.Key, o.VersionID) if !in.Quiet { out.Deleted = append(out.Deleted, d) diff --git a/aws/s3/permissions.go b/aws/s3/permissions.go new file mode 100644 index 0000000..7bd1318 --- /dev/null +++ b/aws/s3/permissions.go @@ -0,0 +1,70 @@ +package s3 + +import ( + "fmt" + "net/http" + + "github.com/parallelworks/squall/access" +) + +// WithPolicy makes the server refuse calls that need an IAM action p lacks, +// as S3 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 } +} + +// actions are the IAM actions each operation needs where they differ from +// "s3:" and its name, from the S3 user guide's policy actions by operation. +var actions = map[string]string{ + "ListBuckets": "s3:ListAllMyBuckets", + "HeadBucket": "s3:ListBucket", + "DeleteBucketTagging": "s3:PutBucketTagging", + "PutBucketCors": "s3:PutBucketCORS", + "ListObjectVersions": "s3:ListBucketVersions", +} + +// needs returns the IAM actions a call to op needs, or nil for DeleteObjects, +// which S3 authorizes key by key. +func needs(op string, c *call) []string { + switch op { + case "DeleteObjects": + return nil + case "DeleteObject": + return []string{deleteAction(c.query.Get("versionId"))} + } + perms := []string{"s3:" + op} + if a, ok := actions[op]; ok { + perms[0] = a + } + switch op { + case "CreateBucket": + if c.r.Header.Get("x-amz-object-ownership") != "" { + perms = append(perms, "s3:PutBucketOwnershipControls") + } + case "PutObject": + if c.r.Header.Get("x-amz-acl") != "" { + perms = append(perms, "s3:PutObjectAcl") + } + } + return perms +} + +// deleteAction is what deleting a key needs: deleting a version is its own action. +func deleteAction(versionID string) string { + if versionID != "" { + return "s3:DeleteObjectVersion" + } + return "s3:DeleteObject" +} + +func notAuthorized(action, resource string) *apiError { + if resource == "" { + resource = "*" + } + return &apiError{ + Status: http.StatusForbidden, + Code: "AccessDenied", + Message: fmt.Sprintf(`User is not authorized to perform: %s on resource: "arn:aws:s3:::%s" because no identity-based policy allows the %s action`, action, resource, action), + } +} diff --git a/aws/s3/server.go b/aws/s3/server.go index 9238296..acb61d6 100644 --- a/aws/s3/server.go +++ b/aws/s3/server.go @@ -29,8 +29,9 @@ // - [Server.PutForeignBucket] holds a name for another account: creating // it fails with BucketAlreadyExists, HeadBucket with 403 and the bucket's // region, and every other call on it with AccessDenied. ListBuckets -// lists only the account's own. Other access denials are a fault rule -// away, such as fault.Reject(403, "AccessDenied"). +// lists only the account's own. [WithPolicy] denies calls by IAM +// action, and other access denials are a fault rule away, such as +// fault.Reject(403, "AccessDenied"). // - CreateBucket takes a LocationConstraint that must name the request's // region, and none for us-east-1, where naming us-east-1 fails with // InvalidLocationConstraint. Any other mismatch fails with @@ -70,6 +71,17 @@ // InvalidArgument rather than ignored, as are the // x-amz-expected-bucket-owner and x-amz-mfa headers. // +// [WithPolicy] grants the caller a set of IAM actions and refuses a call that +// needs one it lacks with 403 AccessDenied, before any fault. Each operation +// needs the action the S3 user guide lists for it, such as s3:ListBucket for +// HeadBucket; deleting a version needs s3:DeleteObjectVersion instead of +// s3:DeleteObject, an ACL header on PutObject s3:PutObjectAcl, and an +// object ownership header on CreateBucket s3:PutBucketOwnershipControls. +// DeleteObjects is checked key by key, as S3 checks it: a key the policy may +// not delete is an AccessDenied error in the result, and the rest are +// deleted. Actions are not checked against the resources they act on or +// against condition keys: a grant covers every bucket. +// // Fault method names are "s3." and the operation, such as "s3.CreateBucket". // A call's resource is the bucket's name, "bucket/key" for a call on one // object, and empty for ListBuckets. S3 has no long-running operations, so @@ -100,6 +112,7 @@ import ( "sync/atomic" "time" + "github.com/parallelworks/squall/access" "github.com/parallelworks/squall/fault" "github.com/parallelworks/squall/internal/stub" ) @@ -118,6 +131,7 @@ const ( // Server is a stand-in for the Amazon S3 API. Create one with [New]. It is safe for concurrent use. type Server struct { faults *fault.Injector + policy *access.Policy now func() time.Time requests atomic.Uint64 @@ -272,6 +286,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeError(w, r, requestID, err) return } + if perms := needs(op.name, c); perms != nil { + if missing := s.policy.Check(access.Call{Method: "s3." + op.name, Resource: c.resource(), Needs: perms}); missing != nil { + writeError(w, r, requestID, notAuthorized(missing[0], c.resource())) + return + } + } f, _ := s.faults.Decide(fault.Call{Method: "s3." + op.name, Resource: c.resource()}) reject := f.Mode == fault.ModeReject || f.Mode == fault.ModeFailOperation stub.Serve(w, r, f, reject, "application/xml", func() { writeError(w, r, requestID, faultError(f)) }, func() (func(), bool) { diff --git a/gcp/compute/permissions.go b/gcp/compute/permissions.go index 169d03f..5586662 100644 --- a/gcp/compute/permissions.go +++ b/gcp/compute/permissions.go @@ -143,7 +143,7 @@ func needs(method string, r *http.Request) []string { } switch method { case "compute.instances.insert": - perms = append(perms, f.permissionFields.needs()...) + perms = append(perms, f.needs()...) add(f.DeletionProtection, "compute.instances.setDeletionProtection") case "compute.instances.bulkInsert": add(f.SourceInstanceTemplate != "", "compute.instanceTemplates.useReadOnly") @@ -222,9 +222,10 @@ func diskUse(mode string) string { } func permissionDenied(missing []string, resource string) *apiError { + perm, _, _ := strings.Cut(missing[0], "|") return &apiError{ Status: http.StatusForbidden, Reason: "forbidden", - Message: fmt.Sprintf("Required '%s' permission for '%s'", strings.Split(missing[0], "|")[0], resource), + Message: fmt.Sprintf("Required '%s' permission for '%s'", perm, resource), } } diff --git a/gcp/storage/permissions.go b/gcp/storage/permissions.go index fa36ddb..c63b1e0 100644 --- a/gcp/storage/permissions.go +++ b/gcp/storage/permissions.go @@ -67,7 +67,7 @@ func (s *Server) exists(resource string) bool { } func permissionDenied(missing []string, resource string) *apiError { - perm := strings.Split(missing[0], "|")[0] + perm, _, _ := strings.Cut(missing[0], "|") return &apiError{ Status: http.StatusForbidden, Reason: "forbidden", diff --git a/sdktest/aws/ec2/permissions_test.go b/sdktest/aws/ec2/permissions_test.go new file mode 100644 index 0000000..2722da9 --- /dev/null +++ b/sdktest/aws/ec2/permissions_test.go @@ -0,0 +1,48 @@ +package ec2_test + +import ( + "net/http" + "slices" + "strings" + "testing" + + awsec2 "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/aws-sdk-go-v2/service/ec2/types" + + "github.com/parallelworks/squall/access" + "github.com/parallelworks/squall/aws/ec2" + "github.com/parallelworks/squall/sdktest/internal/sdkerr" +) + +func TestPolicyNeedsCreateTagsToTagOnCreate(t *testing.T) { + policy := access.Allow("ec2:AllocateAddress", "ec2:DescribeAddresses") + c := newClient(t, ec2.New(ec2.WithPolicy(policy))) + + _, err := c.AllocateAddress(t.Context(), &awsec2.AllocateAddressInput{ + Domain: types.DomainTypeVpc, TagSpecifications: tagSpec(types.ResourceTypeElasticIp, "session", "s"), + }) + if sdkerr.AWSCode(err) != "UnauthorizedOperation" || sdkerr.AWSStatus(err) != http.StatusForbidden { + t.Fatalf("allocate = %v, want 403 UnauthorizedOperation", err) + } + if !strings.Contains(err.Error(), "ec2:CreateTags") { + t.Errorf("error = %v, want it to name ec2:CreateTags", err) + } + addresses, err := describeAddresses(t, c, &awsec2.DescribeAddressesInput{}) + if err != nil { + t.Fatal(err) + } + if len(addresses) != 0 { + t.Errorf("a refused allocate allocated %d addresses", len(addresses)) + } + + if _, err := c.AllocateAddress(t.Context(), &awsec2.AllocateAddressInput{Domain: types.DomainTypeVpc}); err != nil { + t.Fatalf("untagged allocate: %v", err) + } + if got := policy.Unused(); len(got) != 0 { + t.Errorf("Unused() = %v, want none", got) + } + denials := policy.Denials() + if len(denials) != 1 || denials[0].Method != "ec2.AllocateAddress" || !slices.Equal(denials[0].Missing, []string{"ec2:CreateTags"}) { + t.Errorf("Denials() = %+v", denials) + } +} diff --git a/sdktest/aws/s3/permissions_test.go b/sdktest/aws/s3/permissions_test.go new file mode 100644 index 0000000..8ec7b89 --- /dev/null +++ b/sdktest/aws/s3/permissions_test.go @@ -0,0 +1,65 @@ +package s3_test + +import ( + "net/http" + "slices" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + + "github.com/parallelworks/squall/access" + "github.com/parallelworks/squall/aws/s3" + "github.com/parallelworks/squall/sdktest/internal/sdkerr" +) + +func TestPolicyRefusesAnOperationByItsAction(t *testing.T) { + policy := access.Allow("s3:CreateBucket") + c := newClient(t, s3.New(s3.WithPolicy(policy)), "us-east-1") + create(t, c, "data") + + _, err := c.ListObjectVersions(t.Context(), &awss3.ListObjectVersionsInput{Bucket: aws.String("data")}) + if sdkerr.AWSCode(err) != "AccessDenied" || sdkerr.AWSStatus(err) != http.StatusForbidden { + t.Fatalf("list versions = %v, want 403 AccessDenied", err) + } + if !strings.Contains(err.Error(), "s3:ListBucketVersions") { + t.Errorf("error = %v, want it to name s3:ListBucketVersions", err) + } +} + +func TestPolicyChecksDeleteObjectsKeyByKey(t *testing.T) { + policy := access.Allow("s3:CreateBucket", "s3:PutBucketVersioning", "s3:PutObject", "s3:DeleteObject") + c := newClient(t, s3.New(s3.WithPolicy(policy)), "us-east-1") + create(t, c, "data") + if _, err := c.PutBucketVersioning(t.Context(), &awss3.PutBucketVersioningInput{ + Bucket: aws.String("data"), + VersioningConfiguration: &types.VersioningConfiguration{Status: types.BucketVersioningStatusEnabled}, + }); err != nil { + t.Fatal(err) + } + a := put(t, c, "data", "a", strings.NewReader("a")) + put(t, c, "data", "b", strings.NewReader("b")) + + out, err := c.DeleteObjects(t.Context(), &awss3.DeleteObjectsInput{ + Bucket: aws.String("data"), + Delete: &types.Delete{Objects: []types.ObjectIdentifier{ + {Key: aws.String("a"), VersionId: a.VersionId}, + {Key: aws.String("b")}, + }}, + }) + if err != nil { + t.Fatal(err) + } + if len(out.Errors) != 1 || aws.ToString(out.Errors[0].Key) != "a" || aws.ToString(out.Errors[0].Code) != "AccessDenied" { + t.Errorf("Errors = %+v, want AccessDenied for a's version", out.Errors) + } + if len(out.Deleted) != 1 || aws.ToString(out.Deleted[0].Key) != "b" { + t.Errorf("Deleted = %+v, want b", out.Deleted) + } + denials := policy.Denials() + if len(denials) != 1 || denials[0].Resource != "data/a" || !slices.Equal(denials[0].Missing, []string{"s3:DeleteObjectVersion"}) { + t.Errorf("Denials() = %+v", denials) + } +} From 2ea054eeec123a1a8becb19be24dc40f4b5de104 Mon Sep 17 00:00:00 2001 From: Michael McQuade Date: Fri, 23 Oct 2026 16:20:00 -0500 Subject: [PATCH 3/3] perf: a server without a policy does no permission work --- aws/ec2/server.go | 8 +++++--- aws/s3/objects.go | 2 +- aws/s3/server.go | 13 ++++++++----- azure/arm/server.go | 5 +++-- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/aws/ec2/server.go b/aws/ec2/server.go index 4ceb242..397a172 100644 --- a/aws/ec2/server.go +++ b/aws/ec2/server.go @@ -325,9 +325,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } resource := a.target(c) - 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 + 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 diff --git a/aws/s3/objects.go b/aws/s3/objects.go index e10c323..9d0ba12 100644 --- a/aws/s3/objects.go +++ b/aws/s3/objects.go @@ -247,7 +247,7 @@ func (s *Server) deleteObjects(c *call) (*reply, *apiError) { continue } } - if s.policy.Check(access.Call{Method: "s3.DeleteObjects", Resource: c.bucket + "/" + o.Key, Needs: []string{deleteAction(o.VersionID)}}) != nil { + if s.policy != nil && s.policy.Check(access.Call{Method: "s3.DeleteObjects", Resource: c.bucket + "/" + o.Key, Needs: []string{deleteAction(o.VersionID)}}) != nil { out.Errors = append(out.Errors, deleteErrorXML{o.Key, o.VersionID, "AccessDenied", "Access Denied"}) continue } diff --git a/aws/s3/server.go b/aws/s3/server.go index acb61d6..2166b75 100644 --- a/aws/s3/server.go +++ b/aws/s3/server.go @@ -286,13 +286,16 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeError(w, r, requestID, err) return } - if perms := needs(op.name, c); perms != nil { - if missing := s.policy.Check(access.Call{Method: "s3." + op.name, Resource: c.resource(), Needs: perms}); missing != nil { - writeError(w, r, requestID, notAuthorized(missing[0], c.resource())) - return + resource := c.resource() + if s.policy != nil { + if perms := needs(op.name, c); perms != nil { + if missing := s.policy.Check(access.Call{Method: "s3." + op.name, Resource: resource, Needs: perms}); missing != nil { + writeError(w, r, requestID, notAuthorized(missing[0], resource)) + return + } } } - f, _ := s.faults.Decide(fault.Call{Method: "s3." + op.name, Resource: c.resource()}) + f, _ := s.faults.Decide(fault.Call{Method: "s3." + op.name, Resource: resource}) reject := f.Mode == fault.ModeReject || f.Mode == fault.ModeFailOperation stub.Serve(w, r, f, reject, "application/xml", func() { writeError(w, r, requestID, faultError(f)) }, func() (func(), bool) { rep, err := op.handle(c) diff --git a/azure/arm/server.go b/azure/arm/server.go index 1b99a56..f629dc0 100644 --- a/azure/arm/server.go +++ b/azure/arm/server.go @@ -392,11 +392,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeError(w, newError(http.StatusNotFound, "NotFound", "squall: %s /%s is not served", r.Method, path)) return } - if err := s.authorize(rt, r, "/"+path); err != nil { + resource := "/" + path + if err := s.authorize(rt, r, resource); err != nil { writeError(w, err) return } - f, _ := s.faults.Decide(fault.Call{Method: rt.action, Resource: "/" + path}) + f, _ := s.faults.Decide(fault.Call{Method: rt.action, Resource: resource}) reject := f.Mode == fault.ModeReject || f.Mode == fault.ModeFailOperation && !rt.lro stub.Serve(w, r, f, reject, "application/json; charset=utf-8", func() { writeError(w, faultError(f)) }, func() (func(), bool) { c := &call{r: r, vals: vals}