open-consul/agent/grpc/public/services/connectca/watch_roots_test.go

282 lines
8.0 KiB
Go

package connectca
import (
"context"
"errors"
"io"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-uuid"
"github.com/hashicorp/consul/acl"
"github.com/hashicorp/consul/agent/connect"
"github.com/hashicorp/consul/agent/grpc/public"
"github.com/hashicorp/consul/agent/grpc/public/testutils"
"github.com/hashicorp/consul/agent/structs"
"github.com/hashicorp/consul/proto-public/pbconnectca"
)
const testACLToken = "acl-token"
func TestWatchRoots_Success(t *testing.T) {
store := testStateStore(t)
// Set the initial roots and CA configuration.
rootA := connect.TestCA(t, nil)
_, err := store.CARootSetCAS(1, 0, structs.CARoots{rootA})
require.NoError(t, err)
err = store.CASetConfig(0, &structs.CAConfiguration{ClusterID: "cluster-id"})
require.NoError(t, err)
// Mock the ACL Resolver to return an authorizer with `service:write`.
aclResolver := &MockACLResolver{}
aclResolver.On("ResolveTokenAndDefaultMeta", testACLToken, mock.Anything, mock.Anything).
Return(testutils.TestAuthorizer(t), nil)
ctx := public.ContextWithToken(context.Background(), testACLToken)
server := NewServer(Config{
GetStore: func() StateStore { return store },
Logger: hclog.NewNullLogger(),
ACLResolver: aclResolver,
})
// Begin the stream.
client := testClient(t, server)
stream, err := client.WatchRoots(ctx, &emptypb.Empty{})
require.NoError(t, err)
rspCh := handleRootsStream(t, stream)
// Expect an initial message containing current roots (provided by the snapshot).
roots := mustGetRoots(t, rspCh)
require.Equal(t, "cluster-id.consul", roots.TrustDomain)
require.Equal(t, rootA.ID, roots.ActiveRootId)
require.Len(t, roots.Roots, 1)
require.Equal(t, rootA.ID, roots.Roots[0].Id)
// Rotate the roots.
rootB := connect.TestCA(t, nil)
_, err = store.CARootSetCAS(2, 1, structs.CARoots{rootB})
require.NoError(t, err)
// Expect another event containing the new roots.
roots = mustGetRoots(t, rspCh)
require.Equal(t, "cluster-id.consul", roots.TrustDomain)
require.Equal(t, rootB.ID, roots.ActiveRootId)
require.Len(t, roots.Roots, 1)
require.Equal(t, rootB.ID, roots.Roots[0].Id)
}
func TestWatchRoots_InvalidACLToken(t *testing.T) {
store := testStateStore(t)
// Set the initial CA configuration.
err := store.CASetConfig(0, &structs.CAConfiguration{ClusterID: "cluster-id"})
require.NoError(t, err)
// Mock the ACL resolver to return ErrNotFound.
aclResolver := &MockACLResolver{}
aclResolver.On("ResolveTokenAndDefaultMeta", mock.Anything, mock.Anything, mock.Anything).
Return(nil, acl.ErrNotFound)
ctx := public.ContextWithToken(context.Background(), testACLToken)
server := NewServer(Config{
GetStore: func() StateStore { return store },
Logger: hclog.NewNullLogger(),
ACLResolver: aclResolver,
})
// Start the stream.
client := testClient(t, server)
stream, err := client.WatchRoots(ctx, &emptypb.Empty{})
require.NoError(t, err)
rspCh := handleRootsStream(t, stream)
// Expect to get an Unauthenticated error immediately.
err = mustGetError(t, rspCh)
require.Equal(t, codes.Unauthenticated.String(), status.Code(err).String())
}
func TestWatchRoots_ACLTokenInvalidated(t *testing.T) {
store := testStateStore(t)
// Set the initial roots and CA configuration.
rootA := connect.TestCA(t, nil)
_, err := store.CARootSetCAS(1, 0, structs.CARoots{rootA})
require.NoError(t, err)
err = store.CASetConfig(2, &structs.CAConfiguration{ClusterID: "cluster-id"})
require.NoError(t, err)
// Mock the ACL Resolver to return an authorizer with `service:write` the
// first two times it is called (initial connect and first re-auth).
aclResolver := &MockACLResolver{}
aclResolver.On("ResolveTokenAndDefaultMeta", testACLToken, mock.Anything, mock.Anything).
Return(testutils.TestAuthorizer(t), nil).Twice()
ctx := public.ContextWithToken(context.Background(), testACLToken)
server := NewServer(Config{
GetStore: func() StateStore { return store },
Logger: hclog.NewNullLogger(),
ACLResolver: aclResolver,
})
// Start the stream.
client := testClient(t, server)
stream, err := client.WatchRoots(ctx, &emptypb.Empty{})
require.NoError(t, err)
rspCh := handleRootsStream(t, stream)
// Consume the initial response.
mustGetRoots(t, rspCh)
// Update the ACL token to cause the subscription to be force-closed.
accessorID, err := uuid.GenerateUUID()
require.NoError(t, err)
err = store.ACLTokenSet(1, &structs.ACLToken{
AccessorID: accessorID,
SecretID: testACLToken,
})
require.NoError(t, err)
// Update the roots.
rootB := connect.TestCA(t, nil)
_, err = store.CARootSetCAS(3, 1, structs.CARoots{rootB})
require.NoError(t, err)
// Expect the stream to remain open and to receive the new roots.
mustGetRoots(t, rspCh)
// Simulate removing the `service:write` permission.
aclResolver.On("ResolveTokenAndDefaultMeta", testACLToken, mock.Anything, mock.Anything).
Return(acl.DenyAll(), nil)
// Update the ACL token to cause the subscription to be force-closed.
err = store.ACLTokenSet(1, &structs.ACLToken{
AccessorID: accessorID,
SecretID: testACLToken,
})
require.NoError(t, err)
// Expect the stream to be terminated.
err = mustGetError(t, rspCh)
require.Equal(t, codes.PermissionDenied.String(), status.Code(err).String())
}
func TestWatchRoots_StateStoreAbandoned(t *testing.T) {
storeA := testStateStore(t)
// Set the initial roots and CA configuration.
rootA := connect.TestCA(t, nil)
_, err := storeA.CARootSetCAS(1, 0, structs.CARoots{rootA})
require.NoError(t, err)
err = storeA.CASetConfig(0, &structs.CAConfiguration{ClusterID: "cluster-a"})
require.NoError(t, err)
// Mock the ACL Resolver to return an authorizer with `service:write`.
aclResolver := &MockACLResolver{}
aclResolver.On("ResolveTokenAndDefaultMeta", testACLToken, mock.Anything, mock.Anything).
Return(testutils.TestAuthorizer(t), nil)
ctx := public.ContextWithToken(context.Background(), testACLToken)
server := NewServer(Config{
GetStore: func() StateStore { return storeA },
Logger: hclog.NewNullLogger(),
ACLResolver: aclResolver,
})
// Begin the stream.
client := testClient(t, server)
stream, err := client.WatchRoots(ctx, &emptypb.Empty{})
require.NoError(t, err)
rspCh := handleRootsStream(t, stream)
// Consume the initial roots.
mustGetRoots(t, rspCh)
// Simulate a snapshot restore.
storeB := testStateStore(t)
rootB := connect.TestCA(t, nil)
_, err = storeB.CARootSetCAS(1, 0, structs.CARoots{rootB})
require.NoError(t, err)
err = storeB.CASetConfig(0, &structs.CAConfiguration{ClusterID: "cluster-b"})
require.NoError(t, err)
server.GetStore = func() StateStore { return storeB }
storeA.Abandon()
// Expect to get the new store's roots.
newRoots := mustGetRoots(t, rspCh)
require.Equal(t, "cluster-b.consul", newRoots.TrustDomain)
require.Len(t, newRoots.Roots, 1)
require.Equal(t, rootB.ID, newRoots.ActiveRootId)
}
func mustGetRoots(t *testing.T, ch <-chan rootsOrError) *pbconnectca.WatchRootsResponse {
t.Helper()
select {
case rsp := <-ch:
require.NoError(t, rsp.err)
return rsp.rsp
case <-time.After(1 * time.Second):
t.Fatal("timeout waiting for WatchRootsResponse")
return nil
}
}
func mustGetError(t *testing.T, ch <-chan rootsOrError) error {
t.Helper()
select {
case rsp := <-ch:
require.Error(t, rsp.err)
return rsp.err
case <-time.After(1 * time.Second):
t.Fatal("timeout waiting for WatchRootsResponse")
return nil
}
}
func handleRootsStream(t *testing.T, stream pbconnectca.ConnectCAService_WatchRootsClient) <-chan rootsOrError {
t.Helper()
rspCh := make(chan rootsOrError)
go func() {
for {
rsp, err := stream.Recv()
if errors.Is(err, io.EOF) ||
errors.Is(err, context.Canceled) ||
errors.Is(err, context.DeadlineExceeded) {
return
}
rspCh <- rootsOrError{
rsp: rsp,
err: err,
}
}
}()
return rspCh
}
type rootsOrError struct {
rsp *pbconnectca.WatchRootsResponse
err error
}