diff --git a/app/services.go b/app/services.go index ecacfd92f..8e2586a22 100644 --- a/app/services.go +++ b/app/services.go @@ -51,7 +51,7 @@ func InitServices(deps ServiceDeps) (*Services, error) { providerClients := []providers.Client{ bigquery.NewProvider(domain.ProviderTypeBigQuery, deps.Crypto), - metabase.NewProvider(domain.ProviderTypeMetabase, deps.Crypto), + metabase.NewProvider(domain.ProviderTypeMetabase, deps.Crypto, deps.Logger), grafana.NewProvider(domain.ProviderTypeGrafana, deps.Crypto), tableau.NewProvider(domain.ProviderTypeTableau, deps.Crypto), gcloudiam.NewProvider(domain.ProviderTypeGCloudIAM, deps.Crypto), diff --git a/cmd/config.go b/cmd/config.go index 8549307f3..42ea2846a 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -11,7 +11,8 @@ import ( var cliConfig *Config type Config struct { - Host string `mapstructure:"host"` + Host string `mapstructure:"host"` + EncryptionSecretKey string `mapstructure:"encryption_secret_key"` } func LoadConfig() (*Config, error) { diff --git a/cmd/migration.go b/cmd/migration.go new file mode 100644 index 000000000..76c2ca791 --- /dev/null +++ b/cmd/migration.go @@ -0,0 +1,202 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + + "github.com/MakeNowJust/heredoc" + . "github.com/odpf/guardian/api/proto/odpf/guardian/v1beta1" + "github.com/odpf/guardian/domain" + "github.com/odpf/guardian/internal/crypto" + "github.com/odpf/guardian/plugins/migrations" + mb "github.com/odpf/guardian/plugins/migrations/metabase" + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/structpb" +) + +const ( + pending = "pending" + active = "active" +) + +func MigrationCmd(config *Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "migrate", + Short: "Guardian migration", + Long: heredoc.Doc(` + Migrate target system ACL into Guardian. + `), + Example: heredoc.Doc(` + $ guardian migration + `), + Annotations: map[string]string{ + "group:core": "true", + }, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + providerId := args[0] + + say := crypto.NewAES(config.EncryptionSecretKey) + client, cancel, err := createClient(cmd) + if err != nil { + return err + } + defer cancel() + + context := cmd.Context() + provider, err := getProviderConfig(client, context, providerId, say) + if err != nil { + return err + } + + resources, err := getResources(client, context, provider) + if err != nil { + return err + } + + appealResponse, appeals, err := getActiveAndPendingAppeals(client, context, provider) + if err != nil { + return err + } + + var migration migrations.Client + if provider.Type == migrations.Metabase { + migration = mb.NewMigration(provider.Config, resources, appeals) + } else { + return errors.New(fmt.Sprintf("Migration not supported for provider %s", provider.Type)) + } + + appealRequests, err := migration.PopulateAccess() + if err != nil { + return err + } + + //migrate past-run pending appeals + for _, a := range appealResponse { + if a.Status == pending { + err := approveAppeal(a, client, context) + if err != nil { + return err + } else { + fmt.Println(a.Resource.Name, a.AccountId) + } + } + } + + //migrate pending appeals + for _, appealRequest := range appealRequests { + resource := appealRequest.Resource + option, _ := structpb.NewStruct(map[string]interface{}{migrations.Duration: resource.Duration}) + + accountID := appealRequest.AccountID + appeal, err := client.CreateAppeal(context, &CreateAppealRequest{ + AccountId: accountID, + Resources: []*CreateAppealRequest_Resource{ + {Id: resource.ID, Role: resource.Role, Options: option}}, + AccountType: "", + }) + if err != nil { + return err + } else { + appeals := appeal.GetAppeals() + for _, appeal := range appeals { + err := approveAppeal(appeal, client, context) + if err != nil { + return err + } + } + } + } + + return nil + }, + } + + bindFlagsFromConfig(cmd) + + return cmd +} + +func getActiveAndPendingAppeals(client GuardianServiceClient, context context.Context, provider *domain.Provider) ([]*Appeal, []domain.Appeal, error) { + appeals := make([]domain.Appeal, 0) + listAppeals, err := client.ListAppeals(context, &ListAppealsRequest{ProviderUrns: []string{provider.URN}, Statuses: []string{pending, active}}) + if err != nil { + return nil, appeals, err + } + + appealResponses := listAppeals.GetAppeals() + for _, a := range appealResponses { + appeals = append(appeals, domain.Appeal{ + ID: a.Id, + ResourceID: a.ResourceId, + Status: a.Status, + AccountID: a.AccountId, + AccountType: a.AccountType, + Role: a.Role, + }) + } + return appealResponses, appeals, nil +} + +func getResources(client GuardianServiceClient, context context.Context, provider *domain.Provider) ([]domain.Resource, error) { + listResources, err := client.ListResources(context, &ListResourcesRequest{ProviderUrn: provider.URN, IsDeleted: false}) + resources := make([]domain.Resource, 0) + if err != nil { + return resources, err + } + for _, r := range listResources.GetResources() { + resources = append(resources, domain.Resource{ + ID: r.Id, + ProviderType: r.ProviderType, + ProviderURN: r.ProviderUrn, + Type: r.Type, + URN: r.Urn, + Name: r.Name, + }) + } + return resources, nil +} + +func getProviderConfig(client GuardianServiceClient, context context.Context, providerId string, say *crypto.AES) (*domain.Provider, error) { + providerResponse, err := client.GetProvider(context, &GetProviderRequest{Id: providerId}) + if err != nil { + return nil, err + } + + provider := providerResponse.GetProvider() + fields := provider.Config.Credentials.GetStructValue().GetFields() + abc, err := say.Decrypt(fields[migrations.Password].GetStringValue()) + + return &domain.Provider{ + ID: providerId, + Type: provider.Type, + URN: provider.Urn, + Config: &domain.ProviderConfig{ + Type: provider.Config.Type, + URN: provider.Config.Urn, + Credentials: map[string]string{ + migrations.Username: fields[migrations.Username].GetStringValue(), + migrations.Password: abc, + migrations.Host: fields[migrations.Host].GetStringValue(), + }, + }, + }, err +} + +func approveAppeal(appeal *Appeal, client GuardianServiceClient, context context.Context) error { + approvals := appeal.Approvals + for _, approval := range approvals { + if approval.Status == pending { + _, err := client.UpdateApproval(context, &UpdateApprovalRequest{ + Id: appeal.Id, + ApprovalName: approval.Name, + Action: &UpdateApprovalRequest_Action{Action: "approve", Reason: "Metabase migration"}, + }) + if err != nil { + return err + } + } + } + return nil +} diff --git a/cmd/root.go b/cmd/root.go index 114778cd8..e27a2b7c4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -48,6 +48,9 @@ func New(cfg *Config) *cobra.Command { cmd.AddCommand(configCommand()) cmd.AddCommand(VersionCmd()) + //Migration command + cmd.AddCommand(MigrationCmd(cliConfig)) + // Help topics cmdx.SetHelp(cmd) cmd.AddCommand(cmdx.SetCompletionCmd("guardian")) diff --git a/core/provider/service.go b/core/provider/service.go index 4f29ad039..2ad961f69 100644 --- a/core/provider/service.go +++ b/core/provider/service.go @@ -209,7 +209,7 @@ func (s *Service) ValidateAppeal(a *domain.Appeal, p *domain.Provider) error { return err } - isRoleExists := false + isRoleExists := len(roles) == 0 for _, role := range roles { if a.Role == role.ID { isRoleExists = true diff --git a/docs/docs/providers/metabase.md b/docs/docs/providers/metabase.md index 276cb9c48..3ac74e300 100644 --- a/docs/docs/providers/metabase.md +++ b/docs/docs/providers/metabase.md @@ -72,6 +72,22 @@ resources: name: Editor permissions: - write + - type: table + policy: + id: policy_id + version: 1 + roles: + - id: viewer + name: Viewer + permissions: + - all + - type: group + policy: + id: policy_id + version: 1 + roles: + - id: member + name: Member ``` ### `MetabaseCredentials` @@ -85,10 +101,12 @@ resources: ### `MetabaseResourceType` - `database` +- `table` - `collection` +- `group` ### `MetabaseResourcePermission` -| Type | Details | -| :----------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Required. `string` | Metabase permission mapping **Possible values:** - `database`: `schemas:all` \(read table\), `native:write` \(run SQL query\) **Note**: Metabase requires `schemas:all` permission for `native:write` to be able to work - `collection`: `read`, `write` | +| Type | Details | +| :----------------- |:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Required. `string` | Metabase permission mapping **Possible values:** - `database`: `schemas:all` \(read table\), `native:write` \(run SQL query\) **Note**: Metabase requires `schemas:all` permission for `native:write` to be able to work - `collection`: `read`, `write` **Note**: Metabase table requires `all` permission to read table, no write permission on table level **Note**: Metabase group requires no specific permission to be a member of group || diff --git a/mocks/MetabaseClient.go b/mocks/MetabaseClient.go index b2bc2ed16..bfdeae096 100644 --- a/mocks/MetabaseClient.go +++ b/mocks/MetabaseClient.go @@ -1,4 +1,4 @@ -// Code generated by mockery 2.9.0. DO NOT EDIT. +// Code generated by mockery v2.10.0. DO NOT EDIT. package mocks @@ -58,6 +58,47 @@ func (_m *MetabaseClient) GetDatabases() ([]*metabase.Database, error) { return r0, r1 } +// GetGroups provides a mock function with given fields: +func (_m *MetabaseClient) GetGroups() ([]*metabase.Group, metabase.ResourceGroupDetails, metabase.ResourceGroupDetails, error) { + ret := _m.Called() + + var r0 []*metabase.Group + if rf, ok := ret.Get(0).(func() []*metabase.Group); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*metabase.Group) + } + } + + var r1 metabase.ResourceGroupDetails + if rf, ok := ret.Get(1).(func() metabase.ResourceGroupDetails); ok { + r1 = rf() + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(metabase.ResourceGroupDetails) + } + } + + var r2 metabase.ResourceGroupDetails + if rf, ok := ret.Get(2).(func() metabase.ResourceGroupDetails); ok { + r2 = rf() + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(metabase.ResourceGroupDetails) + } + } + + var r3 error + if rf, ok := ret.Get(3).(func() error); ok { + r3 = rf() + } else { + r3 = ret.Error(3) + } + + return r0, r1, r2, r3 +} + // GrantCollectionAccess provides a mock function with given fields: resource, user, role func (_m *MetabaseClient) GrantCollectionAccess(resource *metabase.Collection, user string, role string) error { ret := _m.Called(resource, user, role) @@ -72,13 +113,41 @@ func (_m *MetabaseClient) GrantCollectionAccess(resource *metabase.Collection, u return r0 } -// GrantDatabaseAccess provides a mock function with given fields: resource, user, role -func (_m *MetabaseClient) GrantDatabaseAccess(resource *metabase.Database, user string, role string) error { - ret := _m.Called(resource, user, role) +// GrantDatabaseAccess provides a mock function with given fields: resource, user, role, groups +func (_m *MetabaseClient) GrantDatabaseAccess(resource *metabase.Database, user string, role string, groups map[string]*metabase.Group) error { + ret := _m.Called(resource, user, role, groups) var r0 error - if rf, ok := ret.Get(0).(func(*metabase.Database, string, string) error); ok { - r0 = rf(resource, user, role) + if rf, ok := ret.Get(0).(func(*metabase.Database, string, string, map[string]*metabase.Group) error); ok { + r0 = rf(resource, user, role, groups) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GrantGroupAccess provides a mock function with given fields: groupID, email +func (_m *MetabaseClient) GrantGroupAccess(groupID int, email string) error { + ret := _m.Called(groupID, email) + + var r0 error + if rf, ok := ret.Get(0).(func(int, string) error); ok { + r0 = rf(groupID, email) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GrantTableAccess provides a mock function with given fields: resource, user, role, groups +func (_m *MetabaseClient) GrantTableAccess(resource *metabase.Table, user string, role string, groups map[string]*metabase.Group) error { + ret := _m.Called(resource, user, role, groups) + + var r0 error + if rf, ok := ret.Get(0).(func(*metabase.Table, string, string, map[string]*metabase.Group) error); ok { + r0 = rf(resource, user, role, groups) } else { r0 = ret.Error(0) } @@ -113,3 +182,31 @@ func (_m *MetabaseClient) RevokeDatabaseAccess(resource *metabase.Database, user return r0 } + +// RevokeGroupAccess provides a mock function with given fields: groupID, email +func (_m *MetabaseClient) RevokeGroupAccess(groupID int, email string) error { + ret := _m.Called(groupID, email) + + var r0 error + if rf, ok := ret.Get(0).(func(int, string) error); ok { + r0 = rf(groupID, email) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// RevokeTableAccess provides a mock function with given fields: resource, user, role +func (_m *MetabaseClient) RevokeTableAccess(resource *metabase.Table, user string, role string) error { + ret := _m.Called(resource, user, role) + + var r0 error + if rf, ok := ret.Get(0).(func(*metabase.Table, string, string) error); ok { + r0 = rf(resource, user, role) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/plugins/migrations/client.go b/plugins/migrations/client.go new file mode 100644 index 000000000..03cbc012b --- /dev/null +++ b/plugins/migrations/client.go @@ -0,0 +1,30 @@ +package migrations + +const ( + Metabase = "metabase" + Username = "username" + Host = "host" + Password = "password" + Duration = "duration" + Group = "group" + Member = "member" + DefaultDuration = "720h" +) + +type ResourceRequest struct { + ID string + Name string + Role string + Duration string +} + +type AppealRequest struct { + AccountID string + User string + Resource ResourceRequest +} + +type Client interface { + GetType() string + PopulateAccess() ([]AppealRequest, error) +} diff --git a/plugins/migrations/metabase/client.go b/plugins/migrations/metabase/client.go new file mode 100644 index 000000000..4468a66ca --- /dev/null +++ b/plugins/migrations/metabase/client.go @@ -0,0 +1,200 @@ +package metabase + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + + "github.com/go-playground/validator/v10" +) + +type ClientConfig struct { + Host string `validate:"required,url" mapstructure:"host"` + Username string `validate:"required" mapstructure:"username"` + Password string `validate:"required" mapstructure:"password"` + HTTPClient HTTPClient +} + +type user struct { + ID int `json:"id"` + Email string `json:"email"` + MembershipID int `json:"membership_id"` +} + +type member struct { + MembershipId int `json:"membership_id,omitempty"` + GroupId int `json:"group_id"` + UserId int `json:"user_id"` +} + +type SessionRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type SessionResponse struct { + ID string `json:"id"` +} + +type databasePermission struct { + Native string `json:"native,omitempty" mapstructure:"native"` + Schemas string `json:"schemas" mapstructure:"schemas"` +} + +var ( + databaseViewerPermission = databasePermission{ + Schemas: "all", + } + databaseEditorPermission = databasePermission{ + Schemas: "all", + Native: "write", + } +) + +type client struct { + baseURL *url.URL + + username string + password string + sessionToken string + + httpClient HTTPClient + + userIDs map[string]int +} + +func NewClient(config *ClientConfig) (*client, error) { + if err := validator.New().Struct(config); err != nil { + return nil, err + } + + baseURL, err := url.Parse(config.Host) + if err != nil { + return nil, err + } + + httpClient := config.HTTPClient + if httpClient == nil { + httpClient = &http.Client{} + } + + c := &client{ + baseURL: baseURL, + username: config.Username, + password: config.Password, + httpClient: httpClient, + userIDs: map[string]int{}, + } + + sessionToken, err := c.getSessionToken() + if err != nil { + return nil, err + } + c.sessionToken = sessionToken + + return c, nil +} + +func (c *client) getUsers() ([]user, error) { + req, err := c.newRequest(http.MethodGet, "/api/user", nil) + if err != nil { + return nil, err + } + + var users []user + if _, err := c.do(req, &users); err != nil { + return nil, err + } + + return users, nil +} + +func (c *client) getMembership() (map[string][]member, error) { + req, err := c.newRequest(http.MethodGet, "/api/permissions/membership", nil) + if err != nil { + return nil, err + } + + var members map[string][]member + + if _, err := c.do(req, &members); err != nil { + return nil, err + } + + return members, nil +} + +func (c *client) getSessionToken() (string, error) { + sessionRequest := &SessionRequest{ + Username: c.username, + Password: c.password, + } + req, err := c.newRequest(http.MethodPost, "/api/session", sessionRequest) + if err != nil { + return "", err + } + + var sessionResponse SessionResponse + if _, err := c.do(req, &sessionResponse); err != nil { + return "", err + } + + return sessionResponse.ID, nil +} + +func (c *client) newRequest(method, path string, body interface{}) (*http.Request, error) { + u, err := c.baseURL.Parse(path) + if err != nil { + return nil, err + } + var buf io.ReadWriter + if body != nil { + buf = new(bytes.Buffer) + err := json.NewEncoder(buf).Encode(body) + if err != nil { + return nil, err + } + } + req, err := http.NewRequest(method, u.String(), buf) + if err != nil { + return nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Metabase-Session", c.sessionToken) + return req, nil +} + +func (c *client) do(req *http.Request, v interface{}) (*http.Response, error) { + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + newSessionToken, err := c.getSessionToken() + if err != nil { + return nil, err + } + c.sessionToken = newSessionToken + req.Header.Set("X-Metabase-Session", c.sessionToken) + + // re-do the request + resp, err = c.httpClient.Do(req) + if err != nil { + return nil, err + } + } + + if v != nil { + //all, _ := ioutil.ReadAll(resp.Body) + //fmt.Println(string(all)) + err = json.NewDecoder(resp.Body).Decode(v) + } + return resp, err +} diff --git a/plugins/migrations/metabase/http.go b/plugins/migrations/metabase/http.go new file mode 100644 index 000000000..0cceaaca2 --- /dev/null +++ b/plugins/migrations/metabase/http.go @@ -0,0 +1,7 @@ +package metabase + +import "net/http" + +type HTTPClient interface { + Do(*http.Request) (*http.Response, error) +} diff --git a/plugins/migrations/metabase/migration.go b/plugins/migrations/metabase/migration.go new file mode 100644 index 000000000..fea58d0f9 --- /dev/null +++ b/plugins/migrations/metabase/migration.go @@ -0,0 +1,95 @@ +package metabase + +import ( + "fmt" + "strconv" + + "github.com/odpf/guardian/domain" + . "github.com/odpf/guardian/plugins/migrations" +) + +type migration struct { + typeName string + providerConfig *domain.ProviderConfig + resources []domain.Resource + excludedAppeals []domain.Appeal +} + +const typeName = Metabase + +func NewMigration(providerConfig *domain.ProviderConfig, resources []domain.Resource, excludedAppeals []domain.Appeal) *migration { + return &migration{ + typeName: typeName, + providerConfig: providerConfig, + resources: resources, + excludedAppeals: excludedAppeals, + } +} + +func (p *migration) GetType() string { + return p.typeName +} + +func (p *migration) PopulateAccess() ([]AppealRequest, error) { + resourceMap := make(map[string]domain.Resource, 0) + appealMap := make(map[string][]domain.Appeal, 0) + + for _, resource := range p.resources { + resourceMap[resource.URN] = resource + } + + for _, appeal := range p.excludedAppeals { + if m, ok := appealMap[appeal.ResourceID]; ok { + appealMap[appeal.ResourceID] = append(m, appeal) + } else { + appealMap[appeal.ResourceID] = append(make([]domain.Appeal, 0), appeal) + } + } + + credentials := p.providerConfig.Credentials.(map[string]string) + c, err := NewClient(&ClientConfig{ + Host: credentials[Host], + Username: credentials[Username], + Password: credentials[Password], + HTTPClient: nil, + }) + if err != nil { + return nil, err + } + + userMap := make(map[int]user, 0) + users, err := c.getUsers() + if err != nil { + return nil, err + } + + for _, user := range users { + userMap[user.ID] = user + } + + membership, err := c.getMembership() + if err != nil { + return nil, err + } + + appeals := make([]AppealRequest, 0) + for userID, members := range membership { + for _, m := range members { + userIdInt, _ := strconv.Atoi(userID) + if user, ok := userMap[userIdInt]; ok { + resourceUrn := fmt.Sprintf("%s:%d", Group, m.GroupId) + if resource, ok := resourceMap[resourceUrn]; ok { + if _, ok := appealMap[resource.ID]; !ok { + appeal := AppealRequest{ + AccountID: user.Email, + User: user.Email, + Resource: ResourceRequest{ID: resource.ID, Name: resource.Name, Role: Member, Duration: DefaultDuration}, + } + appeals = append(appeals, appeal) + } + } + } + } + } + return appeals, err +} diff --git a/plugins/providers/metabase/client.go b/plugins/providers/metabase/client.go index f2fa902eb..63e1f4aca 100644 --- a/plugins/providers/metabase/client.go +++ b/plugins/providers/metabase/client.go @@ -3,25 +3,56 @@ package metabase import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" "reflect" "strconv" + "strings" + "sync" + + "github.com/odpf/salt/log" "github.com/mitchellh/mapstructure" "github.com/go-playground/validator/v10" ) +const ( + databaseEndpoint = "/api/database?include=tables" + collectionEndpoint = "/api/collection" + groupEndpoint = "/api/permissions/group" + databasePermissionEndpoint = "/api/permissions/graph" + collectionPermissionEndpoint = "/api/collection/graph" + + data = "data" + database = "database" + collection = "collection" + groups = "groups" + table = "table" + none = "none" + urn = "urn" + name = "name" + permissionsConst = "permissions" + groupConst = "group" +) + +type ResourceGroupDetails map[string][]map[string]interface{} + type MetabaseClient interface { GetDatabases() ([]*Database, error) GetCollections() ([]*Collection, error) - GrantDatabaseAccess(resource *Database, user, role string) error + GetGroups() ([]*Group, ResourceGroupDetails, ResourceGroupDetails, error) + GrantDatabaseAccess(resource *Database, user, role string, groups map[string]*Group) error RevokeDatabaseAccess(resource *Database, user, role string) error GrantCollectionAccess(resource *Collection, user, role string) error RevokeCollectionAccess(resource *Collection, user, role string) error + GrantTableAccess(resource *Table, user, role string, groups map[string]*Group) error + RevokeTableAccess(resource *Table, user, role string) error + GrantGroupAccess(groupID int, email string) error + RevokeGroupAccess(groupID int, email string) error } type ClientConfig struct { @@ -34,7 +65,8 @@ type ClientConfig struct { type user struct { ID int `json:"id"` Email string `json:"email"` - MembershipID int `json:"membership_id"` + MembershipID int `json:"membership_id" mapstructure:"membership_id"` + GroupIds []int `json:"group_ids" mapstructure:"group_ids"` } type group struct { @@ -52,10 +84,7 @@ type SessionResponse struct { ID string `json:"id"` } -type databasePermission struct { - Native string `json:"native,omitempty" mapstructure:"native"` - Schemas string `json:"schemas" mapstructure:"schemas"` -} +type databasePermission map[string]interface{} type databaseGraph struct { Revision int `json:"revision"` @@ -76,11 +105,11 @@ type membershipRequest struct { var ( databaseViewerPermission = databasePermission{ - Schemas: "all", + "schemas": "all", } databaseEditorPermission = databasePermission{ - Schemas: "all", - Native: "write", + "native": "write", + "schemas": "all", } ) @@ -94,9 +123,11 @@ type client struct { httpClient HTTPClient userIDs map[string]int + + logger log.Logger } -func NewClient(config *ClientConfig) (*client, error) { +func NewClient(config *ClientConfig, logger log.Logger) (*client, error) { if err := validator.New().Struct(config); err != nil { return nil, err } @@ -117,6 +148,7 @@ func NewClient(config *ClientConfig) (*client, error) { password: config.Password, httpClient: httpClient, userIDs: map[string]int{}, + logger: logger, } sessionToken, err := c.getSessionToken() @@ -129,7 +161,7 @@ func NewClient(config *ClientConfig) (*client, error) { } func (c *client) GetDatabases() ([]*Database, error) { - req, err := c.newRequest(http.MethodGet, "/api/database", nil) + req, err := c.newRequest(http.MethodGet, databaseEndpoint, nil) if err != nil { return nil, err } @@ -144,8 +176,8 @@ func (c *client) GetDatabases() ([]*Database, error) { if v, ok := response.([]interface{}); ok { err = mapstructure.Decode(v, &databases) // this is for metabase v0.37 - } else if v, ok := response.(map[string]interface{}); ok && v["data"] != nil { - err = mapstructure.Decode(v["data"], &databases) // this is for metabase v0.42 + } else if v, ok := response.(map[string]interface{}); ok && v[data] != nil { + err = mapstructure.Decode(v[data], &databases) // this is for metabase v0.42 } else { return databases, ErrInvalidApiResponse } @@ -153,11 +185,12 @@ func (c *client) GetDatabases() ([]*Database, error) { if err != nil { return databases, err } + c.logger.Info("Fetch database from request", "total", len(databases), req.URL) return databases, err } func (c *client) GetCollections() ([]*Collection, error) { - req, err := c.newRequest(http.MethodGet, "/api/collection", nil) + req, err := c.newRequest(http.MethodGet, collectionEndpoint, nil) if err != nil { return nil, err } @@ -166,11 +199,145 @@ func (c *client) GetCollections() ([]*Collection, error) { if _, err := c.do(req, &collection); err != nil { return nil, err } - + c.logger.Info("Fetch collections from request", "total", len(collection), req.URL) return collection, nil } -func (c *client) GrantDatabaseAccess(resource *Database, user, role string) error { +func (c *client) GetGroups() ([]*Group, ResourceGroupDetails, ResourceGroupDetails, error) { + wg := sync.WaitGroup{} + wg.Add(3) + + var groups []*Group + var err error + go c.fetchGroups(&wg, &groups, err) + + databaseResourceGroups := make(ResourceGroupDetails, 0) + go c.fetchDatabasePermissions(&wg, databaseResourceGroups, err) + + collectionResourceGroups := make(ResourceGroupDetails, 0) + go c.fetchCollectionPermissions(&wg, collectionResourceGroups, err) + + wg.Wait() + + groupMap := make(map[string]*Group, 0) + for _, group := range groups { + groupMap[fmt.Sprintf("group:%d", group.ID)] = group + } + + addResourceToGroup(databaseResourceGroups, groupMap, database) + addResourceToGroup(collectionResourceGroups, groupMap, collection) + + return groups, databaseResourceGroups, collectionResourceGroups, err +} + +func (c *client) fetchGroups(wg *sync.WaitGroup, groups *[]*Group, err error) { + defer wg.Done() + req, err := c.newRequest(http.MethodGet, groupEndpoint, nil) + if err != nil { + return + } + + _, err = c.do(req, &groups) + if err != nil { + return + } + c.logger.Info("Fetch groups from request", "total", len(*groups), req.URL) +} + +func (c *client) fetchDatabasePermissions(wg *sync.WaitGroup, resourceGroups ResourceGroupDetails, err error) { + defer wg.Done() + + req, err := c.newRequest(http.MethodGet, databasePermissionEndpoint, nil) + if err != nil { + return + } + + graphs := make(map[string]interface{}, 0) + _, err = c.do(req, &graphs) + if err != nil { + return + } + + for groupId, r := range graphs[groups].(map[string]interface{}) { + for dbId, role := range r.(map[string]interface{}) { + if roles, ok := role.(map[string]interface{}); ok { + permissions := make([]string, 0) + for key, value := range roles { + if tables, ok := value.(map[string]interface{}); ok { + for _, tables := range tables { + if tables, ok := tables.(map[string]interface{}); ok { + for tableId, tablePermission := range tables { + addGroupToResource(resourceGroups, fmt.Sprintf("%s:%s.%s", table, dbId, tableId), groupId, []string{tablePermission.(string)}, err) + } + } + } + } else { + permissions = append(permissions, fmt.Sprintf("%s:%s", key, value)) + } + } + addGroupToResource(resourceGroups, fmt.Sprintf("%s:%s", database, dbId), groupId, permissions, err) + } + } + } +} + +func (c *client) fetchCollectionPermissions(wg *sync.WaitGroup, resourceGroups ResourceGroupDetails, err error) { + defer wg.Done() + + req, err := c.newRequest(http.MethodGet, collectionPermissionEndpoint, nil) + if err != nil { + return + } + + graphs := make(map[string]interface{}, 0) + _, err = c.do(req, &graphs) + if err != nil { + return + } + c.logger.Info(fmt.Sprintf("Fetch permissions for collections from request: %v", req.URL)) + for groupId, r := range graphs[groups].(map[string]interface{}) { + for collectionId, permission := range r.(map[string]interface{}) { + if permission != none { + addGroupToResource(resourceGroups, fmt.Sprintf("%s:%s", collection, collectionId), groupId, []string{permission.(string)}, err) + } + } + } +} + +func addResourceToGroup(resourceGroups ResourceGroupDetails, groupMap map[string]*Group, resourceType string) { + for resourceId, groups := range resourceGroups { + for _, groupDetails := range groups { + groupID := groupDetails[urn].(string) + if group, ok := groupMap[groupID]; ok { + if strings.HasPrefix(group.Name, GuardianGroupPrefix) { + continue + } + groupDetails[name] = group.Name + if resourceType == database { + group.DatabaseResources = append(group.DatabaseResources, &GroupResource{Urn: resourceId, Permissions: groupDetails[permissionsConst].([]string)}) + } + if resourceType == collection { + group.CollectionResources = append(group.CollectionResources, &GroupResource{Urn: resourceId, Permissions: groupDetails[permissionsConst].([]string)}) + } + } + } + } +} + +func addGroupToResource(resourceGroups ResourceGroupDetails, resourceId string, groupId string, permissions []string, err error) { + id, err := strconv.Atoi(groupId) + if err != nil { + return + } + if groups, ok := resourceGroups[resourceId]; ok { + groups = append(groups, map[string]interface{}{urn: fmt.Sprintf("%s:%d", groupConst, id), permissionsConst: permissions}) + resourceGroups[resourceId] = groups + } else { + resourceGroups[resourceId] = []map[string]interface{}{{urn: fmt.Sprintf("%s:%d", groupConst, id), permissionsConst: permissions}} + } +} + +func (c *client) GrantDatabaseAccess(resource *Database, email, role string, groups map[string]*Group) error { access, err := c.getDatabaseAccess() if err != nil { return err @@ -181,20 +348,28 @@ func (c *client) GrantDatabaseAccess(resource *Database, user, role string) erro dbPermission = databaseViewerPermission } else if role == DatabaseRoleEditor { dbPermission = databaseEditorPermission + } else { + return ErrInvalidRole } resourceIDStr := strconv.Itoa(resource.ID) + toBrGroupName := fmt.Sprintf("%s_%v_%s", ResourceTypeDatabase, resource.ID, role) groupID := c.findDatabaseAccessGroup(access, resourceIDStr, dbPermission) if groupID == "" { - g := &group{ - Name: fmt.Sprintf("%s_%v_%s", ResourceTypeDatabase, resource.ID, role), - } - if err := c.createGroup(g); err != nil { - return err + if g, ok := groups[toBrGroupName]; ok { + groupID = strconv.Itoa(g.ID) + } else { + g := &group{ + Name: toBrGroupName, + } + if err := c.createGroup(g); err != nil { + return err + } + + groupID = strconv.Itoa(g.ID) } - groupID = strconv.Itoa(g.ID) databaseID := fmt.Sprintf("%v", resource.ID) access.Groups[groupID] = map[string]databasePermission{} @@ -204,15 +379,23 @@ func (c *client) GrantDatabaseAccess(resource *Database, user, role string) erro } } - groupIDint, err := strconv.Atoi(groupID) + groupIDInt, err := strconv.Atoi(groupID) if err != nil { return err } - userID, err := c.getUserID(user) + + user, err := c.getUser(email) if err != nil { return err } - return c.addGroupMember(groupIDint, userID) + + for _, groupId := range user.GroupIds { + if groupId == groupIDInt { + return nil + } + } + + return c.addGroupMember(groupIDInt, user.ID) } func (c *client) RevokeDatabaseAccess(resource *Database, user, role string) error { @@ -242,18 +425,22 @@ func (c *client) RevokeDatabaseAccess(resource *Database, user, role string) err return c.removeMembership(groupIDInt, user) } -func (c *client) GrantCollectionAccess(resource *Collection, user, role string) error { +func (c *client) GrantCollectionAccess(resource *Collection, email, role string) error { access, err := c.getCollectionAccess() if err != nil { return err } resourceIDStr := fmt.Sprintf("%v", resource.ID) + if role != CollectionRoleViewer && role != CollectionRoleCurate { + return ErrInvalidRole + } + groupID := c.findCollectionAccessGroup(access, resourceIDStr, role) if groupID == "" { g := &group{ - Name: fmt.Sprintf("%s_%s_%s", ResourceTypeCollection, resource.ID, role), + Name: fmt.Sprintf("%s_%s_%s", ResourceTypeCollection, resourceIDStr, role), } if err := c.createGroup(g); err != nil { return err @@ -273,11 +460,18 @@ func (c *client) GrantCollectionAccess(resource *Collection, user, role string) if err != nil { return err } - userID, err := c.getUserID(user) + user, err := c.getUser(email) if err != nil { return err } - return c.addGroupMember(groupIDInt, userID) + + for _, groupId := range user.GroupIds { + if groupId == groupIDInt { + return nil + } + } + + return c.addGroupMember(groupIDInt, user.ID) } func (c *client) RevokeCollectionAccess(resource *Collection, user, role string) error { @@ -300,6 +494,126 @@ func (c *client) RevokeCollectionAccess(resource *Collection, user, role string) return c.removeMembership(groupIDInt, user) } +func (c *client) GrantTableAccess(resource *Table, email, role string, groups map[string]*Group) error { + access, err := c.getDatabaseAccess() + if err != nil { + return err + } + + var dbPermission databasePermission + resourceIDStr := strconv.Itoa(resource.ID) + databaseId := resource.DbId + databaseIdStr := strconv.Itoa(databaseId) + if role == TableRoleViewer { + dbPermission = map[string]interface{}{ + "schemas": map[string]interface{}{ + "public": map[string]interface{}{ + resourceIDStr: "all", + }, + }, + } + } else { + return ErrInvalidRole + } + + toBrGroupName := fmt.Sprintf("%s_%v_%v_%s", ResourceTypeTable, databaseId, resource.ID, role) + groupID := c.findTableAccessGroup(access, databaseIdStr, dbPermission) + + if groupID == "" { + if g, ok := groups[toBrGroupName]; ok { + groupID = strconv.Itoa(g.ID) + } else { + g := &group{ + Name: toBrGroupName, + } + if err := c.createGroup(g); err != nil { + return err + } + + groupID = strconv.Itoa(g.ID) + } + + access.Groups[groupID] = map[string]databasePermission{} + access.Groups[groupID][databaseIdStr] = dbPermission + if err := c.updateDatabaseAccess(access); err != nil { + return err + } + } + + groupIDInt, err := strconv.Atoi(groupID) + if err != nil { + return err + } + + user, err := c.getUser(email) + if err != nil { + return err + } + + for _, groupId := range user.GroupIds { + if groupId == groupIDInt { + return nil + } + } + + return c.addGroupMember(groupIDInt, user.ID) +} + +func (c *client) RevokeTableAccess(resource *Table, user, role string) error { + access, err := c.getDatabaseAccess() + if err != nil { + return err + } + + var dbPermission databasePermission + resourceIDStr := strconv.Itoa(resource.ID) + databaseId := resource.DbId + databaseIdStr := strconv.Itoa(databaseId) + if role == TableRoleViewer { + dbPermission = map[string]interface{}{ + "schemas": map[string]interface{}{ + "public": map[string]interface{}{ + resourceIDStr: "all", + }, + }, + } + } else { + return ErrInvalidRole + } + + groupID := c.findTableAccessGroup(access, databaseIdStr, dbPermission) + + if groupID == "" { + return ErrPermissionNotFound + } + + groupIDInt, err := strconv.Atoi(groupID) + if err != nil { + return err + } + return c.removeMembership(groupIDInt, user) +} + +func (c *client) GrantGroupAccess(groupID int, email string) error { + user, err := c.getUser(email) + if err != nil { + return err + } + + for _, userGroupId := range user.GroupIds { + if userGroupId == groupID { + c.logger.Warn(fmt.Sprintf("User %s is already member of group %d", email, groupID)) + return nil + } + } + + return c.addGroupMember(groupID, user.ID) +} + +func (c *client) RevokeGroupAccess(groupID int, email string) error { + return c.removeMembership(groupID, email) +} + func (c *client) removeMembership(groupID int, user string) error { group, err := c.getGroup(groupID) if err != nil { @@ -320,40 +634,37 @@ func (c *client) removeMembership(groupID int, user string) error { return c.removeGroupMember(membershipID) } -func (c *client) getUserID(email string) (int, error) { - if c.userIDs[email] != 0 { - return c.userIDs[email], nil - } - - users, err := c.getUsers() +func (c *client) getUser(email string) (user, error) { + req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/user?query=%s", email), nil) if err != nil { - return 0, err + return user{}, err } - userIDs := map[string]int{} - for _, u := range users { - userIDs[u.Email] = u.ID + var users []user + var response interface{} + if _, err := c.do(req, &response); err != nil { + return user{}, err } - c.userIDs = userIDs - if c.userIDs[email] == 0 { - return 0, ErrUserNotFound + if v, ok := response.([]interface{}); ok { + err = mapstructure.Decode(v, &users) // this is for metabase v0.37 + } else if v, ok := response.(map[string]interface{}); ok && v[data] != nil { + err = mapstructure.Decode(v[data], &users) // this is for metabase v0.42 + } else { + return user{}, ErrInvalidApiResponse } - return c.userIDs[email], nil -} -func (c *client) getUsers() ([]user, error) { - req, err := c.newRequest(http.MethodGet, "/api/user", nil) if err != nil { - return nil, err + return user{}, ErrUserNotFound } - var users []user - if _, err := c.do(req, &users); err != nil { - return nil, err + for _, u := range users { + if u.Email == email { + return u, nil + } } - return users, nil + return user{}, ErrUserNotFound } func (c *client) getSessionToken() (string, error) { @@ -375,7 +686,7 @@ func (c *client) getSessionToken() (string, error) { } func (c *client) getCollectionAccess() (*collectionGraph, error) { - req, err := c.newRequest(http.MethodGet, "/api/collection/graph", nil) + req, err := c.newRequest(http.MethodGet, collectionPermissionEndpoint, nil) if err != nil { return nil, err } @@ -389,7 +700,7 @@ func (c *client) getCollectionAccess() (*collectionGraph, error) { } func (c *client) updateCollectionAccess(access *collectionGraph) error { - req, err := c.newRequest(http.MethodPut, "/api/collection/graph", access) + req, err := c.newRequest(http.MethodPut, collectionPermissionEndpoint, access) if err != nil { return err } @@ -402,7 +713,7 @@ func (c *client) updateCollectionAccess(access *collectionGraph) error { } func (c *client) getDatabaseAccess() (*databaseGraph, error) { - req, err := c.newRequest(http.MethodGet, "/api/permissions/graph", nil) + req, err := c.newRequest(http.MethodGet, databasePermissionEndpoint, nil) if err != nil { return nil, err } @@ -416,7 +727,7 @@ func (c *client) getDatabaseAccess() (*databaseGraph, error) { } func (c *client) updateDatabaseAccess(dbGraph *databaseGraph) error { - req, err := c.newRequest(http.MethodPut, "/api/permissions/graph", dbGraph) + req, err := c.newRequest(http.MethodPut, databasePermissionEndpoint, dbGraph) if err != nil { return err } @@ -429,7 +740,8 @@ func (c *client) updateDatabaseAccess(dbGraph *databaseGraph) error { } func (c *client) createGroup(group *group) error { - req, err := c.newRequest(http.MethodPost, "/api/permissions/group", group) + group.Name = GuardianGroupPrefix + group.Name + req, err := c.newRequest(http.MethodPost, groupEndpoint, group) if err != nil { return err } @@ -523,6 +835,20 @@ func (c *client) findDatabaseAccessGroup(access *databaseGraph, resourceID strin return "" } +func (c *client) findTableAccessGroup(access *databaseGraph, databaseID string, role databasePermission) string { + expectedDatabasePermission := map[string]databasePermission{ + databaseID: role, + } + + for groupID, databasePermissions := range access.Groups { + if reflect.DeepEqual(databasePermissions, expectedDatabasePermission) { + return groupID + } + } + + return "" +} + func (c *client) newRequest(method, path string, body interface{}) (*http.Request, error) { u, err := c.baseURL.Parse(path) if err != nil { @@ -551,6 +877,7 @@ func (c *client) newRequest(method, path string, body interface{}) (*http.Reques func (c *client) do(req *http.Request, v interface{}) (*http.Response, error) { resp, err := c.httpClient.Do(req) if err != nil { + c.logger.Error(fmt.Sprintf("Failed to execute request %v with error %v", req.URL, err)) return nil, err } defer resp.Body.Close() @@ -570,6 +897,16 @@ func (c *client) do(req *http.Request, v interface{}) (*http.Response, error) { } } + if resp.StatusCode == http.StatusBadRequest { + byteData, _ := io.ReadAll(resp.Body) + return nil, errors.New(string(byteData)) + } + + if resp.StatusCode == http.StatusInternalServerError { + byteData, _ := io.ReadAll(resp.Body) + return nil, errors.New(string(byteData)) + } + if v != nil { err = json.NewDecoder(resp.Body).Decode(v) } diff --git a/plugins/providers/metabase/client_test.go b/plugins/providers/metabase/client_test.go index 0a87c2c5b..9358d0024 100644 --- a/plugins/providers/metabase/client_test.go +++ b/plugins/providers/metabase/client_test.go @@ -4,6 +4,8 @@ import ( "errors" "testing" + "github.com/odpf/salt/log" + "github.com/odpf/guardian/mocks" "github.com/odpf/guardian/plugins/providers/metabase" "github.com/stretchr/testify/assert" @@ -13,8 +15,8 @@ import ( func TestNewClient(t *testing.T) { t.Run("should return error if config is invalid", func(t *testing.T) { invalidConfig := &metabase.ClientConfig{} - - actualClient, actualError := metabase.NewClient(invalidConfig) + logger := log.NewLogrus(log.LogrusWithLevel("info")) + actualClient, actualError := metabase.NewClient(invalidConfig, logger) assert.Nil(t, actualClient) assert.Error(t, actualError) @@ -26,8 +28,8 @@ func TestNewClient(t *testing.T) { Password: "test-password", Host: "invalid-url", } - - actualClient, actualError := metabase.NewClient(invalidHostConfig) + logger := log.NewLogrus(log.LogrusWithLevel("info")) + actualClient, actualError := metabase.NewClient(invalidHostConfig, logger) assert.Nil(t, actualClient) assert.Error(t, actualError) @@ -41,11 +43,11 @@ func TestNewClient(t *testing.T) { Host: "http://localhost", HTTPClient: mockHttpClient, } - + logger := log.NewLogrus(log.LogrusWithLevel("info")) expectedError := errors.New("request error") mockHttpClient.On("Do", mock.Anything).Return(nil, expectedError).Once() - actualClient, actualError := metabase.NewClient(config) + actualClient, actualError := metabase.NewClient(config, logger) mockHttpClient.AssertExpectations(t) assert.Nil(t, actualClient) diff --git a/plugins/providers/metabase/config.go b/plugins/providers/metabase/config.go index 9c221fe7e..794564665 100644 --- a/plugins/providers/metabase/config.go +++ b/plugins/providers/metabase/config.go @@ -11,8 +11,11 @@ import ( ) const ( - DatabaseRoleViewer = "schemas:all" - DatabaseRoleEditor = "native:write" + DatabaseRoleViewer = "schemas:all" + DatabaseRoleEditor = "native:write" + CollectionRoleViewer = "read" + CollectionRoleCurate = "write" + TableRoleViewer = "all" AccountTypeUser = "user" ) @@ -136,7 +139,7 @@ func (c *Config) validateCredentials(value interface{}) (*Credentials, error) { } func (c *Config) validateResourceConfig(resource *domain.ResourceConfig) error { - resourceTypeValidation := fmt.Sprintf("oneof=%s %s", ResourceTypeCollection, ResourceTypeDatabase) + resourceTypeValidation := fmt.Sprintf("oneof=%s %s %s %s", ResourceTypeCollection, ResourceTypeDatabase, ResourceTypeTable, ResourceTypeGroup) if err := c.validator.Var(resource.Type, resourceTypeValidation); err != nil { return err } @@ -170,6 +173,10 @@ func (c *Config) validatePermission(resourceType string, value interface{}) (*Pe nameValidation = "oneof=schemas:all native:write" } else if resourceType == ResourceTypeCollection { nameValidation = "oneof=read write" + } else if resourceType == ResourceTypeTable { + nameValidation = "oneof=all" + } else if resourceType == ResourceTypeGroup { + nameValidation = "oneof=member" } if err := c.validator.Var(pc, nameValidation); err != nil { diff --git a/plugins/providers/metabase/errors.go b/plugins/providers/metabase/errors.go index dc18b8110..e0964d571 100644 --- a/plugins/providers/metabase/errors.go +++ b/plugins/providers/metabase/errors.go @@ -12,4 +12,8 @@ var ( ErrInvalidResourceType = errors.New("invalid resource type") ErrPermissionNotFound = errors.New("permission not found") ErrInvalidApiResponse = errors.New("invalid api response") + ErrInvalidDatabaseURN = errors.New("database URN is invalid") + ErrInvalidTableURN = errors.New("table URN is invalid") + ErrInvalidGroupURN = errors.New("group URN is invalid") + ErrInvalidCollectionURN = errors.New("collection URN is invalid") ) diff --git a/plugins/providers/metabase/provider.go b/plugins/providers/metabase/provider.go index 1f268299a..542bb19fa 100644 --- a/plugins/providers/metabase/provider.go +++ b/plugins/providers/metabase/provider.go @@ -1,22 +1,27 @@ package metabase import ( + "strings" + "github.com/mitchellh/mapstructure" pv "github.com/odpf/guardian/core/provider" "github.com/odpf/guardian/domain" + "github.com/odpf/salt/log" ) type provider struct { typeName string Clients map[string]MetabaseClient crypto domain.Crypto + logger log.Logger } -func NewProvider(typeName string, crypto domain.Crypto) *provider { +func NewProvider(typeName string, crypto domain.Crypto, logger log.Logger) *provider { return &provider{ typeName: typeName, Clients: map[string]MetabaseClient{}, crypto: crypto, + logger: logger, } } @@ -45,42 +50,153 @@ func (p *provider) GetResources(pc *domain.ProviderConfig) ([]*domain.Resource, return nil, err } - var resourceTypes []string + var resourceTypes = make(map[string]bool, 0) for _, rc := range pc.Resources { - resourceTypes = append(resourceTypes, rc.Type) + resourceTypes[rc.Type] = true } resources := []*domain.Resource{} - if containsString(resourceTypes, ResourceTypeDatabase) { - databases, err := client.GetDatabases() + var databases []*Database + var collections []*Collection + if _, ok := resourceTypes[ResourceTypeDatabase]; ok { + databases, err = client.GetDatabases() if err != nil { return nil, err } - for _, d := range databases { - db := d.ToDomain() - db.ProviderType = pc.Type - db.ProviderURN = pc.URN - resources = append(resources, db) + resources = p.addDatabases(pc, databases, resources) + } + + if _, ok := resourceTypes[ResourceTypeTable]; ok { + if databases == nil { + databases, err = client.GetDatabases() + } + if err != nil { + return nil, err } + resources = p.addTables(pc, databases, resources) } - if containsString(resourceTypes, ResourceTypeCollection) { - collections, err := client.GetCollections() + if _, ok := resourceTypes[ResourceTypeCollection]; ok { + collections, err = client.GetCollections() if err != nil { return nil, err } - for _, c := range collections { - db := c.ToDomain() - db.ProviderType = pc.Type - db.ProviderURN = pc.URN - resources = append(resources, db) + resources = p.addCollection(pc, collections, resources) + } + + groups, databaseResourceGroups, collectionResourceGroups, err := client.GetGroups() + if err != nil { + return nil, err + } + + for _, resource := range resources { + if resource.Type == ResourceTypeDatabase || resource.Type == ResourceTypeTable { + if groups, ok := databaseResourceGroups[resource.URN]; ok { + resource.Details["groups"] = groups + } + } + if resource.Type == ResourceTypeCollection { + if groups, ok := collectionResourceGroups[resource.URN]; ok { + resource.Details["groups"] = groups + } + } + } + + if _, ok := resourceTypes[ResourceTypeGroup]; ok && resourceTypes[ResourceTypeGroup] { + databaseResourceMap := make(map[string]*domain.Resource, 0) + collectionResourceMap := make(map[string]*domain.Resource, 0) + + if databases == nil { + databases, err = client.GetDatabases() + if err != nil { + return nil, err + } + } + for _, database := range databases { + resource := database.ToDomain() + databaseResourceMap[resource.URN] = resource + } + + if collections == nil { + collections, err = client.GetCollections() + if err != nil { + return nil, err + } + } + for _, collection := range collections { + resource := collection.ToDomain() + collectionResourceMap[resource.URN] = resource + } + + for _, g := range groups { + if strings.HasPrefix(g.Name, GuardianGroupPrefix) { + continue + } + + for _, groupResource := range g.DatabaseResources { + resourceId := groupResource.Urn + if resource, ok := databaseResourceMap[resourceId]; ok { + groupResource.Name = resource.Name + groupResource.Type = resource.Type + } + } + + for _, groupResource := range g.CollectionResources { + resourceId := groupResource.Urn + if resource, ok := collectionResourceMap[resourceId]; ok { + groupResource.Name = resource.Name + groupResource.Type = resource.Type + } + } + + group := g.ToDomain() + group.ProviderType = pc.Type + group.ProviderURN = pc.URN + resources = append(resources, group) } } return resources, nil } +func (p *provider) addCollection(pc *domain.ProviderConfig, collections []*Collection, resources []*domain.Resource) []*domain.Resource { + for _, c := range collections { + db := c.ToDomain() + db.ProviderType = pc.Type + db.ProviderURN = pc.URN + resources = append(resources, db) + } + return resources +} + +func (p *provider) addDatabases(pc *domain.ProviderConfig, databases []*Database, resources []*domain.Resource) []*domain.Resource { + for _, d := range databases { + db := d.ToDomain() + db.ProviderType = pc.Type + db.ProviderURN = pc.URN + resources = append(resources, db) + } + return resources +} + +func (p *provider) addTables(pc *domain.ProviderConfig, databases []*Database, resources []*domain.Resource) []*domain.Resource { + for _, d := range databases { + db := d.ToDomain() + db.ProviderType = pc.Type + db.ProviderURN = pc.URN + + for _, t := range d.Tables { + t.Database = db + table := t.ToDomain() + table.ProviderType = pc.Type + table.ProviderURN = pc.URN + resources = append(resources, table) + } + } + return resources +} + func (p *provider) GrantAccess(pc *domain.ProviderConfig, a *domain.Appeal) error { // TODO: validate provider config and appeal @@ -98,6 +214,16 @@ func (p *provider) GrantAccess(pc *domain.ProviderConfig, a *domain.Appeal) erro return err } + groups, _, _, err := client.GetGroups() + if err != nil { + return err + } + + groupMap := make(map[string]*Group, 0) + for _, group := range groups { + groupMap[group.Name] = group + } + if a.Resource.Type == ResourceTypeDatabase { d := new(Database) if err := d.FromDomain(a.Resource); err != nil { @@ -105,7 +231,7 @@ func (p *provider) GrantAccess(pc *domain.ProviderConfig, a *domain.Appeal) erro } for _, p := range permissions { - if err := client.GrantDatabaseAccess(d, a.AccountID, string(p)); err != nil { + if err := client.GrantDatabaseAccess(d, a.AccountID, string(p), groupMap); err != nil { return err } } @@ -123,6 +249,28 @@ func (p *provider) GrantAccess(pc *domain.ProviderConfig, a *domain.Appeal) erro } } + return nil + } else if a.Resource.Type == ResourceTypeGroup { + g := new(Group) + if err := g.FromDomain(a.Resource); err != nil { + return err + } + + if err := client.GrantGroupAccess(g.ID, a.AccountID); err != nil { + return err + } + return nil + } else if a.Resource.Type == ResourceTypeTable { + t := new(Table) + if err := t.FromDomain(a.Resource); err != nil { + return err + } + + for _, p := range permissions { + if err := client.GrantTableAccess(t, a.AccountID, string(p), groupMap); err != nil { + return err + } + } return nil } @@ -169,6 +317,29 @@ func (p *provider) RevokeAccess(pc *domain.ProviderConfig, a *domain.Appeal) err } } + return nil + } else if a.Resource.Type == ResourceTypeGroup { + g := new(Group) + if err := g.FromDomain(a.Resource); err != nil { + return err + } + + if err := client.RevokeGroupAccess(g.ID, a.AccountID); err != nil { + return err + } + + return nil + } else if a.Resource.Type == ResourceTypeTable { + t := new(Table) + if err := t.FromDomain(a.Resource); err != nil { + return err + } + + for _, p := range permissions { + if err := client.RevokeTableAccess(t, a.AccountID, string(p)); err != nil { + return err + } + } return nil } @@ -197,7 +368,7 @@ func (p *provider) getClient(providerURN string, credentials Credentials) (Metab Host: credentials.Host, Username: credentials.Username, Password: credentials.Password, - }) + }, p.logger) if err != nil { return nil, err } @@ -217,13 +388,18 @@ func getPermissions(resourceConfigs []*domain.ResourceConfig, a *domain.Appeal) return nil, ErrInvalidResourceType } - var role *domain.Role - for _, r := range resourceConfig.Roles { - if r.ID == a.Role { + roles := resourceConfig.Roles + role := &domain.Role{} + isRoleExists := len(roles) == 0 + for _, r := range roles { + if a.Role == r.ID { + isRoleExists = true role = r + break } } - if role == nil { + + if !isRoleExists { return nil, ErrInvalidRole } @@ -239,12 +415,3 @@ func getPermissions(resourceConfigs []*domain.ResourceConfig, a *domain.Appeal) return permissions, nil } - -func containsString(arr []string, v string) bool { - for _, item := range arr { - if item == v { - return true - } - } - return false -} diff --git a/plugins/providers/metabase/provider_test.go b/plugins/providers/metabase/provider_test.go index 84941ecd6..1c513e9c8 100644 --- a/plugins/providers/metabase/provider_test.go +++ b/plugins/providers/metabase/provider_test.go @@ -4,6 +4,8 @@ import ( "errors" "testing" + "github.com/odpf/salt/log" + "github.com/mitchellh/mapstructure" "github.com/odpf/guardian/domain" "github.com/odpf/guardian/mocks" @@ -15,8 +17,9 @@ import ( func TestGetType(t *testing.T) { t.Run("should return provider type name", func(t *testing.T) { expectedTypeName := domain.ProviderTypeMetabase + logger := log.NewLogrus(log.LogrusWithLevel("info")) crypto := new(mocks.Crypto) - p := metabase.NewProvider(expectedTypeName, crypto) + p := metabase.NewProvider(expectedTypeName, crypto, logger) actualTypeName := p.GetType() @@ -27,7 +30,8 @@ func TestGetType(t *testing.T) { func TestGetResources(t *testing.T) { t.Run("should return error if credentials is invalid", func(t *testing.T) { crypto := new(mocks.Crypto) - p := metabase.NewProvider("", crypto) + logger := log.NewLogrus(log.LogrusWithLevel("info")) + p := metabase.NewProvider("", crypto, logger) pc := &domain.ProviderConfig{ Credentials: "invalid-creds", @@ -41,7 +45,8 @@ func TestGetResources(t *testing.T) { t.Run("should return error if there are any on client initialization", func(t *testing.T) { crypto := new(mocks.Crypto) - p := metabase.NewProvider("", crypto) + logger := log.NewLogrus(log.LogrusWithLevel("info")) + p := metabase.NewProvider("", crypto, logger) expectedError := errors.New("decrypt error") crypto.On("Decrypt", "test-password").Return("", expectedError).Once() @@ -61,7 +66,8 @@ func TestGetResources(t *testing.T) { providerURN := "test-provider-urn" crypto := new(mocks.Crypto) client := new(mocks.MetabaseClient) - p := metabase.NewProvider("", crypto) + logger := log.NewLogrus(log.LogrusWithLevel("info")) + p := metabase.NewProvider("", crypto, logger) p.Clients = map[string]metabase.MetabaseClient{ providerURN: client, } @@ -88,7 +94,8 @@ func TestGetResources(t *testing.T) { providerURN := "test-provider-urn" crypto := new(mocks.Crypto) client := new(mocks.MetabaseClient) - p := metabase.NewProvider("", crypto) + logger := log.NewLogrus(log.LogrusWithLevel("info")) + p := metabase.NewProvider("", crypto, logger) p.Clients = map[string]metabase.MetabaseClient{ providerURN: client, } @@ -115,7 +122,8 @@ func TestGetResources(t *testing.T) { providerURN := "test-provider-urn" crypto := new(mocks.Crypto) client := new(mocks.MetabaseClient) - p := metabase.NewProvider("", crypto) + logger := log.NewLogrus(log.LogrusWithLevel("info")) + p := metabase.NewProvider("", crypto, logger) p.Clients = map[string]metabase.MetabaseClient{ providerURN: client, } @@ -126,19 +134,33 @@ func TestGetResources(t *testing.T) { Resources: []*domain.ResourceConfig{ { Type: metabase.ResourceTypeDatabase, + }, { + Type: metabase.ResourceTypeTable, }, { Type: metabase.ResourceTypeCollection, + }, { + Type: metabase.ResourceTypeGroup, }, }, } expectedDatabases := []*metabase.Database{ { - ID: 1, - Name: "db_1", + ID: 1, + Name: "db_1", + Tables: []metabase.Table{{ID: 2, Name: "table_1", DbId: 1}}, }, } client.On("GetDatabases").Return(expectedDatabases, nil).Once() + + d := []*metabase.GroupResource{{Urn: "database:1", Permissions: []string{"read", "write"}}} + c := []*metabase.GroupResource{{Urn: "collection:1", Permissions: []string{"read", "write"}}} + group := metabase.Group{Name: "All Users", DatabaseResources: d, CollectionResources: c} + + client.On("GetGroups").Return([]*metabase.Group{&group, {Name: metabase.GuardianGroupPrefix + "database_1_schema:all", DatabaseResources: d, CollectionResources: c}}, + metabase.ResourceGroupDetails{"database:1": {{"urn": "group:1", "permissions": []string{"read", "write"}}}}, + metabase.ResourceGroupDetails{"collection:1": {{"urn": "group:1", "permissions": []string{"write"}}}}, nil).Once() + expectedCollections := []*metabase.Collection{ { ID: 1, @@ -149,9 +171,23 @@ func TestGetResources(t *testing.T) { expectedResources := []*domain.Resource{ { Type: metabase.ResourceTypeDatabase, - URN: "1", + URN: "database:1", ProviderURN: providerURN, Name: "db_1", + Details: map[string]interface{}{ + "auto_run_queries": false, + "cache_field_values_schedule": "", + "engine": "", + "metadata_sync_schedule": "", + "native_permissions": "", + "timezone": "", + "groups": []map[string]interface{}{{"urn": "group:1", "permissions": []string{"read", "write"}}}, + }, + }, { + Type: metabase.ResourceTypeTable, + URN: "table:1.2", + ProviderURN: providerURN, + Name: "table_1", Details: map[string]interface{}{ "auto_run_queries": false, "cache_field_values_schedule": "", @@ -163,10 +199,22 @@ func TestGetResources(t *testing.T) { }, { Type: metabase.ResourceTypeCollection, - URN: "1", + URN: "collection:1", ProviderURN: providerURN, Name: "col_1", - Details: map[string]interface{}{}, + Details: map[string]interface{}{ + "groups": []map[string]interface{}{{"urn": "group:1", "permissions": []string{"write"}}}, + }, + }, + { + Type: metabase.ResourceTypeGroup, + URN: "group:0", + ProviderURN: providerURN, + Name: "All Users", + Details: map[string]interface{}{ + "collection": []*metabase.GroupResource{{Name: "col_1", Type: "collection", Urn: "collection:1", Permissions: []string{"read", "write"}}}, + "database": []*metabase.GroupResource{{Name: "db_1", Type: "database", Urn: "database:1", Permissions: []string{"read", "write"}}}, + }, }, } @@ -241,7 +289,8 @@ func TestGrantAccess(t *testing.T) { for _, tc := range testcases { crypto := new(mocks.Crypto) - p := metabase.NewProvider("", crypto) + logger := log.NewLogrus(log.LogrusWithLevel("info")) + p := metabase.NewProvider("", crypto, logger) providerConfig := &domain.ProviderConfig{ Resources: tc.resourceConfigs, @@ -254,7 +303,8 @@ func TestGrantAccess(t *testing.T) { t.Run("should return error if credentials is invalid", func(t *testing.T) { crypto := new(mocks.Crypto) - p := metabase.NewProvider("", crypto) + logger := log.NewLogrus(log.LogrusWithLevel("info")) + p := metabase.NewProvider("", crypto, logger) pc := &domain.ProviderConfig{ Credentials: "invalid-credentials", @@ -283,7 +333,8 @@ func TestGrantAccess(t *testing.T) { t.Run("should return error if there are any on client initialization", func(t *testing.T) { crypto := new(mocks.Crypto) - p := metabase.NewProvider("", crypto) + logger := log.NewLogrus(log.LogrusWithLevel("info")) + p := metabase.NewProvider("", crypto, logger) expectedError := errors.New("decrypt error") crypto.On("Decrypt", "test-password").Return("", expectedError).Once() @@ -319,7 +370,8 @@ func TestGrantAccess(t *testing.T) { t.Run("should return error if resource type in unknown", func(t *testing.T) { crypto := new(mocks.Crypto) - p := metabase.NewProvider("", crypto) + logger := log.NewLogrus(log.LogrusWithLevel("info")) + p := metabase.NewProvider("", crypto, logger) expectedError := errors.New("invalid resource type") crypto.On("Decrypt", "test-password").Return("", expectedError).Once() @@ -360,11 +412,18 @@ func TestGrantAccess(t *testing.T) { expectedError := errors.New("client error") crypto := new(mocks.Crypto) client := new(mocks.MetabaseClient) - p := metabase.NewProvider("", crypto) + logger := log.NewLogrus(log.LogrusWithLevel("info")) + p := metabase.NewProvider("", crypto, logger) p.Clients = map[string]metabase.MetabaseClient{ providerURN: client, } - client.On("GrantDatabaseAccess", mock.Anything, mock.Anything, mock.Anything).Return(expectedError).Once() + client.On("GrantDatabaseAccess", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(expectedError).Once() + d := []*metabase.GroupResource{{Urn: "database:1", Permissions: []string{"read", "write"}}} + c := []*metabase.GroupResource{{Urn: "collection:1", Permissions: []string{"read", "write"}}} + group := metabase.Group{Name: "All Users", DatabaseResources: d, CollectionResources: c} + client.On("GetGroups").Return([]*metabase.Group{&group}, + metabase.ResourceGroupDetails{"database:1": {{"urn": "group:1", "permissions": []string{"read", "write"}}}}, + metabase.ResourceGroupDetails{"collection:1": {{"urn": "group:1", "permissions": []string{"write"}}}}, nil).Once() pc := &domain.ProviderConfig{ Credentials: metabase.Credentials{ @@ -388,7 +447,7 @@ func TestGrantAccess(t *testing.T) { a := &domain.Appeal{ Resource: &domain.Resource{ Type: metabase.ResourceTypeDatabase, - URN: "999", + URN: "database:999", Name: "test-database", }, Role: "test-role", @@ -402,6 +461,7 @@ func TestGrantAccess(t *testing.T) { t.Run("should return nil error if granting access is successful", func(t *testing.T) { providerURN := "test-provider-urn" crypto := new(mocks.Crypto) + logger := log.NewLogrus(log.LogrusWithLevel("info")) client := new(mocks.MetabaseClient) expectedDatabase := &metabase.Database{ Name: "test-database", @@ -409,11 +469,18 @@ func TestGrantAccess(t *testing.T) { } expectedUser := "test@email.com" expectedRole := metabase.DatabaseRoleViewer - p := metabase.NewProvider("", crypto) + p := metabase.NewProvider("", crypto, logger) p.Clients = map[string]metabase.MetabaseClient{ providerURN: client, } - client.On("GrantDatabaseAccess", expectedDatabase, expectedUser, expectedRole).Return(nil).Once() + client.On("GrantDatabaseAccess", expectedDatabase, expectedUser, expectedRole, mock.Anything).Return(nil).Once() + + d := []*metabase.GroupResource{{Urn: "database:1", Permissions: []string{"read", "write"}}} + c := []*metabase.GroupResource{{Urn: "collection:1", Permissions: []string{"read", "write"}}} + group := metabase.Group{Name: "All Users", DatabaseResources: d, CollectionResources: c} + client.On("GetGroups").Return([]*metabase.Group{&group}, + metabase.ResourceGroupDetails{"database:1": {{"urn": "group:1", "permissions": []string{"read", "write"}}}}, + metabase.ResourceGroupDetails{"collection:1": {{"urn": "group:1", "permissions": []string{"write"}}}}, nil).Once() pc := &domain.ProviderConfig{ Credentials: metabase.Credentials{ @@ -437,7 +504,7 @@ func TestGrantAccess(t *testing.T) { a := &domain.Appeal{ Resource: &domain.Resource{ Type: metabase.ResourceTypeDatabase, - URN: "999", + URN: "database:999", Name: "test-database", }, Role: "viewer", @@ -453,16 +520,24 @@ func TestGrantAccess(t *testing.T) { }) t.Run("given collection resource", func(t *testing.T) { - t.Run("should return error if there is an error in grandting collection access", func(t *testing.T) { + t.Run("should return error if there is an error in granting collection access", func(t *testing.T) { providerURN := "test-provider-urn" expectedError := errors.New("client error") crypto := new(mocks.Crypto) client := new(mocks.MetabaseClient) - p := metabase.NewProvider("", crypto) + logger := log.NewLogrus(log.LogrusWithLevel("info")) + p := metabase.NewProvider("", crypto, logger) p.Clients = map[string]metabase.MetabaseClient{ providerURN: client, } - client.On("GrantCollectionAccess", mock.Anything, mock.Anything, mock.Anything).Return(expectedError).Once() + client.On("GrantCollectionAccess", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(expectedError).Once() + + d := []*metabase.GroupResource{{Urn: "database:1", Permissions: []string{"read", "write"}}} + c := []*metabase.GroupResource{{Urn: "collection:1", Permissions: []string{"read", "write"}}} + group := metabase.Group{Name: "All Users", DatabaseResources: d, CollectionResources: c} + client.On("GetGroups").Return([]*metabase.Group{&group}, + metabase.ResourceGroupDetails{"database:1": {{"urn": "group:1", "permissions": []string{"read", "write"}}}}, + metabase.ResourceGroupDetails{"collection:1": {{"urn": "group:1", "permissions": []string{"write"}}}}, nil).Once() pc := &domain.ProviderConfig{ Credentials: metabase.Credentials{ @@ -486,7 +561,7 @@ func TestGrantAccess(t *testing.T) { a := &domain.Appeal{ Resource: &domain.Resource{ Type: metabase.ResourceTypeCollection, - URN: "999", + URN: "collection:999", Name: "test-collection", }, Role: "test-role", @@ -507,12 +582,20 @@ func TestGrantAccess(t *testing.T) { } expectedUser := "test@email.com" expectedRole := "viewer" - p := metabase.NewProvider("", crypto) + logger := log.NewLogrus(log.LogrusWithLevel("info")) + p := metabase.NewProvider("", crypto, logger) p.Clients = map[string]metabase.MetabaseClient{ providerURN: client, } - client.On("GrantCollectionAccess", expectedCollection, expectedUser, expectedRole).Return(nil).Once() + client.On("GrantCollectionAccess", expectedCollection, expectedUser, expectedRole, mock.Anything).Return(nil).Once() + + d := []*metabase.GroupResource{{Urn: "database:1", Permissions: []string{"read", "write"}}} + c := []*metabase.GroupResource{{Urn: "collection:1", Permissions: []string{"read", "write"}}} + group := metabase.Group{Name: "All Users", DatabaseResources: d, CollectionResources: c} + client.On("GetGroups").Return([]*metabase.Group{&group}, + metabase.ResourceGroupDetails{"database:1": {{"urn": "group:1", "permissions": []string{"read", "write"}}}}, + metabase.ResourceGroupDetails{"collection:1": {{"urn": "group:1", "permissions": []string{"write"}}}}, nil).Once() pc := &domain.ProviderConfig{ Credentials: metabase.Credentials{ @@ -536,7 +619,7 @@ func TestGrantAccess(t *testing.T) { a := &domain.Appeal{ Resource: &domain.Resource{ Type: metabase.ResourceTypeCollection, - URN: "999", + URN: "collection:999", Name: "test-collection", }, Role: "viewer", diff --git a/plugins/providers/metabase/resource.go b/plugins/providers/metabase/resource.go index 4b6e2d81e..57de8b6c7 100644 --- a/plugins/providers/metabase/resource.go +++ b/plugins/providers/metabase/resource.go @@ -3,24 +3,53 @@ package metabase import ( "fmt" "strconv" + "strings" + + "github.com/mitchellh/mapstructure" "github.com/odpf/guardian/domain" ) const ( ResourceTypeDatabase = "database" + ResourceTypeTable = "table" ResourceTypeCollection = "collection" + ResourceTypeGroup = "group" + + GuardianGroupPrefix = "_guardian_" ) type Database struct { - ID int `json:"id"` - Name string `json:"name"` - CacheFieldValuesSchedule string `json:"cache_field_values_schedule"` - Timezone string `json:"timezone"` - AutoRunQueries bool `json:"auto_run_queries"` - MetadataSyncSchedule string `json:"metadata_sync_schedule"` - Engine string `json:"engine"` - NativePermissions string `json:"native_permissions"` + ID int `json:"id"` + Name string `json:"name"` + CacheFieldValuesSchedule string `json:"cache_field_values_schedule"` + Timezone string `json:"timezone"` + AutoRunQueries bool `json:"auto_run_queries"` + MetadataSyncSchedule string `json:"metadata_sync_schedule"` + Engine string `json:"engine"` + NativePermissions string `json:"native_permissions"` + Tables []Table `json:"tables"` +} + +type Table struct { + ID int `mapstructure:"id"` + Name string `json:"name"` + DbId int `mapstructure:"db_id"` + Database *domain.Resource +} + +type GroupResource struct { + Name string `json:"name"` + Permissions []string `json:"permission"` + Urn string `json:"urn"` + Type string `json:"type"` +} + +type Group struct { + ID int `json:"id"` + Name string `json:"name"` + DatabaseResources []*GroupResource `json:"database"` + CollectionResources []*GroupResource `json:"collection"` } func (d *Database) FromDomain(r *domain.Resource) error { @@ -28,7 +57,11 @@ func (d *Database) FromDomain(r *domain.Resource) error { return ErrInvalidResourceType } - id, err := strconv.Atoi(r.URN) + databaseURN := strings.Split(r.URN, ":") + if len(databaseURN) != 2 { + return ErrInvalidDatabaseURN + } + id, err := strconv.Atoi(databaseURN[1]) if err != nil { return err } @@ -42,7 +75,7 @@ func (d *Database) ToDomain() *domain.Resource { return &domain.Resource{ Type: ResourceTypeDatabase, Name: d.Name, - URN: fmt.Sprintf("%v", d.ID), + URN: fmt.Sprintf("database:%v", d.ID), Details: map[string]interface{}{ "cache_field_values_schedule": d.CacheFieldValuesSchedule, "timezone": d.Timezone, @@ -54,6 +87,76 @@ func (d *Database) ToDomain() *domain.Resource { } } +func (t *Table) FromDomain(r *domain.Resource) error { + if r.Type != ResourceTypeTable { + return ErrInvalidResourceType + } + + tableURN := strings.Split(r.URN, ":") + if len(tableURN) != 2 { + return ErrInvalidTableURN + } + + tableURN = strings.Split(tableURN[1], ".") + id, err := strconv.Atoi(tableURN[1]) + if err != nil { + return err + } + + t.ID = id + t.Name = r.Name + t.DbId, err = strconv.Atoi(tableURN[0]) + if err != nil { + return err + } + return nil +} + +func (t *Table) ToDomain() *domain.Resource { + return &domain.Resource{ + Type: ResourceTypeTable, + Name: t.Name, + URN: fmt.Sprintf("table:%d.%d", t.DbId, t.ID), + Details: t.Database.Details, + } +} + +func (g *Group) FromDomain(r *domain.Resource) error { + if r.Type != ResourceTypeGroup { + return ErrInvalidResourceType + } + + groupUrn := strings.Split(r.URN, ":") + if len(groupUrn) != 2 { + return ErrInvalidGroupURN + } + id, err := strconv.Atoi(groupUrn[1]) + if err != nil { + return err + } + + g.ID = id + g.Name = r.Name + _ = mapstructure.Decode(r.Details["database"], &g.DatabaseResources) + _ = mapstructure.Decode(r.Details["collection"], &g.CollectionResources) + if err != nil { + return err + } + return nil +} + +func (g *Group) ToDomain() *domain.Resource { + return &domain.Resource{ + Type: ResourceTypeGroup, + Name: g.Name, + URN: fmt.Sprintf("group:%d", g.ID), + Details: map[string]interface{}{ + "database": g.DatabaseResources, + "collection": g.CollectionResources, + }, + } +} + type Collection struct { ID interface{} `json:"id"` Name string `json:"name"` @@ -67,7 +170,15 @@ func (c *Collection) FromDomain(r *domain.Resource) error { return ErrInvalidResourceType } - id, _ := strconv.Atoi(r.URN) + collectionUrn := strings.Split(r.URN, ":") + if len(collectionUrn) != 2 { + return ErrInvalidCollectionURN + } + id, err := strconv.Atoi(collectionUrn[1]) + if err != nil { + return err + } + if id == 0 { c.ID = r.URN } else { @@ -88,7 +199,7 @@ func (c *Collection) ToDomain() *domain.Resource { return &domain.Resource{ Type: ResourceTypeCollection, Name: c.Name, - URN: fmt.Sprintf("%v", c.ID), + URN: fmt.Sprintf("collection:%v", c.ID), Details: details, } } diff --git a/plugins/providers/metabase/resource_test.go b/plugins/providers/metabase/resource_test.go index c3fc23b57..d4ac63717 100644 --- a/plugins/providers/metabase/resource_test.go +++ b/plugins/providers/metabase/resource_test.go @@ -23,7 +23,7 @@ func TestDatabase(t *testing.T) { expectedResource: &domain.Resource{ Type: metabase.ResourceTypeDatabase, Name: "database 1", - URN: "1", + URN: "database:1", }, }, } @@ -69,7 +69,7 @@ func TestDatabase(t *testing.T) { } r := &domain.Resource{ - URN: "1", + URN: "database:1", Type: metabase.ResourceTypeDatabase, Name: "test-resource", } @@ -97,7 +97,7 @@ func TestCollection(t *testing.T) { expectedResource: &domain.Resource{ Type: metabase.ResourceTypeCollection, Name: "collection 1", - URN: "root", + URN: "collection:root", }, }, { @@ -108,7 +108,7 @@ func TestCollection(t *testing.T) { expectedResource: &domain.Resource{ Type: metabase.ResourceTypeCollection, Name: "collection 2", - URN: "1", + URN: "collection:1", }, }, } @@ -144,18 +144,18 @@ func TestCollection(t *testing.T) { }{ { expectedResource: &domain.Resource{ - URN: "non-numeric", + URN: "collection:2", Name: "test-collection", Type: metabase.ResourceTypeCollection, }, expectedCollection: &metabase.Collection{ - ID: "non-numeric", + ID: 2, Name: "test-collection", }, }, { expectedResource: &domain.Resource{ - URN: "1", + URN: "collection:1", Name: "test-collection", Type: metabase.ResourceTypeCollection, },