Access control: Add access control sql filter to org user queries (#43961)

* Add access control SQL filter to org user queries
This commit is contained in:
Karl Persson 2022-01-13 14:40:32 +01:00 committed by GitHub
parent ccd9e46dda
commit f999910dc6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 266 additions and 36 deletions

View File

@ -54,7 +54,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/datasources/", authorize(reqOrgAdmin, dataSourcesConfigurationAccessEvaluator), hs.Index)
r.Get("/datasources/new", authorize(reqOrgAdmin, dataSourcesNewAccessEvaluator), hs.Index)
r.Get("/datasources/edit/*", authorize(reqOrgAdmin, dataSourcesEditAccessEvaluator), hs.Index)
r.Get("/org/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead, ac.ScopeUsersAll)), hs.Index)
r.Get("/org/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), hs.Index)
r.Get("/org/users/new", reqOrgAdmin, hs.Index)
r.Get("/org/users/invite", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index)
r.Get("/org/teams", reqCanAccessTeams, hs.Index)
@ -206,8 +206,8 @@ func (hs *HTTPServer) registerRoutes() {
userIDScope := ac.Scope("users", "id", ac.Parameter(":userId"))
orgRoute.Put("/", authorize(reqOrgAdmin, ac.EvalPermission(ActionOrgsWrite)), routing.Wrap(UpdateCurrentOrg))
orgRoute.Put("/address", authorize(reqOrgAdmin, ac.EvalPermission(ActionOrgsWrite)), routing.Wrap(UpdateCurrentOrgAddress))
orgRoute.Get("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead, ac.ScopeUsersAll)), routing.Wrap(hs.GetOrgUsersForCurrentOrg))
orgRoute.Get("/users/search", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead, ac.ScopeUsersAll)), routing.Wrap(hs.SearchOrgUsersWithPaging))
orgRoute.Get("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.GetOrgUsersForCurrentOrg))
orgRoute.Get("/users/search", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.SearchOrgUsersWithPaging))
orgRoute.Post("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd, ac.ScopeUsersAll)), quota("user"), routing.Wrap(hs.AddOrgUserToCurrentOrg))
orgRoute.Patch("/users/:userId", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRoleUpdate, userIDScope)), routing.Wrap(hs.UpdateOrgUserForCurrentOrg))
orgRoute.Delete("/users/:userId", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRemove, userIDScope)), routing.Wrap(hs.RemoveOrgUserForCurrentOrg))
@ -224,7 +224,7 @@ func (hs *HTTPServer) registerRoutes() {
// current org without requirement of user to be org admin
apiRoute.Group("/org", func(orgRoute routing.RouteRegister) {
orgRoute.Get("/users/lookup", authorize(reqOrgAdminFolderAdminOrTeamAdmin, ac.EvalPermission(ac.ActionOrgUsersRead, ac.ScopeUsersAll)), routing.Wrap(hs.GetOrgUsersForCurrentOrgLookup))
orgRoute.Get("/users/lookup", authorize(reqOrgAdminFolderAdminOrTeamAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.GetOrgUsersForCurrentOrgLookup))
})
// create new org

View File

@ -22,6 +22,7 @@ import (
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
acmiddleware "github.com/grafana/grafana/pkg/services/accesscontrol/middleware"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol"
"github.com/grafana/grafana/pkg/services/auth"
@ -355,6 +356,8 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont
c.Map(initCtx)
})
m.Use(acmiddleware.LoadPermissionsMiddleware(hs.AccessControl))
// Register all routes
hs.registerRoutes()
hs.RouteRegister.Register(m.Router)

View File

@ -251,7 +251,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
})
}
if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead, ac.ScopeUsersAll)) {
if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)) {
configNodes = append(configNodes, &dtos.NavLink{
Text: "Users",
Id: "users",

View File

@ -71,6 +71,7 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrg(c *models.ReqContext) response.Re
OrgId: c.OrgId,
Query: c.Query("query"),
Limit: c.QueryInt("limit"),
User: c.SignedInUser,
}, c.SignedInUser)
if err != nil {
@ -86,6 +87,7 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrgLookup(c *models.ReqContext) respo
OrgId: c.OrgId,
Query: c.Query("query"),
Limit: c.QueryInt("limit"),
User: c.SignedInUser,
}, c.SignedInUser)
if err != nil {
@ -124,6 +126,7 @@ func (hs *HTTPServer) GetOrgUsers(c *models.ReqContext) response.Response {
OrgId: c.ParamsInt64(":orgId"),
Query: "",
Limit: 0,
User: c.SignedInUser,
}, c.SignedInUser)
if err != nil {
@ -183,8 +186,9 @@ func (hs *HTTPServer) SearchOrgUsersWithPaging(c *models.ReqContext) response.Re
query := &models.SearchOrgUsersQuery{
OrgID: c.OrgId,
Query: c.Query("query"),
Limit: perPage,
Page: page,
Limit: perPage,
User: c.SignedInUser,
}
if err := hs.SQLStore.SearchOrgUsers(ctx, query); err != nil {

View File

@ -38,6 +38,7 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) {
hs := &HTTPServer{Cfg: settings}
sqlStore := sqlstore.InitTestDB(t)
sqlStore.Cfg = settings
hs.SQLStore = sqlStore
loggedInUserScenario(t, "When calling GET on", "api/org/users", func(sc *scenarioContext) {
@ -556,7 +557,10 @@ func TestPostOrgUsersAPIEndpoint_AccessControl(t *testing.T) {
require.NoError(t, err)
assert.EqualValuesf(t, tc.expectedMessage, message, "server did not answer expected message")
getUsersQuery := models.GetOrgUsersQuery{OrgId: tc.targetOrg}
getUsersQuery := models.GetOrgUsersQuery{OrgId: tc.targetOrg, User: &models.SignedInUser{
OrgId: tc.targetOrg,
Permissions: map[int64]map[string][]string{tc.targetOrg: {"org.users:read": {"users:*"}}},
}}
err = sc.db.GetOrgUsers(context.Background(), &getUsersQuery)
require.NoError(t, err)
assert.Len(t, getUsersQuery.Result, tc.expectedUserCount)
@ -799,7 +803,13 @@ func TestDeleteOrgUsersAPIEndpoint_AccessControl(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, tc.expectedMessage, message)
getUsersQuery := models.GetOrgUsersQuery{OrgId: tc.targetOrg}
getUsersQuery := models.GetOrgUsersQuery{
OrgId: tc.targetOrg,
User: &models.SignedInUser{
OrgId: tc.targetOrg,
Permissions: map[int64]map[string][]string{tc.targetOrg: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}},
},
}
err = sc.db.GetOrgUsers(context.Background(), &getUsersQuery)
require.NoError(t, err)
assert.Len(t, getUsersQuery.Result, tc.expectedUserCount)

View File

@ -112,6 +112,7 @@ type GetOrgUsersQuery struct {
Limit int
IsServiceAccount bool
User *SignedInUser
Result []*OrgUserDTO
}
@ -122,6 +123,7 @@ type SearchOrgUsersQuery struct {
Limit int
IsServiceAccount bool
User *SignedInUser
Result SearchOrgUsersQueryResult
}

View File

@ -10,7 +10,9 @@ import (
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
var sqlIDAcceptList = map[string]struct{}{}
var sqlIDAcceptList = map[string]struct{}{
"org_user.user_id": {},
}
type SQLDialect interface {
DriverName() string
@ -97,3 +99,12 @@ func postgresQuery(scopes []string, sqlID, prefix string) (string, []interface{}
)
`, sqlID, sqlID), args
}
// SetAcceptListForTest allow us to mutate the list for blackbox testing
func SetAcceptListForTest(list map[string]struct{}) func() {
original := sqlIDAcceptList
sqlIDAcceptList = list
return func() {
sqlIDAcceptList = original
}
}

View File

@ -1,4 +1,4 @@
package accesscontrol
package accesscontrol_test
import (
"context"
@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
@ -21,19 +22,20 @@ func benchmarkFilter(b *testing.B, numDs, numPermissions int) {
b.ResetTimer()
// set sqlIDAcceptList before running tests
sqlIDAcceptList = map[string]struct{}{
restore := accesscontrol.SetAcceptListForTest(map[string]struct{}{
"data_source.id": {},
}
})
defer restore()
for i := 0; i < b.N; i++ {
baseSql := `SELECT data_source.* FROM data_source WHERE`
query, args, err := Filter(
query, args, err := accesscontrol.Filter(
context.Background(),
&FakeDriver{name: "sqlite3"},
"data_source.id",
"datasources",
"datasources:read",
&models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{1: GroupScopesByAction(permissions)}},
&models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(permissions)}},
)
require.NoError(b, err)
@ -46,7 +48,7 @@ func benchmarkFilter(b *testing.B, numDs, numPermissions int) {
}
}
func setupFilterBenchmark(b *testing.B, numDs, numPermissions int) (*sqlstore.SQLStore, []*Permission) {
func setupFilterBenchmark(b *testing.B, numDs, numPermissions int) (*sqlstore.SQLStore, []*accesscontrol.Permission) {
b.Helper()
store := sqlstore.InitTestDB(b)
@ -62,11 +64,11 @@ func setupFilterBenchmark(b *testing.B, numDs, numPermissions int) (*sqlstore.SQ
numPermissions = numDs
}
permissions := make([]*Permission, 0, numPermissions)
permissions := make([]*accesscontrol.Permission, 0, numPermissions)
for i := 1; i <= numPermissions; i++ {
permissions = append(permissions, &Permission{
permissions = append(permissions, &accesscontrol.Permission{
Action: "datasources:read",
Scope: Scope("datasources", "id", strconv.Itoa(i)),
Scope: accesscontrol.Scope("datasources", "id", strconv.Itoa(i)),
})
}

View File

@ -1,4 +1,4 @@
package accesscontrol
package accesscontrol_test
import (
"context"
@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
@ -18,7 +19,7 @@ type filterTest struct {
sqlID string
action string
prefix string
permissions []*Permission
permissions []*accesscontrol.Permission
expectedQuery string
expectedArgs []interface{}
}
@ -31,7 +32,7 @@ func TestFilter(t *testing.T) {
sqlID: "data_source.id",
prefix: "datasources",
action: "datasources:query",
permissions: []*Permission{
permissions: []*accesscontrol.Permission{
{Action: "datasources:query", Scope: "datasources:id:1"},
{Action: "datasources:query", Scope: "datasources:id:2"},
{Action: "datasources:query", Scope: "datasources:id:3"},
@ -65,7 +66,7 @@ func TestFilter(t *testing.T) {
sqlID: "dashboard.id",
prefix: "dashboards",
action: "dashboards:read",
permissions: []*Permission{
permissions: []*accesscontrol.Permission{
{Action: "dashboards:read", Scope: "dashboards:id:1"},
{Action: "dashboards:read", Scope: "dashboards:id:2"},
{Action: "dashboards:read", Scope: "dashboards:id:5"},
@ -95,7 +96,7 @@ func TestFilter(t *testing.T) {
sqlID: "user.id",
prefix: "users",
action: "users:read",
permissions: []*Permission{
permissions: []*accesscontrol.Permission{
{Action: "users:read", Scope: "users:id:1"},
{Action: "users:read", Scope: "users:id:100"},
// Other permissions
@ -123,21 +124,22 @@ func TestFilter(t *testing.T) {
}
// set sqlIDAcceptList before running tests
sqlIDAcceptList = map[string]struct{}{
restore := accesscontrol.SetAcceptListForTest(map[string]struct{}{
"user.id": {},
"dashboard.id": {},
"data_source.id": {},
}
})
defer restore()
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
query, args, err := Filter(
query, args, err := accesscontrol.Filter(
context.Background(),
FakeDriver{name: tt.driverName},
tt.sqlID,
tt.prefix,
tt.action,
&models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{1: GroupScopesByAction(tt.permissions)}},
&models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}},
)
require.NoError(t, err)
assert.Equal(t, tt.expectedQuery, query)
@ -153,7 +155,7 @@ func TestFilter(t *testing.T) {
type filterDatasourcesTestCase struct {
desc string
sqlID string
permissions []*Permission
permissions []*accesscontrol.Permission
expectedDataSources []string
expectErr bool
}
@ -163,7 +165,7 @@ func TestFilter_Datasources(t *testing.T) {
{
desc: "expect all data sources to be returned",
sqlID: "data_source.id",
permissions: []*Permission{
permissions: []*accesscontrol.Permission{
{Action: "datasources:read", Scope: "datasources:*"},
},
expectedDataSources: []string{"ds:1", "ds:2", "ds:3", "ds:4", "ds:5", "ds:6", "ds:7", "ds:8", "ds:9", "ds:10"},
@ -171,13 +173,13 @@ func TestFilter_Datasources(t *testing.T) {
{
desc: "expect no data sources to be returned",
sqlID: "data_source.id",
permissions: []*Permission{},
permissions: []*accesscontrol.Permission{},
expectedDataSources: []string{},
},
{
desc: "expect data sources with id 3, 7 and 8 to be returned",
sqlID: "data_source.id",
permissions: []*Permission{
permissions: []*accesscontrol.Permission{
{Action: "datasources:read", Scope: "datasources:id:3"},
{Action: "datasources:read", Scope: "datasources:id:7"},
{Action: "datasources:read", Scope: "datasources:id:8"},
@ -187,7 +189,7 @@ func TestFilter_Datasources(t *testing.T) {
{
desc: "expect error if sqlID is not in the accept list",
sqlID: "other.id",
permissions: []*Permission{
permissions: []*accesscontrol.Permission{
{Action: "datasources:read", Scope: "datasources:id:3"},
{Action: "datasources:read", Scope: "datasources:id:7"},
{Action: "datasources:read", Scope: "datasources:id:8"},
@ -198,9 +200,10 @@ func TestFilter_Datasources(t *testing.T) {
}
// set sqlIDAcceptList before running tests
sqlIDAcceptList = map[string]struct{}{
restore := accesscontrol.SetAcceptListForTest(map[string]struct{}{
"data_source.id": {},
}
})
defer restore()
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
@ -216,13 +219,13 @@ func TestFilter_Datasources(t *testing.T) {
}
baseSql := `SELECT data_source.* FROM data_source WHERE`
query, args, err := Filter(
query, args, err := accesscontrol.Filter(
context.Background(),
&FakeDriver{name: "sqlite3"},
tt.sqlID,
"datasources",
"datasources:read",
&models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{1: GroupScopesByAction(tt.permissions)}},
&models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}},
)
if !tt.expectErr {

View File

@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/util"
)
@ -111,6 +112,15 @@ func (ss *SQLStore) GetOrgUsers(ctx context.Context, query *models.GetOrgUsersQu
// service accounts table in the modelling
whereConditions = append(whereConditions, fmt.Sprintf("%s.is_service_account = %t", x.Dialect().Quote("user"), query.IsServiceAccount))
if ss.Cfg.FeatureToggles["accesscontrol"] {
q, args, err := accesscontrol.Filter(ctx, ss.Dialect, "org_user.user_id", "users", "org.users:read", query.User)
if err != nil {
return err
}
whereConditions = append(whereConditions, q)
whereParams = append(whereParams, args...)
}
if query.Query != "" {
queryWithWildcards := "%" + query.Query + "%"
whereConditions = append(whereConditions, "(email "+dialect.LikeStr()+" ? OR name "+dialect.LikeStr()+" ? OR login "+dialect.LikeStr()+" ?)")
@ -165,6 +175,15 @@ func (ss *SQLStore) SearchOrgUsers(ctx context.Context, query *models.SearchOrgU
// service accounts table in the modelling
whereConditions = append(whereConditions, fmt.Sprintf("%s.is_service_account = %t", x.Dialect().Quote("user"), query.IsServiceAccount))
if ss.Cfg.FeatureToggles["accesscontrol"] {
q, args, err := accesscontrol.Filter(ctx, ss.Dialect, "org_user.user_id", "users", "org.users:read", query.User)
if err != nil {
return err
}
whereConditions = append(whereConditions, q)
whereParams = append(whereParams, args...)
}
if query.Query != "" {
queryWithWildcards := "%" + query.Query + "%"
whereConditions = append(whereConditions, "(email "+dialect.LikeStr()+" ? OR name "+dialect.LikeStr()+" ? OR login "+dialect.LikeStr()+" ?)")

View File

@ -0,0 +1,176 @@
package sqlstore
import (
"context"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/models"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
)
type getOrgUsersTestCase struct {
desc string
query *models.GetOrgUsersQuery
expectedNumUsers int
}
func TestSQLStore_GetOrgUsers(t *testing.T) {
tests := []getOrgUsersTestCase{
{
desc: "should return all users",
query: &models.GetOrgUsersQuery{
OrgId: 1,
User: &models.SignedInUser{
OrgId: 1,
Permissions: map[int64]map[string][]string{1: {ac.ActionOrgUsersRead: {ac.ScopeUsersAll}}},
},
},
expectedNumUsers: 10,
},
{
desc: "should return no users",
query: &models.GetOrgUsersQuery{
OrgId: 1,
User: &models.SignedInUser{
OrgId: 1,
Permissions: map[int64]map[string][]string{1: {ac.ActionOrgUsersRead: {""}}},
},
},
expectedNumUsers: 0,
},
{
desc: "should return some users",
query: &models.GetOrgUsersQuery{
OrgId: 1,
User: &models.SignedInUser{
OrgId: 1,
Permissions: map[int64]map[string][]string{1: {ac.ActionOrgUsersRead: {
"users:id:1",
"users:id:5",
"users:id:9",
}}},
},
},
expectedNumUsers: 3,
},
}
store := InitTestDB(t)
store.Cfg.FeatureToggles = map[string]bool{"accesscontrol": true}
seedOrgUsers(t, store, 10)
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
err := store.GetOrgUsers(context.Background(), tt.query)
require.NoError(t, err)
require.Len(t, tt.query.Result, tt.expectedNumUsers)
if !hasWildcardScope(tt.query.User, ac.ActionOrgUsersRead) {
for _, u := range tt.query.Result {
assert.Contains(t, tt.query.User.Permissions[tt.query.User.OrgId][ac.ActionOrgUsersRead], fmt.Sprintf("users:id:%d", u.UserId))
}
}
})
}
}
type searchOrgUsersTestCase struct {
desc string
query *models.SearchOrgUsersQuery
expectedNumUsers int
}
func TestSQLStore_SearchOrgUsers(t *testing.T) {
tests := []searchOrgUsersTestCase{
{
desc: "should return all users",
query: &models.SearchOrgUsersQuery{
OrgID: 1,
User: &models.SignedInUser{
OrgId: 1,
Permissions: map[int64]map[string][]string{1: {ac.ActionOrgUsersRead: {ac.ScopeUsersAll}}},
},
},
expectedNumUsers: 10,
},
{
desc: "should return no users",
query: &models.SearchOrgUsersQuery{
OrgID: 1,
User: &models.SignedInUser{
OrgId: 1,
Permissions: map[int64]map[string][]string{1: {ac.ActionOrgUsersRead: {""}}},
},
},
expectedNumUsers: 0,
},
{
desc: "should return some users",
query: &models.SearchOrgUsersQuery{
OrgID: 1,
User: &models.SignedInUser{
OrgId: 1,
Permissions: map[int64]map[string][]string{1: {ac.ActionOrgUsersRead: {
"users:id:1",
"users:id:5",
"users:id:9",
}}},
},
},
expectedNumUsers: 3,
},
}
store := InitTestDB(t)
store.Cfg.FeatureToggles = map[string]bool{"accesscontrol": true}
seedOrgUsers(t, store, 10)
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
err := store.SearchOrgUsers(context.Background(), tt.query)
require.NoError(t, err)
assert.Len(t, tt.query.Result.OrgUsers, tt.expectedNumUsers)
if !hasWildcardScope(tt.query.User, ac.ActionOrgUsersRead) {
for _, u := range tt.query.Result.OrgUsers {
assert.Contains(t, tt.query.User.Permissions[tt.query.User.OrgId][ac.ActionOrgUsersRead], fmt.Sprintf("users:id:%d", u.UserId))
}
}
})
}
}
func seedOrgUsers(t *testing.T, store *SQLStore, numUsers int) {
t.Helper()
// Seed users
for i := 1; i <= numUsers; i++ {
user, err := store.CreateUser(context.Background(), models.CreateUserCommand{
Login: fmt.Sprintf("user-%d", i),
OrgId: 1,
})
require.NoError(t, err)
if i != 1 {
err = store.AddOrgUser(context.Background(), &models.AddOrgUserCommand{
Role: "Viewer",
OrgId: 1,
UserId: user.Id,
})
require.NoError(t, err)
}
}
}
func hasWildcardScope(user *models.SignedInUser, action string) bool {
for _, scope := range user.Permissions[user.OrgId][action] {
if strings.HasSuffix(scope, ":*") {
return true
}
}
return false
}