mirror of https://github.com/hashicorp/consul
aahel
12 months ago
committed by
GitHub
21 changed files with 611 additions and 142 deletions
@ -0,0 +1,90 @@ |
|||||||
|
// Copyright (c) HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package types |
||||||
|
|
||||||
|
import ( |
||||||
|
"github.com/hashicorp/go-multierror" |
||||||
|
|
||||||
|
"github.com/hashicorp/consul/acl" |
||||||
|
"github.com/hashicorp/consul/internal/resource" |
||||||
|
pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v2beta1" |
||||||
|
"github.com/hashicorp/consul/proto-public/pbresource" |
||||||
|
) |
||||||
|
|
||||||
|
type DecodedNodeHealthStatus = resource.DecodedResource[*pbcatalog.NodeHealthStatus] |
||||||
|
|
||||||
|
func RegisterNodeHealthStatus(r resource.Registry) { |
||||||
|
r.Register(resource.Registration{ |
||||||
|
Type: pbcatalog.NodeHealthStatusType, |
||||||
|
Proto: &pbcatalog.NodeHealthStatus{}, |
||||||
|
Scope: resource.ScopePartition, |
||||||
|
Validate: ValidateNodeHealthStatus, |
||||||
|
ACLs: &resource.ACLHooks{ |
||||||
|
Read: resource.AuthorizeReadWithResource(aclReadHookNodeHealthStatus), |
||||||
|
Write: aclWriteHookNodeHealthStatus, |
||||||
|
List: resource.NoOpACLListHook, |
||||||
|
}, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
var ValidateNodeHealthStatus = resource.DecodeAndValidate(validateNodeHealthStatus) |
||||||
|
|
||||||
|
func validateNodeHealthStatus(res *DecodedNodeHealthStatus) error { |
||||||
|
var err error |
||||||
|
|
||||||
|
// Should we allow empty types? I think for now it will be safest to require
|
||||||
|
// the type field is set and we can relax this restriction in the future
|
||||||
|
// if we deem it desirable.
|
||||||
|
if res.Data.Type == "" { |
||||||
|
err = multierror.Append(err, resource.ErrInvalidField{ |
||||||
|
Name: "type", |
||||||
|
Wrapped: resource.ErrMissing, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
switch res.Data.Status { |
||||||
|
case pbcatalog.Health_HEALTH_PASSING, |
||||||
|
pbcatalog.Health_HEALTH_WARNING, |
||||||
|
pbcatalog.Health_HEALTH_CRITICAL, |
||||||
|
pbcatalog.Health_HEALTH_MAINTENANCE: |
||||||
|
default: |
||||||
|
err = multierror.Append(err, resource.ErrInvalidField{ |
||||||
|
Name: "status", |
||||||
|
Wrapped: errInvalidHealth, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Ensure that the NodeHealthStatus' owner is a type that we want to allow. The
|
||||||
|
// owner is currently the resource that this NodeHealthStatus applies to. If we
|
||||||
|
// change this to be a parent reference within the NodeHealthStatus.Data then
|
||||||
|
// we could allow for other owners.
|
||||||
|
if res.Resource.Owner == nil { |
||||||
|
err = multierror.Append(err, resource.ErrInvalidField{ |
||||||
|
Name: "owner", |
||||||
|
Wrapped: resource.ErrMissing, |
||||||
|
}) |
||||||
|
} else if !resource.EqualType(res.Owner.Type, pbcatalog.NodeType) { |
||||||
|
err = multierror.Append(err, resource.ErrOwnerTypeInvalid{ResourceType: res.Id.Type, OwnerType: res.Owner.Type}) |
||||||
|
} |
||||||
|
|
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
func aclReadHookNodeHealthStatus(authorizer acl.Authorizer, authzContext *acl.AuthorizerContext, res *pbresource.Resource) error { |
||||||
|
// For a health status of a node we need to check node:read perms.
|
||||||
|
if res.GetOwner() != nil && resource.EqualType(res.GetOwner().GetType(), pbcatalog.NodeType) { |
||||||
|
return authorizer.ToAllowAuthorizer().NodeReadAllowed(res.GetOwner().GetName(), authzContext) |
||||||
|
} |
||||||
|
|
||||||
|
return acl.PermissionDenied("cannot read catalog.NodeHealthStatus because there is no owner") |
||||||
|
} |
||||||
|
|
||||||
|
func aclWriteHookNodeHealthStatus(authorizer acl.Authorizer, authzContext *acl.AuthorizerContext, res *pbresource.Resource) error { |
||||||
|
// For a health status of a node we need to check node:write perms.
|
||||||
|
if res.GetOwner() != nil && resource.EqualType(res.GetOwner().GetType(), pbcatalog.NodeType) { |
||||||
|
return authorizer.ToAllowAuthorizer().NodeWriteAllowed(res.GetOwner().GetName(), authzContext) |
||||||
|
} |
||||||
|
|
||||||
|
return acl.PermissionDenied("cannot write catalog.NodeHealthStatus because there is no owner") |
||||||
|
} |
@ -0,0 +1,273 @@ |
|||||||
|
// Copyright (c) HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package types |
||||||
|
|
||||||
|
import ( |
||||||
|
"testing" |
||||||
|
|
||||||
|
"github.com/hashicorp/consul/internal/resource" |
||||||
|
"github.com/hashicorp/consul/internal/resource/resourcetest" |
||||||
|
pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v2beta1" |
||||||
|
"github.com/hashicorp/consul/proto-public/pbresource" |
||||||
|
"github.com/stretchr/testify/require" |
||||||
|
"google.golang.org/protobuf/reflect/protoreflect" |
||||||
|
"google.golang.org/protobuf/types/known/anypb" |
||||||
|
) |
||||||
|
|
||||||
|
var ( |
||||||
|
defaultNodeHealthStatusOwnerTenancy = &pbresource.Tenancy{ |
||||||
|
Partition: "default", |
||||||
|
PeerName: "local", |
||||||
|
} |
||||||
|
|
||||||
|
defaultNodeHealthStatusOwner = &pbresource.ID{ |
||||||
|
Type: pbcatalog.NodeType, |
||||||
|
Tenancy: defaultNodeHealthStatusOwnerTenancy, |
||||||
|
Name: "foo", |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
func createNodeHealthStatusResource(t *testing.T, data protoreflect.ProtoMessage, owner *pbresource.ID) *pbresource.Resource { |
||||||
|
res := &pbresource.Resource{ |
||||||
|
Id: &pbresource.ID{ |
||||||
|
Type: pbcatalog.NodeHealthStatusType, |
||||||
|
Tenancy: &pbresource.Tenancy{ |
||||||
|
Partition: "default", |
||||||
|
PeerName: "local", |
||||||
|
}, |
||||||
|
Name: "test-status", |
||||||
|
}, |
||||||
|
Owner: owner, |
||||||
|
} |
||||||
|
|
||||||
|
var err error |
||||||
|
res.Data, err = anypb.New(data) |
||||||
|
require.NoError(t, err) |
||||||
|
return res |
||||||
|
} |
||||||
|
|
||||||
|
func TestValidateNodeHealthStatus_Ok(t *testing.T) { |
||||||
|
data := &pbcatalog.NodeHealthStatus{ |
||||||
|
Type: "tcp", |
||||||
|
Status: pbcatalog.Health_HEALTH_PASSING, |
||||||
|
Description: "Doesn't matter as this is user settable", |
||||||
|
Output: "Health check executors are free to use this field", |
||||||
|
} |
||||||
|
|
||||||
|
type testCase struct { |
||||||
|
owner *pbresource.ID |
||||||
|
} |
||||||
|
|
||||||
|
cases := map[string]testCase{ |
||||||
|
"node-owned": { |
||||||
|
owner: &pbresource.ID{ |
||||||
|
Type: pbcatalog.NodeType, |
||||||
|
Tenancy: defaultNodeHealthStatusOwnerTenancy, |
||||||
|
Name: "bar-node", |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for name, tcase := range cases { |
||||||
|
t.Run(name, func(t *testing.T) { |
||||||
|
res := createNodeHealthStatusResource(t, data, tcase.owner) |
||||||
|
err := ValidateNodeHealthStatus(res) |
||||||
|
require.NoError(t, err) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestValidateNodeHealthStatus_ParseError(t *testing.T) { |
||||||
|
// Any type other than the NodeHealthStatus type would work
|
||||||
|
// to cause the error we are expecting
|
||||||
|
data := &pbcatalog.IP{Address: "198.18.0.1"} |
||||||
|
|
||||||
|
res := createNodeHealthStatusResource(t, data, defaultNodeHealthStatusOwner) |
||||||
|
|
||||||
|
err := ValidateNodeHealthStatus(res) |
||||||
|
require.Error(t, err) |
||||||
|
require.ErrorAs(t, err, &resource.ErrDataParse{}) |
||||||
|
} |
||||||
|
|
||||||
|
func TestValidateNodeHealthStatus_InvalidHealth(t *testing.T) { |
||||||
|
// while this is a valid enum value it is not allowed to be used
|
||||||
|
// as the Status field.
|
||||||
|
data := &pbcatalog.NodeHealthStatus{ |
||||||
|
Type: "tcp", |
||||||
|
Status: pbcatalog.Health_HEALTH_ANY, |
||||||
|
} |
||||||
|
|
||||||
|
res := createNodeHealthStatusResource(t, data, defaultNodeHealthStatusOwner) |
||||||
|
|
||||||
|
err := ValidateNodeHealthStatus(res) |
||||||
|
require.Error(t, err) |
||||||
|
expected := resource.ErrInvalidField{ |
||||||
|
Name: "status", |
||||||
|
Wrapped: errInvalidHealth, |
||||||
|
} |
||||||
|
var actual resource.ErrInvalidField |
||||||
|
require.ErrorAs(t, err, &actual) |
||||||
|
require.Equal(t, expected, actual) |
||||||
|
} |
||||||
|
|
||||||
|
func TestValidateNodeHealthStatus_MissingType(t *testing.T) { |
||||||
|
data := &pbcatalog.NodeHealthStatus{ |
||||||
|
Status: pbcatalog.Health_HEALTH_PASSING, |
||||||
|
} |
||||||
|
|
||||||
|
res := createNodeHealthStatusResource(t, data, defaultNodeHealthStatusOwner) |
||||||
|
|
||||||
|
err := ValidateNodeHealthStatus(res) |
||||||
|
require.Error(t, err) |
||||||
|
expected := resource.ErrInvalidField{ |
||||||
|
Name: "type", |
||||||
|
Wrapped: resource.ErrMissing, |
||||||
|
} |
||||||
|
var actual resource.ErrInvalidField |
||||||
|
require.ErrorAs(t, err, &actual) |
||||||
|
require.Equal(t, expected, actual) |
||||||
|
} |
||||||
|
|
||||||
|
func TestValidateNodeHealthStatus_MissingOwner(t *testing.T) { |
||||||
|
data := &pbcatalog.NodeHealthStatus{ |
||||||
|
Type: "tcp", |
||||||
|
Status: pbcatalog.Health_HEALTH_PASSING, |
||||||
|
} |
||||||
|
|
||||||
|
res := createNodeHealthStatusResource(t, data, nil) |
||||||
|
|
||||||
|
err := ValidateNodeHealthStatus(res) |
||||||
|
require.Error(t, err) |
||||||
|
expected := resource.ErrInvalidField{ |
||||||
|
Name: "owner", |
||||||
|
Wrapped: resource.ErrMissing, |
||||||
|
} |
||||||
|
var actual resource.ErrInvalidField |
||||||
|
require.ErrorAs(t, err, &actual) |
||||||
|
require.Equal(t, expected, actual) |
||||||
|
} |
||||||
|
|
||||||
|
func TestValidateNodeHealthStatus_InvalidOwner(t *testing.T) { |
||||||
|
data := &pbcatalog.NodeHealthStatus{ |
||||||
|
Type: "tcp", |
||||||
|
Status: pbcatalog.Health_HEALTH_PASSING, |
||||||
|
} |
||||||
|
|
||||||
|
type testCase struct { |
||||||
|
owner *pbresource.ID |
||||||
|
} |
||||||
|
|
||||||
|
cases := map[string]testCase{ |
||||||
|
"group-mismatch": { |
||||||
|
owner: &pbresource.ID{ |
||||||
|
Type: &pbresource.Type{ |
||||||
|
Group: "fake", |
||||||
|
GroupVersion: pbcatalog.Version, |
||||||
|
Kind: pbcatalog.NodeKind, |
||||||
|
}, |
||||||
|
Tenancy: defaultNodeHealthStatusOwnerTenancy, |
||||||
|
Name: "baz", |
||||||
|
}, |
||||||
|
}, |
||||||
|
"group-version-mismatch": { |
||||||
|
owner: &pbresource.ID{ |
||||||
|
Type: &pbresource.Type{ |
||||||
|
Group: pbcatalog.GroupName, |
||||||
|
GroupVersion: "v99", |
||||||
|
Kind: pbcatalog.NodeKind, |
||||||
|
}, |
||||||
|
Tenancy: defaultNodeHealthStatusOwnerTenancy, |
||||||
|
Name: "baz", |
||||||
|
}, |
||||||
|
}, |
||||||
|
"kind-mismatch": { |
||||||
|
owner: &pbresource.ID{ |
||||||
|
Type: pbcatalog.ServiceType, |
||||||
|
Tenancy: defaultNodeHealthStatusOwnerTenancy, |
||||||
|
Name: "baz", |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for name, tcase := range cases { |
||||||
|
t.Run(name, func(t *testing.T) { |
||||||
|
res := createNodeHealthStatusResource(t, data, tcase.owner) |
||||||
|
err := ValidateNodeHealthStatus(res) |
||||||
|
require.Error(t, err) |
||||||
|
expected := resource.ErrOwnerTypeInvalid{ |
||||||
|
ResourceType: pbcatalog.NodeHealthStatusType, |
||||||
|
OwnerType: tcase.owner.Type, |
||||||
|
} |
||||||
|
var actual resource.ErrOwnerTypeInvalid |
||||||
|
require.ErrorAs(t, err, &actual) |
||||||
|
require.Equal(t, expected, actual) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestNodeHealthStatusACLs(t *testing.T) { |
||||||
|
registry := resource.NewRegistry() |
||||||
|
Register(registry) |
||||||
|
|
||||||
|
node := resourcetest.Resource(pbcatalog.NodeType, "test").ID() |
||||||
|
|
||||||
|
nodehealthStatusData := &pbcatalog.NodeHealthStatus{ |
||||||
|
Type: "tcp", |
||||||
|
Status: pbcatalog.Health_HEALTH_PASSING, |
||||||
|
} |
||||||
|
|
||||||
|
cases := map[string]resourcetest.ACLTestCase{ |
||||||
|
"no rules": { |
||||||
|
Rules: ``, |
||||||
|
Data: nodehealthStatusData, |
||||||
|
Owner: node, |
||||||
|
Typ: pbcatalog.NodeHealthStatusType, |
||||||
|
ReadOK: resourcetest.DENY, |
||||||
|
WriteOK: resourcetest.DENY, |
||||||
|
ListOK: resourcetest.DEFAULT, |
||||||
|
}, |
||||||
|
"service test read with node owner": { |
||||||
|
Rules: `service "test" { policy = "read" }`, |
||||||
|
Data: nodehealthStatusData, |
||||||
|
Owner: node, |
||||||
|
Typ: pbcatalog.NodeHealthStatusType, |
||||||
|
ReadOK: resourcetest.DENY, |
||||||
|
WriteOK: resourcetest.DENY, |
||||||
|
ListOK: resourcetest.DEFAULT, |
||||||
|
}, |
||||||
|
"service test write with node owner": { |
||||||
|
Rules: `service "test" { policy = "write" }`, |
||||||
|
Data: nodehealthStatusData, |
||||||
|
Owner: node, |
||||||
|
Typ: pbcatalog.NodeHealthStatusType, |
||||||
|
ReadOK: resourcetest.DENY, |
||||||
|
WriteOK: resourcetest.DENY, |
||||||
|
ListOK: resourcetest.DEFAULT, |
||||||
|
}, |
||||||
|
"node test read with node owner": { |
||||||
|
Rules: `node "test" { policy = "read" }`, |
||||||
|
Data: nodehealthStatusData, |
||||||
|
Owner: node, |
||||||
|
Typ: pbcatalog.NodeHealthStatusType, |
||||||
|
ReadOK: resourcetest.ALLOW, |
||||||
|
WriteOK: resourcetest.DENY, |
||||||
|
ListOK: resourcetest.DEFAULT, |
||||||
|
}, |
||||||
|
"node test write with node owner": { |
||||||
|
Rules: `node "test" { policy = "write" }`, |
||||||
|
Data: nodehealthStatusData, |
||||||
|
Owner: node, |
||||||
|
Typ: pbcatalog.NodeHealthStatusType, |
||||||
|
ReadOK: resourcetest.ALLOW, |
||||||
|
WriteOK: resourcetest.ALLOW, |
||||||
|
ListOK: resourcetest.DEFAULT, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
for name, tc := range cases { |
||||||
|
t.Run(name, func(t *testing.T) { |
||||||
|
resourcetest.RunACLTestCase(t, tc, registry) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue