mesh: add ACL checks for xRoute resources (#18926)

xRoute resources are not name-aligned with the Services they control. They
have a list of "parent ref" services that they alter traffic flow for, and they
contain a list of "backend ref" services that they direct that traffic to.

The ACLs should be:

- list: (default)
- read:
  - ALL service:<parent_ref_service>:read
- write:
  - ALL service:<parent_ref_service>:write
  - ALL service:<backend_ref_service>:read
This commit is contained in:
R.B. Boyer 2023-09-22 14:24:44 -05:00 committed by GitHub
parent 43a8dbb188
commit 633c6c9458
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 333 additions and 0 deletions

View File

@ -35,6 +35,7 @@ func RegisterGRPCRoute(r resource.Registry) {
Scope: resource.ScopeNamespace,
Mutate: MutateGRPCRoute,
Validate: ValidateGRPCRoute,
ACLs: xRouteACLHooks[*pbmesh.GRPCRoute](),
})
}

View File

@ -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()
})
}

View File

@ -37,6 +37,7 @@ func RegisterHTTPRoute(r resource.Registry) {
Scope: resource.ScopeNamespace,
Mutate: MutateHTTPRoute,
Validate: ValidateHTTPRoute,
ACLs: xRouteACLHooks[*pbmesh.HTTPRoute](),
})
}

View File

@ -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()
})
}

View File

@ -34,6 +34,7 @@ func RegisterTCPRoute(r resource.Registry) {
Scope: resource.ScopeNamespace,
Mutate: MutateTCPRoute,
Validate: ValidateTCPRoute,
ACLs: xRouteACLHooks[*pbmesh.TCPRoute](),
})
}

View File

@ -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()
})
}

View File

@ -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
}

View File

@ -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)
})
}