diff --git a/core/provider/client_factory.go b/core/provider/client_factory.go new file mode 100644 index 000000000..2996587e0 --- /dev/null +++ b/core/provider/client_factory.go @@ -0,0 +1,61 @@ +package provider + +import ( + "fmt" + + "github.com/goto/guardian/domain" + "github.com/goto/guardian/plugins/providers/newpoc" +) + +// TODO: move this to guardian/plugins/providers +type pluginFactory struct { + clients map[string]clientV2 + configs map[string]providerConfig +} + +func (f *pluginFactory) getConfig(pc *domain.ProviderConfig) (providerConfig, error) { + if f.configs == nil { + f.configs = make(map[string]providerConfig) + } + + key := pc.URN + if config, ok := f.configs[key]; ok { + return config, nil + } + + switch pc.Type { + case newpoc.ProviderType: + config, err := newpoc.NewConfig(pc) + if err != nil { + return nil, err + } + f.configs[key] = config + return config, nil + default: + return nil, fmt.Errorf("unknown provider type: %q", pc.Type) + } +} + +func (f *pluginFactory) getClient(cfg providerConfig) (clientV2, error) { + if f.clients == nil { + f.clients = make(map[string]clientV2) + } + + key := cfg.GetProviderConfig().URN + if client, ok := f.clients[key]; ok { + return client, nil + } + + providerType := cfg.GetProviderConfig().Type + switch providerType { + case newpoc.ProviderType: + client, err := newpoc.NewClient(cfg.(*newpoc.Config)) + if err != nil { + return nil, err + } + f.clients[key] = client + return client, nil + default: + return nil, fmt.Errorf("unknown provider type: %q", providerType) + } +} diff --git a/core/provider/common.go b/core/provider/common.go index cd81a2f7b..710b69a16 100644 --- a/core/provider/common.go +++ b/core/provider/common.go @@ -1,5 +1,7 @@ package provider +// TODO: remove this file + import ( "context" "fmt" diff --git a/core/provider/plugin_adapter.go b/core/provider/plugin_adapter.go new file mode 100644 index 000000000..5c0769526 --- /dev/null +++ b/core/provider/plugin_adapter.go @@ -0,0 +1,168 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/goto/guardian/domain" + "github.com/goto/guardian/plugins/providers/newpoc" +) + +type pluginAdapter struct { + providerType string + allowedAccountTypes []string + factory *pluginFactory + + validator *validator.Validate + crypto domain.Crypto +} + +func (a *pluginAdapter) GetType() string { + return a.providerType +} + +func (a *pluginAdapter) CreateConfig(pc *domain.ProviderConfig) error { + config, err := a.factory.getConfig(pc) + if err != nil { + return fmt.Errorf("initializing config for %q: %w", pc.Type, err) + } + + if err := config.Validate(context.TODO(), a.validator); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + + if encryptableConfig, ok := config.(encryptable); ok { + if err := encryptableConfig.Encrypt(a.crypto); err != nil { + return fmt.Errorf("encrypting config: %w", err) + } + } + + return nil +} + +func (a *pluginAdapter) GetResources(pc *domain.ProviderConfig) ([]*domain.Resource, error) { + config, err := a.factory.getConfig(pc) + if err != nil { + return nil, fmt.Errorf("initializing config for %q: %w", pc.URN, err) + } + client, err := a.factory.getClient(config) + if err != nil { + return nil, fmt.Errorf("initializing client for %q: %w", pc.URN, err) + } + + resourceables, err := client.ListResources(context.TODO()) + if err != nil { + return nil, fmt.Errorf("listing resources for %q: %w", pc.URN, err) + } + + resources := make([]*domain.Resource, 0, len(resourceables)) + for _, resourceable := range resourceables { + r := &domain.Resource{ + ProviderType: pc.Type, + ProviderURN: pc.URN, + Type: resourceable.GetType(), + URN: resourceable.GetURN(), + Name: resourceable.GetDisplayName(), + } + if md := resourceable.GetMetadata(); md != nil { + r.Details = map[string]interface{}{ + "__metadata": resourceable.GetMetadata(), + } + } + resources = append(resources, r) + } + + return resources, nil +} + +func (a *pluginAdapter) GrantAccess(pc *domain.ProviderConfig, grant domain.Grant) error { + config, err := a.factory.getConfig(pc) + if err != nil { + return fmt.Errorf("initializing config for %q: %w", pc.URN, err) + } + client, err := a.factory.getClient(config) + if err != nil { + return fmt.Errorf("initializing client for %q: %w", pc.URN, err) + } + + return client.GrantAccess(context.TODO(), grant) +} + +func (a *pluginAdapter) RevokeAccess(pc *domain.ProviderConfig, grant domain.Grant) error { + config, err := a.factory.getConfig(pc) + if err != nil { + return fmt.Errorf("initializing config for %q: %w", pc.URN, err) + } + client, err := a.factory.getClient(config) + if err != nil { + return fmt.Errorf("initializing client for %q: %w", pc.URN, err) + } + + return client.RevokeAccess(context.TODO(), grant) +} + +func (a *pluginAdapter) GetRoles(pc *domain.ProviderConfig, resourceType string) ([]*domain.Role, error) { + for _, r := range pc.Resources { + if r.Type == resourceType { + return r.Roles, nil + } + } + + return nil, ErrInvalidResourceType +} + +func (a *pluginAdapter) GetAccountTypes() []string { + return a.allowedAccountTypes +} + +func (a *pluginAdapter) ListAccess(ctx context.Context, pc domain.ProviderConfig, resources []*domain.Resource) (domain.MapResourceAccess, error) { + config, err := a.factory.getConfig(&pc) + if err != nil { + return nil, fmt.Errorf("initializing config for %q: %w", pc.URN, err) + } + client, err := a.factory.getClient(config) + if err != nil { + return nil, fmt.Errorf("initializing client for %q: %w", pc.URN, err) + } + + if accessImporter, ok := client.(AccessImporter); ok { + return accessImporter.ListAccess(ctx, pc, resources) + } + + return nil, fmt.Errorf("ListAccess %w", ErrUnimplementedMethod) +} + +func (a *pluginAdapter) GetPermissions(pc *domain.ProviderConfig, resourceType, role string) ([]interface{}, error) { + for _, rc := range pc.Resources { + if rc.Type != resourceType { + continue + } + for _, r := range rc.Roles { + if r.ID == role { + if r.Permissions == nil { + return make([]interface{}, 0), nil + } + return r.Permissions, nil + } + } + return nil, ErrInvalidRole + } + return nil, ErrInvalidResourceType +} + +func getNewPlugins(pluginFactory *pluginFactory, validator *validator.Validate, crypto domain.Crypto) map[string]Client { + return map[string]Client{ + newpoc.ProviderType: &pluginAdapter{ + providerType: newpoc.ProviderType, + allowedAccountTypes: []string{ + newpoc.AccountTypeUser, + newpoc.AccountTypeGroup, + newpoc.AccountTypeServiceAccount, + }, + factory: pluginFactory, + validator: validator, + crypto: crypto, + }, + } +} diff --git a/core/provider/service.go b/core/provider/service.go index 95ef16c87..4fd2eac95 100644 --- a/core/provider/service.go +++ b/core/provider/service.go @@ -26,6 +26,12 @@ const ( ReservedDetailsKeyPolicyQuestions = "__policy_questions" ) +var ( + migratedPluginTypes = map[string]bool{ + "newpoc": true, + } +) + //go:generate mockery --name=repository --exported --with-expecter type repository interface { Create(context.Context, *domain.Provider) error @@ -37,6 +43,29 @@ type repository interface { Delete(ctx context.Context, id string) error } +type providerConfig interface { + GetProviderConfig() *domain.ProviderConfig + Validate(ctx context.Context, validator *validator.Validate) error +} + +type encryptable interface { + Encrypt(encryptor domain.Encryptor) error +} + +type decryptable interface { + Decrypt(decryptor domain.Decryptor) error +} + +type clientV2 interface { + ListResources(context.Context) ([]domain.Resourceable, error) + GrantAccess(context.Context, domain.Grant) error + RevokeAccess(context.Context, domain.Grant) error +} + +type AccessImporter interface { + ListAccess(context.Context, domain.ProviderConfig, []*domain.Resource) (domain.MapResourceAccess, error) +} + //go:generate mockery --name=Client --exported --with-expecter type Client interface { providers.PermissionManager @@ -79,14 +108,20 @@ type ServiceDeps struct { Validator *validator.Validate Logger log.Logger AuditLogger auditLogger + Crypto domain.Crypto } // NewService returns service struct func NewService(deps ServiceDeps) *Service { + pluginFactory := &pluginFactory{} + mapProviderClients := make(map[string]Client) for _, c := range deps.Clients { mapProviderClients[c.GetType()] = c } + for providerType, adaptedPlugin := range getNewPlugins(pluginFactory, deps.Validator, deps.Crypto) { + mapProviderClients[providerType] = adaptedPlugin + } return &Service{ deps.Repository, diff --git a/domain/provider.go b/domain/provider.go index 1890800e4..3d789b76c 100644 --- a/domain/provider.go +++ b/domain/provider.go @@ -1,9 +1,12 @@ package domain import ( + "context" "fmt" "sort" "time" + + "github.com/go-playground/validator/v10" ) const ( @@ -105,3 +108,8 @@ type ProviderType struct { Name string `json:"name" yaml:"name"` ResourceTypes []string `json:"resource_types" yaml:"resource_types"` } + +type ProviderConfigurable interface { + GetProvider() *Provider + Validate(context.Context, *validator.Validate) error +} diff --git a/domain/resource.go b/domain/resource.go index 85074a8f4..08e47fd99 100644 --- a/domain/resource.go +++ b/domain/resource.go @@ -2,6 +2,13 @@ package domain import "time" +type Resourceable interface { + GetType() string + GetURN() string + GetDisplayName() string + GetMetadata() map[string]interface{} +} + // Resource struct type Resource struct { ID string `json:"id" yaml:"id"` @@ -19,6 +26,10 @@ type Resource struct { Children []*Resource `json:"children,omitempty" yaml:"children,omitempty"` } +func (r *Resource) GetType() string { + return r.Type +} + func (r *Resource) GetFlattened() []*Resource { resources := []*Resource{r} for _, child := range r.Children { diff --git a/internal/server/services.go b/internal/server/services.go index 2ee12be3b..fe612dfd9 100644 --- a/internal/server/services.go +++ b/internal/server/services.go @@ -128,6 +128,7 @@ func InitServices(deps ServiceDeps) (*Services, error) { Validator: deps.Validator, Logger: deps.Logger, AuditLogger: auditLogger, + Crypto: deps.Crypto, }) activityService := activity.NewService(activity.ServiceDeps{ Repository: activityRepository, diff --git a/pkg/option/option.go b/pkg/option/option.go new file mode 100644 index 000000000..dcbb0f864 --- /dev/null +++ b/pkg/option/option.go @@ -0,0 +1,15 @@ +package option + +import "github.com/go-playground/validator/v10" + +type options struct { + validator *validator.Validate +} + +type Option func(*options) + +func WithValidator(validator *validator.Validate) Option { + return func(opts *options) { + opts.validator = validator + } +} diff --git a/plugins/providers/newpoc/client.go b/plugins/providers/newpoc/client.go new file mode 100644 index 000000000..500220ca1 --- /dev/null +++ b/plugins/providers/newpoc/client.go @@ -0,0 +1,221 @@ +package newpoc + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/goto/guardian/domain" + "google.golang.org/api/cloudresourcemanager/v1" + "google.golang.org/api/iam/v1" + "google.golang.org/api/option" +) + +const ( + AccountTypeUser = "user" + AccountTypeServiceAccount = "serviceAccount" + AccountTypeGroup = "group" + + ResourceNameOrganizationPrefix = "organizations/" + ResourceNameProjectPrefix = "projects/" +) + +// Client implements BasicProviderClient +type Client struct { + config *Config + iamService *iam.Service + cloudResourceManagerService *cloudresourcemanager.Service +} + +func NewClient(cfg *Config, opts ...option.ClientOption) (*Client, error) { + if cfg == nil { + return nil, errors.New("config is nil") + } + + // validator := validator.New() // TODO: use option to override validator + // if err := cfg.Validate(context.TODO(), validator); err != nil { + // return nil, err + // } + + ctx := context.Background() + options := []option.ClientOption{ + option.WithCredentialsJSON([]byte(cfg.credentials.ServiceAccountKey)), + } + options = append(options, opts...) + iamService, err := iam.NewService(ctx, options...) + if err != nil { + return nil, err + } + cloudResourceManagerService, err := cloudresourcemanager.NewService(ctx, options...) + if err != nil { + return nil, err + } + + c := &Client{ + config: cfg, + iamService: iamService, + cloudResourceManagerService: cloudResourceManagerService, + } + + return c, nil +} + +func (c *Client) GetAllowedAccountTypes(ctx context.Context) []string { + return []string{ + AccountTypeUser, + AccountTypeServiceAccount, + AccountTypeGroup, + } +} + +func (c *Client) ListResources(ctx context.Context) ([]domain.Resourceable, error) { + return []domain.Resourceable{ + &resource{ + Type: c.config.resourceType, + URN: c.config.credentials.ResourceName, + }, + }, nil +} + +func (c *Client) GrantAccess(ctx context.Context, g domain.Grant) error { + for _, permission := range g.Permissions { + policy, err := c.getIamPolicy(ctx) + if err != nil { + return err + } + + member := fmt.Sprintf("%s:%s", g.AccountType, g.AccountID) + roleExists := false + for _, b := range policy.Bindings { + if b.Role == permission { + roleExists = true + if containsString(b.Members, member) { + // Permission already exists + continue + } + b.Members = append(b.Members, member) + } + } + if !roleExists { + policy.Bindings = append(policy.Bindings, &cloudresourcemanager.Binding{ + Role: permission, + Members: []string{member}, + }) + } + + if _, err = c.setIamPolicy(ctx, policy); err != nil { + return err + } + } + return nil + +} + +func (c *Client) RevokeAccess(ctx context.Context, g domain.Grant) error { + if g.Resource.Type != ResourceTypeProject && g.Resource.Type != ResourceTypeOrganization { + return ErrInvalidResourceType + } + + for _, permission := range g.Permissions { + policy, err := c.getIamPolicy(ctx) + if err != nil { + return err + } + + member := fmt.Sprintf("%s:%s", g.AccountType, g.AccountID) + for _, b := range policy.Bindings { + if b.Role == permission { + removeIndex := -1 + for i, m := range b.Members { + if m == member { + removeIndex = i + } + } + if removeIndex == -1 { + // permission doesn't exist + continue + } + b.Members = append(b.Members[:removeIndex], b.Members[removeIndex+1:]...) + } + } + + if _, err := c.setIamPolicy(ctx, policy); err != nil { + return err + } + } + return nil +} + +func (c *Client) ListAccess(ctx context.Context, resources []*domain.Resource) (domain.MapResourceAccess, error) { + policy, err := c.getIamPolicy(ctx) + if err != nil { + return nil, fmt.Errorf("getting IAM policy: %w", err) + } + + access := make(domain.MapResourceAccess) + for _, resource := range resources { + for _, binding := range policy.Bindings { + for _, member := range binding.Members { + account := strings.Split(member, ":") + ae := domain.AccessEntry{ + AccountType: account[0], + AccountID: account[1], + Permission: binding.Role, + } + access[resource.URN] = append(access[resource.URN], ae) + } + } + } + + return access, nil +} + +func (c *Client) getIamPolicy(ctx context.Context) (*cloudresourcemanager.Policy, error) { + switch c.config.resourceType { + case ResourceTypeProject: + return c.cloudResourceManagerService.Projects. + GetIamPolicy(c.config.resourceID, &cloudresourcemanager.GetIamPolicyRequest{}). + Context(ctx).Do() + case ResourceTypeOrganization: + return c.cloudResourceManagerService.Organizations. + GetIamPolicy(c.config.resourceID, &cloudresourcemanager.GetIamPolicyRequest{}). + Context(ctx).Do() + default: + return nil, fmt.Errorf("%w: %q", ErrInvalidResourceType, c.config.resourceType) + } +} + +func (c *Client) setIamPolicy(ctx context.Context, policy *cloudresourcemanager.Policy) (*cloudresourcemanager.Policy, error) { + setIamPolicyRequest := &cloudresourcemanager.SetIamPolicyRequest{ + Policy: policy, + } + switch c.config.resourceType { + case ResourceTypeProject: + return c.cloudResourceManagerService.Projects. + SetIamPolicy(c.config.resourceID, setIamPolicyRequest). + Context(ctx).Do() + case ResourceTypeOrganization: + return c.cloudResourceManagerService.Organizations. + SetIamPolicy(c.config.resourceID, setIamPolicyRequest). + Context(ctx).Do() + default: + return nil, fmt.Errorf("%w: %q", ErrInvalidResourceType, c.config.resourceType) + } +} + +func (c *Client) listGrantableRoles(ctx context.Context) ([]string, error) { + req := &iam.QueryGrantableRolesRequest{ + FullResourceName: fmt.Sprintf("//cloudresourcemanager.googleapis.com/%s", c.config.credentials.ResourceName), + } + res, err := c.iamService.Roles.QueryGrantableRoles(req).Context(ctx).Do() + if err != nil { + return nil, err + } + + roles := make([]string, len(res.Roles)) + for i, r := range res.Roles { + roles[i] = r.Name + } + return roles, nil +} diff --git a/plugins/providers/newpoc/client_test.go b/plugins/providers/newpoc/client_test.go new file mode 100644 index 000000000..fca38dcdd --- /dev/null +++ b/plugins/providers/newpoc/client_test.go @@ -0,0 +1,168 @@ +package newpoc_test + +import ( + "context" + "fmt" + "reflect" + "testing" + + "github.com/goto/guardian/domain" + "github.com/goto/guardian/plugins/providers/newpoc" + "google.golang.org/api/cloudresourcemanager/v1" + "google.golang.org/api/iam/v1" +) + +func TestClient_GetAllowedAccountTypes(t *testing.T) { + type fields struct { + providerConfig *domain.ProviderConfig + cloudResourceManagerService *cloudresourcemanager.Service + iamService *iam.Service + } + type args struct { + ctx context.Context + } + tests := []struct { + name string + fields fields + args args + want []string + }{ + { + name: "should return allowed account types", + fields: fields{ + providerConfig: &domain.ProviderConfig{}, + cloudResourceManagerService: &cloudresourcemanager.Service{}, + iamService: &iam.Service{}, + }, + args: args{ + ctx: context.Background(), + }, + want: []string{ + newpoc.AccountTypeUser, + newpoc.AccountTypeServiceAccount, + newpoc.AccountTypeGroup, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := newpoc.NewClient( + &newpoc.ClientDependencies{ + ProviderConfig: tt.fields.providerConfig, + CloudResourceManagerService: tt.fields.cloudResourceManagerService, + IamService: tt.fields.iamService, + }, + ) + if err != nil { + t.Errorf("NewClient() error = %v", err) + return + } + + if got := c.GetAllowedAccountTypes(tt.args.ctx); !reflect.DeepEqual(got, tt.want) { + t.Errorf("Client.GetAllowedAccountTypes() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestClient_ListResources(t *testing.T) { + type fields struct { + providerConfig *domain.ProviderConfig + cloudResourceManagerService *cloudresourcemanager.Service + iamService *iam.Service + } + type args struct { + ctx context.Context + } + tests := []struct { + name string + fields fields + args args + want []newpoc.IResource + wantErr bool + }{ + { + name: "should return list of resources with type project", + fields: fields{ + providerConfig: &domain.ProviderConfig{ + Type: "newpoc", + URN: "newpoc", + Credentials: map[string]interface{}{ + "service_account_key": "service_account_key", + "resource_name": "projects/test", + }, + }, + cloudResourceManagerService: &cloudresourcemanager.Service{}, + iamService: &iam.Service{}, + }, + args: args{ + ctx: context.Background(), + }, + want: []newpoc.IResource{ + &domain.Resource{ + ProviderType: "newpoc", + ProviderURN: "newpoc", + Type: newpoc.ResourceTypeProject, + URN: "projects/test", + Name: fmt.Sprintf("%s - GCP IAM", "projects/test"), + }, + }, + }, + { + name: "should return list of resources with type organization", + fields: fields{ + providerConfig: &domain.ProviderConfig{ + Type: "newpoc", + URN: "newpoc", + Credentials: map[string]interface{}{ + "service_account_key": "service_account_key", + "resource_name": "organizations/test", + }, + }, + cloudResourceManagerService: &cloudresourcemanager.Service{}, + iamService: &iam.Service{}, + }, + args: args{ + ctx: context.Background(), + }, + want: []newpoc.IResource{ + &domain.Resource{ + ProviderType: "newpoc", + ProviderURN: "newpoc", + Type: newpoc.ResourceTypeOrganization, + URN: "organizations/test", + Name: fmt.Sprintf("%s - GCP IAM", "organizations/test"), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := newpoc.NewClient( + &newpoc.ClientDependencies{ + ProviderConfig: tt.fields.providerConfig, + CloudResourceManagerService: tt.fields.cloudResourceManagerService, + IamService: tt.fields.iamService, + }, + ) + if err != nil { + t.Errorf("NewClient() error = %v", err) + return + } + + got, err := c.ListResources(tt.args.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("Client.ListResources() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != len(tt.want) { + t.Errorf("Client.ListResources() has %v elements, want %v elements", len(got), len(tt.want)) + } + for i := range got { + if !reflect.DeepEqual(got[i], tt.want[i]) { + t.Errorf("Client.ListResources()[%v] = %v, want %v", i, got[i], tt.want[i]) + } + } + }) + } +} diff --git a/plugins/providers/newpoc/config.go b/plugins/providers/newpoc/config.go new file mode 100644 index 000000000..2d0963ddf --- /dev/null +++ b/plugins/providers/newpoc/config.go @@ -0,0 +1,170 @@ +package newpoc + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/goto/guardian/domain" + "github.com/mitchellh/mapstructure" +) + +const ( + ProviderType = "newpoc" +) + +var ( + ErrShouldHaveOneResource = errors.New("gcloud_iam should have one resource") + ErrInvalidCredentials = errors.New("invalid credentials type") + ErrRolesShouldNotBeEmpty = errors.New("gcloud_iam provider should not have empty roles") + ErrProviderShouldNotBeNil = errors.New("provider should not be nil") + + resourceTypeValidation = fmt.Sprintf("oneof=%s %s", ResourceTypeProject, ResourceTypeOrganization) +) + +type credentials struct { + ServiceAccountKey string `mapstructure:"service_account_key" json:"service_account_key" validate:"required"` + ResourceName string `mapstructure:"resource_name" json:"resource_name" validate:"startswith=projects/|startswith=organizations/"` +} + +func (c *credentials) Decode(v interface{}) error { + if decodedCreds, ok := v.(*credentials); ok { + *c = *decodedCreds + return nil + } + + if err := mapstructure.Decode(v, c); err != nil { + return err + } + + // attempt to decode service account key in case of it is base64 encoded + if decoded, err := base64.StdEncoding.DecodeString(c.ServiceAccountKey); err == nil { + c.ServiceAccountKey = string(decoded) + } + + return nil +} + +func (c *credentials) Validate(validator *validator.Validate) error { + if err := validator.Struct(c); err != nil { + return err + } + return nil +} + +type Config struct { + pc *domain.ProviderConfig + credentials *credentials + resourceType string + resourceID string +} + +func NewConfig(pc *domain.ProviderConfig) (*Config, error) { + if pc.Type != ProviderType { + return nil, fmt.Errorf("%w: expected provider type: %q", ErrInvalidProviderType, ProviderType) + } + + creds := new(credentials) + if err := creds.Decode(pc.Credentials); err != nil { + return nil, fmt.Errorf("decoding credentials: %w", err) + } + + resourceType, resourceID, err := getResourceIdentifier(creds.ResourceName) + if err != nil { + return nil, err + } + + return &Config{ + pc: pc, + credentials: creds, + resourceType: resourceType, + resourceID: resourceID, + }, nil +} + +func (c *Config) GetProviderConfig() *domain.ProviderConfig { + return c.pc +} + +func (c *Config) Validate(ctx context.Context, validator *validator.Validate) error { + if c.pc == nil { + return ErrProviderShouldNotBeNil + } + + // validate credentials + if err := c.credentials.Validate(validator); err != nil { + return fmt.Errorf("validating credentials: %w", err) + } + + // validate resource config + if len(c.pc.Resources) != 1 { + return ErrShouldHaveOneResource + } + rc := c.pc.Resources[0] + if err := validator.Var(rc.Type, resourceTypeValidation); err != nil { + return fmt.Errorf("validating resource type %q: %w", rc.Type, err) + } + if len(rc.Roles) == 0 { + return ErrRolesShouldNotBeEmpty + } + + // validate permissions (gcloud roles) + tmpClient, err := NewClient(c) // TODO: client should be overrideable with an existing client instance through option param + if err != nil { + return fmt.Errorf("initializing client: %w", err) + } + grantableRoles, err := tmpClient.listGrantableRoles(ctx) + if err != nil { + return fmt.Errorf("listing grantable roles: %w", err) + } + grantableRolesMap := make(map[string]bool) + for _, r := range grantableRoles { + grantableRolesMap[r] = true + } + for _, role := range rc.Roles { + for _, permission := range role.Permissions { + permissionString, ok := permission.(string) + if !ok { + return fmt.Errorf("invalid permission type for %q: %T", permission, permission) + } + if !grantableRolesMap[permissionString] { + return fmt.Errorf("permission %q is not grantable to %q", permissionString, c.credentials.ResourceName) + } + } + } + + return nil +} + +// Encrypt encrypts the service account key in ProviderConfig.Credentials +func (c *Config) Encrypt(encryptor domain.Encryptor) error { + credentialsString, ok := c.pc.Credentials.(map[string]interface{})["service_account_key"].(string) + if !ok { + return fmt.Errorf("invalid credentials type: %T", c.pc.Credentials) + } + + encryptedSA, err := encryptor.Encrypt(credentialsString) + if err != nil { + return err + } + + c.pc.Credentials.(map[string]interface{})["service_account_key"] = encryptedSA + return nil +} + +func (c *Config) Decrypt(decryptor domain.Decryptor) error { + credentialsString, ok := c.pc.Credentials.(map[string]interface{})["service_account_key"].(string) + if !ok { + return fmt.Errorf("invalid credentials type: %T", c.pc.Credentials) + } + + decryptedSA, err := decryptor.Decrypt(credentialsString) + if err != nil { + return err + } + + c.pc.Credentials.(map[string]interface{})["service_account_key"] = decryptedSA + return nil +} diff --git a/plugins/providers/newpoc/config_test.go b/plugins/providers/newpoc/config_test.go new file mode 100644 index 000000000..9972ec503 --- /dev/null +++ b/plugins/providers/newpoc/config_test.go @@ -0,0 +1,367 @@ +package newpoc_test + +import ( + "context" + "errors" + "testing" + + "github.com/go-playground/validator/v10" + "github.com/goto/guardian/domain" + "github.com/goto/guardian/plugins/providers/gcs/mocks" + "github.com/goto/guardian/plugins/providers/newpoc" + "github.com/stretchr/testify/mock" +) + +func TestConfigManager_Validate(t *testing.T) { + type fields struct { + validator *validator.Validate + crypto domain.Crypto + } + type args struct { + ctx context.Context + p *domain.Provider + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + name: "should return error if provider is nil", + fields: fields{ + validator: validator.New(), + crypto: nil, + }, + args: args{ + ctx: context.Background(), + p: nil, + }, + wantErr: true, + }, + { + name: "should return error decoding credentials", + fields: fields{ + validator: validator.New(), + crypto: nil, + }, + args: args{ + ctx: context.Background(), + p: &domain.Provider{ + Config: &domain.ProviderConfig{ + Credentials: "invalid", + }, + }, + }, + wantErr: true, + }, + { + name: "should return error if credentials are invalid", + fields: fields{ + validator: validator.New(), + crypto: nil, + }, + args: args{ + ctx: context.Background(), + p: &domain.Provider{ + Config: &domain.ProviderConfig{ + Credentials: map[string]interface{}{ + "service_account_key": "", + "resource_name": "", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "should return error if resource length is not 1", + fields: fields{ + validator: validator.New(), + crypto: nil, + }, + args: args{ + ctx: context.Background(), + p: &domain.Provider{ + Config: &domain.ProviderConfig{ + Credentials: map[string]interface{}{ + "service_account_key": "service_account_key", + "resource_name": "projects/test", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "should return error if resource name is invalid", + fields: fields{ + validator: validator.New(), + crypto: nil, + }, + args: args{ + ctx: context.Background(), + p: &domain.Provider{ + Config: &domain.ProviderConfig{ + Credentials: map[string]interface{}{ + "service_account_key": "service_account_key", + "resource_name": "projects/test", + }, + Resources: []*domain.ResourceConfig{ + { + Type: "invalid", + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "should return error if roles is empty", + fields: fields{ + validator: validator.New(), + crypto: nil, + }, + args: args{ + ctx: context.Background(), + p: &domain.Provider{ + Config: &domain.ProviderConfig{ + Credentials: map[string]interface{}{ + "service_account_key": "service_account_key", + "resource_name": "projects/test", + }, + Resources: []*domain.ResourceConfig{ + { + Type: "project", + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "should return nil if config is valid", + fields: fields{ + validator: validator.New(), + crypto: nil, + }, + args: args{ + ctx: context.Background(), + p: &domain.Provider{ + Config: &domain.ProviderConfig{ + Credentials: map[string]interface{}{ + "service_account_key": "service_account_key", + "resource_name": "projects/test", + }, + Resources: []*domain.ResourceConfig{ + { + Type: "project", + Roles: []*domain.Role{ + { + Name: "roles/owner", + }, + }, + }, + }, + }, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newpoc.NewConfigManager( + tt.fields.validator, + tt.fields.crypto, + ) + if err := m.Validate(tt.args.ctx, tt.args.p); (err != nil) != tt.wantErr { + t.Errorf("ConfigManager.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestConfigManager_Encrypt(t *testing.T) { + type fields struct { + validator *validator.Validate + crypto domain.Crypto + } + type args struct { + ctx context.Context + p *domain.Provider + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + name: "should return error decoding credentials", + fields: fields{ + validator: validator.New(), + crypto: nil, + }, + args: args{ + ctx: context.Background(), + p: &domain.Provider{ + Config: &domain.ProviderConfig{ + Credentials: "invalid", + }, + }, + }, + wantErr: true, + }, + { + name: "should return error if encrypt fails", + fields: fields{ + validator: validator.New(), + crypto: func() domain.Crypto { + c := new(mocks.Crypto) + c.On("Encrypt", mock.Anything, mock.Anything).Return("", errors.New("error")) + return c + }(), + }, + args: args{ + ctx: context.Background(), + p: &domain.Provider{ + Config: &domain.ProviderConfig{ + Credentials: map[string]interface{}{ + "service_account_key": "service_account_key", + "resource_name": "projects/test", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "should return nil if encrypt succeeds", + fields: fields{ + validator: validator.New(), + crypto: func() domain.Crypto { + c := new(mocks.Crypto) + c.On("Encrypt", mock.Anything, mock.Anything).Return("encrypted", nil) + return c + }(), + }, + args: args{ + ctx: context.Background(), + p: &domain.Provider{ + Config: &domain.ProviderConfig{ + Credentials: map[string]interface{}{ + "service_account_key": "service_account_key", + "resource_name": "projects/test", + }, + }, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newpoc.NewConfigManager( + tt.fields.validator, + tt.fields.crypto, + ) + if err := m.Encrypt(tt.args.ctx, tt.args.p); (err != nil) != tt.wantErr { + t.Errorf("ConfigManager.Encrypt() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestConfigManager_Decrypt(t *testing.T) { + type fields struct { + validator *validator.Validate + crypto domain.Crypto + } + type args struct { + ctx context.Context + p *domain.Provider + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + name: "should return error decoding credentials", + fields: fields{ + validator: validator.New(), + crypto: nil, + }, + args: args{ + ctx: context.Background(), + p: &domain.Provider{ + Config: &domain.ProviderConfig{ + Credentials: "invalid", + }, + }, + }, + wantErr: true, + }, + { + name: "should return error if decrypt fails", + fields: fields{ + validator: validator.New(), + crypto: func() domain.Crypto { + c := new(mocks.Crypto) + c.On("Decrypt", mock.Anything, mock.Anything).Return("", errors.New("error")) + return c + }(), + }, + args: args{ + ctx: context.Background(), + p: &domain.Provider{ + Config: &domain.ProviderConfig{ + Credentials: map[string]interface{}{ + "service_account_key": "service_account_key", + "resource_name": "projects/test", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "should return nil if decrypt succeeds", + fields: fields{ + validator: validator.New(), + crypto: func() domain.Crypto { + c := new(mocks.Crypto) + c.On("Decrypt", mock.Anything, mock.Anything).Return("encrypted", nil) + return c + }(), + }, + args: args{ + ctx: context.Background(), + p: &domain.Provider{ + Config: &domain.ProviderConfig{ + Credentials: map[string]interface{}{ + "service_account_key": "service_account_key", + "resource_name": "projects/test", + }, + }, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newpoc.NewConfigManager( + tt.fields.validator, + tt.fields.crypto, + ) + if err := m.Decrypt(tt.args.ctx, tt.args.p); (err != nil) != tt.wantErr { + t.Errorf("ConfigManager.Decrypt() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/plugins/providers/newpoc/errors.go b/plugins/providers/newpoc/errors.go new file mode 100644 index 000000000..312326445 --- /dev/null +++ b/plugins/providers/newpoc/errors.go @@ -0,0 +1,16 @@ +package newpoc + +import "errors" + +var ( + ErrUnableToEncryptNilCredentials = errors.New("unable to encrypt nil credentials") + ErrUnableToDecryptNilCredentials = errors.New("unable to decrypt nil credentials") + ErrInvalidPermissionConfig = errors.New("invalid permission config type") + ErrPermissionAlreadyExists = errors.New("permission already exists") + ErrPermissionNotFound = errors.New("permission not found") + ErrInvalidResourceType = errors.New("invalid resource type") + ErrInvalidRole = errors.New("invalid role") + ErrInvalidResourceName = errors.New("invalid resource name: resource name should be projects/{{project-id}} or organizations/{{org-id}}") + ErrInvalidProjectRole = errors.New("provided role is not supported for project in gcloud") + ErrInvalidProviderType = errors.New("invalid provider type") +) diff --git a/plugins/providers/newpoc/plugin.go b/plugins/providers/newpoc/plugin.go new file mode 100644 index 000000000..3debf7885 --- /dev/null +++ b/plugins/providers/newpoc/plugin.go @@ -0,0 +1,100 @@ +package newpoc + +import ( + "context" + + "github.com/go-playground/validator/v10" + "github.com/goto/guardian/domain" +) + +// domain/provider.go +// +// type ProviderConfigEncryptor interface { +// Encrypt(context.Context, *Provider) error +// } +// +// type Provider struct{} +// func (p *Provider) Encrypt(ctx context.Context, e ProviderConfigEncryptor) error { +// return e.Encrypt(ctx, p) +// } + +// plugin will export two main structs. 1. ConfigManager, 2. Client + +// TODO: all of these interfaces should be defined in core/provider/service.go only + +type ProviderConfigurator interface { + Validate(*validator.Validate) error + Encrypt(domain.Encryptor) error + Decrypt(domain.Decryptor) error +} + +// BasicProviderClient depends on a valid provider config +type BasicProviderClient interface { + GetAllowedAccountTypes(context.Context) []string + ListResources(context.Context) ([]IResource, error) + GrantAccess(context.Context, domain.Grant) error + RevokeAccess(context.Context, domain.Grant) error +} + +type IResource interface { + GetType() string + GetURN() string + GetDisplayName() string + GetMetadata() map[string]interface{} +} + +type AccessImporter interface { + ListAccess(context.Context, domain.ProviderConfig) (domain.MapResourceAccess, error) +} + +type ActivityImporter interface { + ListActivities(context.Context) ([]IActivity, error) +} + +type IActivity interface { + GetID() string + // TODO: complete methods of activity interface +} + +// type Dataset struct {} +// func (d Dataset) GetType() string { return "dataset"} + +// type Table struct {} +// func (t Table) GetType() string { return "table"} + +// type IBigQueryResource interface { +// Dataset | Table +// } + +// type BigQueryResource[T IBigQueryResource] struct { + +// } + +// in provider service struct: +// cached bigqueryClient for provider A (credentials A) +// cached bigqueryClient for provider B (credentials B) +// cached gcsCLient for provider C (credentials C) + +// provider config: +// resource type config: +// roles config: +// - id: my-custom-role-1 +// permissions: roleA, roleB +// - id: my-custom-role-2 +// permissions: roleB, roleC + +// gcp +// role: roles/viewer, roles/bigquery.dataViewer, etc. +// permissions: projects.list, projects.get, datasets.list, etc. + +// pv.PermissionManager +// grafana +// metabase +// shield +// tableau + +// provider.PermissionManager +// bigquery +// gcloudiam +// gcs +// noop diff --git a/plugins/providers/newpoc/resource.go b/plugins/providers/newpoc/resource.go new file mode 100644 index 000000000..95c186dd1 --- /dev/null +++ b/plugins/providers/newpoc/resource.go @@ -0,0 +1,29 @@ +package newpoc + +import "fmt" + +const ( + ResourceTypeProject = "project" + ResourceTypeOrganization = "organization" +) + +type resource struct { + Type string + URN string +} + +func (r resource) GetType() string { + return r.Type +} + +func (r resource) GetURN() string { + return r.URN +} + +func (r resource) GetDisplayName() string { + return fmt.Sprintf("%s - GCP IAM", r.GetURN()) +} + +func (r resource) GetMetadata() map[string]interface{} { + return nil +} diff --git a/plugins/providers/newpoc/utils.go b/plugins/providers/newpoc/utils.go new file mode 100644 index 000000000..0405856b5 --- /dev/null +++ b/plugins/providers/newpoc/utils.go @@ -0,0 +1,30 @@ +package newpoc + +import ( + "fmt" + "strings" +) + +func containsString(arr []string, v string) bool { + for _, item := range arr { + if item == v { + return true + } + } + return false +} + +func getResourceIdentifier(urn string) (rType, id string, err error) { + resourceName := strings.Split(urn, "/") + if len(resourceName) != 2 { + return "", "", fmt.Errorf("invalid resource name: %s", urn) + } + resourceType := resourceName[0] + if resourceType == "projects" { + resourceType = ResourceTypeProject + } else if resourceType == "organizations" { + resourceType = ResourceTypeOrganization + } + + return resourceType, resourceName[1], nil +}