diff --git a/internal/mesh/internal/types/grpc_route.go b/internal/mesh/internal/types/grpc_route.go index 06d2d8a2b8..84e5740858 100644 --- a/internal/mesh/internal/types/grpc_route.go +++ b/internal/mesh/internal/types/grpc_route.go @@ -35,6 +35,7 @@ func RegisterGRPCRoute(r resource.Registry) { Scope: resource.ScopeNamespace, Mutate: MutateGRPCRoute, Validate: ValidateGRPCRoute, + ACLs: xRouteACLHooks[*pbmesh.GRPCRoute](), }) } diff --git a/internal/mesh/internal/types/grpc_route_test.go b/internal/mesh/internal/types/grpc_route_test.go index 534678db96..65234c83eb 100644 --- a/internal/mesh/internal/types/grpc_route_test.go +++ b/internal/mesh/internal/types/grpc_route_test.go @@ -10,6 +10,7 @@ import ( "google.golang.org/protobuf/proto" "github.com/hashicorp/consul/internal/catalog" + "github.com/hashicorp/consul/internal/resource" "github.com/hashicorp/consul/internal/resource/resourcetest" pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v2beta1" "github.com/hashicorp/consul/proto-public/pbresource" @@ -614,3 +615,33 @@ func TestValidateGRPCRoute(t *testing.T) { }) } } + +func TestGRPCRouteACLs(t *testing.T) { + testXRouteACLs[*pbmesh.GRPCRoute](t, func(t *testing.T, parentRefs, backendRefs []*pbresource.Reference) *pbresource.Resource { + data := &pbmesh.GRPCRoute{ + ParentRefs: nil, + } + for _, ref := range parentRefs { + data.ParentRefs = append(data.ParentRefs, &pbmesh.ParentReference{ + Ref: ref, + }) + } + + var ruleRefs []*pbmesh.GRPCBackendRef + for _, ref := range backendRefs { + ruleRefs = append(ruleRefs, &pbmesh.GRPCBackendRef{ + BackendRef: &pbmesh.BackendReference{ + Ref: ref, + }, + }) + } + data.Rules = []*pbmesh.GRPCRouteRule{ + {BackendRefs: ruleRefs}, + } + + return resourcetest.Resource(GRPCRouteType, "api-grpc-route"). + WithTenancy(resource.DefaultNamespacedTenancy()). + WithData(t, data). + Build() + }) +} diff --git a/internal/mesh/internal/types/http_route.go b/internal/mesh/internal/types/http_route.go index 0d84b7d5c6..374f4d7de5 100644 --- a/internal/mesh/internal/types/http_route.go +++ b/internal/mesh/internal/types/http_route.go @@ -37,6 +37,7 @@ func RegisterHTTPRoute(r resource.Registry) { Scope: resource.ScopeNamespace, Mutate: MutateHTTPRoute, Validate: ValidateHTTPRoute, + ACLs: xRouteACLHooks[*pbmesh.HTTPRoute](), }) } diff --git a/internal/mesh/internal/types/http_route_test.go b/internal/mesh/internal/types/http_route_test.go index b32965b739..eec0b2e8c6 100644 --- a/internal/mesh/internal/types/http_route_test.go +++ b/internal/mesh/internal/types/http_route_test.go @@ -1174,3 +1174,33 @@ func newTestTenancy(s string) *pbresource.Tenancy { return &pbresource.Tenancy{Partition: "BAD", Namespace: "BAD", PeerName: "BAD"} } } + +func TestHTTPRouteACLs(t *testing.T) { + testXRouteACLs[*pbmesh.HTTPRoute](t, func(t *testing.T, parentRefs, backendRefs []*pbresource.Reference) *pbresource.Resource { + data := &pbmesh.HTTPRoute{ + ParentRefs: nil, + } + for _, ref := range parentRefs { + data.ParentRefs = append(data.ParentRefs, &pbmesh.ParentReference{ + Ref: ref, + }) + } + + var ruleRefs []*pbmesh.HTTPBackendRef + for _, ref := range backendRefs { + ruleRefs = append(ruleRefs, &pbmesh.HTTPBackendRef{ + BackendRef: &pbmesh.BackendReference{ + Ref: ref, + }, + }) + } + data.Rules = []*pbmesh.HTTPRouteRule{ + {BackendRefs: ruleRefs}, + } + + return resourcetest.Resource(HTTPRouteType, "api-http-route"). + WithTenancy(resource.DefaultNamespacedTenancy()). + WithData(t, data). + Build() + }) +} diff --git a/internal/mesh/internal/types/tcp_route.go b/internal/mesh/internal/types/tcp_route.go index 0fb2ddd7c5..20332c24a6 100644 --- a/internal/mesh/internal/types/tcp_route.go +++ b/internal/mesh/internal/types/tcp_route.go @@ -34,6 +34,7 @@ func RegisterTCPRoute(r resource.Registry) { Scope: resource.ScopeNamespace, Mutate: MutateTCPRoute, Validate: ValidateTCPRoute, + ACLs: xRouteACLHooks[*pbmesh.TCPRoute](), }) } diff --git a/internal/mesh/internal/types/tcp_route_test.go b/internal/mesh/internal/types/tcp_route_test.go index f4b86a4786..d8c2053131 100644 --- a/internal/mesh/internal/types/tcp_route_test.go +++ b/internal/mesh/internal/types/tcp_route_test.go @@ -200,3 +200,33 @@ func TestValidateTCPRoute(t *testing.T) { }) } } + +func TestTCPRouteACLs(t *testing.T) { + testXRouteACLs[*pbmesh.TCPRoute](t, func(t *testing.T, parentRefs, backendRefs []*pbresource.Reference) *pbresource.Resource { + data := &pbmesh.TCPRoute{ + ParentRefs: nil, + } + for _, ref := range parentRefs { + data.ParentRefs = append(data.ParentRefs, &pbmesh.ParentReference{ + Ref: ref, + }) + } + + var ruleRefs []*pbmesh.TCPBackendRef + for _, ref := range backendRefs { + ruleRefs = append(ruleRefs, &pbmesh.TCPBackendRef{ + BackendRef: &pbmesh.BackendReference{ + Ref: ref, + }, + }) + } + data.Rules = []*pbmesh.TCPRouteRule{ + {BackendRefs: ruleRefs}, + } + + return resourcetest.Resource(TCPRouteType, "api-tcp-route"). + WithTenancy(resource.DefaultNamespacedTenancy()). + WithData(t, data). + Build() + }) +} diff --git a/internal/mesh/internal/types/xroute.go b/internal/mesh/internal/types/xroute.go index db10c96b26..aef6332b42 100644 --- a/internal/mesh/internal/types/xroute.go +++ b/internal/mesh/internal/types/xroute.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/go-multierror" "google.golang.org/protobuf/proto" + "github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/internal/catalog" "github.com/hashicorp/consul/internal/resource" pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v2beta1" @@ -282,3 +283,75 @@ func isValidRetryCondition(retryOn string) bool { return false } } + +func xRouteACLHooks[R XRouteData]() *resource.ACLHooks { + hooks := &resource.ACLHooks{ + Read: aclReadHookXRoute[R], + Write: aclWriteHookXRoute[R], + List: aclListHookXRoute[R], + } + + return hooks +} + +func aclReadHookXRoute[R XRouteData](authorizer acl.Authorizer, _ *acl.AuthorizerContext, _ *pbresource.ID, res *pbresource.Resource) error { + if res == nil { + return resource.ErrNeedData + } + + dec, err := resource.Decode[R](res) + if err != nil { + return err + } + + route := dec.Data + + // Need service:read on ALL of the services this is controlling traffic for. + for _, parentRef := range route.GetParentRefs() { + parentAuthzContext := resource.AuthorizerContext(parentRef.Ref.GetTenancy()) + parentServiceName := parentRef.Ref.GetName() + + if err := authorizer.ToAllowAuthorizer().ServiceReadAllowed(parentServiceName, parentAuthzContext); err != nil { + return err + } + } + + return nil +} + +func aclWriteHookXRoute[R XRouteData](authorizer acl.Authorizer, _ *acl.AuthorizerContext, res *pbresource.Resource) error { + dec, err := resource.Decode[R](res) + if err != nil { + return err + } + + route := dec.Data + + // Need service:write on ALL of the services this is controlling traffic for. + for _, parentRef := range route.GetParentRefs() { + parentAuthzContext := resource.AuthorizerContext(parentRef.Ref.GetTenancy()) + parentServiceName := parentRef.Ref.GetName() + + if err := authorizer.ToAllowAuthorizer().ServiceWriteAllowed(parentServiceName, parentAuthzContext); err != nil { + return err + } + } + + // Need service:read on ALL of the services this directs traffic at. + for _, backendRef := range route.GetUnderlyingBackendRefs() { + backendAuthzContext := resource.AuthorizerContext(backendRef.Ref.GetTenancy()) + backendServiceName := backendRef.Ref.GetName() + + if err := authorizer.ToAllowAuthorizer().ServiceReadAllowed(backendServiceName, backendAuthzContext); err != nil { + return err + } + } + + return nil +} + +func aclListHookXRoute[R XRouteData](authorizer acl.Authorizer, authzContext *acl.AuthorizerContext) error { + // No-op List permission as we want to default to filtering resources + // from the list using the Read enforcement. + return nil +} diff --git a/internal/mesh/internal/types/xroute_test.go b/internal/mesh/internal/types/xroute_test.go new file mode 100644 index 0000000000..5aa6f2f6f3 --- /dev/null +++ b/internal/mesh/internal/types/xroute_test.go @@ -0,0 +1,166 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package types + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/internal/catalog" + "github.com/hashicorp/consul/internal/resource" + "github.com/hashicorp/consul/internal/resource/resourcetest" + "github.com/hashicorp/consul/proto-public/pbresource" +) + +func testXRouteACLs[R XRouteData](t *testing.T, newRoute func(t *testing.T, parentRefs, backendRefs []*pbresource.Reference) *pbresource.Resource) { + // Wire up a registry to generically invoke hooks + registry := resource.NewRegistry() + Register(registry) + + userNewRoute := newRoute + newRoute = func(t *testing.T, parentRefs, backendRefs []*pbresource.Reference) *pbresource.Resource { + res := userNewRoute(t, parentRefs, backendRefs) + resourcetest.ValidateAndNormalize(t, registry, res) + return res + } + + type testcase struct { + res *pbresource.Resource + rules string + check func(t *testing.T, authz acl.Authorizer, res *pbresource.Resource) + readOK string + writeOK string + } + + const ( + DENY = "deny" + ALLOW = "allow" + DEFAULT = "default" + ) + + checkF := func(t *testing.T, name string, expect string, got error) { + switch expect { + case ALLOW: + if acl.IsErrPermissionDenied(got) { + t.Fatal(name + " should be allowed") + } + case DENY: + if !acl.IsErrPermissionDenied(got) { + t.Fatal(name + " should be denied") + } + case DEFAULT: + require.Nil(t, got, name+" expected fallthrough decision") + default: + t.Fatalf(name+" unexpected expectation: %q", expect) + } + } + + resOneParentOneBackend := newRoute(t, + []*pbresource.Reference{ + newRef(catalog.ServiceType, "api1"), + }, + []*pbresource.Reference{ + newRef(catalog.ServiceType, "backend1"), + }, + ) + resTwoParentsOneBackend := newRoute(t, + []*pbresource.Reference{ + newRef(catalog.ServiceType, "api1"), + newRef(catalog.ServiceType, "api2"), + }, + []*pbresource.Reference{ + newRef(catalog.ServiceType, "backend1"), + }, + ) + resOneParentTwoBackends := newRoute(t, + []*pbresource.Reference{ + newRef(catalog.ServiceType, "api1"), + }, + []*pbresource.Reference{ + newRef(catalog.ServiceType, "backend1"), + newRef(catalog.ServiceType, "backend2"), + }, + ) + resTwoParentsTwoBackends := newRoute(t, + []*pbresource.Reference{ + newRef(catalog.ServiceType, "api1"), + newRef(catalog.ServiceType, "api2"), + }, + []*pbresource.Reference{ + newRef(catalog.ServiceType, "backend1"), + newRef(catalog.ServiceType, "backend2"), + }, + ) + + run := func(t *testing.T, name string, tc testcase) { + t.Run(name, func(t *testing.T) { + config := acl.Config{ + WildcardName: structs.WildcardSpecifier, + } + authz, err := acl.NewAuthorizerFromRules(tc.rules, &config, nil) + require.NoError(t, err) + authz = acl.NewChainedAuthorizer([]acl.Authorizer{authz, acl.DenyAll()}) + + reg, ok := registry.Resolve(tc.res.Id.GetType()) + require.True(t, ok) + + err = reg.ACLs.Read(authz, &acl.AuthorizerContext{}, tc.res.Id, nil) + require.ErrorIs(t, err, resource.ErrNeedData, "read hook should require the data payload") + + checkF(t, "read", tc.readOK, reg.ACLs.Read(authz, &acl.AuthorizerContext{}, tc.res.Id, tc.res)) + checkF(t, "write", tc.writeOK, reg.ACLs.Write(authz, &acl.AuthorizerContext{}, tc.res)) + checkF(t, "list", DEFAULT, reg.ACLs.List(authz, &acl.AuthorizerContext{})) + }) + } + + serviceRead := func(name string) string { + return fmt.Sprintf(` service %q { policy = "read" } `, name) + } + serviceWrite := func(name string) string { + return fmt.Sprintf(` service %q { policy = "write" } `, name) + } + + assert := func(t *testing.T, name string, rules string, res *pbresource.Resource, readOK, writeOK string) { + tc := testcase{ + res: res, + rules: rules, + readOK: readOK, + writeOK: writeOK, + } + run(t, name, tc) + } + + t.Run("no rules", func(t *testing.T) { + rules := `` + assert(t, "1parent 1backend", rules, resOneParentOneBackend, DENY, DENY) + assert(t, "1parent 2backends", rules, resOneParentTwoBackends, DENY, DENY) + assert(t, "2parents 1backend", rules, resTwoParentsOneBackend, DENY, DENY) + assert(t, "2parents 2backends", rules, resTwoParentsTwoBackends, DENY, DENY) + }) + t.Run("api1:read", func(t *testing.T) { + rules := serviceRead("api1") + assert(t, "1parent 1backend", rules, resOneParentOneBackend, ALLOW, DENY) + assert(t, "1parent 2backends", rules, resOneParentTwoBackends, ALLOW, DENY) + assert(t, "2parents 1backend", rules, resTwoParentsOneBackend, DENY, DENY) + assert(t, "2parents 2backends", rules, resTwoParentsTwoBackends, DENY, DENY) + }) + t.Run("api1:write", func(t *testing.T) { + rules := serviceWrite("api1") + assert(t, "1parent 1backend", rules, resOneParentOneBackend, ALLOW, DENY) + assert(t, "1parent 2backends", rules, resOneParentTwoBackends, ALLOW, DENY) + assert(t, "2parents 1backend", rules, resTwoParentsOneBackend, DENY, DENY) + assert(t, "2parents 2backends", rules, resTwoParentsTwoBackends, DENY, DENY) + }) + t.Run("api1:write backend1:read", func(t *testing.T) { + rules := serviceWrite("api1") + serviceRead("backend1") + assert(t, "1parent 1backend", rules, resOneParentOneBackend, ALLOW, ALLOW) + assert(t, "1parent 2backends", rules, resOneParentTwoBackends, ALLOW, DENY) + assert(t, "2parents 1backend", rules, resTwoParentsOneBackend, DENY, DENY) + assert(t, "2parents 2backends", rules, resTwoParentsTwoBackends, DENY, DENY) + }) +}