Skip to content

Commit

Permalink
resource: add MutateAndValidate endpoint (#20311)
Browse files Browse the repository at this point in the history
  • Loading branch information
analogue authored Jan 25, 2024
1 parent ec0df00 commit efdf804
Show file tree
Hide file tree
Showing 17 changed files with 1,251 additions and 511 deletions.
139 changes: 139 additions & 0 deletions agent/grpc-external/services/resource/mutate_and_validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package resource

import (
"context"
"strings"

"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"

"github.com/hashicorp/consul/acl"
"github.com/hashicorp/consul/internal/resource"
"github.com/hashicorp/consul/proto-public/pbresource"
)

func (s *Server) MutateAndValidate(ctx context.Context, req *pbresource.MutateAndValidateRequest) (*pbresource.MutateAndValidateResponse, error) {
tenancyMarkedForDeletion, err := s.mutateAndValidate(ctx, req.Resource)
if err != nil {
return nil, err
}

if tenancyMarkedForDeletion {
return nil, status.Errorf(
codes.InvalidArgument,
"tenancy marked for deletion: %s/%s",
req.Resource.Id.Tenancy.Partition,
req.Resource.Id.Tenancy.Namespace,
)
}
return &pbresource.MutateAndValidateResponse{Resource: req.Resource}, nil
}

// private DRY impl that is used by both the Write and MutateAndValidate RPCs.
func (s *Server) mutateAndValidate(ctx context.Context, res *pbresource.Resource) (tenancyMarkedForDeletion bool, err error) {
reg, err := s.ensureResourceValid(res)
if err != nil {
return false, err
}

v1EntMeta := v2TenancyToV1EntMeta(res.Id.Tenancy)
authz, authzContext, err := s.getAuthorizer(tokenFromContext(ctx), v1EntMeta)
if err != nil {
return false, err
}
v1EntMetaToV2Tenancy(reg, v1EntMeta, res.Id.Tenancy)

// Check the user sent the correct type of data.
if res.Data != nil && !res.Data.MessageIs(reg.Proto) {
got := strings.TrimPrefix(res.Data.TypeUrl, "type.googleapis.com/")

return false, status.Errorf(
codes.InvalidArgument,
"resource.data is of wrong type (expected=%q, got=%q)",
reg.Proto.ProtoReflect().Descriptor().FullName(),
got,
)
}

if err = reg.Mutate(res); err != nil {
return false, status.Errorf(codes.Internal, "failed mutate hook: %v", err.Error())
}

if err = reg.Validate(res); err != nil {
return false, status.Error(codes.InvalidArgument, err.Error())
}

// ACL check comes before tenancy existence checks to not leak tenancy "existence".
err = reg.ACLs.Write(authz, authzContext, res)
switch {
case acl.IsErrPermissionDenied(err):
return false, status.Error(codes.PermissionDenied, err.Error())
case err != nil:
return false, status.Errorf(codes.Internal, "failed write acl: %v", err)
}

// Check tenancy exists for the V2 resource
if err = tenancyExists(reg, s.TenancyBridge, res.Id.Tenancy, codes.InvalidArgument); err != nil {
return false, err
}

// This is used later in the "create" and "update" paths to block non-delete related writes
// when a tenancy unit has been marked for deletion.
tenancyMarkedForDeletion, err = isTenancyMarkedForDeletion(reg, s.TenancyBridge, res.Id.Tenancy)
if err != nil {
return false, status.Errorf(codes.Internal, "failed tenancy marked for deletion check: %v", err)
}
if tenancyMarkedForDeletion {
return true, nil
}
return false, nil
}

func (s *Server) ensureResourceValid(res *pbresource.Resource) (*resource.Registration, error) {
var field string
switch {
case res == nil:
field = "resource"
case res.Id == nil:
field = "resource.id"
}

if field != "" {
return nil, status.Errorf(codes.InvalidArgument, "%s is required", field)
}

if err := validateId(res.Id, "resource.id"); err != nil {
return nil, err
}

if res.Owner != nil {
if err := validateId(res.Owner, "resource.owner"); err != nil {
return nil, err
}
}

// Check type exists.
reg, err := s.resolveType(res.Id.Type)
if err != nil {
return nil, err
}

if err = checkV2Tenancy(s.UseV2Tenancy, res.Id.Type); err != nil {
return nil, err
}

// Check scope
if reg.Scope == resource.ScopePartition && res.Id.Tenancy.Namespace != "" {
return nil, status.Errorf(
codes.InvalidArgument,
"partition scoped resource %s cannot have a namespace. got: %s",
resource.ToGVK(res.Id.Type),
res.Id.Tenancy.Namespace,
)
}

return reg, nil
}
212 changes: 212 additions & 0 deletions agent/grpc-external/services/resource/mutate_and_validate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package resource_test

import (
"fmt"
"testing"

"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"

svc "github.com/hashicorp/consul/agent/grpc-external/services/resource"
svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing"
"github.com/hashicorp/consul/internal/resource/demo"
"github.com/hashicorp/consul/proto-public/pbresource"
pbdemov2 "github.com/hashicorp/consul/proto/private/pbdemo/v2"
"github.com/hashicorp/consul/proto/private/prototest"
)

func TestMutateAndValidate_InputValidation(t *testing.T) {
run := func(t *testing.T, client pbresource.ResourceServiceClient, tc resourceValidTestCase) {
artist, err := demo.GenerateV2Artist()
require.NoError(t, err)

recordLabel, err := demo.GenerateV1RecordLabel("looney-tunes")
require.NoError(t, err)

req := &pbresource.MutateAndValidateRequest{Resource: tc.modFn(artist, recordLabel)}
_, err = client.MutateAndValidate(testContext(t), req)
require.Error(t, err)
require.Equal(t, codes.InvalidArgument.String(), status.Code(err).String())
require.ErrorContains(t, err, tc.errContains)
}

for _, v2tenancy := range []bool{false, true} {
t.Run(fmt.Sprintf("v2tenancy %v", v2tenancy), func(t *testing.T) {
client := svctest.NewResourceServiceBuilder().
WithRegisterFns(demo.RegisterTypes).
WithV2Tenancy(v2tenancy).
Run(t)

for desc, tc := range resourceValidTestCases(t) {
t.Run(desc, func(t *testing.T) {
run(t, client, tc)
})
}
})
}
}

func TestMutateAndValidate_OwnerValidation(t *testing.T) {
run := func(t *testing.T, client pbresource.ResourceServiceClient, tc ownerValidTestCase) {
artist, err := demo.GenerateV2Artist()
require.NoError(t, err)

album, err := demo.GenerateV2Album(artist.Id)
require.NoError(t, err)

tc.modFn(album)

_, err = client.MutateAndValidate(testContext(t), &pbresource.MutateAndValidateRequest{Resource: album})
require.Error(t, err)
require.Equal(t, codes.InvalidArgument.String(), status.Code(err).String())
require.ErrorContains(t, err, tc.errorContains)
}

for _, v2tenancy := range []bool{false, true} {
t.Run(fmt.Sprintf("v2tenancy %v", v2tenancy), func(t *testing.T) {
client := svctest.NewResourceServiceBuilder().
WithRegisterFns(demo.RegisterTypes).
WithV2Tenancy(v2tenancy).
Run(t)

for desc, tc := range ownerValidationTestCases(t) {
t.Run(desc, func(t *testing.T) {
run(t, client, tc)
})
}
})
}
}

func TestMutateAndValidate_TypeNotFound(t *testing.T) {
run := func(t *testing.T, client pbresource.ResourceServiceClient) {
res, err := demo.GenerateV2Artist()
require.NoError(t, err)

_, err = client.MutateAndValidate(testContext(t), &pbresource.MutateAndValidateRequest{Resource: res})
require.Error(t, err)
require.Equal(t, codes.InvalidArgument.String(), status.Code(err).String())
require.Contains(t, err.Error(), "resource type demo.v2.Artist not registered")
}

for _, v2tenancy := range []bool{false, true} {
t.Run(fmt.Sprintf("v2tenancy %v", v2tenancy), func(t *testing.T) {
client := svctest.NewResourceServiceBuilder().WithV2Tenancy(v2tenancy).Run(t)
run(t, client)
})
}
}

func TestMutateAndValidate_Success(t *testing.T) {
run := func(t *testing.T, client pbresource.ResourceServiceClient, tc mavOrWriteSuccessTestCase) {
recordLabel, err := demo.GenerateV1RecordLabel("looney-tunes")
require.NoError(t, err)

artist, err := demo.GenerateV2Artist()
require.NoError(t, err)

rsp, err := client.MutateAndValidate(testContext(t), &pbresource.MutateAndValidateRequest{Resource: tc.modFn(artist, recordLabel)})
require.NoError(t, err)
prototest.AssertDeepEqual(t, tc.expectedTenancy, rsp.Resource.Id.Tenancy)
}

for _, v2tenancy := range []bool{false, true} {
t.Run(fmt.Sprintf("v2tenancy %v", v2tenancy), func(t *testing.T) {
client := svctest.NewResourceServiceBuilder().
WithRegisterFns(demo.RegisterTypes).
WithV2Tenancy(v2tenancy).
Run(t)

for desc, tc := range mavOrWriteSuccessTestCases(t) {
t.Run(desc, func(t *testing.T) {
run(t, client, tc)
})
}
})
}
}

func TestMutateAndValidate_Mutate(t *testing.T) {
for _, v2tenancy := range []bool{false, true} {
t.Run(fmt.Sprintf("v2tenancy %v", v2tenancy), func(t *testing.T) {
client := svctest.NewResourceServiceBuilder().
WithRegisterFns(demo.RegisterTypes).
WithV2Tenancy(v2tenancy).
Run(t)

artist, err := demo.GenerateV2Artist()
require.NoError(t, err)

artistData := &pbdemov2.Artist{}
artist.Data.UnmarshalTo(artistData)
require.NoError(t, err)

// mutate hook sets genre to disco when unspecified
artistData.Genre = pbdemov2.Genre_GENRE_UNSPECIFIED
artist.Data.MarshalFrom(artistData)
require.NoError(t, err)

rsp, err := client.MutateAndValidate(testContext(t), &pbresource.MutateAndValidateRequest{Resource: artist})
require.NoError(t, err)

// verify mutate hook set genre to disco
require.NoError(t, rsp.Resource.Data.UnmarshalTo(artistData))
require.Equal(t, pbdemov2.Genre_GENRE_DISCO, artistData.Genre)
})
}
}

func TestMutateAndValidate_Tenancy_NotFound(t *testing.T) {
for desc, tc := range mavOrWriteTenancyNotFoundTestCases(t) {
t.Run(desc, func(t *testing.T) {
client := svctest.NewResourceServiceBuilder().
WithV2Tenancy(true).
WithRegisterFns(demo.RegisterTypes).
Run(t)

recordLabel, err := demo.GenerateV1RecordLabel("looney-tunes")
require.NoError(t, err)

artist, err := demo.GenerateV2Artist()
require.NoError(t, err)

_, err = client.MutateAndValidate(testContext(t), &pbresource.MutateAndValidateRequest{Resource: tc.modFn(artist, recordLabel)})
require.Error(t, err)
require.Equal(t, codes.InvalidArgument.String(), status.Code(err).String())
require.Contains(t, err.Error(), tc.errContains)
})
}
}

func TestMutateAndValidate_TenancyMarkedForDeletion_Fails(t *testing.T) {
for desc, tc := range mavOrWriteTenancyMarkedForDeletionTestCases(t) {
t.Run(desc, func(t *testing.T) {
server := testServer(t)
client := testClient(t, server)
demo.RegisterTypes(server.Registry)

recordLabel, err := demo.GenerateV1RecordLabel("looney-tunes")
require.NoError(t, err)
recordLabel.Id.Tenancy.Partition = "ap1"

artist, err := demo.GenerateV2Artist()
require.NoError(t, err)
artist.Id.Tenancy.Partition = "ap1"
artist.Id.Tenancy.Namespace = "ns1"

mockTenancyBridge := &svc.MockTenancyBridge{}
mockTenancyBridge.On("PartitionExists", "ap1").Return(true, nil)
mockTenancyBridge.On("NamespaceExists", "ap1", "ns1").Return(true, nil)
server.TenancyBridge = mockTenancyBridge

_, err = client.MutateAndValidate(testContext(t), &pbresource.MutateAndValidateRequest{Resource: tc.modFn(artist, recordLabel, mockTenancyBridge)})
require.Error(t, err)
require.Equal(t, codes.InvalidArgument.String(), status.Code(err).String())
require.Contains(t, err.Error(), tc.errContains)
})
}
}
2 changes: 1 addition & 1 deletion agent/grpc-external/services/resource/testing/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ func (b *Builder) Run(t testutil.TestingTB) pbresource.ResourceServiceClient {
switch config.TenancyBridge.(type) {
case *tenancy.V2TenancyBridge:
config.TenancyBridge.(*tenancy.V2TenancyBridge).WithClient(client)
// Default partition namespace can finally be created
// Default partition and namespace can finally be created
require.NoError(t, initTenancy(ctx, backend))

for _, tenancy := range b.tenancies {
Expand Down
3 changes: 2 additions & 1 deletion agent/grpc-external/services/resource/testing/testing_ce.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ func FillAuthorizerContext(authzContext *acl.AuthorizerContext) {
// nothing to to in CE.
}

// initTenancy create the base tenancy objects (default/default)
// initTenancy creates the builtin v2 namespace resource only. The builtin
// v2 partition is not created because we're in CE.
func initTenancy(ctx context.Context, b *inmem.Backend) error {
nsData, err := anypb.New(&pbtenancy.Namespace{Description: "default namespace in default partition"})
if err != nil {
Expand Down
Loading

0 comments on commit efdf804

Please sign in to comment.