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:
Elim Tsiagbey authored and GitHub committed 2024-10-21 10:47:06 -04:00
1 parent 636432f2fb
commit 6b917b170d
14 files changed
+992 -90

No files matched your search

+67
View File
@@ -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
}
+163
View File
@@ -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)
})
}
}
+17
View File
@@ -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(
+43
View File
@@ -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)
})
}
}
+6
View File
@@ -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"
)
+4 -56
View File
@@ -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
}
+11
View File
@@ -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),
}
}
+79
View File
@@ -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
}