diff --git a/build.sh b/build.sh index 790dc87..c208212 100755 --- a/build.sh +++ b/build.sh @@ -28,4 +28,4 @@ mkdir -p ${OUTPUT_DIR}/web ${OUTPUT_DIR}/plugins (cd ${WORKING_DIR} && for item in ${PLUGINS}; do go build -buildmode=plugin -ldflags "${LDFLAGS}" -o dist/plugins/$item.so plugins/$item/$item.go; done) # build web -(cd ${WORKING_DIR}/web && rm -rf node_modules > /dev/null 2>&1 && corepack enable && pnpm install --frozen-lockfile --ignore-scripts && pnpm run build) +# (cd ${WORKING_DIR}/web && rm -rf node_modules > /dev/null 2>&1 && corepack enable && pnpm install --frozen-lockfile --ignore-scripts && pnpm run build) diff --git a/cmd/heimdall/heimdall.go b/cmd/heimdall/heimdall.go index 08bef3b..5387357 100644 --- a/cmd/heimdall/heimdall.go +++ b/cmd/heimdall/heimdall.go @@ -8,7 +8,7 @@ import ( "github.com/patterninc/heimdall/internal/pkg/heimdall" "github.com/patterninc/heimdall/internal/pkg/janitor" - "github.com/patterninc/heimdall/internal/pkg/server" + // "github.com/patterninc/heimdall/internal/pkg/server" ) const ( @@ -38,13 +38,13 @@ func main() { // setup defaults before we unmarshal config h := heimdall.Heimdall{ - Server: &server.Server{ - Address: defaultAddress, - ReadTimeout: defaultReadTimeout, - WriteTimeout: defaulWriteTimeout, - IdleTimeout: defaultIdleTimeout, - ReadHeaderTimeout: defaultReadHeaderTimeout, - }, + // Server: &server.Server{ + // Address: defaultAddress, + // ReadTimeout: defaultReadTimeout, + // WriteTimeout: defaulWriteTimeout, + // IdleTimeout: defaultIdleTimeout, + // ReadHeaderTimeout: defaultReadHeaderTimeout, + // }, Janitor: &janitor.Janitor{ Keepalive: defaultJanitorKeepalive, StaleJob: defaultStaleJob, @@ -52,16 +52,16 @@ func main() { } // load config file - if err := config.LoadYAML(configFile, &h); err != nil { + if err := config.LoadYAML("/Users/ivanhladush/git/heimdall/configs/local.yaml", &h); err != nil { process.Bail(`config`, err) } - // setup version - if Build != `` { - h.Version = Build - } else { - h.Version = defaultBuild - } + // // setup version + // if Build != `` { + // h.Version = Build + // } else { + // h.Version = defaultBuild + // } // start proxy if err := h.Start(); err != nil { diff --git a/configs/local.yaml b/configs/local.yaml index b420f30..5cc8f6d 100644 --- a/configs/local.yaml +++ b/configs/local.yaml @@ -19,16 +19,27 @@ auth: # supported commands commands: - - name: ping-0.0.1 + # # - name: ping-0.0.1 + # status: active + # plugin: ping + # version: 0.0.1 + # store_result_sync: false + # description: Test ping command + # tags: + # - type:ping + # cluster_tags: + # - type:localhost + - name: trino-475 status: active - plugin: ping - version: 0.0.1 - store_result_sync: false - description: Test ping command + plugin: trino + version: 475 + description: Run Trino queries + context: + poll_interval: 250 # milliseconds between poll attempts tags: - - type:ping + - type:trino cluster_tags: - - type:localhost + - type:trino # supported clusters clusters: @@ -38,4 +49,14 @@ clusters: description: Just a localhost tags: - type:localhost - - data:local \ No newline at end of file + - data:local + - name: eks-trino-475 + status: active + version: 475 + description: Trino cluster in us-west-2 + tags: + - type:trino + - data:prod + rbacs: + - my-ranger + diff --git a/docker-compose.yaml b/docker-compose.yaml index 15d3151..f7ad0d1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,7 @@ --- services: postgres: - image: postgres:16 + image: postgres:16.1 container_name: heimdall_postgres environment: POSTGRES_USER: heimdall diff --git a/go.mod b/go.mod index 6fa4cb9..4e89351 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/linkedin/goavro v2.1.0+incompatible github.com/shopspring/decimal v1.4.0 github.com/snowflakedb/gosnowflake v1.15.0 + github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.33.4 k8s.io/apimachinery v0.33.4 @@ -95,6 +96,7 @@ require ( github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect @@ -102,6 +104,7 @@ require ( github.com/segmentio/asm v1.2.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.opentelemetry.io/otel v1.38.0 // indirect diff --git a/internal/pkg/heimdall/auth.go b/internal/pkg/heimdall/auth.go index e635cbf..c52cce9 100644 --- a/internal/pkg/heimdall/auth.go +++ b/internal/pkg/heimdall/auth.go @@ -28,10 +28,10 @@ func (h *Heimdall) auth(next http.Handler) http.Handler { // let's get username from the header username := `` - if h.Auth != nil { - // TODO: process error here... - username, _ = h.Auth.GetUser(r) - } + // if h.Auth != nil { + // // TODO: process error here... + // username, _ = h.Auth.GetUser(r) + // } // let's write this username to request context... ctx := context.WithValue(r.Context(), userNameKey, username) diff --git a/internal/pkg/heimdall/heimdall.go b/internal/pkg/heimdall/heimdall.go index 1db07b2..15107cd 100644 --- a/internal/pkg/heimdall/heimdall.go +++ b/internal/pkg/heimdall/heimdall.go @@ -11,7 +11,6 @@ import ( "github.com/gorilla/mux" - "github.com/patterninc/heimdall/internal/pkg/auth" "github.com/patterninc/heimdall/internal/pkg/database" "github.com/patterninc/heimdall/internal/pkg/janitor" "github.com/patterninc/heimdall/internal/pkg/pool" @@ -20,6 +19,7 @@ import ( "github.com/patterninc/heimdall/pkg/object/command" "github.com/patterninc/heimdall/pkg/object/job" "github.com/patterninc/heimdall/pkg/plugin" + "github.com/patterninc/heimdall/internal/pkg/rbac" ) const ( @@ -40,19 +40,20 @@ const ( type Heimdall struct { Server *server.Server `yaml:"server,omitempty" json:"server,omitempty"` - Commands command.Commands `yaml:"commands,omitempty" json:"commands,omitempty"` - Clusters cluster.Clusters `yaml:"clusters,omitempty" json:"clusters,omitempty"` - JobsDirectory string `yaml:"jobs_directory,omitempty" json:"jobs_directory,omitempty"` - ArchiveDirectory string `yaml:"archive_directory,omitempty" json:"archive_directory,omitempty"` - ResultDirectory string `yaml:"result_directory,omitempty" json:"result_directory,omitempty"` - PluginsDirectory string `yaml:"plugin_directory,omitempty" json:"plugin_directory,omitempty"` - Database *database.Database `yaml:"database,omitempty" json:"database,omitempty"` + Commands command.Commands `yaml:"commands,omitempty" json:"commands,omitempty"` + Clusters cluster.Clusters `yaml:"clusters,omitempty" json:"clusters,omitempty"` + RBACs rbac.RBACs `yaml:"rbacs,omitempty" json:"rbacs,omitempty"` + JobsDirectory string `yaml:"jobs_directory,omitempty" json:"jobs_directory,omitempty"` + ArchiveDirectory string `yaml:"archive_directory,omitempty" json:"archive_directory,omitempty"` + ResultDirectory string `yaml:"result_directory,omitempty" json:"result_directory,omitempty"` + PluginsDirectory string `yaml:"plugin_directory,omitempty" json:"plugin_directory,omitempty"` + Database *database.Database `yaml:"database,omitempty" json:"database,omitempty"` Pool *pool.Pool[*job.Job] `yaml:"pool,omitempty" json:"pool,omitempty"` Auth *auth.Auth `yaml:"auth,omitempty" json:"auth,omitempty"` - Janitor *janitor.Janitor `yaml:"janitor,omitempty" json:"janitor,omitempty"` - Version string `yaml:"-" json:"-"` - agentName string - commandHandlers map[string]plugin.Handler + Janitor *janitor.Janitor `yaml:"janitor,omitempty" json:"janitor,omitempty"` + Version string `yaml:"-" json:"-"` + agentName string + commandHandlers map[string]plugin.Handler } func (h *Heimdall) Init() error { @@ -62,12 +63,12 @@ func (h *Heimdall) Init() error { h.JobsDirectory = defaultJobsDirectory } - // set archive directory if not set + // // set archive directory if not set if h.ArchiveDirectory == `` { h.ArchiveDirectory = defaultArchiveDirectory } - // set result directory if not set + // // set result directory if not set if h.ResultDirectory == `` { h.ResultDirectory = defaultResultDirectory } @@ -114,6 +115,13 @@ func (h *Heimdall) Init() error { } + rbacsByName := map[string]rbac.RBAC{} + for rbacName, r := range h.RBACs { + if err := r.Init(); err != nil { + return fmt.Errorf("failed to init rbac %s: %w", rbacName, err) + } + rbacsByName[rbacName] = r + } // process commands / add default values if missing, write commands to db for _, c := range h.Clusters { @@ -126,17 +134,24 @@ func (h *Heimdall) Init() error { if err := h.clusterUpsert(c); err != nil { return err } - + if len(c.RBACNames) > 0 { + for _, rbacName := range c.RBACNames { + r, found := rbacsByName[rbacName] + if !found { + return fmt.Errorf("failed to find rbac %s for cluster %s", rbacName, c.Name) + } + c.RBACs = append(c.RBACs, r) + } + } } - // start janitor + // // start janitor if err := h.Janitor.Start(h.Database); err != nil { return err } // let's start the agent return h.Pool.Start(h.runAsyncJob, h.getAsyncJobs) - } func (h *Heimdall) Start() error { diff --git a/internal/pkg/heimdall/plugins.go b/internal/pkg/heimdall/plugins.go index 89213df..f934d85 100644 --- a/internal/pkg/heimdall/plugins.go +++ b/internal/pkg/heimdall/plugins.go @@ -18,6 +18,7 @@ const ( func (h *Heimdall) loadPlugins() (map[string]func(*context.Context) (hp.Handler, error), error) { + return nil,nil plugins := make(map[string]func(*context.Context) (hp.Handler, error)) files, err := os.ReadDir(h.PluginsDirectory) diff --git a/internal/pkg/object/command/trino/client.go b/internal/pkg/object/command/trino/client.go index e8e1f4c..9443974 100644 --- a/internal/pkg/object/command/trino/client.go +++ b/internal/pkg/object/command/trino/client.go @@ -45,7 +45,7 @@ type response struct { Error map[string]any `json:"error"` } -func newRequest(r *plugin.Runtime, j *job.Job, c *cluster.Cluster) (*request, error) { +func newRequest(r *plugin.Runtime, j *job.Job, c *cluster.Cluster, jobCtx *jobContext) (*request, error) { // get cluster context clusterCtx := &clusterContext{} @@ -55,15 +55,6 @@ func newRequest(r *plugin.Runtime, j *job.Job, c *cluster.Cluster) (*request, er } } - // get job context - jobCtx := &jobContext{} - if j.Context != nil { - if err := j.Context.Unmarshal(jobCtx); err != nil { - return nil, err - } - } - jobCtx.Query = normalizeTrinoQuery(jobCtx.Query) - // form context for trino request req := &request{ endpoint: clusterCtx.Endpoint, diff --git a/internal/pkg/object/command/trino/trino.go b/internal/pkg/object/command/trino/trino.go index f54f16f..d18ae99 100644 --- a/internal/pkg/object/command/trino/trino.go +++ b/internal/pkg/object/command/trino/trino.go @@ -2,6 +2,7 @@ package trino import ( "fmt" + "log" "time" "github.com/patterninc/heimdall/pkg/context" @@ -47,8 +48,21 @@ func New(ctx *context.Context) (plugin.Handler, error) { func (t *commandContext) handler(r *plugin.Runtime, j *job.Job, c *cluster.Cluster) error { + // get job context + jobCtx := &jobContext{} + if j.Context != nil { + if err := j.Context.Unmarshal(jobCtx); err != nil { + return err + } + } + jobCtx.Query = normalizeTrinoQuery(jobCtx.Query) + + if !canQueryBeExecuted(jobCtx.Query, j.User, c) { + log.Printf("user %s is not allowed to run the query", j.User) + // todo add metrics here and eventually enable in prod + } // let's submit our query to trino - req, err := newRequest(r, j, c) + req, err := newRequest(r, j, c, jobCtx) if err != nil { return err } @@ -72,3 +86,25 @@ func (t *commandContext) handler(r *plugin.Runtime, j *job.Job, c *cluster.Clust return nil } + +func canQueryBeExecuted(query, user string, c *cluster.Cluster) bool { + log.Printf("checking if user %s can run the query: %s\n", user, query) + // todo add metrics for time spent here + if query == `` { + return false + } + + for _, rbac := range c.RBACs { + allowed, err := rbac.HasAccess(user, query) + if err != nil { + log.Printf("failed to check rbac: %v", err) + return false + } + if !allowed { + log.Printf("user %s is not allowed to run the query", user) + return false + } + } + log.Printf("user %s is allowed to run the query", user) + return true +} diff --git a/internal/pkg/rbac/ranger/client.go b/internal/pkg/rbac/ranger/client.go new file mode 100644 index 0000000..3a17fa3 --- /dev/null +++ b/internal/pkg/rbac/ranger/client.go @@ -0,0 +1,192 @@ +package ranger + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" +) + +const ( + getUsersEndpoint = `/service/xusers/users` + getServicePoliciesEndpoint = `/service/public/v2/api/service/%s/policy` +) + +//go:generate go run github.com/vektra/mockery/v2@v2.53.4 --name=Client --output=./mocks --outpkg=mocks +type Client interface { + GetUsers() (map[string]*User, error) + GetPolicies(serviceName string) ([]*Policy, error) +} + +type User struct { + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + EmailAddress string `json:"emailAddress,omitempty"` + UserRoleList []string `json:"userRoleList,omitempty"` + Password string `json:"password,omitempty"` + SyncSource string `json:"syncSource,omitempty"` + GroupNameList []string `json:"groupNameList,omitempty"` +} + +type Group struct { + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + SyncSource string `json:"syncSource,omitempty"` +} + +type getResponse struct { + PageSize int `json:"pageSize"` + StartIndex int `json:"startIndex"` + ResultSize int `json:"resultSize"` + VXUsers []*User `json:"vXUsers,omitempty"` + VXGroups []*Group `json:"vXGroups,omitempty"` + VXGroupUsers []*vXGroupUsers `json:"vXGroupUsers,omitempty"` +} + +type vXGroupUsers struct { + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + FirstName string `json:"firstName,omitempty"` +} + +type client struct { + URL string `yaml:"url,omitempty" json:"url,omitempty"` + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` + client *http.Client +} + +func NewClient(url, username, password string) Client { + return &client{ + URL: url, + Username: username, + Password: password, + client: &http.Client{}, + } +} + +func (c *client) GetUsers() (map[string]*User, error) { + + responses, err := c.executeBatchRequest(http.MethodGet, getUsersEndpoint) + if err != nil { + return nil, err + } + + usersMap := make(map[string]*User) + + // Process all response batches into the map + for _, resp := range responses { + // Use type assertion to get the correct response type + for _, user := range resp.VXUsers { + usersMap[user.Name] = user + } + + } + + log.Printf("Number of Ranger Users pulled: %d\n", len(usersMap)) + return usersMap, nil + +} + +func (c *client) GetPolicies(serviceName string) ([]*Policy, error) { + var policies []*Policy + err := c.executeRequest(http.MethodGet, fmt.Sprintf(getServicePoliciesEndpoint, serviceName), &policies, nil) + return policies, err +} + +func (c *client) createRequest(method, endpoint string, reqBody interface{}) (*http.Request, error) { + var jsonBody []byte + var err error + + // Marshal body if POST request + if reqBody != nil { + jsonBody, err = json.Marshal(reqBody) + if err != nil { + return nil, err + } + } + + // Create Request + req, err := http.NewRequest(method, fmt.Sprintf("%s%s", c.URL, endpoint), bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, err + } + + // Add auth headers + req.SetBasicAuth(c.Username, c.Password) + req.Header.Set("Content-Type", "application/json") + + return req, nil + +} + +func (c *client) executeRequest(method string, endpoint string, v interface{}, reqBody interface{}) error { + req, err := c.createRequest(method, endpoint, reqBody) + if err != nil { + return err + } + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(resp.Body) + bodyString := string(bodyBytes) + + if strings.Contains(bodyString, "INVALID_INPUT_DATA") { + return nil + } + + return fmt.Errorf("request to %s failed with status %s\n%s", req.URL.String(), resp.Status, bodyString) + } + + vals, _ := io.ReadAll(resp.Body) + fmt.Println(string(vals)) + resp.Body = io.NopCloser(bytes.NewReader(vals)) + if v != nil { + return json.NewDecoder(resp.Body).Decode(v) + } + + return nil +} + +// executeBatchRequest performs paginated API requests and returns all aggregated results +func (c *client) executeBatchRequest(method string, endpoint string) ([]getResponse, error) { + results := make([]getResponse, 500) + pageSize := 500 + startIndex := 0 + + for { + + batchEndpoint := fmt.Sprintf("%s?pageSize=%d&startIndex=%d", endpoint, pageSize, startIndex) + + // Marshall into generic get + getResponse := &getResponse{} + if err := c.executeRequest(method, batchEndpoint, getResponse, nil); err != nil { + return nil, err + } + + // Add this batch's response to our results + results = append(results, *getResponse) + + fmt.Printf("%v - Pulled batch with %d items...\n", time.Now().Format("2006-01-02 15:04:05"), getResponse.ResultSize) + + if getResponse.ResultSize < int(pageSize) { + break + } + + startIndex += pageSize + } + + return results, nil +} diff --git a/internal/pkg/rbac/ranger/mocks/Client.go b/internal/pkg/rbac/ranger/mocks/Client.go new file mode 100644 index 0000000..0bd7025 --- /dev/null +++ b/internal/pkg/rbac/ranger/mocks/Client.go @@ -0,0 +1,87 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package mocks + +import ( + ranger "github.com/patterninc/heimdall/internal/pkg/rbac/ranger" + mock "github.com/stretchr/testify/mock" +) + +// Client is an autogenerated mock type for the Client type +type Client struct { + mock.Mock +} + +// GetPolicies provides a mock function with given fields: serviceName +func (_m *Client) GetPolicies(serviceName string) ([]*ranger.Policy, error) { + ret := _m.Called(serviceName) + + if len(ret) == 0 { + panic("no return value specified for GetPolicies") + } + + var r0 []*ranger.Policy + var r1 error + if rf, ok := ret.Get(0).(func(string) ([]*ranger.Policy, error)); ok { + return rf(serviceName) + } + if rf, ok := ret.Get(0).(func(string) []*ranger.Policy); ok { + r0 = rf(serviceName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*ranger.Policy) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(serviceName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetUsers provides a mock function with no fields +func (_m *Client) GetUsers() (map[string]*ranger.User, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetUsers") + } + + var r0 map[string]*ranger.User + var r1 error + if rf, ok := ret.Get(0).(func() (map[string]*ranger.User, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() map[string]*ranger.User); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]*ranger.User) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewClient(t interface { + mock.TestingT + Cleanup(func()) +}) *Client { + mock := &Client{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/pkg/rbac/ranger/policy.go b/internal/pkg/rbac/ranger/policy.go new file mode 100644 index 0000000..38aac8a --- /dev/null +++ b/internal/pkg/rbac/ranger/policy.go @@ -0,0 +1,250 @@ +package ranger + +import ( + "log" + "regexp" + "strings" + + "github.com/patterninc/heimdall/internal/pkg/sql/parser" +) + +const ( + allActionAccessType = "all" +) + +var ( + actionByName = map[string]parser.Action{ + "select": parser.SELECT, + "insert": parser.INSERT, + "update": parser.UPDATE, + "delete": parser.DELETE, + "create": parser.CREATE, + "drop": parser.DROP, + "use": parser.USE, + "alter": parser.ALTER, + "grant": parser.GRANT, + "revoke": parser.REVOKE, + "show": parser.SHOW, + "impersonate": parser.IMPERSONATE, + "execute": parser.EXECUTE, + "read_system_information": parser.READ_SYSTEM_INFORMATION, + "write_system_information": parser.WRITE_SYSTEM_INFORMATION, + } + + allActions = []parser.Action{ + parser.SELECT, + parser.INSERT, + parser.UPDATE, + parser.DELETE, + parser.CREATE, + parser.DROP, + parser.USE, + parser.ALTER, + parser.GRANT, + parser.REVOKE, + parser.SHOW, + parser.IMPERSONATE, + parser.EXECUTE, + parser.READ_SYSTEM_INFORMATION, + parser.WRITE_SYSTEM_INFORMATION, + } +) + +type Policy struct { + ID int `json:"id"` + GUID string `json:"guid"` + IsEnabled bool `json:"isEnabled"` + Version int `json:"version"` + Service string `json:"service"` + Name string `json:"name"` + PolicyType int `json:"policyType"` + PolicyPriority int `json:"policyPriority"` + Description string `json:"description"` + IsAuditEnabled bool `json:"isAuditEnabled"` + Resources *Resource `json:"resources"` + AdditionalResources []*Resource `json:"additionalResources"` + AllResources []*Resource + PolicyItems []PolicyItem `json:"policyItems"` + DenyPolicyItems []PolicyItem `json:"denyPolicyItems"` + AllowExceptions []PolicyItem `json:"allowExceptions"` + DenyExceptions []PolicyItem `json:"denyExceptions"` + ServiceType string `json:"serviceType"` +} + +type Resource struct { + Schema *ResourceField `json:"schema,omitempty"` + Catalog *ResourceField `json:"catalog,omitempty"` + Table *ResourceField `json:"table,omitempty"` + Column *ResourceField `json:"column,omitempty"` +} + +type ResourceField struct { + Values []string `json:"values"` + IsExcludes bool `json:"isExcludes"` + regexp *regexp.Regexp +} + +type Access struct { + Type string `json:"type"` +} + +type PolicyItem struct { + Accesses []Access `json:"accesses"` + Users []string `json:"users,omitempty"` + Groups []string `json:"groups,omitempty"` + Actions []parser.Action +} + +type ControlledActions struct { + allowedActionsByUser map[string][]parser.Action + deniedActionsByUser map[string][]parser.Action +} + +func (p *Policy) init() error { + p.AllResources = append([]*Resource{p.Resources}, p.AdditionalResources...) + for _, v := range p.AllResources { + if len(v.Catalog.Values) != 0 { + v.Catalog.regexp = regexp.MustCompile("^(" + patternsToRegex(v.Catalog.Values) + ")$") + } + if len(v.Schema.Values) != 0 { + v.Schema.regexp = regexp.MustCompile("^(" + patternsToRegex(v.Schema.Values) + ")$") + } + if len(v.Table.Values) != 0 { + v.Table.regexp = regexp.MustCompile("^(" + patternsToRegex(v.Table.Values) + ")$") + } + } + return nil +} + +func (p *Policy) doesControlAnAccess(access parser.Access) bool { + switch a := access.(type) { + case *parser.TableAccess: + return p.doesControlTableAccess(a) + } + return false +} + +func (p *Policy) doesControlTableAccess(a *parser.TableAccess) bool { + for _, v := range p.AllResources { + match := v.Catalog.regexp.MatchString(a.Catalog) + if match == v.Catalog.IsExcludes { + continue + } + + match = v.Schema.regexp.MatchString(a.Schema) + if match == v.Schema.IsExcludes { + continue + } + + match = v.Table.regexp.MatchString(a.Table) + if match == v.Table.IsExcludes { + continue + } + + return true + } + return false +} + +func (p *Policy) getControlledActions(usersByGroup map[string][]string) ControlledActions { + return ControlledActions{ + allowedActionsByUser: p.getAllPolicyByUser(p.PolicyItems, p.AllowExceptions, usersByGroup), + deniedActionsByUser: p.getAllPolicyByUser(p.DenyPolicyItems, p.DenyExceptions, usersByGroup), + } +} + +func (p *Policy) getAllPolicyByUser( + items []PolicyItem, + exceptions []PolicyItem, + usersByGroup map[string][]string, +) map[string][]parser.Action { + policiesItem := policyItemsToActionsByUser(items, usersByGroup) + exceptionsItem := policyItemsToActionsByUser(exceptions, usersByGroup) + + for user, actions := range exceptionsItem { + if _, ok := policiesItem[user]; !ok { + continue + } + for action := range actions { + delete(policiesItem[user], action) + } + if len(policiesItem[user]) == 0 { + delete(policiesItem, user) + } + } + + result := map[string][]parser.Action{} + for user, actionsMap := range policiesItem { + actions := make([]parser.Action, 0, len(actionsMap)) + for action := range actionsMap { + actions = append(actions, action) + } + result[user] = actions + } + return result +} + +func globToRegex(pattern string) string { + escaped := regexp.QuoteMeta(pattern) + escaped = strings.ReplaceAll(escaped, "\\*", ".*") + escaped = strings.ReplaceAll(escaped, "\\?", ".") + return escaped +} + +func patternsToRegex(patterns []string) string { + var regexes []string + for _, pat := range patterns { + regexes = append(regexes, globToRegex(pat)) + } + return strings.Join(regexes, "|") +} + +func (p *PolicyItem) getPermissions() []parser.Action { + if p.Actions != nil { + return p.Actions + } + p.Actions = make([]parser.Action, 0) + for _, access := range p.Accesses { + accessType := strings.ToLower(access.Type) + + if accessType == allActionAccessType { + return allActions + } + if action, ok := actionByName[accessType]; ok { + p.Actions = append(p.Actions, action) + continue + } else { + log.Println("Unknown action type in ranger policy:", accessType) + } + } + return p.Actions + +} + +func policyItemsToActionsByUser(items []PolicyItem, usersByGroup map[string][]string) map[string]map[parser.Action]struct{} { + permissions := make(map[string]map[parser.Action]struct{}) + + for _, item := range items { + actions := item.getPermissions() + for _, user := range item.Users { + addActionsToPermissions(permissions, user, actions) + } + for _, group := range item.Groups { + for _, user := range usersByGroup[group] { + addActionsToPermissions(permissions, user, actions) + } + } + } + + return permissions +} + +func addActionsToPermissions(permissions map[string]map[parser.Action]struct{}, user string, actions []parser.Action) { + user = strings.ToLower(user) + if _, ok := permissions[user]; !ok { + permissions[user] = make(map[parser.Action]struct{}) + } + for _, action := range actions { + permissions[user][action] = struct{}{} + } +} diff --git a/internal/pkg/rbac/ranger/ranger.go b/internal/pkg/rbac/ranger/ranger.go new file mode 100644 index 0000000..25ccfcf --- /dev/null +++ b/internal/pkg/rbac/ranger/ranger.go @@ -0,0 +1,221 @@ +package ranger + +import ( + "context" + "errors" + "log" + "strings" + "time" + + "gopkg.in/yaml.v3" + + "github.com/patterninc/heimdall/internal/pkg/sql/parser" + "github.com/patterninc/heimdall/internal/pkg/sql/parser/factory" +) + +var ( + ErrRangerClientConfigIsRequired = errors.New("ranger client_config is required") + ErrRangerParserConfigIsRequired = errors.New("ranger parser_config is required") + ErrRangerParserTypeIsRequired = errors.New("ranger parser_config.type is required") + ErrRangerParserDefaultCatalogIsRequired = errors.New("ranger parser_config.default_catalog is required") + ErrRangerUnsupportedParserType = errors.New("unsupported ranger parser_config.type. supported types: trino") +) + +type Ranger struct { + Name string `yaml:"name,omitempty" json:"name,omitempty"` + ServiceName string `yaml:"service_name,omitempty" json:"service_name,omitempty"` + Client Client + SyncIntervalInMinutes int `yaml:"sync_interval_in_minutes,omitempty" json:"sync_interval_in_minutes,omitempty"` + AccessReceiver parser.AccessReceiver `yaml:"parser,omitempty" json:"parser,omitempty"` + permissionsByUser map[string]*userPermissions +} + +type parserConfig struct { + Type string `yaml:"type,omitempty" json:"type,omitempty"` + DefaultCatalog string `yaml:"default_catalog,omitempty" json:"default_catalog,omitempty"` +} + +type clientConfig struct { + Endpoint string `yaml:"endpoint,omitempty" json:"endpoint,omitempty"` + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` +} + +type userPermissions struct { + AllowPolicies map[parser.Action][]*Policy + DenyPolicies map[parser.Action][]*Policy +} + +func (r *Ranger) Init() error { + // first time lets sync state explicitly + if err := r.SyncState(); err != nil { + return err + } + go func() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ticker := time.NewTicker(time.Duration(r.SyncIntervalInMinutes) * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Println("Stopping Apache Ranger sync goroutine") + return + case <-ticker.C: + log.Println("Syncing policies from Apache Ranger for service:", r.ServiceName) + if err := r.SyncState(); err != nil { + log.Println("Error syncing users and groups from Apache Ranger", "error", err) + } + } + } + }() + return nil +} + +func (r *Ranger) HasAccess(user string, query string) (bool, error) { + user = strings.ToLower(user) + if _, ok := r.permissionsByUser[user]; !ok { + log.Println("User not found in ranger policies. User: ", user) + return false, nil + } + accessList, err := r.AccessReceiver.ParseAccess(query) + if err != nil { + return false, err + } + + permissions := r.permissionsByUser[user] + + for _, access := range accessList { + for _, permition := range permissions.DenyPolicies[access.Action()] { + if permition.doesControlAnAccess(access) { + log.Println("Access denied by ranger policy", "user", user, "query", query, "policy", permition.Name, "action", access.Action(), "resource", access.QualifiedName()) + return false, nil + } + } + foundAllowPolicy := false + for _, permition := range permissions.AllowPolicies[access.Action()] { + if permition.doesControlAnAccess(access) { + log.Println("Access allowed by ranger policy", "user", user, "query", query, "policy", permition.Name, "action", access.Action(), "resource", access.QualifiedName()) + foundAllowPolicy = true + break + } + } + if !foundAllowPolicy { + log.Println("Access denied by ranger policy", "user", user, "query", query, "action", access.Action(), "resource", access.QualifiedName()) + return false, nil + } + } + return true, nil +} + +func (r *Ranger) GetName() string { + return r.Name +} + +func (r *Ranger) SyncState() error { + policies, err := r.Client.GetPolicies(r.ServiceName) + if err != nil { + return err + } + users, err := r.Client.GetUsers() + if err != nil { + return err + } + + usersByGroup := map[string][]string{} + for _, user := range users { + for _, gName := range user.GroupNameList { + usersByGroup[gName] = append(usersByGroup[gName], user.Name) + } + } + + newPermissionsByUser := map[string]*userPermissions{} + for _, policy := range policies { + if !policy.IsEnabled { + continue + } + if policy.Resources == nil || policy.Resources.Catalog == nil || policy.Resources.Schema == nil || policy.Resources.Table == nil { + // Skip policies that do not have catalog, schema, or table defined + continue + } + + if err := policy.init(); err != nil { + log.Println("Error initializing policy:", err) + return err + } + + controlledActions := policy.getControlledActions(usersByGroup) + for userName, actions := range controlledActions.allowedActionsByUser { + if _, ok := newPermissionsByUser[userName]; !ok { + newPermissionsByUser[userName] = &userPermissions{ + AllowPolicies: map[parser.Action][]*Policy{}, + DenyPolicies: map[parser.Action][]*Policy{}, + } + } + for _, action := range actions { + newPermissionsByUser[userName].AllowPolicies[action] = append(newPermissionsByUser[userName].AllowPolicies[action], policy) + } + } + for userName, actions := range controlledActions.deniedActionsByUser { + if _, ok := newPermissionsByUser[userName]; !ok { + newPermissionsByUser[userName] = &userPermissions{ + AllowPolicies: map[parser.Action][]*Policy{}, + DenyPolicies: map[parser.Action][]*Policy{}, + } + } + for _, action := range actions { + newPermissionsByUser[userName].DenyPolicies[action] = append(newPermissionsByUser[userName].DenyPolicies[action], policy) + } + } + } + + r.permissionsByUser = newPermissionsByUser + log.Println("Syncing users and groups from Apache Ranger for service:", r.ServiceName) + return nil +} + +func (r *Ranger) UnmarshalYAML(value *yaml.Node) error { + type rawRanger struct { + Name string `yaml:"name,omitempty" json:"name,omitempty"` + ServiceName string `yaml:"service_name,omitempty" json:"service_name,omitempty"` + SyncIntervalInMinutes int `yaml:"sync_interval_in_minutes,omitempty" json:"sync_interval_in_minutes,omitempty"` + Client *clientConfig `yaml:"client"` + Parser *parserConfig `yaml:"parser"` + } + + var raw rawRanger + if err := value.Decode(&raw); err != nil { + return err + } + + if raw.Client == nil { + return ErrRangerClientConfigIsRequired + } + if raw.Parser == nil { + return ErrRangerParserConfigIsRequired + } + if raw.Parser.Type == "" { + return ErrRangerParserTypeIsRequired + } + if raw.Parser.DefaultCatalog == "" { + return ErrRangerParserDefaultCatalogIsRequired + } + + r.Name = raw.Name + r.ServiceName = raw.ServiceName + r.SyncIntervalInMinutes = raw.SyncIntervalInMinutes + r.Client = NewClient(raw.Client.Endpoint, raw.Client.Username, raw.Client.Password) + + accessReceiver, err := factory.CreateParserByType(raw.Parser.Type, raw.Parser.DefaultCatalog) + if err != nil { + return ErrRangerUnsupportedParserType + } + + r.AccessReceiver = accessReceiver + if r.SyncIntervalInMinutes == 0 { + r.SyncIntervalInMinutes = 5 + } + return nil +} diff --git a/internal/pkg/rbac/ranger/tests/group_policy_test.go b/internal/pkg/rbac/ranger/tests/group_policy_test.go new file mode 100644 index 0000000..caa9a4a --- /dev/null +++ b/internal/pkg/rbac/ranger/tests/group_policy_test.go @@ -0,0 +1,385 @@ +package tests + +import ( + "testing" + + "github.com/patterninc/heimdall/internal/pkg/rbac/ranger" +) + +func TestAllowPermissionsForGroups(t *testing.T) { + tests := []testCase{ + { + name: "Policy allows all actions for group", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultGroupAllowPolicy([]string{"all"})}, + }, + { + name: "Policy allows select action for group", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultGroupAllowPolicy([]string{"select"})}, + }, + { + name: "Policy allows insert action for group, but query is select", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultGroupAllowPolicy([]string{"insert"})}, + }, + { + name: "Policy allows multiple actions including select for group", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultGroupAllowPolicy([]string{"insert", "select", "update"})}, + }, + { + name: "Policy allows multiple actions excluding select for group", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultGroupAllowPolicy([]string{"insert", "update", "delete"})}, + }, + { + name: "No policy for group", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{}, + }, + { + name: "Policy allows select but query requires also insert", + query: "INSERT INTO default_catalog.public.table1 as SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultGroupAllowPolicy([]string{"select"})}, + }, + { + name: "Policy allows all actions", + query: "INSERT INTO default_catalog.public.table1 as SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultGroupAllowPolicy([]string{"all"})}, + }, + { + name: "Policy many actions and many are required", + query: "INSERT INTO default_catalog.public.table1 as SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultGroupAllowPolicy([]string{"delete", "insert", "select", "update"})}, + }, + { + name: "Policy exclude user from the select action", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultAllActionsGroupPolicyWithExcludeForDefaultGroup([]string{"select"})}, + }, + { + name: "Policy exclude user from the insert action, but query is select and insert", + query: "INSERT INTO default_catalog.public.table1 as SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultAllActionsGroupPolicyWithExcludeForDefaultGroup([]string{"insert"})}, + }, + { + name: "Policy exclude user from the insert action, but query is select ", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultAllActionsGroupPolicyWithExcludeForDefaultGroup([]string{"insert"})}, + }, + } + + runTests(t, tests) +} + +func TestDenyPermissionsForGroups(t *testing.T) { + tests := []testCase{ + { + name: "Policy denies select action for group", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: getAllowAllPolicyWithDenyForGroup([]string{"select"}), + }, + { + name: "Policy denies insert action for group, but query is select", + query: "INSERT INTO default_catalog.public.table1 VALUES (1, 'data')", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: getAllowAllPolicyWithDenyForGroup([]string{"insert"}), + }, + { + name: "Policy denies update action for group but query is select", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: getAllowAllPolicyWithDenyForGroup([]string{"update"}), + }, + { + name: "Policy denies multiple actions including select for group", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: getAllowAllPolicyWithDenyForGroup([]string{"insert", "select", "update"}), + }, + { + name: "Policy denies multiple actions excluding select for group", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: getAllowAllPolicyWithDenyForGroup([]string{"insert", "update", "delete"}), + }, + { + name: "Policy denies select and insert actions for group", + query: "INSERT INTO default_catalog.public.table1 VALUES (1, 'data')", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: getAllowAllPolicyWithDenyAndExceptionForGroup([]string{"select", "insert"}, []string{"all"}), + }, + { + name: "Policy denies all actions for group but exception allows select", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: getAllowAllPolicyWithDenyAndExceptionForGroup([]string{"all"}, []string{"select"}), + }, + { + name: "Policy denies all actions for group but exception allows insert, but query is select", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: getAllowAllPolicyWithDenyAndExceptionForGroup([]string{"all"}, []string{"insert"}), + }, + { + name: "Policy denies all actions for group but exception allows select and insert, but query is select", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: getAllowAllPolicyWithDenyAndExceptionForGroup([]string{"all"}, []string{"select", "insert"}), + }, + { + name: "Policy denies all actions for group but exception allows select and insert, but query is insert", + query: "INSERT INTO default_catalog.public.table1 VALUES (SELECT * FROM default_catalog.public.table1)", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: getAllowAllPolicyWithDenyAndExceptionForGroup([]string{"all"}, []string{"insert", "select"}), + }, + } + + runTests(t, tests) +} + +func getDefaultGroupAllowPolicy(accessType []string) *ranger.Policy { + return &ranger.Policy{ + ID: 1, + GUID: "policy-1", + IsEnabled: true, + Name: "Allow select for alice", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: func() []ranger.Access { + var accesses []ranger.Access + for _, at := range accessType { + accesses = append(accesses, ranger.Access{Type: at}) + } + return accesses + }(), + }, + }, + } +} + +func getDefaultAllActionsGroupPolicyWithExcludeForDefaultGroup(excludeAccess []string) *ranger.Policy { + return &ranger.Policy{ + ID: 1, + GUID: "policy-1", + IsEnabled: true, + Name: "Allow select for alice", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: []ranger.Access{ + {Type: "all"}, + }, + }, + }, + AllowExceptions: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: func() []ranger.Access { + var accesses []ranger.Access + for _, ex := range excludeAccess { + accesses = append(accesses, ranger.Access{Type: ex}) + } + return accesses + }(), + }, + }, + } +} + +func getAllowAllPolicyWithDenyForGroup(denyAccess []string) []*ranger.Policy { + return []*ranger.Policy{ + { + ID: 1, + GUID: "policy-1", + IsEnabled: true, + Name: "Allow select for alice", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: []ranger.Access{ + {Type: "all"}, + }, + }, + }, + DenyPolicyItems: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: func() []ranger.Access { + var accesses []ranger.Access + for _, a := range denyAccess { + accesses = append(accesses, ranger.Access{Type: a}) + } + return accesses + }(), + }, + }, + }, + } +} + +func getAllowAllPolicyWithDenyAndExceptionForGroup(denyAccess, exceptionAccess []string) []*ranger.Policy { + return []*ranger.Policy{ + { + ID: 1, + GUID: "policy-1", + IsEnabled: true, + Name: "Allow select for alice", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: []ranger.Access{ + {Type: "all"}, + }, + }, + }, + DenyPolicyItems: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: func() []ranger.Access { + var accesses []ranger.Access + for _, a := range denyAccess { + accesses = append(accesses, ranger.Access{Type: a}) + } + return accesses + }(), + }, + }, + DenyExceptions: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: func() []ranger.Access { + var accesses []ranger.Access + for _, a := range exceptionAccess { + accesses = append(accesses, ranger.Access{Type: a}) + } + return accesses + }(), + }, + }, + }, + } +} diff --git a/internal/pkg/rbac/ranger/tests/ranger_policy_check_test.go b/internal/pkg/rbac/ranger/tests/ranger_policy_check_test.go new file mode 100644 index 0000000..3bc169e --- /dev/null +++ b/internal/pkg/rbac/ranger/tests/ranger_policy_check_test.go @@ -0,0 +1,847 @@ +package tests + +import ( + "testing" + + "github.com/patterninc/heimdall/internal/pkg/rbac/ranger" + "github.com/patterninc/heimdall/internal/pkg/rbac/ranger/mocks" + "github.com/patterninc/heimdall/internal/pkg/sql/parser/trino" +) + +const ( + serviceName = "test_service" + testGroupName = "test_group" + testUserName = "test_user" +) + +var ( + testDefaultUsers = map[string]*ranger.User{ + testUserName: {ID: 11, Name: testUserName, GroupNameList: []string{testGroupName}}, + } +) + +type testCase struct { + name string + query string + username string + expectedResult bool + users map[string]*ranger.User + policies []*ranger.Policy +} + +func TestRangerPolicyCheck(t *testing.T) { + tests := []testCase{ + { + name: "User with direct allow policy", + query: "SELECT * FROM default_catalog.public.table1", + username: "alice", + expectedResult: true, + users: map[string]*ranger.User{ + "alice": {ID: 1, Name: "alice", GroupNameList: []string{testGroupName}}, + }, + policies: []*ranger.Policy{ + { + ID: 1, + GUID: "policy-1", + IsEnabled: true, + Name: "Allow select for alice", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Users: []string{"alice"}, + Accesses: []ranger.Access{ + {Type: "select"}, + }, + }, + }, + }, + }, + }, + { + name: "User with group allow policy", + query: "SELECT * FROM default_catalog.public.table1", + username: "bob", + expectedResult: true, + users: map[string]*ranger.User{ + "bob": {ID: 2, Name: "bob", GroupNameList: []string{testGroupName}}, + }, + policies: []*ranger.Policy{ + { + ID: 2, + GUID: "policy-2", + IsEnabled: true, + Name: "Allow select for testGroupName", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: []ranger.Access{ + {Type: "select"}, + }, + }, + }, + }, + }, + }, + { + name: "User with deny policy", + query: "SELECT * FROM default_catalog.public.table1", + username: "charlie", + expectedResult: false, + users: map[string]*ranger.User{ + "charlie": {ID: 3, Name: "charlie", GroupNameList: []string{testGroupName}}, + }, + policies: []*ranger.Policy{ + { + ID: 3, + GUID: "policy-3", + IsEnabled: true, + Name: "Deny select for charlie", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Users: []string{"charlie"}, + Accesses: []ranger.Access{ + {Type: "all"}, + }, + }, + }, + DenyPolicyItems: []ranger.PolicyItem{ + { + Users: []string{"charlie"}, + Accesses: []ranger.Access{ + {Type: "select"}, + }, + }, + }, + }, + }, + }, + { + name: "User without any policy", + query: "SELECT * FROM default_catalog.public.table1", + username: "dave", + expectedResult: false, + users: map[string]*ranger.User{ + "dave": {ID: 4, Name: "dave", GroupNameList: []string{}}, + }, + policies: []*ranger.Policy{}, + }, + { + name: "User with conflicting allow and deny policies (deny should take precedence)", + query: "SELECT * FROM default_catalog.public.table1", + username: "eve", + expectedResult: false, + users: map[string]*ranger.User{ + "eve": {ID: 5, Name: "eve", GroupNameList: []string{testGroupName}}, + }, + policies: []*ranger.Policy{ + { + ID: 4, + GUID: "policy-4", + IsEnabled: true, + Name: "Allow select for eve", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Users: []string{"eve"}, + Accesses: []ranger.Access{ + {Type: "select"}, + }, + }, + }, + }, + { + ID: 5, + GUID: "policy-5", + IsEnabled: true, + Name: "Deny select for group3", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + DenyPolicyItems: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: []ranger.Access{ + {Type: "select"}, + }, + }, + }, + }, + }, + }, + { + name: "User has different type of an access", + query: "SHOW TABLES FROM default_catalog.public", + username: "frank", + expectedResult: false, + users: map[string]*ranger.User{ + "frank": {ID: 6, Name: "frank", GroupNameList: []string{testGroupName}}, + }, + policies: []*ranger.Policy{ + { + ID: 6, + GUID: "policy-6", + IsEnabled: true, + Name: "Allow show for group4", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: []ranger.Access{ + {Type: "select"}, + }, + }, + }, + }, + }, + }, + { + name: "User allowed via regexp in table name", + query: "SELECT * FROM default_catalog.public.table_xyz", + username: "grace", + expectedResult: true, + users: map[string]*ranger.User{ + "grace": {ID: 7, Name: "grace", GroupNameList: []string{testGroupName}}, + }, + policies: []*ranger.Policy{ + { + ID: 7, + GUID: "policy-7", + IsEnabled: true, + Name: "Allow select for testGroupName on tables matching regex", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"*"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: []ranger.Access{ + {Type: "select"}, + }, + }, + }, + }, + }, + }, + { + name: "User denied via regexp in table name", + query: "SELECT * FROM default_catalog.public.table_abc", + username: "heidi", + expectedResult: false, + users: map[string]*ranger.User{ + "heidi": {ID: 8, Name: "heidi", GroupNameList: []string{testGroupName}}, + }, + policies: []*ranger.Policy{ + { + ID: 8, + GUID: "policy-8", + IsEnabled: true, + Name: "Deny select for testGroupName on tables matching regex", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table_*"}, + IsExcludes: false, + }, + }, + DenyPolicyItems: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: []ranger.Access{ + {Type: "select"}, + }, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: []ranger.Access{ + {Type: "*"}, + }, + }, + }, + }, + }, + }, + { + name: "User is exclude from allow policy", + query: "SELECT * FROM default_catalog.public.table1", + username: "ivan", + expectedResult: false, + users: map[string]*ranger.User{ + "ivan": {ID: 9, Name: "ivan", GroupNameList: []string{testGroupName}}, + }, + policies: []*ranger.Policy{ + { + ID: 9, + GUID: "policy-9", + IsEnabled: true, + Name: "Allow select for testGroupName excluding user ivan", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: []ranger.Access{ + {Type: "select"}, + }, + }, + }, + AllowExceptions: []ranger.PolicyItem{ + { + Users: []string{"ivan"}, + Accesses: []ranger.Access{ + {Type: "all"}, + }, + }, + }, + }, + }, + }, + { + name: "User is denied via group policy", + query: "SELECT * FROM default_catalog.public.table1", + username: "judy", + expectedResult: false, + users: map[string]*ranger.User{ + "judy": {ID: 10, Name: "judy", GroupNameList: []string{testGroupName}}, + }, + policies: []*ranger.Policy{ + { + ID: 10, + GUID: "policy-10", + IsEnabled: true, + Name: "Deny select for testGroupName excluding user judy", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Users: []string{"judy"}, + Accesses: []ranger.Access{ + {Type: "all"}, + }, + }, + }, + DenyPolicyItems: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: []ranger.Access{ + {Type: "select"}, + }, + }, + }, + }, + }, + }, + { + name: "User is exclude from deny policy", + query: "SELECT * FROM default_catalog.public.table1", + username: "judy", + expectedResult: true, + users: map[string]*ranger.User{ + "judy": {ID: 10, Name: "judy", GroupNameList: []string{testGroupName}}, + }, + policies: []*ranger.Policy{ + { + ID: 10, + GUID: "policy-10", + IsEnabled: true, + Name: "Deny select for testGroupName excluding user judy", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Users: []string{"judy"}, + Accesses: []ranger.Access{ + {Type: "all"}, + }, + }, + }, + DenyPolicyItems: []ranger.PolicyItem{ + { + Groups: []string{"group8"}, + Accesses: []ranger.Access{ + {Type: "select"}, + }, + }, + }, + DenyExceptions: []ranger.PolicyItem{ + { + Users: []string{"judy"}, + Accesses: []ranger.Access{ + {Type: "all"}, + }, + }, + }, + }, + }, + }, + { + name: "User is allowed when resource has excludes", + query: "SELECT * FROM default_catalog.public.table1", + username: "kate", + expectedResult: true, + users: map[string]*ranger.User{ + "kate": {ID: 11, Name: "kate", GroupNameList: []string{testGroupName}}, + }, + policies: []*ranger.Policy{ + { + ID: 11, + GUID: "policy-11", + IsEnabled: true, + Name: "Allow select for testGroupName excluding schema 'internal'", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"internal"}, + IsExcludes: true, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Groups: []string{testGroupName}, + Accesses: []ranger.Access{ + {Type: "select"}, + }, + }, + }, + }, + }, + }, + } + + runTests(t, tests) +} + +// TestResourcesSelection tests the resource selection logic in Ranger policies. +// In this tests users always have all permissions +func TestResourcesSelection(t *testing.T) { + tests := []testCase{ + { + name: "Policy doesn't control the resource, different table name", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResource("default_catalog", "public", "table2"), nil)}, + }, + { + name: "Policy doesn't control the resource, different schema name", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResource("default_catalog", "private", "table1"), nil)}, + }, + { + name: "Policy doesn't control the resource, different catalog name", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResource("not_default_catalog", "public", "table1"), nil)}, + }, + { + name: "Policy and subpolicy doesn't control the resource, different catalog/table name", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResource("not_default_catalog", "public", "table1"), createResource("default_catalog", "public", "table2"))}, + }, + { + name: "Policy controls the resource, catalog is regexp", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResource("default_*", "public", "table1"), nil)}, + }, + { + name: "Policy controls the resource, schema is regexp", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResource("default_catalog", "p*c", "table1"), nil)}, + }, + { + name: "Policy controls the resource, table is regexp", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResource("default_catalog", "public", "t*l*"), nil)}, + }, + { + name: "Policy controls the resource, table is regexp", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResource("default_catalog", "public", "t*l*"), nil)}, + }, + { + name: "Policy controls the resource, exact match", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResource("default_catalog", "public", "table1"), nil)}, + }, + { + name: "Policy controls the resource, catalog is exclude", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResourceWithExcludeOptionForCatalog("catalog", "public", "table1", true), nil)}, + }, + { + name: "Policy controls the resource, catalog is exclude regexp", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResourceWithExcludeOptionForCatalog("catalo*", "public", "table1", true), nil)}, + }, + { + name: "Policy doesn't control the resource, catalog is exclude regexp but match", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResourceWithExcludeOptionForCatalog("defa*", "public", "table1", true), nil)}, + }, + { + name: "Policy and subpolicy control the resource, catalog is exclude but subpolicy match", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResourceWithExcludeOptionForCatalog("defa*", "public", "table1", true), createResource("default_catalog", "public", "table1"))}, + }, + { + name: "Policy doesn't control the resource, schema is exclude and match", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResourceWithExcludeOptionForSchema("defa*", "public", "table1", true), nil)}, + }, + { + name: "Policy controls the resource, schema is exclude but not match", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResourceWithExcludeOptionForSchema("defa*", "privat*", "table1", true), nil)}, + }, + { + name: "Policy and subpolicy control the resource, schema is exclude but subpolicy match", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResourceWithExcludeOptionForSchema("defa*", "public", "table1", true), createResource("default_catalog", "public", "table1"))}, + }, + { + name: "Policy doesn't control the resources table is excluded", + query: "SELECT * from default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResourceWithExcludeOptionForTable("defau*", "public", "table1", true), nil)}, + }, + { + name: "Policy does control the resources, table is excluded but doesn't match", + query: "SELECT * from default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getAllowAllPolicy(createResourceWithExcludeOptionForTable("defau*", "public", "table2", true), nil)}, + }, + } + runTests(t, tests) + +} + +func runTests(t *testing.T, tests []testCase) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + rbac := &ranger.Ranger{ + AccessReceiver: trino.NewTrinoAccessReceiver("default_catalog"), + Client: getMockRangerClient(tt.users, tt.policies), + ServiceName: serviceName, + } + rbac.SyncState() + actualResult, err := rbac.HasAccess(tt.username, tt.query) + if err != nil { + t.Errorf("error checking access for %q: %v", tt.name, err) + return + } + if actualResult != tt.expectedResult { + t.Errorf("unexpected result for %q: got %v, want %v", tt.name, actualResult, tt.expectedResult) + } + }) + } +} + +func createResourceWithExcludeOptionForTable(catalogs, schemas, table string, excludeTable bool) *ranger.Resource { + return &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{catalogs}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{schemas}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{table}, + IsExcludes: excludeTable, + }, + } +} + +func createResourceWithExcludeOptionForSchema(catalog, schema, table string, excludeSchema bool) *ranger.Resource { + return &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{catalog}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{schema}, + IsExcludes: excludeSchema, + }, + Table: &ranger.ResourceField{ + Values: []string{table}, + IsExcludes: false, + }, + } +} + +func createResourceWithExcludeOptionForCatalog(catalogs, schemas, tables string, excludeCatalog bool) *ranger.Resource { + return &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{catalogs}, + IsExcludes: excludeCatalog, + }, + Schema: &ranger.ResourceField{ + Values: []string{schemas}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{tables}, + IsExcludes: false, + }, + } +} + +func createResource(catalogs, schemas, tables string) *ranger.Resource { + return &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{catalogs}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{schemas}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{tables}, + IsExcludes: false, + }, + } +} + +func getAllowAllPolicy(resource *ranger.Resource, additionalResource *ranger.Resource) *ranger.Policy { + var additionalResources []*ranger.Resource + if additionalResource != nil { + additionalResources = []*ranger.Resource{additionalResource} + } + return &ranger.Policy{ + ID: 1, + GUID: "policy-1", + IsEnabled: true, + Name: "Allow select for alice", + PolicyType: 0, + PolicyPriority: 1, + Resources: resource, + AdditionalResources: additionalResources, + PolicyItems: []ranger.PolicyItem{ + { + Users: []string{testUserName}, + Accesses: []ranger.Access{ + {Type: "all"}, + }, + }, + }, + } +} + +func getMockRangerClient(users map[string]*ranger.User, policies []*ranger.Policy) ranger.Client { + m := new(mocks.Client) + m.On("GetUsers").Return(users, nil) + m.On("GetPolicies", serviceName).Return(policies, nil) + return m +} diff --git a/internal/pkg/rbac/ranger/tests/user_policy_test.go b/internal/pkg/rbac/ranger/tests/user_policy_test.go new file mode 100644 index 0000000..bd2d92d --- /dev/null +++ b/internal/pkg/rbac/ranger/tests/user_policy_test.go @@ -0,0 +1,352 @@ +package tests + +import ( + "testing" + + "github.com/patterninc/heimdall/internal/pkg/rbac/ranger" +) + +func TestDenyPermissionsForUser(t *testing.T) { + tests := []testCase{ + { + name: "Policy denies select action for user", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + + policies: getAllowAllPolicyWithDenyForUser([]string{"select"}), + }, + { + name: "Policy denies insert action for user", + query: "INSERT INTO default_catalog.public.table1 VALUES (1, 'data')", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + + policies: getAllowAllPolicyWithDenyForUser([]string{"insert"}), + }, + { + name: "Policy denies update action for user but query is select", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + + policies: getAllowAllPolicyWithDenyForUser([]string{"update"}), + }, + { + name: "Policy denies select and insert actions for user", + query: "INSERT INTO default_catalog.public.table1 VALUES (1, 'data')", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + + policies: getAllowAllPolicyWithDenyAndExceptionForUser([]string{"select", "insert"}, []string{"all"}), + }, + { + name: "Policy denies all actions for user but exception allows select", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + + policies: getAllowAllPolicyWithDenyAndExceptionForUser([]string{"all"}, []string{"select"}), + }, + } + + runTests(t, tests) +} + +func TestAllowPermissionsForUser(t *testing.T) { + tests := []testCase{ + { + name: "Policy allows all actions for user", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultUserAllowPolicy([]string{"all"})}, + }, + { + name: "Policy allows select action for user", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultUserAllowPolicy([]string{"select"})}, + }, + { + name: "Policy allows insert action for user, but query is select", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultUserAllowPolicy([]string{"insert"})}, + }, + { + name: "Policy allows multiple actions including select for user", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultUserAllowPolicy([]string{"insert", "select", "update"})}, + }, + { + name: "Policy allows multiple actions excluding select for user", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultUserAllowPolicy([]string{"insert", "update", "delete"})}, + }, + { + name: "No policy for user", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{}, + }, + { + name: "Policy allows select but query requires also insert", + query: "INSERT INTO default_catalog.public.table1 as SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultUserAllowPolicy([]string{"select"})}, + }, + { + name: "Policy allows all actions", + query: "INSERT INTO default_catalog.public.table1 as SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + + policies: []*ranger.Policy{getDefaultUserAllowPolicy([]string{"all"})}, + }, + { + name: "Policy many actions and many are required", + query: "INSERT INTO default_catalog.public.table1 as SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultUserAllowPolicy([]string{"delete", "insert", "select", "update"})}, + }, + { + name: "Policy exclude user from the select action", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultAllActionsUserPolicyWithExcludeForDefaultUser([]string{"select"})}, + }, + { + name: "Policy exclude user from the insert action, but query is select and insert", + query: "INSERT INTO default_catalog.public.table1 as SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: false, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultAllActionsUserPolicyWithExcludeForDefaultUser([]string{"insert"})}, + }, + { + name: "Policy exclude user from the insert action, but query is select ", + query: "SELECT * FROM default_catalog.public.table1", + username: testUserName, + expectedResult: true, + users: testDefaultUsers, + policies: []*ranger.Policy{getDefaultAllActionsUserPolicyWithExcludeForDefaultUser([]string{"insert"})}, + }, + } + + runTests(t, tests) + +} + +func getAllowAllPolicyWithDenyForUser(denyAccess []string) []*ranger.Policy { + return []*ranger.Policy{ + { + ID: 1, + GUID: "policy-1", + IsEnabled: true, + Name: "Allow select for alice", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Users: []string{testUserName}, + Accesses: []ranger.Access{ + {Type: "all"}, + }, + }, + }, + DenyPolicyItems: []ranger.PolicyItem{ + { + Users: []string{testUserName}, + Accesses: func() []ranger.Access { + var accesses []ranger.Access + for _, a := range denyAccess { + accesses = append(accesses, ranger.Access{Type: a}) + } + return accesses + }(), + }, + }, + }, + } +} + +func getAllowAllPolicyWithDenyAndExceptionForUser(denyAccess, exceptionAccess []string) []*ranger.Policy { + return []*ranger.Policy{ + { + ID: 1, + GUID: "policy-1", + IsEnabled: true, + Name: "Allow select for alice", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Users: []string{testUserName}, + Accesses: []ranger.Access{ + {Type: "all"}, + }, + }, + }, + DenyPolicyItems: []ranger.PolicyItem{ + { + Users: []string{testUserName}, + Accesses: func() []ranger.Access { + var accesses []ranger.Access + for _, a := range denyAccess { + accesses = append(accesses, ranger.Access{Type: a}) + } + return accesses + }(), + }, + }, + DenyExceptions: []ranger.PolicyItem{ + { + Users: []string{testUserName}, + Accesses: func() []ranger.Access { + var accesses []ranger.Access + for _, a := range exceptionAccess { + accesses = append(accesses, ranger.Access{Type: a}) + } + return accesses + }(), + }, + }, + }, + } +} + +func getDefaultUserAllowPolicy(accessType []string) *ranger.Policy { + return &ranger.Policy{ + ID: 1, + GUID: "policy-1", + IsEnabled: true, + Name: "Allow select for alice", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Users: []string{testUserName}, + Accesses: func() []ranger.Access { + var accesses []ranger.Access + for _, at := range accessType { + accesses = append(accesses, ranger.Access{Type: at}) + } + return accesses + }(), + }, + }, + } +} + +func getDefaultAllActionsUserPolicyWithExcludeForDefaultUser(excludeAccess []string) *ranger.Policy { + return &ranger.Policy{ + ID: 1, + GUID: "policy-1", + IsEnabled: true, + Name: "Allow select for alice", + PolicyType: 0, + PolicyPriority: 1, + Resources: &ranger.Resource{ + Catalog: &ranger.ResourceField{ + Values: []string{"default_catalog"}, + IsExcludes: false, + }, + Schema: &ranger.ResourceField{ + Values: []string{"public"}, + IsExcludes: false, + }, + Table: &ranger.ResourceField{ + Values: []string{"table1"}, + IsExcludes: false, + }, + }, + PolicyItems: []ranger.PolicyItem{ + { + Users: []string{testUserName}, + Accesses: []ranger.Access{ + {Type: "all"}, + }, + }, + }, + AllowExceptions: []ranger.PolicyItem{ + { + Users: []string{testUserName}, + Accesses: func() []ranger.Access { + var accesses []ranger.Access + for _, ex := range excludeAccess { + accesses = append(accesses, ranger.Access{Type: ex}) + } + return accesses + }(), + }, + }, + } +} diff --git a/internal/pkg/rbac/rbac.go b/internal/pkg/rbac/rbac.go new file mode 100644 index 0000000..d4cd19e --- /dev/null +++ b/internal/pkg/rbac/rbac.go @@ -0,0 +1,79 @@ +package rbac + +import ( + "errors" + "fmt" + + "github.com/patterninc/heimdall/internal/pkg/rbac/ranger" + "gopkg.in/yaml.v3" +) + +var ( + ErrRBACIDsAreNotUnique = errors.New("rbac IDs are not unique") + supportedRBACs = map[string]func() RBAC{ + `apache_ranger`: NewRanger, + } +) + +type RBAC interface { + Init() error + HasAccess(user string, query string) (bool, error) + GetName() string +} + +type RBACs map[string]RBAC + +type configs struct { + RBAC []RBAC +} + +func (c *RBACs) UnmarshalYAML(unmarshal func(interface{}) error) error { + + var temp configs + + if err := unmarshal(&temp); err != nil { + return err + } + + items := make(map[string]RBAC) + + for _, t := range temp.RBAC { + items[t.GetName()] = t + } + + if len(temp.RBAC) != len(items) { + return ErrRBACIDsAreNotUnique + } + + *c = items + + return nil + +} + +// Implements custom unmarshaling based on `type` field in YAML +func (c *configs) UnmarshalYAML(value *yaml.Node) error { + for _, value := range value.Content { + var probe struct { + Type string `yaml:"type"` + } + if err := value.Decode(&probe); err != nil { + return err + } + + supportedRBAC, ok := supportedRBACs[probe.Type] + if !ok { + return fmt.Errorf("unsupported RBAC type: %s", probe.Type) + } + r := supportedRBAC() + if err := value.Decode(r); err != nil { + return err + } + c.RBAC = append(c.RBAC, r) + } + return nil +} + +func NewRanger() RBAC { + return &ranger.Ranger{} +} diff --git a/pkg/sql/parser/README.md b/internal/pkg/sql/parser/README.md similarity index 100% rename from pkg/sql/parser/README.md rename to internal/pkg/sql/parser/README.md diff --git a/internal/pkg/sql/parser/factory/factory.go b/internal/pkg/sql/parser/factory/factory.go new file mode 100644 index 0000000..5fd5c3c --- /dev/null +++ b/internal/pkg/sql/parser/factory/factory.go @@ -0,0 +1,23 @@ +package factory + +import ( + "fmt" + + "github.com/patterninc/heimdall/internal/pkg/sql/parser" + "github.com/patterninc/heimdall/internal/pkg/sql/parser/trino" +) + +type ParserType string + +const ( + ParserTypeTrino ParserType = "trino" +) + +func CreateParserByType(typ string, defaultCatalog string) (parser.AccessReceiver, error) { + switch typ { + case string(ParserTypeTrino): + return trino.NewTrinoAccessReceiver(defaultCatalog), nil + default: + return nil, fmt.Errorf("unknown parser type: %s", typ) + } +} diff --git a/pkg/sql/parser/sql.go b/internal/pkg/sql/parser/sql.go similarity index 69% rename from pkg/sql/parser/sql.go rename to internal/pkg/sql/parser/sql.go index 7be8c87..3ce7a3b 100644 --- a/pkg/sql/parser/sql.go +++ b/internal/pkg/sql/parser/sql.go @@ -1,5 +1,9 @@ package parser +import ( + "fmt" +) + type AccessKind int const ( @@ -13,16 +17,25 @@ type Action int const ( SELECT Action = iota INSERT - UPDATE - DELETE CREATE DROP + DELETE + USE ALTER + GRANT + REVOKE + SHOW + IMPERSONATE + EXECUTE + UPDATE + READ_SYSTEM_INFORMATION + WRITE_SYSTEM_INFORMATION ) type Access interface { Kind() AccessKind Action() Action + QualifiedName() string } type TableAccess struct { @@ -35,6 +48,10 @@ type TableAccess struct { func (t *TableAccess) Kind() AccessKind { return TableAccessKind } func (t *TableAccess) Action() Action { return t.Act } +func (t *TableAccess) QualifiedName() string { + return fmt.Sprintf("%s.%s.%s", t.Catalog, t.Schema, t.Table) +} + type AccessReceiver interface { ParseAccess(sql string) ([]Access, error) } diff --git a/pkg/sql/parser/trino/TrinoLexer.g4 b/internal/pkg/sql/parser/trino/TrinoLexer.g4 similarity index 100% rename from pkg/sql/parser/trino/TrinoLexer.g4 rename to internal/pkg/sql/parser/trino/TrinoLexer.g4 diff --git a/pkg/sql/parser/trino/TrinoParser.g4 b/internal/pkg/sql/parser/trino/TrinoParser.g4 similarity index 100% rename from pkg/sql/parser/trino/TrinoParser.g4 rename to internal/pkg/sql/parser/trino/TrinoParser.g4 diff --git a/pkg/sql/parser/trino/grammar/TrinoLexer.interp b/internal/pkg/sql/parser/trino/grammar/TrinoLexer.interp similarity index 100% rename from pkg/sql/parser/trino/grammar/TrinoLexer.interp rename to internal/pkg/sql/parser/trino/grammar/TrinoLexer.interp diff --git a/pkg/sql/parser/trino/grammar/TrinoLexer.tokens b/internal/pkg/sql/parser/trino/grammar/TrinoLexer.tokens similarity index 100% rename from pkg/sql/parser/trino/grammar/TrinoLexer.tokens rename to internal/pkg/sql/parser/trino/grammar/TrinoLexer.tokens diff --git a/pkg/sql/parser/trino/grammar/TrinoParser.interp b/internal/pkg/sql/parser/trino/grammar/TrinoParser.interp similarity index 100% rename from pkg/sql/parser/trino/grammar/TrinoParser.interp rename to internal/pkg/sql/parser/trino/grammar/TrinoParser.interp diff --git a/pkg/sql/parser/trino/grammar/TrinoParser.tokens b/internal/pkg/sql/parser/trino/grammar/TrinoParser.tokens similarity index 100% rename from pkg/sql/parser/trino/grammar/TrinoParser.tokens rename to internal/pkg/sql/parser/trino/grammar/TrinoParser.tokens diff --git a/pkg/sql/parser/trino/grammar/trino_lexer.go b/internal/pkg/sql/parser/trino/grammar/trino_lexer.go similarity index 100% rename from pkg/sql/parser/trino/grammar/trino_lexer.go rename to internal/pkg/sql/parser/trino/grammar/trino_lexer.go diff --git a/pkg/sql/parser/trino/grammar/trino_parser.go b/internal/pkg/sql/parser/trino/grammar/trino_parser.go similarity index 100% rename from pkg/sql/parser/trino/grammar/trino_parser.go rename to internal/pkg/sql/parser/trino/grammar/trino_parser.go diff --git a/pkg/sql/parser/trino/grammar/trinoparser_base_listener.go b/internal/pkg/sql/parser/trino/grammar/trinoparser_base_listener.go similarity index 100% rename from pkg/sql/parser/trino/grammar/trinoparser_base_listener.go rename to internal/pkg/sql/parser/trino/grammar/trinoparser_base_listener.go diff --git a/pkg/sql/parser/trino/grammar/trinoparser_base_visitor.go b/internal/pkg/sql/parser/trino/grammar/trinoparser_base_visitor.go similarity index 100% rename from pkg/sql/parser/trino/grammar/trinoparser_base_visitor.go rename to internal/pkg/sql/parser/trino/grammar/trinoparser_base_visitor.go diff --git a/pkg/sql/parser/trino/grammar/trinoparser_listener.go b/internal/pkg/sql/parser/trino/grammar/trinoparser_listener.go similarity index 100% rename from pkg/sql/parser/trino/grammar/trinoparser_listener.go rename to internal/pkg/sql/parser/trino/grammar/trinoparser_listener.go diff --git a/pkg/sql/parser/trino/grammar/trinoparser_visitor.go b/internal/pkg/sql/parser/trino/grammar/trinoparser_visitor.go similarity index 100% rename from pkg/sql/parser/trino/grammar/trinoparser_visitor.go rename to internal/pkg/sql/parser/trino/grammar/trinoparser_visitor.go diff --git a/pkg/sql/parser/trino/listener.go b/internal/pkg/sql/parser/trino/listener.go similarity index 95% rename from pkg/sql/parser/trino/listener.go rename to internal/pkg/sql/parser/trino/listener.go index bce0367..27682af 100644 --- a/pkg/sql/parser/trino/listener.go +++ b/internal/pkg/sql/parser/trino/listener.go @@ -1,8 +1,8 @@ package trino import ( - "github.com/patterninc/heimdall/pkg/sql/parser" - "github.com/patterninc/heimdall/pkg/sql/parser/trino/grammar" + "github.com/patterninc/heimdall/internal/pkg/sql/parser" + "github.com/patterninc/heimdall/internal/pkg/sql/parser/trino/grammar" ) type trinoListener struct { diff --git a/pkg/sql/parser/trino/parser.go b/internal/pkg/sql/parser/trino/parser.go similarity index 84% rename from pkg/sql/parser/trino/parser.go rename to internal/pkg/sql/parser/trino/parser.go index 006e3e3..b08cfb9 100644 --- a/pkg/sql/parser/trino/parser.go +++ b/internal/pkg/sql/parser/trino/parser.go @@ -4,8 +4,8 @@ import ( "log" "github.com/antlr4-go/antlr/v4" - "github.com/patterninc/heimdall/pkg/sql/parser" - "github.com/patterninc/heimdall/pkg/sql/parser/trino/grammar" + "github.com/patterninc/heimdall/internal/pkg/sql/parser" + "github.com/patterninc/heimdall/internal/pkg/sql/parser/trino/grammar" ) type TrinoAccessReceiver struct { @@ -30,3 +30,4 @@ func (t *TrinoAccessReceiver) ParseAccess(sql string) ([]parser.Access, error) { return col.collected, nil } + diff --git a/pkg/sql/parser/trino/tests/alter_test.go b/internal/pkg/sql/parser/trino/tests/alter_test.go similarity index 96% rename from pkg/sql/parser/trino/tests/alter_test.go rename to internal/pkg/sql/parser/trino/tests/alter_test.go index 58be4e6..6d05be2 100644 --- a/pkg/sql/parser/trino/tests/alter_test.go +++ b/internal/pkg/sql/parser/trino/tests/alter_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/patterninc/heimdall/pkg/sql/parser" - "github.com/patterninc/heimdall/pkg/sql/parser/trino" + "github.com/patterninc/heimdall/internal/pkg/sql/parser/trino" ) func TestParseSQLAlter(t *testing.T) { diff --git a/pkg/sql/parser/trino/tests/create_test.go b/internal/pkg/sql/parser/trino/tests/create_test.go similarity index 96% rename from pkg/sql/parser/trino/tests/create_test.go rename to internal/pkg/sql/parser/trino/tests/create_test.go index 8b01829..9af5883 100644 --- a/pkg/sql/parser/trino/tests/create_test.go +++ b/internal/pkg/sql/parser/trino/tests/create_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/patterninc/heimdall/pkg/sql/parser" - "github.com/patterninc/heimdall/pkg/sql/parser/trino" + "github.com/patterninc/heimdall/internal/pkg/sql/parser/trino" ) func TestParseSQLCreate(t *testing.T) { diff --git a/pkg/sql/parser/trino/tests/delete_test.go b/internal/pkg/sql/parser/trino/tests/delete_test.go similarity index 98% rename from pkg/sql/parser/trino/tests/delete_test.go rename to internal/pkg/sql/parser/trino/tests/delete_test.go index 37eebc8..d0dd49b 100644 --- a/pkg/sql/parser/trino/tests/delete_test.go +++ b/internal/pkg/sql/parser/trino/tests/delete_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/patterninc/heimdall/pkg/sql/parser" - "github.com/patterninc/heimdall/pkg/sql/parser/trino" + "github.com/patterninc/heimdall/internal/pkg/sql/parser/trino" ) func TestParseSQLDelete(t *testing.T) { diff --git a/pkg/sql/parser/trino/tests/drop_test.go b/internal/pkg/sql/parser/trino/tests/drop_test.go similarity index 95% rename from pkg/sql/parser/trino/tests/drop_test.go rename to internal/pkg/sql/parser/trino/tests/drop_test.go index 1eb1a3f..739d4f5 100644 --- a/pkg/sql/parser/trino/tests/drop_test.go +++ b/internal/pkg/sql/parser/trino/tests/drop_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/patterninc/heimdall/pkg/sql/parser" - "github.com/patterninc/heimdall/pkg/sql/parser/trino" + "github.com/patterninc/heimdall/internal/pkg/sql/parser/trino" ) func TestParseSQLDrop(t *testing.T) { diff --git a/pkg/sql/parser/trino/tests/insert_test.go b/internal/pkg/sql/parser/trino/tests/insert_test.go similarity index 98% rename from pkg/sql/parser/trino/tests/insert_test.go rename to internal/pkg/sql/parser/trino/tests/insert_test.go index 67c6a32..7cd10b2 100644 --- a/pkg/sql/parser/trino/tests/insert_test.go +++ b/internal/pkg/sql/parser/trino/tests/insert_test.go @@ -5,8 +5,8 @@ import ( "reflect" "testing" + "github.com/patterninc/heimdall/internal/pkg/sql/parser/trino" "github.com/patterninc/heimdall/pkg/sql/parser" - "github.com/patterninc/heimdall/pkg/sql/parser/trino" ) func TestParseSQLInsert(t *testing.T) { diff --git a/pkg/sql/parser/trino/tests/merge_into_test.go b/internal/pkg/sql/parser/trino/tests/merge_into_test.go similarity index 97% rename from pkg/sql/parser/trino/tests/merge_into_test.go rename to internal/pkg/sql/parser/trino/tests/merge_into_test.go index 2b03999..5a2ce90 100644 --- a/pkg/sql/parser/trino/tests/merge_into_test.go +++ b/internal/pkg/sql/parser/trino/tests/merge_into_test.go @@ -5,8 +5,8 @@ import ( "reflect" "testing" + "github.com/patterninc/heimdall/internal/pkg/sql/parser/trino" "github.com/patterninc/heimdall/pkg/sql/parser" - "github.com/patterninc/heimdall/pkg/sql/parser/trino" ) func TestParseSQLMergeInto(t *testing.T) { diff --git a/pkg/sql/parser/trino/tests/select_test.go b/internal/pkg/sql/parser/trino/tests/select_test.go similarity index 99% rename from pkg/sql/parser/trino/tests/select_test.go rename to internal/pkg/sql/parser/trino/tests/select_test.go index ff9b0dc..887f534 100644 --- a/pkg/sql/parser/trino/tests/select_test.go +++ b/internal/pkg/sql/parser/trino/tests/select_test.go @@ -5,8 +5,8 @@ import ( "reflect" "testing" + "github.com/patterninc/heimdall/internal/pkg/sql/parser/trino" "github.com/patterninc/heimdall/pkg/sql/parser" - "github.com/patterninc/heimdall/pkg/sql/parser/trino" ) const ( diff --git a/pkg/sql/parser/trino/tests/update_test.go b/internal/pkg/sql/parser/trino/tests/update_test.go similarity index 97% rename from pkg/sql/parser/trino/tests/update_test.go rename to internal/pkg/sql/parser/trino/tests/update_test.go index 25968b2..3118387 100644 --- a/pkg/sql/parser/trino/tests/update_test.go +++ b/internal/pkg/sql/parser/trino/tests/update_test.go @@ -5,8 +5,8 @@ import ( "reflect" "testing" + "github.com/patterninc/heimdall/internal/pkg/sql/parser/trino" "github.com/patterninc/heimdall/pkg/sql/parser" - "github.com/patterninc/heimdall/pkg/sql/parser/trino" ) func TestParseSQLUpdate(t *testing.T) { diff --git a/pkg/sql/parser/trino/unused_listener_functions.go b/internal/pkg/sql/parser/trino/unused_listener_functions.go similarity index 99% rename from pkg/sql/parser/trino/unused_listener_functions.go rename to internal/pkg/sql/parser/trino/unused_listener_functions.go index 39a829a..a859f67 100644 --- a/pkg/sql/parser/trino/unused_listener_functions.go +++ b/internal/pkg/sql/parser/trino/unused_listener_functions.go @@ -4,7 +4,7 @@ import ( "log" "reflect" - "github.com/patterninc/heimdall/pkg/sql/parser/trino/grammar" + "github.com/patterninc/heimdall/internal/pkg/sql/parser/trino/grammar" ) // EnterParse is called when production parse is entered. diff --git a/pkg/object/cluster/cluster.go b/pkg/object/cluster/cluster.go index 23956e7..f4d7aec 100644 --- a/pkg/object/cluster/cluster.go +++ b/pkg/object/cluster/cluster.go @@ -5,6 +5,7 @@ import ( "github.com/patterninc/heimdall/pkg/object" "github.com/patterninc/heimdall/pkg/object/status" + "github.com/patterninc/heimdall/pkg/rbac" ) var ( @@ -14,6 +15,8 @@ var ( type Cluster struct { object.Object `yaml:",inline" json:",inline"` Status status.Status `yaml:"status,omitempty" json:"status,omitempty"` + RBACNames []string `yaml:"rbacs,omitempty" json:"rbacs,omitempty"` + RBACs []rbac.RBAC `yaml:"-" json:"-"` } type Clusters map[string]*Cluster diff --git a/pkg/rbac/README.md b/pkg/rbac/README.md new file mode 100644 index 0000000..b1eac5b --- /dev/null +++ b/pkg/rbac/README.md @@ -0,0 +1,216 @@ +# RBAC Module + +The RBAC (Role-Based Access Control) module provides authorization capabilities for Heimdall, specifically designed to integrate with Apache Ranger for fine-grained access control over SQL resources. + +## Overview + +This module enables Heimdall to: +- Authorize SQL queries based on Ranger policies +- Support table-level, schema-level, and catalog-level access control +- Handle user groups and permissions +- Automatically sync policies from Ranger + +## Architecture + +The module consists of several key components: + +### Core Interfaces + +- **`RBAC`**: Main interface for access control providers +### Apache Ranger Integration + +The module currently supports Apache Ranger as the primary RBAC provider through: + +- **`ApacheRanger`**: Main implementation of RBAC interface +- **`Policy`**: Represents Ranger policies with resources and permissions +- **`User`** and **`Group`**: Represent Ranger users and groups + +## Configuration + +### YAML Configuration Example + +```yaml +rbacs: + - type: apache_ranger + name: my-ranger + service_name: my_service + sync_interval_in_minutes: 30 + client: + url: https://ranger.example.com + username: admin + password: secret + parser: + type: trino + default_catalog: hive +``` +### Configuration Parameters + +- **`type`**: RBAC provider type (`apache_ranger`) +- **`name`**: Unique identifier for this RBAC instance +- **`service_name`**: Ranger service name to fetch policies from +- **`sync_interval_in_minutes`**: How often to sync policies from Ranger +- **`client`**: Ranger connection configuration +- **`parser`**: SQL parser configuration for query analysis + +### YAML Cluster Configuration + +To enable RBAC in your cluster configuration, add the `rbacs` section and specify the RBAC provider names you want to use. This allows you to define a chain of permission providers. + +Example: + +```yaml +cluster: + name: my-cluster + rbacs: + - my-ranger + - another-rbac-provider +``` + +The `rbacs` list enables multiple RBAC providers to be evaluated in order, allowing flexible and layered access control. + + +## Usage + +### Initialization + +```go +import "github.com/patterninc/heimdall/pkg/rbac" + +// Parse configuration +var rbacs rbac.RBACs +err := yaml.Unmarshal(configData, &rbacs) + +// Initialize RBAC providers +ctx := context.Background() +for _, rbac := range rbacs { + err := rbac.Init(ctx) + if err != nil { + log.Fatal(err) + } +} +``` + +### Access Control + +```go +// Check if user has access to execute a query +user := "john.doe" +query := "SELECT * FROM catalog.schema.table" + +hasAccess, err := rbac.HasAccess(user, query) +if err != nil { + log.Error("Error checking access:", err) + return +} + +if !hasAccess { + log.Info("Access denied for user:", user) + return +} + +// Execute query... +``` + +## Features + +### Supported SQL Actions + +The module supports fine-grained control over SQL operations: + +- `SELECT`: Read data from tables +- `INSERT`: Insert data into tables +- `UPDATE`: Update existing data +- `DELETE`: Delete data from tables +- `CREATE`: Create new objects (tables, schemas, etc.) +- `DROP`: Drop existing objects +- `ALTER`: Modify existing objects +- `USE`: Use/switch to a schema or catalog +- `GRANT`/`REVOKE`: Manage permissions +- `SHOW`: Show system information +- `IMPERSONATE`: Act as another user +- `EXECUTE`: Execute procedures/functions + +### Resource Matching + +Policies support wildcard patterns for flexible resource matching: + +- `*`: Matches any characters +- `?`: Matches single character +- Regular expressions for complex patterns + +### Policy Types + +- **Allow Policies**: Grant specific permissions to users/groups +- **Deny Policies**: Explicitly deny permissions (takes precedence) +- **Exceptions**: Override allow/deny policies for specific cases + +### Automatic Synchronization + +- Policies are automatically synced from Ranger at configured intervals +- Users and groups are kept up-to-date +- Background goroutine handles sync without blocking operations + +## API Reference + +### RBAC Interface + +```go +type RBAC interface { + Init(ctx context.Context) error + HasAccess(user string, query string) (bool, error) + GetName() string +} +``` + + +## Error Handling + +The module handles various error scenarios: + +- **Network failures**: Retries and graceful degradation +- **Invalid policies**: Logs warnings and skips malformed policies +- **Unknown users**: Returns access denied for unknown users +- **Parsing errors**: Returns detailed error messages for invalid queries + +## Performance Considerations + +- **Caching**: User permissions are cached in memory for fast access +- **Batch requests**: API calls use pagination for large datasets +- **Background sync**: Policy updates don't block query processing +- **Regex compilation**: Resource patterns are pre-compiled for efficiency + +## Security + +- **Basic Authentication**: Secure communication with Ranger +- **Case-insensitive usernames**: Consistent user matching +- **Deny-by-default**: Unknown users/resources are denied access +- **Audit logging**: All access decisions are logged + +## Troubleshooting + +### Common Issues + +1. **User not found in policies** + - Ensure user exists in Ranger + - Check group memberships + - Verify sync is working + +2. **Access denied unexpectedly** + - Check policy resource patterns + - Verify deny policies aren't blocking access + - Review query parsing results + +3. **Sync failures** + - Verify Ranger connectivity + - Check authentication credentials + - Review Ranger service configuration + +### Debugging + +Enable verbose logging to see detailed access control decisions: + +```go +log.SetLevel(log.DebugLevel) +``` + +This will show policy matches, resource patterns, and access decisions for each query. \ No newline at end of file diff --git a/pkg/rbac/rbac.go b/pkg/rbac/rbac.go new file mode 100644 index 0000000..53e43fd --- /dev/null +++ b/pkg/rbac/rbac.go @@ -0,0 +1,7 @@ +package rbac + +type RBAC interface { + Init() error + HasAccess(user string, query string) (bool, error) + GetName() string +}