feat: OnCreateCatalog (#8)
* feat: OnCreateCatalog - Update onCreateCatalog to support credential rotation. Service authentication type creds will be rotated since we will have the credentials for the base service account without sufficient permissions. - `onCreateCatalog` will also do a dry run validation to validate the credentials. - The secrets and last rotated time will be returned
This commit is contained in:
1 parent
636432f2fb
commit
6b917b170d
14 files changed
+992
-90
No files matched your search
@@ -72,3 +72,70 @@ func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error)
|
||||
TargetServiceAccountId: targetServiceAccountId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCredentialsConfig parses values out of a protobuf struct secrets and returns a
|
||||
// Config used for configuring an GCP session. An status error is returned
|
||||
// with an InvalidArgument code if any unrecognized fields are found in the protobuf
|
||||
// struct input.
|
||||
func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes) (*Config, error) {
|
||||
// initialize secrets if it is nil
|
||||
// secrets can be nil if the authentication type is Application Default Credential(ADC)
|
||||
if secrets == nil {
|
||||
secrets = &structpb.Struct{
|
||||
Fields: make(map[string]*structpb.Value),
|
||||
}
|
||||
}
|
||||
|
||||
// initialize attrs if it is nil
|
||||
// attributes can be nil if the authentication type is Application Default Credential(ADC)
|
||||
// where the credentials are inferred from the environment and no fields are provided
|
||||
// https://cloud.google.com/docs/authentication/application-default-credentials
|
||||
if attrs == nil {
|
||||
attrs = &CredentialAttributes{}
|
||||
}
|
||||
|
||||
unknownFields := values.StructFields(secrets)
|
||||
badFields := make(map[string]string)
|
||||
|
||||
privateKeyId, err := values.GetStringValue(secrets, ConstPrivateKeyId, false)
|
||||
if err != nil {
|
||||
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKeyId)] = err.Error()
|
||||
}
|
||||
delete(unknownFields, ConstPrivateKeyId)
|
||||
|
||||
privateKey, err := values.GetStringValue(secrets, ConstPrivateKey, false)
|
||||
if err != nil {
|
||||
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = err.Error()
|
||||
}
|
||||
delete(unknownFields, ConstPrivateKey)
|
||||
|
||||
delete(unknownFields, ConstCredsLastRotatedTime)
|
||||
for s := range unknownFields {
|
||||
badFields[fmt.Sprintf("secrets.%s", s)] = "unrecognized field"
|
||||
}
|
||||
|
||||
switch {
|
||||
// dynamic credentials requires the target service account id, cl and private key
|
||||
case attrs.TargetServiceAccountId != "" && (privateKey == "" || attrs.ClientEmail == ""):
|
||||
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = "must not be empty when target service account id is set"
|
||||
badFields[fmt.Sprintf("attributes.%s", ConstClientEmail)] = "must not be empty when target service account id is set"
|
||||
// static credentials requires the private key and client email
|
||||
case privateKey != "" && attrs.ClientEmail == "":
|
||||
badFields[fmt.Sprintf("attributes.%s", ConstClientEmail)] = "must not be empty when private key is set"
|
||||
case attrs.ClientEmail != "" && privateKey == "":
|
||||
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = "must not be empty when client email is set"
|
||||
}
|
||||
|
||||
if len(badFields) > 0 {
|
||||
return nil, errors.InvalidArgumentError("Error in the secrets provided", badFields)
|
||||
}
|
||||
|
||||
return &Config{
|
||||
ProjectId: attrs.ProjectId,
|
||||
ClientEmail: attrs.ClientEmail,
|
||||
Zone: attrs.Zone,
|
||||
PrivateKey: privateKey,
|
||||
PrivateKeyId: privateKeyId,
|
||||
TargetServiceAccountId: attrs.TargetServiceAccountId,
|
||||
}, nil
|
||||
}
|
||||
@@ -104,3 +104,166 @@ func TestGetCredentialAttributes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCredentialsConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
secrets map[string]any
|
||||
attrs *CredentialAttributes
|
||||
expected *Config
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "nil secrets",
|
||||
attrs: &CredentialAttributes{
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
expected: &Config{
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil attributes",
|
||||
attrs: nil,
|
||||
expected: &Config{},
|
||||
},
|
||||
{
|
||||
name: "unknown fields in secrets",
|
||||
secrets: map[string]any{
|
||||
"unknown_field": "value",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
expectedErrContains: "secrets.unknown_field: unrecognized field",
|
||||
},
|
||||
{
|
||||
name: "valid ignore creds_last_rotated_time",
|
||||
secrets: map[string]any{
|
||||
ConstPrivateKeyId: "test-private-key-id",
|
||||
ConstPrivateKey: "test-private-key",
|
||||
ConstCredsLastRotatedTime: "2006-01-02T15:04:05+07:00",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
ClientEmail: "test@test.com",
|
||||
},
|
||||
expected: &Config{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
ClientEmail: "test@test.com",
|
||||
PrivateKey: "test-private-key",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing private key with target service account id",
|
||||
secrets: map[string]any{
|
||||
ConstClientEmail: "test@test.com",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
TargetServiceAccountId: "test-target-service-account",
|
||||
},
|
||||
expectedErrContains: "must not be empty when target service account id is set",
|
||||
},
|
||||
{
|
||||
name: "missing client email with target service account id",
|
||||
secrets: map[string]any{
|
||||
ConstPrivateKey: "private-key",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
TargetServiceAccountId: "test-target-service-account",
|
||||
},
|
||||
expectedErrContains: "must not be empty when target service account id is set",
|
||||
},
|
||||
{
|
||||
name: "missing client email with private key",
|
||||
secrets: map[string]any{
|
||||
ConstPrivateKeyId: "test-private-key-id",
|
||||
ConstPrivateKey: "test-private-key",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
expectedErrContains: "must not be empty when private key is set",
|
||||
},
|
||||
{
|
||||
name: "missing private key with client email",
|
||||
secrets: map[string]any{},
|
||||
attrs: &CredentialAttributes{
|
||||
ClientEmail: "test@test.com",
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
expectedErrContains: "must not be empty when client email is set",
|
||||
},
|
||||
{
|
||||
name: "valid static credentials",
|
||||
secrets: map[string]any{
|
||||
ConstPrivateKeyId: "test-private-key-id",
|
||||
ConstPrivateKey: "test-private-key",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ClientEmail: "test@test.com",
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
expected: &Config{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
PrivateKey: "test-private-key",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
ClientEmail: "test@test.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid dynamic credentials",
|
||||
secrets: map[string]any{
|
||||
ConstPrivateKeyId: "test-private-key-id",
|
||||
ConstPrivateKey: "test-private-key",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ClientEmail: "test@test.com",
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
TargetServiceAccountId: "test-target-service-account",
|
||||
},
|
||||
expected: &Config{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
PrivateKey: "test-private-key",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
ClientEmail: "test@test.com",
|
||||
TargetServiceAccountId: "test-target-service-account",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
secrets, err := structpb.NewStruct(tc.secrets)
|
||||
require.NoError(err)
|
||||
|
||||
actual, err := GetCredentialsConfig(secrets, tc.attrs)
|
||||
if tc.expectedErrContains != "" {
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), tc.expectedErrContains)
|
||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.EqualValues(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ type Config struct {
|
||||
ProjectId string
|
||||
PrivateKey string
|
||||
PrivateKeyId string
|
||||
Zone string
|
||||
ClientEmail string
|
||||
TargetServiceAccountId string
|
||||
Scopes []string
|
||||
@@ -166,6 +167,22 @@ func (c *Config) credentialsFromADC(ctx context.Context) (*google.Credentials, e
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// IsRotatable returns a boolean indicating if the credentials are rotatable.
|
||||
func (c *Config) IsRotatable() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(c.TargetServiceAccountId) > 0 && len(c.PrivateKey) > 0 && len(c.ClientEmail) > 0:
|
||||
return true
|
||||
case len(c.PrivateKey) > 0 && len(c.ClientEmail) > 0:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// impersonateServiceAccount impersonates the service account.
|
||||
// This is used to impersonate the service account to generate an access token.
|
||||
func impersonateServiceAccount(
|
||||
|
||||
@@ -340,3 +340,46 @@ func TestClone(t *testing.T) {
|
||||
require.NotEqual(t, originalConfig.TargetServiceAccountId, clonedConfig.TargetServiceAccountId)
|
||||
require.NotEqual(t, originalConfig.Scopes, clonedConfig.Scopes)
|
||||
}
|
||||
|
||||
func TestIsRotatable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Nil Config",
|
||||
config: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Service Account Impersonation Config",
|
||||
config: &Config{
|
||||
PrivateKey: "fake-private-key",
|
||||
ClientEmail: "test-email@example.com",
|
||||
TargetServiceAccountId: "target-service-account@example.com",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Static GCP Config",
|
||||
config: &Config{
|
||||
PrivateKey: "fake-private-key",
|
||||
ClientEmail: "test-email@example.com",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Default Config",
|
||||
config: &Config{},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.config.IsRotatable()
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,10 @@ const (
|
||||
|
||||
// ConstTargetServiceAccountID is the unique identifier for the service account that will be impersonated.
|
||||
ConstTargetServiceAccountID = "target_service_account_id"
|
||||
|
||||
// ConstPrivateKeyId is the private key id associated with the service account
|
||||
ConstPrivateKeyId = "private_key_id"
|
||||
|
||||
// ConstPrivateKey is the private key associated with the service account
|
||||
ConstPrivateKey = "private_key"
|
||||
)
|
||||
@@ -6,8 +6,6 @@ package credential
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"cloud.google.com/go/iam/admin/apiv1/adminpb"
|
||||
@@ -19,7 +17,6 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
func TestRotateServiceAccountKey(t *testing.T) {
|
||||
@@ -106,10 +103,10 @@ func TestRotateServiceAccountKey(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gsrv := newGRPCServer()
|
||||
gsrv := NewGRPCServer()
|
||||
adminpb.RegisterIAMServer(gsrv.Server, testIAMAdminServer)
|
||||
resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer)
|
||||
addr, err := gsrv.serve()
|
||||
addr, err := gsrv.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -245,9 +242,9 @@ func TestValidateIamPermissions(t *testing.T) {
|
||||
testResourceServer.testIamPermissionsResponse = tt.testIamResponse
|
||||
testResourceServer.testIamPermissionsError = tt.testIamPermissionsError
|
||||
|
||||
gsrv := newGRPCServer()
|
||||
gsrv := NewGRPCServer()
|
||||
resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer)
|
||||
addr, err := gsrv.serve()
|
||||
addr, err := gsrv.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
testOptions := []googleOption.ClientOption{
|
||||
@@ -267,52 +264,3 @@ func TestValidateIamPermissions(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type testIAMAdminServer struct {
|
||||
adminpb.UnimplementedIAMServer
|
||||
testCreateServiceAccountKeyError error
|
||||
testDeleteServiceAccountKeyError error
|
||||
}
|
||||
|
||||
func (f *testIAMAdminServer) CreateServiceAccountKey(ctx context.Context, req *adminpb.CreateServiceAccountKeyRequest) (*adminpb.ServiceAccountKey, error) {
|
||||
resp := &adminpb.ServiceAccountKey{
|
||||
Name: "projects/-/serviceAccounts/1234/keys/updated-private-key-id",
|
||||
PrivateKeyData: []byte("updated-private-key"),
|
||||
}
|
||||
return resp, f.testCreateServiceAccountKeyError
|
||||
}
|
||||
|
||||
func (f *testIAMAdminServer) DeleteServiceAccountKey(ctx context.Context, req *adminpb.DeleteServiceAccountKeyRequest) (*emptypb.Empty, error) {
|
||||
return &emptypb.Empty{}, f.testDeleteServiceAccountKeyError
|
||||
}
|
||||
|
||||
type testResourceServer struct {
|
||||
resourcemanagerpb.UnimplementedProjectsServer
|
||||
testIamPermissionsResponse *iampb.TestIamPermissionsResponse
|
||||
testIamPermissionsError error
|
||||
}
|
||||
|
||||
func (f *testResourceServer) TestIamPermissions(context.Context, *iampb.TestIamPermissionsRequest) (*iampb.TestIamPermissionsResponse, error) {
|
||||
return f.testIamPermissionsResponse, f.testIamPermissionsError
|
||||
}
|
||||
|
||||
type grpcServer struct {
|
||||
*grpc.Server
|
||||
}
|
||||
|
||||
func newGRPCServer() *grpcServer {
|
||||
return &grpcServer{Server: grpc.NewServer()}
|
||||
}
|
||||
|
||||
func (s *grpcServer) serve() (string, error) {
|
||||
l, err := net.Listen("tcp", "localhost:0")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to listen: %v", err)
|
||||
}
|
||||
go func() {
|
||||
if err := s.Serve(l); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
return l.Addr().String(), nil
|
||||
}
|
||||
@@ -28,3 +28,14 @@ func NewPersistedState(opt ...Option) (*PersistedState, error) {
|
||||
s.CredsLastRotatedTime = opts.WithCredsLastRotatedTime
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ToMap returns a map of the credentials stored in the persisted state.
|
||||
// ToMap will return a map for long-term credentials with following keys:
|
||||
// private_key_id, private_key & creds_last_rotated_time
|
||||
func (s *PersistedState) ToMap() map[string]any {
|
||||
return map[string]any{
|
||||
ConstPrivateKey: s.CredentialsConfig.PrivateKey,
|
||||
ConstPrivateKeyId: s.CredentialsConfig.PrivateKeyId,
|
||||
ConstCredsLastRotatedTime: s.CredsLastRotatedTime.Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"cloud.google.com/go/iam/admin/apiv1/adminpb"
|
||||
"cloud.google.com/go/iam/apiv1/iampb"
|
||||
"cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
type testIAMAdminServer struct {
|
||||
adminpb.UnimplementedIAMServer
|
||||
testCreateServiceAccountKeyError error
|
||||
testDeleteServiceAccountKeyError error
|
||||
}
|
||||
|
||||
func NewTestIAMAdminServer(createServiceAccountKeyError error, deleteServiceAccountKeyError error) *testIAMAdminServer {
|
||||
return &testIAMAdminServer{
|
||||
testCreateServiceAccountKeyError: createServiceAccountKeyError,
|
||||
testDeleteServiceAccountKeyError: deleteServiceAccountKeyError,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *testIAMAdminServer) CreateServiceAccountKey(ctx context.Context, req *adminpb.CreateServiceAccountKeyRequest) (*adminpb.ServiceAccountKey, error) {
|
||||
resp := &adminpb.ServiceAccountKey{
|
||||
Name: "projects/-/serviceAccounts/1234/keys/updated-private-key-id",
|
||||
PrivateKeyData: []byte("updated-private-key"),
|
||||
}
|
||||
return resp, f.testCreateServiceAccountKeyError
|
||||
}
|
||||
|
||||
func (f *testIAMAdminServer) DeleteServiceAccountKey(ctx context.Context, req *adminpb.DeleteServiceAccountKeyRequest) (*emptypb.Empty, error) {
|
||||
return &emptypb.Empty{}, f.testDeleteServiceAccountKeyError
|
||||
}
|
||||
|
||||
type testResourceServer struct {
|
||||
resourcemanagerpb.UnimplementedProjectsServer
|
||||
testIamPermissionsResponse *iampb.TestIamPermissionsResponse
|
||||
testIamPermissionsError error
|
||||
}
|
||||
|
||||
func NewTestResourceServer(testIamPermissionsResponse *iampb.TestIamPermissionsResponse, testIamPermissionsError error) *testResourceServer {
|
||||
return &testResourceServer{
|
||||
testIamPermissionsResponse: testIamPermissionsResponse,
|
||||
testIamPermissionsError: testIamPermissionsError,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *testResourceServer) TestIamPermissions(context.Context, *iampb.TestIamPermissionsRequest) (*iampb.TestIamPermissionsResponse, error) {
|
||||
return f.testIamPermissionsResponse, f.testIamPermissionsError
|
||||
}
|
||||
|
||||
type grpcServer struct {
|
||||
*grpc.Server
|
||||
}
|
||||
|
||||
func NewGRPCServer() *grpcServer {
|
||||
return &grpcServer{Server: grpc.NewServer()}
|
||||
}
|
||||
|
||||
func (s *grpcServer) Start() (string, error) {
|
||||
l, err := net.Listen("tcp", "localhost:0")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to listen: %v", err)
|
||||
}
|
||||
go func() {
|
||||
if err := s.Serve(l); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
return l.Addr().String(), nil
|
||||
}
|
||||
Reference in new issue
Block a user