diff --git a/server/.env.example b/server/.env.example index d34888a1a4..48ea25d2a4 100644 --- a/server/.env.example +++ b/server/.env.example @@ -220,6 +220,66 @@ # Default: # RBAC_LOG= +############################################################################### +# Limit the number of resources to keep in the in-memory index. +# When set to -1, max size is used. +# When set to 0, the in memory index is not used. +# Type: int +# Default: +# RBAC_MAX_INDEX_SIZE= + +############################################################################### +# Synchronous lets us make all the procedures synchronous for ease of testing +# This should always be false in production +# Type: bool +# Default: +# RBAC_SYNCHRONOUS= + +############################################################################### +# Reindex strategy defines what strategy we should use. +# The available options are: +# +# . `memory`: prioritize memory consumption which reduces performance during reindexing. +# . `speed`: prioritize speed during reindexing; memory consumption will be 2n where n is the current index size. +# +# If you wish to prioritize memory and speed, consider using `speed` with a lower max index size +# Type: string +# Default: +# RBAC_REINDEX_STRATEGY= + +############################################################################### +# Decay factor controls how long an item should be kept in the index while not in use. +# Type: float64 +# Default: +# RBAC_DECAY_FACTOR= + +############################################################################### +# Decay interval controls how fast the decay factor is applied to the index key. +# Type: time.Duration +# Default: +# RBAC_DECAY_INTERVAL= + +############################################################################### +# Cleanup interval controls when unused/low-scored index items should be yanked out of the index counter. +# Type: time.Duration +# Default: +# RBAC_CLEANUP_INTERVAL= + +############################################################################### +# Reindex interval controls when the index should be re-calculated. +# Type: time.Duration +# Default: +# RBAC_REINDEX_INTERVAL= + +############################################################################### +# [IMPORTANT] +# ==== +# Unused, will be added when state preservation is implemented. +# ==== +# Type: time.Duration +# Default: +# RBAC_INDEX_FLUSH_INTERVAL= + ############################################################################### # Type: string # Default: diff --git a/server/app/boot_levels.go b/server/app/boot_levels.go index 58f99793c5..b5f8889fd9 100644 --- a/server/app/boot_levels.go +++ b/server/app/boot_levels.go @@ -1,53 +1,53 @@ package app import ( - "context" - "crypto/tls" - "fmt" - "net/url" - "os" - "regexp" - "strings" - "time" - - authService "github.com/cortezaproject/corteza/server/auth" - "github.com/cortezaproject/corteza/server/auth/saml" - authSettings "github.com/cortezaproject/corteza/server/auth/settings" - autService "github.com/cortezaproject/corteza/server/automation/service" - cmpService "github.com/cortezaproject/corteza/server/compose/service" - cmpEvent "github.com/cortezaproject/corteza/server/compose/service/event" - discoveryService "github.com/cortezaproject/corteza/server/discovery/service" - fedService "github.com/cortezaproject/corteza/server/federation/service" - "github.com/cortezaproject/corteza/server/pkg/actionlog" - "github.com/cortezaproject/corteza/server/pkg/apigw" - apigwTypes "github.com/cortezaproject/corteza/server/pkg/apigw/types" - "github.com/cortezaproject/corteza/server/pkg/auth" - "github.com/cortezaproject/corteza/server/pkg/corredor" - "github.com/cortezaproject/corteza/server/pkg/eventbus" - "github.com/cortezaproject/corteza/server/pkg/healthcheck" - "github.com/cortezaproject/corteza/server/pkg/http" - "github.com/cortezaproject/corteza/server/pkg/id" - "github.com/cortezaproject/corteza/server/pkg/locale" - "github.com/cortezaproject/corteza/server/pkg/logger" - "github.com/cortezaproject/corteza/server/pkg/mail" - "github.com/cortezaproject/corteza/server/pkg/messagebus" - "github.com/cortezaproject/corteza/server/pkg/monitor" - "github.com/cortezaproject/corteza/server/pkg/options" - "github.com/cortezaproject/corteza/server/pkg/provision" - "github.com/cortezaproject/corteza/server/pkg/rbac" - "github.com/cortezaproject/corteza/server/pkg/scheduler" - "github.com/cortezaproject/corteza/server/pkg/sentry" - "github.com/cortezaproject/corteza/server/pkg/valuestore" - "github.com/cortezaproject/corteza/server/pkg/version" - "github.com/cortezaproject/corteza/server/pkg/websocket" - "github.com/cortezaproject/corteza/server/store" - "github.com/cortezaproject/corteza/server/system/service" - sysService "github.com/cortezaproject/corteza/server/system/service" - sysEvent "github.com/cortezaproject/corteza/server/system/service/event" - "github.com/cortezaproject/corteza/server/system/types" - "github.com/lestrrat-go/jwx/jwt" - "go.uber.org/zap" - gomail "gopkg.in/mail.v2" + "context" + "crypto/tls" + "fmt" + "net/url" + "os" + "regexp" + "strings" + "time" + + authService "github.com/cortezaproject/corteza/server/auth" + "github.com/cortezaproject/corteza/server/auth/saml" + authSettings "github.com/cortezaproject/corteza/server/auth/settings" + autService "github.com/cortezaproject/corteza/server/automation/service" + cmpService "github.com/cortezaproject/corteza/server/compose/service" + cmpEvent "github.com/cortezaproject/corteza/server/compose/service/event" + discoveryService "github.com/cortezaproject/corteza/server/discovery/service" + fedService "github.com/cortezaproject/corteza/server/federation/service" + "github.com/cortezaproject/corteza/server/pkg/actionlog" + "github.com/cortezaproject/corteza/server/pkg/apigw" + apigwTypes "github.com/cortezaproject/corteza/server/pkg/apigw/types" + "github.com/cortezaproject/corteza/server/pkg/auth" + "github.com/cortezaproject/corteza/server/pkg/corredor" + "github.com/cortezaproject/corteza/server/pkg/eventbus" + "github.com/cortezaproject/corteza/server/pkg/healthcheck" + "github.com/cortezaproject/corteza/server/pkg/http" + "github.com/cortezaproject/corteza/server/pkg/id" + "github.com/cortezaproject/corteza/server/pkg/locale" + "github.com/cortezaproject/corteza/server/pkg/logger" + "github.com/cortezaproject/corteza/server/pkg/mail" + "github.com/cortezaproject/corteza/server/pkg/messagebus" + "github.com/cortezaproject/corteza/server/pkg/monitor" + "github.com/cortezaproject/corteza/server/pkg/options" + "github.com/cortezaproject/corteza/server/pkg/provision" + "github.com/cortezaproject/corteza/server/pkg/rbac" + "github.com/cortezaproject/corteza/server/pkg/scheduler" + "github.com/cortezaproject/corteza/server/pkg/sentry" + "github.com/cortezaproject/corteza/server/pkg/valuestore" + "github.com/cortezaproject/corteza/server/pkg/version" + "github.com/cortezaproject/corteza/server/pkg/websocket" + "github.com/cortezaproject/corteza/server/store" + "github.com/cortezaproject/corteza/server/system/service" + sysService "github.com/cortezaproject/corteza/server/system/service" + sysEvent "github.com/cortezaproject/corteza/server/system/service/event" + "github.com/cortezaproject/corteza/server/system/types" + "github.com/lestrrat-go/jwx/jwt" + "go.uber.org/zap" + gomail "gopkg.in/mail.v2" ) const ( @@ -258,15 +258,10 @@ func (app *CortezaApp) Provision(ctx context.Context) (err error) { // @todo envoy should be decoupled from RBAC and import directly into store, // w/o using any access control - var ( - ac = rbac.NewService(zap.NewNop(), app.Store) - acr = make([]*rbac.Role, 0) - ) - for _, r := range auth.ProvisionUser().Roles() { - acr = append(acr, rbac.BypassRole.Make(r, auth.BypassRoleHandle)) - } - ac.UpdateRoles(acr...) - rbac.SetGlobal(ac) + rbac.SetGlobal(rbac.NoopSvc(rbac.Allow, rbac.Config{ + RuleStorage: app.Store, + RoleStorage: app.Store, + })) defer rbac.SetGlobal(nil) } @@ -355,10 +350,30 @@ func (app *CortezaApp) InitServices(ctx context.Context) (err error) { } // Initialize RBAC subsystem - ac := rbac.NewService(log, app.Store) - - // and (re)load rules from the storage backend - ac.Reload(ctx) + // @todo add state management + // @todo potentially add .Activate like other services? + ac, err := rbac.NewService(ctx, log, app.Store, rbac.Config{ + MaxIndexSize: app.Opt.RBAC.MaxIndexSize, + Synchronous: app.Opt.RBAC.Synchronous, + ReindexStrategy: rbac.ReindexStrategy(app.Opt.RBAC.ReindexStrategy), + DecayFactor: app.Opt.RBAC.DecayFactor, + DecayInterval: app.Opt.RBAC.DecayInterval, + CleanupInterval: app.Opt.RBAC.CleanupInterval, + IndexFlushInterval: app.Opt.RBAC.IndexFlushInterval, + ReindexInterval: app.Opt.RBAC.ReindexInterval, + RuleStorage: app.Store, + RoleStorage: app.Store, + + PullInitialState: func(ctx context.Context, n int) ([]string, error) { + return nil, nil + }, + FlushIndexState: func(ctx context.Context, s []string) error { + return nil + }, + }) + if err != nil { + return fmt.Errorf("failed to initialize RBAC service: %w", err) + } rbac.SetGlobal(ac) } @@ -504,8 +519,6 @@ func (app *CortezaApp) Activate(ctx context.Context) (err error) { monitor.Watcher(ctx) - rbac.Global().Watch(ctx) - if err = sysService.Activate(ctx); err != nil { return fmt.Errorf("could not activate system services: %w", err) @@ -562,9 +575,9 @@ func (app *CortezaApp) Activate(ctx context.Context) (err error) { app.AuthService.Watch(ctx) - updateSassInstallSettings(ctx, sysService.DefaultStylesheet.SassInstalled(), app.Log) + updateSassInstallSettings(ctx, sysService.DefaultStylesheet.SassInstalled(), app.Log) //Generate CSS for webapps - if err = sysService.DefaultStylesheet.GenerateCSS(sysService.CurrentSettings, app.Opt.Webapp.ScssDirPath, app.Log); err != nil { + if err = sysService.DefaultStylesheet.GenerateCSS(sysService.CurrentSettings, app.Opt.Webapp.ScssDirPath, app.Log); err != nil { return fmt.Errorf("could not generate css for webapps: %w", err) } diff --git a/server/app/options/RBAC.cue b/server/app/options/RBAC.cue index b04b62dfce..e9fc24fe33 100644 --- a/server/app/options/RBAC.cue +++ b/server/app/options/RBAC.cue @@ -13,6 +13,83 @@ RBAC: schema.#optionsGroup & { type: "bool" description: "Log RBAC related events and actions" } + + max_index_size: { + type: "int" + defaultGoExpr: "-1" + description: """ + Limit the number of resources to keep in the in-memory index. + When set to -1, max size is used. + When set to 0, the in memory index is not used. + """ + } + + synchronous: { + type: "bool" + defaultGoExpr: "false" + description: """ + Synchronous lets us make all the procedures synchronous for ease of testing + This should always be false in production + """ + } + + reindex_strategy: { + type: "string" + defaultValue: "" + description: """ + Reindex strategy defines what strategy we should use. + The available options are: + + . `memory`: prioritize memory consumption which reduces performance during reindexing. + . `speed`: prioritize speed during reindexing; memory consumption will be 2n where n is the current index size. + + If you wish to prioritize memory and speed, consider using `speed` with a lower max index size + """ + } + + decay_factor: { + type: "float64" + defaultGoExpr: "0.9" + description: """ + Decay factor controls how long an item should be kept in the index while not in use. + """ + } + + decay_interval: { + type: "time.Duration" + defaultGoExpr: "time.Minute * 30" + description: """ + Decay interval controls how fast the decay factor is applied to the index key. + """ + } + + cleanup_interval: { + type: "time.Duration" + defaultGoExpr: "time.Minute * 31" + description: """ + Cleanup interval controls when unused/low-scored index items should be yanked out of the index counter. + """ + } + + reindex_interval: { + type: "time.Duration" + defaultGoExpr: "time.Minute * 10" + description: """ + Reindex interval controls when the index should be re-calculated. + """ + } + + index_flush_interval: { + type: "time.Duration" + defaultGoExpr: "time.Minute * 35" + description: """ + [IMPORTANT] + ==== + Unused, will be added when state preservation is implemented. + ==== + """ + } + service_user: {} bypass_roles: { defaultValue: "super-admin" diff --git a/server/app/resources.cue b/server/app/resources.cue index 3a9be8a78a..3131cb24d3 100644 --- a/server/app/resources.cue +++ b/server/app/resources.cue @@ -22,7 +22,12 @@ resources: { [key=_]: {"handle": key, "component": "system", "platform": "cortez identPlural: "rules" expIdent: "Rule" - features: _allFeaturesDisabled + features: { + sorting: true + labels: false + paging: false + checkFn: false + } model: { @@ -32,6 +37,7 @@ resources: { [key=_]: {"handle": key, "component": "system", "platform": "cortez goType: "uint64", ident: "roleID", storeIdent: "rel_role" + sortable: true, dal: { type: "Ref", refModelResType: "corteza::system:role" } } resource: { @@ -51,6 +57,17 @@ resources: { [key=_]: {"handle": key, "component": "system", "platform": "cortez } } + filter: { + struct: { + resource: {goType: "[]string", ident: "resource", storeIdent: "resource"} + operation: {goType: "string", ident: "operation", storeIdent: "operation"} + role_id: {goType: "uint64", ident: "roleID", storeIdent: "rel_role"} + } + + byValue: ["resource", "operation", "role_id"] + rawFilter: true + } + store: { ident: "rbacRule" diff --git a/server/automation/service/access_control.gen.go b/server/automation/service/access_control.gen.go index d9964efd91..41b7a011e2 100644 --- a/server/automation/service/access_control.gen.go +++ b/server/automation/service/access_control.gen.go @@ -22,9 +22,9 @@ import ( type ( rbacService interface { Can(rbac.Session, string, rbac.Resource) bool - Trace(rbac.Session, string, rbac.Resource) *rbac.Trace + Trace(rbac.Session, string, rbac.Resource) (*rbac.Trace, error) Grant(context.Context, ...*rbac.Rule) error - FindRulesByRoleID(roleID uint64) (rr rbac.RuleSet) + FindRulesByRoleID(ctx context.Context, roleID uint64) (rr rbac.RuleSet, err error) } accessControl struct { @@ -120,11 +120,16 @@ func (svc accessControl) Trace(ctx context.Context, userID uint64, roles []uint6 return nil, fmt.Errorf("no roles specified") } + var auxTrace *rbac.Trace session := rbac.ParamsToSession(ctx, userID, roles...) for _, res := range resources { r := res.RbacResource() for op := range rbacResourceOperations(r) { - ee = append(ee, svc.rbac.Trace(session, op, res)) + auxTrace, err = svc.rbac.Trace(session, op, res) + if err != nil { + return + } + ee = append(ee, auxTrace) } } @@ -300,7 +305,7 @@ func (svc accessControl) FindRulesByRoleID(ctx context.Context, roleID uint64) ( return nil, AccessControlErrNotAllowedToSetPermissions() } - return svc.rbac.FindRulesByRoleID(roleID), nil + return svc.rbac.FindRulesByRoleID(ctx, roleID) } // CanReadWorkflow checks if current user can read workflow diff --git a/server/codegen/assets/templates/gocode/rbac/$component_access_control.go.tpl b/server/codegen/assets/templates/gocode/rbac/$component_access_control.go.tpl index 666ac18a2d..6418d4e089 100644 --- a/server/codegen/assets/templates/gocode/rbac/$component_access_control.go.tpl +++ b/server/codegen/assets/templates/gocode/rbac/$component_access_control.go.tpl @@ -21,9 +21,9 @@ import ( type ( rbacService interface { Can(rbac.Session, string, rbac.Resource) bool - Trace(rbac.Session, string, rbac.Resource) *rbac.Trace + Trace(rbac.Session, string, rbac.Resource) (*rbac.Trace, error) Grant(context.Context, ...*rbac.Rule) error - FindRulesByRoleID(roleID uint64) (rr rbac.RuleSet) + FindRulesByRoleID(ctx context.Context, roleID uint64) (rr rbac.RuleSet, err error) } accessControl struct { @@ -119,11 +119,16 @@ func (svc accessControl) Trace(ctx context.Context, userID uint64, roles []uint6 return nil, fmt.Errorf("no roles specified") } + var auxTrace *rbac.Trace session := rbac.ParamsToSession(ctx, userID, roles...) for _, res := range resources { r := res.RbacResource() for op := range rbacResourceOperations(r) { - ee = append(ee, svc.rbac.Trace(session, op, res)) + auxTrace, err = svc.rbac.Trace(session, op, res) + if err != nil { + return + } + ee = append(ee, auxTrace) } } @@ -245,7 +250,7 @@ func (svc accessControl) FindRulesByRoleID(ctx context.Context, roleID uint64) ( return nil, AccessControlErrNotAllowedToSetPermissions() } - return svc.rbac.FindRulesByRoleID(roleID), nil + return svc.rbac.FindRulesByRoleID(ctx, roleID) } {{- range .operations }} diff --git a/server/codegen/assets/templates/gocode/store/rdbms/filters.go.tpl b/server/codegen/assets/templates/gocode/store/rdbms/filters.go.tpl index 1264819c81..298e8ca9d8 100644 --- a/server/codegen/assets/templates/gocode/store/rdbms/filters.go.tpl +++ b/server/codegen/assets/templates/gocode/store/rdbms/filters.go.tpl @@ -97,6 +97,14 @@ func {{ .expIdent }}Filter(d drivers.Dialect, f {{ .goFilterType }})(ee []goqu.E } {{ end }} + {{ if .filter.rawFilter }} + if f.RawFilter != "" { + // @note this is only acceptable for system-generated queries. + // This should be improved regardless. + ee = append(ee, goqu.Literal(f.RawFilter)) + } + {{- end }} + return ee, f, err } {{ end }} diff --git a/server/codegen/schema/resource.cue b/server/codegen/schema/resource.cue index 9ec0aef54c..0d3a8ceee1 100644 --- a/server/codegen/schema/resource.cue +++ b/server/codegen/schema/resource.cue @@ -47,6 +47,9 @@ import ( // filter resources by fields (eq) "byValue": [...string] + + // filter allows raw query construction + "rawFilter": bool | *false } // operations: #Operations diff --git a/server/codegen/server.store.cue b/server/codegen/server.store.cue index c05b3efae1..6c5916d8f9 100644 --- a/server/codegen/server.store.cue +++ b/server/codegen/server.store.cue @@ -60,6 +60,8 @@ _StoreResource: { "byValue": [ for name in res.filter.byValue {res.filter.struct[name]}] "byLabel": res.features.labels "byFlag": res.features.flags + + "rawFilter": res.filter.rawFilter } auxIdent: "aux\(expIdent)" @@ -146,8 +148,8 @@ _StoreResource: { fields: { for attr in res.model.attributes if attr.sortable || attr.unique || list.Contains(pkAttrNames, attr.name) { { - "\(strings.ToLower(attr.name))": attr.name - "\(strings.ToLower(attr.ident))": attr.name + "\(strings.ToLower(attr.name))": attr.storeIdent + "\(strings.ToLower(attr.ident))": attr.storeIdent } } } diff --git a/server/compose/rest/namespace.go b/server/compose/rest/namespace.go index 08ce6f9091..5f1423878b 100644 --- a/server/compose/rest/namespace.go +++ b/server/compose/rest/namespace.go @@ -497,7 +497,10 @@ func (ctrl Namespace) exportCompose(ctx context.Context, namespaceID uint64) (re func (ctrl Namespace) exportRBAC(ctx context.Context, base envoyx.NodeSet) (resources envoyx.NodeSet, err error) { // Prepare RBAC Rules - rawRules := rbac.Global().Rules() + rawRules, err := rbac.Global().Rules(ctx) + if err != nil { + return + } resources, err = envoyx.RBACRulesForNodes(rawRules, base...) if err != nil { diff --git a/server/compose/service/access_control.gen.go b/server/compose/service/access_control.gen.go index 8513edaf1b..0a01030786 100644 --- a/server/compose/service/access_control.gen.go +++ b/server/compose/service/access_control.gen.go @@ -22,9 +22,9 @@ import ( type ( rbacService interface { Can(rbac.Session, string, rbac.Resource) bool - Trace(rbac.Session, string, rbac.Resource) *rbac.Trace + Trace(rbac.Session, string, rbac.Resource) (*rbac.Trace, error) Grant(context.Context, ...*rbac.Rule) error - FindRulesByRoleID(roleID uint64) (rr rbac.RuleSet) + FindRulesByRoleID(ctx context.Context, roleID uint64) (rr rbac.RuleSet, err error) } accessControl struct { @@ -120,11 +120,16 @@ func (svc accessControl) Trace(ctx context.Context, userID uint64, roles []uint6 return nil, fmt.Errorf("no roles specified") } + var auxTrace *rbac.Trace session := rbac.ParamsToSession(ctx, userID, roles...) for _, res := range resources { r := res.RbacResource() for op := range rbacResourceOperations(r) { - ee = append(ee, svc.rbac.Trace(session, op, res)) + auxTrace, err = svc.rbac.Trace(session, op, res) + if err != nil { + return + } + ee = append(ee, auxTrace) } } @@ -446,7 +451,7 @@ func (svc accessControl) FindRulesByRoleID(ctx context.Context, roleID uint64) ( return nil, AccessControlErrNotAllowedToSetPermissions() } - return svc.rbac.FindRulesByRoleID(roleID), nil + return svc.rbac.FindRulesByRoleID(ctx, roleID) } // CanReadChart checks if current user can read diff --git a/server/compose/service/chart_test.go b/server/compose/service/chart_test.go index 736dfc29c4..e125477ea3 100644 --- a/server/compose/service/chart_test.go +++ b/server/compose/service/chart_test.go @@ -48,7 +48,10 @@ func TestCharts(t *testing.T) { req := require.New(t) svc := &chart{ store: s, - ac: &accessControl{rbac: &rbac.ServiceAllowAll{}}, + ac: &accessControl{rbac: rbac.NoopSvc(rbac.Allow, rbac.Config{ + RuleStorage: s, + RoleStorage: s, + })}, } res, err := svc.Create(ctx, &types.Chart{Name: "My first chart", NamespaceID: namespaceID}) req.NoError(unwrapChartInternal(err)) diff --git a/server/compose/service/module_test.go b/server/compose/service/module_test.go index 83abfc7975..0c3892b7e0 100644 --- a/server/compose/service/module_test.go +++ b/server/compose/service/module_test.go @@ -58,10 +58,6 @@ func makeTestModuleService(t *testing.T, mods ...any) *module { } } - if svc.ac == nil { - svc.ac = &accessControl{rbac: rbac.NewService(log, nil)} - } - if svc.store == nil { t.Log("using SQLite in-memory Store") svc.store, err = sqlite.ConnectInMemoryWithDebug(ctx) @@ -78,7 +74,20 @@ func makeTestModuleService(t *testing.T, mods ...any) *module { req.NoError(store.TruncateComposeModuleFields(ctx, svc.store)) req.NoError(store.TruncateRbacRules(ctx, svc.store)) req.NoError(store.TruncateLabels(ctx, svc.store)) + } + if svc.ac == nil { + rc, err := rbac.NewService(ctx, log, svc.store, rbac.Config{ + Synchronous: true, + DecayInterval: time.Hour * 2, + CleanupInterval: time.Hour * 2, + ReindexInterval: time.Hour * 2, + IndexFlushInterval: time.Hour * 2, + RuleStorage: svc.store, + RoleStorage: svc.store, + }) + require.NoError(t, err) + svc.ac = &accessControl{rbac: rc} } resourceMaker(ctx, t, svc.store, mods...) @@ -122,7 +131,7 @@ func TestModules(t *testing.T) { svc := makeTestModuleService(t, ns, - &rbac.ServiceAllowAll{}, + rbac.NoopSvc(rbac.Allow, rbac.Config{}), ) res, err := svc.Create(ctx, &types.Module{Name: "My first module", NamespaceID: ns.ID}) @@ -167,7 +176,7 @@ func TestModule_LabelSearch(t *testing.T) { req = require.New(t) svc = makeTestModuleService(t, ns, - &rbac.ServiceAllowAll{}, + rbac.NoopSvc(rbac.Allow, rbac.Config{}), ) ctx = context.Background() @@ -239,7 +248,7 @@ func TestModule_LabelCRUD(t *testing.T) { req = require.New(t) svc = makeTestModuleService(t, ns, - &rbac.ServiceAllowAll{}, + rbac.NoopSvc(rbac.Allow, rbac.Config{}), ) findAndReturnLabel = func(id uint64) map[string]string { diff --git a/server/compose/service/namespace.go b/server/compose/service/namespace.go index 38a8476af3..6b3c1a2315 100644 --- a/server/compose/service/namespace.go +++ b/server/compose/service/namespace.go @@ -850,8 +850,14 @@ func (svc namespace) reloadServices(ctx context.Context, ns *types.Namespace) (e return } - // Reload RBAC rules (in case import brought in something new) - rbac.Global().Reload(ctx) + // Reload some RBAC bits in case things changed + // + // @note no need to reload rules; just roles for now + err = rbac.Global().ReloadRoles(ctx) + if err != nil { + return + } + if err = locale.Global().ReloadResourceTranslations(ctx); err != nil { return } diff --git a/server/compose/service/page_test.go b/server/compose/service/page_test.go index 73ca659a82..5a04bee2f6 100644 --- a/server/compose/service/page_test.go +++ b/server/compose/service/page_test.go @@ -49,8 +49,11 @@ func TestPageDeleting(t *testing.T) { } svc = &page{ - store: s, - ac: &accessControl{rbac: &rbac.ServiceAllowAll{}}, + store: s, + ac: &accessControl{rbac: rbac.NoopSvc(rbac.Allow, rbac.Config{ + RuleStorage: s, + RoleStorage: s, + })}, eventbus: eventbus.New(), locale: ResourceTranslationsManager(locale.Static()), } diff --git a/server/compose/service/record.go b/server/compose/service/record.go index b588eb87e9..1d4ab96f17 100644 --- a/server/compose/service/record.go +++ b/server/compose/service/record.go @@ -12,6 +12,7 @@ import ( "github.com/cortezaproject/corteza/server/pkg/envoyx" "github.com/cortezaproject/corteza/server/pkg/filter" + "github.com/cortezaproject/corteza/server/pkg/rbac" "github.com/cortezaproject/corteza/server/pkg/revisions" "github.com/spf13/cast" @@ -44,6 +45,7 @@ type ( actionlog actionlog.Recorder + rbacSvc *rbac.Service ac recordAccessController eventbus eventDispatcher diff --git a/server/compose/service/record_test.go b/server/compose/service/record_test.go index 3d36e8165f..11b3c0b322 100644 --- a/server/compose/service/record_test.go +++ b/server/compose/service/record_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "testing" + "time" "github.com/cortezaproject/corteza/server/pkg/dal" "github.com/cortezaproject/corteza/server/pkg/eventbus" @@ -26,7 +27,6 @@ import ( ) func makeTestRecordService(t *testing.T, mods ...any) *record { - var ( err error req = require.New(t) @@ -68,10 +68,6 @@ func makeTestRecordService(t *testing.T, mods ...any) *record { } } - if svc.ac == nil { - svc.ac = &accessControl{rbac: rbac.NewService(log, nil)} - } - if svc.store == nil { svc.store, err = sqlite.ConnectInMemoryWithDebug(ctx) req.NoError(err) @@ -83,7 +79,21 @@ func makeTestRecordService(t *testing.T, mods ...any) *record { req.NoError(store.TruncateComposeModules(ctx, svc.store)) req.NoError(store.TruncateComposeModuleFields(ctx, svc.store)) req.NoError(store.TruncateRbacRules(ctx, svc.store)) + } + if svc.ac == nil { + rc, err := rbac.NewService(ctx, log, svc.store, rbac.Config{ + Synchronous: true, + DecayInterval: time.Hour * 2, + CleanupInterval: time.Hour * 2, + ReindexInterval: time.Hour * 2, + IndexFlushInterval: time.Hour * 2, + RuleStorage: svc.store, + RoleStorage: svc.store, + }) + require.NoError(t, err) + svc.rbacSvc = rc + svc.ac = &accessControl{rbac: rc} } resourceMaker(ctx, t, svc.store, mods...) @@ -255,8 +265,8 @@ func TestRecord_boolFieldPermissionIssueKBR(t *testing.T) { modConf = types.ModuleConfig{DAL: types.ModuleConfigDAL{ConnectionID: 1}} mod = &types.Module{ID: nextID(), NamespaceID: ns.ID, Config: modConf} - stringField = &types.ModuleField{ID: nextID(), ModuleID: mod.ID, Name: "string", Kind: "String"} - boolField = &types.ModuleField{ID: nextID(), ModuleID: mod.ID, Name: "bool", Kind: "Boolean"} + stringField = &types.ModuleField{ID: nextID(), NamespaceID: ns.ID, ModuleID: mod.ID, Name: "string", Kind: "String"} + boolField = &types.ModuleField{ID: nextID(), NamespaceID: ns.ID, ModuleID: mod.ID, Name: "bool", Kind: "Boolean"} authRoleID uint64 = 1 @@ -264,14 +274,8 @@ func TestRecord_boolFieldPermissionIssueKBR(t *testing.T) { writerRole = &sysTypes.Role{Name: "writer", ID: nextID()} // - rbacService = rbac.NewService( - zap.NewNop(), - //logger.MakeDebugLogger(), - nil, - ) svc = makeTestRecordService(t, - rbacService, logger.MakeDebugLogger(), u, ns, @@ -294,13 +298,13 @@ func TestRecord_boolFieldPermissionIssueKBR(t *testing.T) { svc.validator = defaultValidator(svc) - rbacService.UpdateRoles( + svc.rbacSvc.UpdateRoles( rbac.CommonRole.Make(readerRole.ID, readerRole.Name), rbac.CommonRole.Make(writerRole.ID, writerRole.Name), rbac.AuthenticatedRole.Make(authRoleID, "authenticated"), ) - rbacService.Grant(ctx, + svc.rbacSvc.Grant(ctx, // base permissions rbac.AllowRule(authRoleID, mod.RbacResource(), "record.create"), rbac.AllowRule(authRoleID, types.RecordRbacResource(0, 0, 0), "read"), @@ -382,12 +386,6 @@ func TestRecord_defValueFieldPermissionIssue(t *testing.T) { req = require.New(t) ctx = context.Background() - rbacService = rbac.NewService( - //zap.NewNop(), - logger.MakeDebugLogger(), - nil, - ) - user = &sysTypes.User{ID: nextID()} modConf = types.ModuleConfig{DAL: types.ModuleConfigDAL{ConnectionID: 1}} @@ -398,7 +396,6 @@ func TestRecord_defValueFieldPermissionIssue(t *testing.T) { readableField = &types.ModuleField{ID: nextID(), ModuleID: mod.ID, NamespaceID: ns.ID, Name: "readable", Kind: "String", DefaultValue: types.RecordValueSet{{Value: "def-r"}}} svc = makeTestRecordService(t, - rbacService, user, ns, mod, @@ -440,12 +437,12 @@ func TestRecord_defValueFieldPermissionIssue(t *testing.T) { t.Log("setting up security") - rbacService.UpdateRoles( + svc.rbacSvc.UpdateRoles( rbac.CommonRole.Make(editorRole.ID, editorRole.Name), rbac.AuthenticatedRole.Make(authRoleID, "authenticated"), ) - rbacService.Grant(ctx, + svc.rbacSvc.Grant(ctx, // base permissions rbac.AllowRule(authRoleID, mod.RbacResource(), "record.create"), rbac.AllowRule(authRoleID, types.RecordRbacResource(0, 0, 0), "read"), @@ -526,11 +523,6 @@ func TestRecord_refAccessControl(t *testing.T) { req.NoError(store.TruncateRbacRules(ctx, s)) var ( - rbacService = rbac.NewService( - //zap.NewNop(), - logger.MakeDebugLogger(), - nil, - ) nextIDi uint64 = 1 nextID = func() uint64 { nextIDi++ @@ -558,7 +550,6 @@ func TestRecord_refAccessControl(t *testing.T) { testerRole = &sysTypes.Role{Name: "tester", ID: nextID()} svc = makeTestRecordService(t, - rbacService, user, ns, mod1, @@ -581,7 +572,7 @@ func TestRecord_refAccessControl(t *testing.T) { svc.validator = defaultValidator(svc) t.Log("inform rbac service about new roles") - rbacService.UpdateRoles( + svc.rbacSvc.UpdateRoles( rbac.CommonRole.Make(testerRole.ID, testerRole.Name), ) @@ -596,7 +587,7 @@ func TestRecord_refAccessControl(t *testing.T) { req.EqualError(err, "not allowed to create records") t.Logf("granting permissions to create records on this module") - req.NoError(rbacService.Grant(ctx, rbac.AllowRule(testerRole.ID, mod1.RbacResource(), "record.create"))) + req.NoError(svc.rbacSvc.Grant(ctx, rbac.AllowRule(testerRole.ID, mod1.RbacResource(), "record.create"))) t.Log("retry creating record on 1st module; should fail because we do not have permissions to update field") _, _, err = svc.Create(ctx, mod1rec1) @@ -604,7 +595,7 @@ func TestRecord_refAccessControl(t *testing.T) { req.True(types.IsRecordValueErrorSet(err).HasKind("updateDenied")) t.Logf("granting permissions to update records values on module field") - req.NoError(rbacService.Grant(ctx, rbac.AllowRule(testerRole.ID, mod1strField.RbacResource(), "record.value.update"))) + req.NoError(svc.rbacSvc.Grant(ctx, rbac.AllowRule(testerRole.ID, mod1strField.RbacResource(), "record.value.update"))) t.Log("retry creating record on 1st module; should succeed") mod1rec1, _, err = svc.Create(ctx, mod1rec1) @@ -624,17 +615,17 @@ func TestRecord_refAccessControl(t *testing.T) { req.EqualError(err, "not allowed to create records") t.Log("grant record.create on namespace level") - req.NoError(rbacService.Grant(ctx, rbac.AllowRule(testerRole.ID, types.ModuleRbacResource(ns.ID, 0), "record.create"))) + req.NoError(svc.rbacSvc.Grant(ctx, rbac.AllowRule(testerRole.ID, types.ModuleRbacResource(ns.ID, 0), "record.create"))) t.Log("grant record.value.update on namespace level") - req.NoError(rbacService.Grant(ctx, rbac.AllowRule(testerRole.ID, types.ModuleFieldRbacResource(ns.ID, 0, 0), "record.value.update"))) + req.NoError(svc.rbacSvc.Grant(ctx, rbac.AllowRule(testerRole.ID, types.ModuleFieldRbacResource(ns.ID, 0, 0), "record.value.update"))) t.Log("create record on 2nd module with ref to record on the 1st module; most fail, not allowed to read (referenced) mod1rec1") _, _, err = svc.Create(ctx, mod2rec1) req.EqualError(err, "invalid record value input") t.Log("grant read on record") - req.NoError(rbacService.Grant(ctx, rbac.AllowRule(testerRole.ID, mod1rec1.RbacResource(), "read"))) + req.NoError(svc.rbacSvc.Grant(ctx, rbac.AllowRule(testerRole.ID, mod1rec1.RbacResource(), "read"))) t.Log("create record on 2nd module with ref to record on the 1st module") mod2rec1, _, err = svc.Create(ctx, mod2rec1) @@ -646,7 +637,7 @@ func TestRecord_refAccessControl(t *testing.T) { req.EqualError(err, "not allowed to update this record") t.Log("grant update on namespace level") - req.NoError(rbacService.Grant(ctx, rbac.AllowRule(testerRole.ID, types.RecordRbacResource(ns.ID, 0, 0), "update"))) + req.NoError(svc.rbacSvc.Grant(ctx, rbac.AllowRule(testerRole.ID, types.RecordRbacResource(ns.ID, 0, 0), "update"))) t.Log("update record on 2nd module with unchanged values") mod2rec1, _, err = svc.Update(ctx, mod2rec1) @@ -664,7 +655,7 @@ func TestRecord_refAccessControl(t *testing.T) { } { t.Log("revoke read on record") - req.NoError(rbacService.Grant(ctx, rbac.DenyRule(testerRole.ID, mod1rec1.RbacResource(), "read"))) + req.NoError(svc.rbacSvc.Grant(ctx, rbac.DenyRule(testerRole.ID, mod1rec1.RbacResource(), "read"))) t.Log("link 2nd record to 1st one again but w/o permissions; must work, value did not change") mod2rec1.Values = mod2rec1.Values.Set(&types.RecordValue{Name: "ref", Value: fmt.Sprintf("%d", mod1rec1.ID)}) @@ -679,12 +670,6 @@ func TestRecord_searchAccessControl(t *testing.T) { req = require.New(t) ctx = context.Background() - rbacService = rbac.NewService( - //zap.NewNop(), - logger.MakeDebugLogger(), - nil, - ) - nextIDi uint64 = 1 nextID = func() uint64 { nextIDi++ @@ -699,7 +684,6 @@ func TestRecord_searchAccessControl(t *testing.T) { strField = &types.ModuleField{ID: nextID(), NamespaceID: ns.ID, ModuleID: mod.ID, Name: "str", Kind: "String"} svc = makeTestRecordService(t, - rbacService, user, ns, mod, @@ -734,13 +718,13 @@ func TestRecord_searchAccessControl(t *testing.T) { } t.Log("inform rbac service about new roles") - rbacService.UpdateRoles( + svc.rbacSvc.UpdateRoles( rbac.CommonRole.Make(testerRole.ID, testerRole.Name), ) t.Log("log-in with test user ") ctx = auth.SetIdentityToContext(ctx, auth.Authenticated(user.ID, testerRole.ID)) - req.NoError(rbacService.Grant(ctx, rbac.AllowRule(testerRole.ID, mod.RbacResource(), "records.search"))) + req.NoError(svc.rbacSvc.Grant(ctx, rbac.AllowRule(testerRole.ID, mod.RbacResource(), "records.search"))) t.Log("search for the newly created records; should not find any (all denied)") f.IncTotal = true @@ -750,8 +734,8 @@ func TestRecord_searchAccessControl(t *testing.T) { req.Equal(uint(0), f.Total) t.Log("allow read access for two records") - req.NoError(rbacService.Grant(ctx, rbac.AllowRule(testerRole.ID, rr[3].RbacResource(), "read"))) - req.NoError(rbacService.Grant(ctx, rbac.AllowRule(testerRole.ID, rr[6].RbacResource(), "read"))) + req.NoError(svc.rbacSvc.Grant(ctx, rbac.AllowRule(testerRole.ID, rr[3].RbacResource(), "read"))) + req.NoError(svc.rbacSvc.Grant(ctx, rbac.AllowRule(testerRole.ID, rr[6].RbacResource(), "read"))) t.Log("search for the newly created records; should find 2 we're allowed to read") f.IncTotal = true @@ -770,8 +754,6 @@ func TestRecord_contextualRolesAccessControl(t *testing.T) { //log = zap.NewNop() log = logger.MakeDebugLogger() - rbacService = rbac.NewService(log, nil) - nextIDi uint64 = 1 nextID = func() uint64 { nextIDi++ @@ -794,7 +776,6 @@ func TestRecord_contextualRolesAccessControl(t *testing.T) { boolField = &types.ModuleField{ID: nextID(), NamespaceID: ns.ID, ModuleID: mod.ID, Name: "yes", Kind: "String"} svc = makeTestRecordService(t, - rbacService, log, user, ns, @@ -878,14 +859,14 @@ func TestRecord_contextualRolesAccessControl(t *testing.T) { // read: x x x x x x x x x (all but one) t.Log("inform rbac service about new roles") - rbacService.UpdateRoles( + svc.rbacSvc.UpdateRoles( rbac.CommonRole.Make(baseRole.ID, baseRole.Name), rbac.MakeContextRole(ownerRole.ID, ownerRole.Name, roleCheckFnMaker("resource.ownedBy == userID"), types.RecordResourceType), rbac.MakeContextRole(truthyRole.ID, truthyRole.Name, roleCheckFnMaker(`has(resource.values, "yes") ? resource.values.yes : false`), types.RecordResourceType), rbac.MakeContextRole(tttRole.ID, tttRole.Name, roleCheckFnMaker(`has(resource.values, "num") ? resource.values.num == 333 : false`), types.RecordResourceType), ) - req.NoError(rbacService.Grant(ctx, rbac.AllowRule(baseRole.ID, types.ModuleRbacResource(0, 0), "records.search"))) + req.NoError(svc.rbacSvc.Grant(ctx, rbac.AllowRule(baseRole.ID, types.ModuleRbacResource(0, 0), "records.search"))) t.Log("log-in with test user") ctx = auth.SetIdentityToContext(ctx, auth.Authenticated(user.ID, baseRole.ID)) @@ -896,19 +877,19 @@ func TestRecord_contextualRolesAccessControl(t *testing.T) { req.Len(hits, 0) t.Log("expecting to find 5 records (owned by us)") - req.NoError(rbacService.Grant(ctx, rbac.AllowRule(ownerRole.ID, types.RecordRbacResource(0, 0, 0), "read"))) + req.NoError(svc.rbacSvc.Grant(ctx, rbac.AllowRule(ownerRole.ID, types.RecordRbacResource(0, 0, 0), "read"))) hits, _, err = svc.Find(ctx, f) req.NoError(err) req.Len(hits, 5) t.Log("expecting to find 2 records (owned by us and with true value for 'yes' field)") - req.NoError(rbacService.Grant(ctx, rbac.AllowRule(truthyRole.ID, types.RecordRbacResource(0, 0, 0), "read"))) + req.NoError(svc.rbacSvc.Grant(ctx, rbac.AllowRule(truthyRole.ID, types.RecordRbacResource(0, 0, 0), "read"))) hits, _, err = svc.Find(ctx, f) req.NoError(err) req.Len(hits, 8) t.Log("expecting to find 2 records (owned by us and with true value for 'yes' field + 333 for num)") - req.NoError(rbacService.Grant(ctx, rbac.AllowRule(tttRole.ID, types.RecordRbacResource(0, 0, 0), "read"))) + req.NoError(svc.rbacSvc.Grant(ctx, rbac.AllowRule(tttRole.ID, types.RecordRbacResource(0, 0, 0), "read"))) hits, _, err = svc.Find(ctx, f) req.NoError(err) req.Len(hits, 9) @@ -931,11 +912,15 @@ func TestSetRecordOwner(t *testing.T) { req.NoError(store.TruncateRbacRules(ctx, s)) var ( - rbacService = rbac.NewService( - zap.NewNop(), - //logger.MakeDebugLogger(), - nil, - ) + rbacService = rbac.NewServiceMust(ctx, zap.NewNop(), s, rbac.Config{ + Synchronous: true, + DecayInterval: time.Hour * 2, + CleanupInterval: time.Hour * 2, + ReindexInterval: time.Hour * 2, + IndexFlushInterval: time.Hour * 2, + RuleStorage: s, + RoleStorage: s, + }) ac = &accessControl{rbac: rbacService} invoker = &sysTypes.User{ID: 1001} diff --git a/server/discovery/rest/internal/documents/compose.go b/server/discovery/rest/internal/documents/compose.go index de78438992..21406d932c 100644 --- a/server/discovery/rest/internal/documents/compose.go +++ b/server/discovery/rest/internal/documents/compose.go @@ -22,7 +22,7 @@ type ( settings *sysTypes.AppSettings rbac interface { - SignificantRoles(res rbac.Resource, op string) (aRR, dRR []uint64) + SignificantRoles(ctx context.Context, res rbac.Resource, op string) (aRR, dRR []uint64, err error) } ac interface { @@ -168,7 +168,10 @@ func (d composeResources) Namespaces(ctx context.Context, limit uint, cur string }, } - doc.Security.AllowedRoles, doc.Security.DeniedRoles = d.rbac.SignificantRoles(ns, "read") + doc.Security.AllowedRoles, doc.Security.DeniedRoles, err = d.rbac.SignificantRoles(ctx, ns, "read") + if err != nil { + return + } rsp.Documents[i].Source = doc } @@ -255,7 +258,10 @@ func (d composeResources) Modules(ctx context.Context, namespaceID uint64, limit }, } - doc.Security.AllowedRoles, doc.Security.DeniedRoles = d.rbac.SignificantRoles(mod, "read") + doc.Security.AllowedRoles, doc.Security.DeniedRoles, err = d.rbac.SignificantRoles(ctx, mod, "read") + if err != nil { + return + } rsp.Documents[i].Source = doc } @@ -378,7 +384,10 @@ func (d composeResources) Records(ctx context.Context, namespaceID, moduleID uin // Values and value labels doc.Values, doc.ValueLabels = d.recordValues(ctx, rec, mod.Fields) - doc.Security.AllowedRoles, doc.Security.DeniedRoles = d.rbac.SignificantRoles(rec, "read") + doc.Security.AllowedRoles, doc.Security.DeniedRoles, err = d.rbac.SignificantRoles(ctx, rec, "read") + if err != nil { + return + } rsp.Documents[i].Source = doc } diff --git a/server/discovery/rest/internal/documents/system.go b/server/discovery/rest/internal/documents/system.go index 5b7760b934..d24b105694 100644 --- a/server/discovery/rest/internal/documents/system.go +++ b/server/discovery/rest/internal/documents/system.go @@ -21,7 +21,7 @@ type ( settings *types.AppSettings rbac interface { - SignificantRoles(res rbac.Resource, op string) (aRR, dRR []uint64) + SignificantRoles(ctx context.Context, res rbac.Resource, op string) (aRR, dRR []uint64, err error) } ac interface { @@ -92,7 +92,10 @@ func (d systemResources) Users(ctx context.Context, limit uint, cur string, user doc.Url = fmt.Sprintf("%s/admin/system/user/edit/%d", d.opt.CortezaDomain, u.ID) } - doc.Security.AllowedRoles, doc.Security.DeniedRoles = d.rbac.SignificantRoles(u, "read") + doc.Security.AllowedRoles, doc.Security.DeniedRoles, err = d.rbac.SignificantRoles(ctx, u, "read") + if err != nil { + return + } rsp.Documents[i].ID = u.ID rsp.Documents[i].Source = doc diff --git a/server/discovery/rest/internal/feed/resource.go b/server/discovery/rest/internal/feed/resource.go index a56116ca5a..f0cace2f14 100644 --- a/server/discovery/rest/internal/feed/resource.go +++ b/server/discovery/rest/internal/feed/resource.go @@ -2,12 +2,13 @@ package feed import ( "context" + "time" + "github.com/cortezaproject/corteza/server/discovery/service" "github.com/cortezaproject/corteza/server/discovery/types" "github.com/cortezaproject/corteza/server/pkg/errors" "github.com/cortezaproject/corteza/server/pkg/filter" "github.com/cortezaproject/corteza/server/pkg/options" - "time" "github.com/cortezaproject/corteza/server/pkg/rbac" ) @@ -17,7 +18,7 @@ type ( opt options.DiscoveryOpt rbac interface { - SignificantRoles(res rbac.Resource, op string) (aRR, dRR []uint64) + SignificantRoles(ctx context.Context, res rbac.Resource, op string) (aRR, dRR []uint64, err error) } ac interface { diff --git a/server/federation/service/access_control.gen.go b/server/federation/service/access_control.gen.go index 7af080b2f5..f752c29fda 100644 --- a/server/federation/service/access_control.gen.go +++ b/server/federation/service/access_control.gen.go @@ -22,9 +22,9 @@ import ( type ( rbacService interface { Can(rbac.Session, string, rbac.Resource) bool - Trace(rbac.Session, string, rbac.Resource) *rbac.Trace + Trace(rbac.Session, string, rbac.Resource) (*rbac.Trace, error) Grant(context.Context, ...*rbac.Rule) error - FindRulesByRoleID(roleID uint64) (rr rbac.RuleSet) + FindRulesByRoleID(ctx context.Context, roleID uint64) (rr rbac.RuleSet, err error) } accessControl struct { @@ -120,11 +120,16 @@ func (svc accessControl) Trace(ctx context.Context, userID uint64, roles []uint6 return nil, fmt.Errorf("no roles specified") } + var auxTrace *rbac.Trace session := rbac.ParamsToSession(ctx, userID, roles...) for _, res := range resources { r := res.RbacResource() for op := range rbacResourceOperations(r) { - ee = append(ee, svc.rbac.Trace(session, op, res)) + auxTrace, err = svc.rbac.Trace(session, op, res) + if err != nil { + return + } + ee = append(ee, auxTrace) } } @@ -287,7 +292,7 @@ func (svc accessControl) FindRulesByRoleID(ctx context.Context, roleID uint64) ( return nil, AccessControlErrNotAllowedToSetPermissions() } - return svc.rbac.FindRulesByRoleID(roleID), nil + return svc.rbac.FindRulesByRoleID(ctx, roleID) } // CanManageNode checks if current user can manage federation node diff --git a/server/pkg/options/options.gen.go b/server/pkg/options/options.gen.go index 9eb9c82aa0..9755cbdaa9 100644 --- a/server/pkg/options/options.gen.go +++ b/server/pkg/options/options.gen.go @@ -52,11 +52,19 @@ type ( } RbacOpt struct { - Log bool `env:"RBAC_LOG"` - ServiceUser string `env:"RBAC_SERVICE_USER"` - BypassRoles string `env:"RBAC_BYPASS_ROLES"` - AuthenticatedRoles string `env:"RBAC_AUTHENTICATED_ROLES"` - AnonymousRoles string `env:"RBAC_ANONYMOUS_ROLES"` + Log bool `env:"RBAC_LOG"` + MaxIndexSize int `env:"RBAC_MAX_INDEX_SIZE"` + Synchronous bool `env:"RBAC_SYNCHRONOUS"` + ReindexStrategy string `env:"RBAC_REINDEX_STRATEGY"` + DecayFactor float64 `env:"RBAC_DECAY_FACTOR"` + DecayInterval time.Duration `env:"RBAC_DECAY_INTERVAL"` + CleanupInterval time.Duration `env:"RBAC_CLEANUP_INTERVAL"` + ReindexInterval time.Duration `env:"RBAC_REINDEX_INTERVAL"` + IndexFlushInterval time.Duration `env:"RBAC_INDEX_FLUSH_INTERVAL"` + ServiceUser string `env:"RBAC_SERVICE_USER"` + BypassRoles string `env:"RBAC_BYPASS_ROLES"` + AuthenticatedRoles string `env:"RBAC_AUTHENTICATED_ROLES"` + AnonymousRoles string `env:"RBAC_ANONYMOUS_ROLES"` } SCIMOpt struct { @@ -378,6 +386,14 @@ func HttpServer() (o *HttpServerOpt) { // This function is auto-generated func Rbac() (o *RbacOpt) { o = &RbacOpt{ + MaxIndexSize: -1, + Synchronous: false, + ReindexStrategy: "", + DecayFactor: 0.9, + DecayInterval: time.Minute * 30, + CleanupInterval: time.Minute * 31, + ReindexInterval: time.Minute * 10, + IndexFlushInterval: time.Minute * 35, BypassRoles: "super-admin", AuthenticatedRoles: "authenticated", AnonymousRoles: "anonymous", diff --git a/server/pkg/rbac/index_stats.go b/server/pkg/rbac/index_stats.go new file mode 100644 index 0000000000..60be24aa06 --- /dev/null +++ b/server/pkg/rbac/index_stats.go @@ -0,0 +1,9 @@ +package rbac + +type ( + statLogger interface { + CacheHit(roles []uint64, resource string, op string) + CacheMiss(roles []uint64, resource string, op string) + CacheUpdate(*Rule) + } +) diff --git a/server/pkg/rbac/mocks_test.go b/server/pkg/rbac/mocks_test.go new file mode 100644 index 0000000000..6c399894ff --- /dev/null +++ b/server/pkg/rbac/mocks_test.go @@ -0,0 +1,87 @@ +package rbac + +import ( + "context" + "strings" + "time" + + "github.com/cortezaproject/corteza/server/system/types" + "github.com/spf13/cast" +) + +type ( + mockRuleStore struct { + searchResponse []*Rule + + searchResponses [][]*Rule + searchCount int + + searches []RuleFilter + access Access + } +) + +func (svc *mockRuleStore) SearchRbacRules(ctx context.Context, f RuleFilter) (out RuleSet, _ RuleFilter, err error) { + svc.searches = append(svc.searches, f) + + if svc.searchResponses != nil { + out = svc.searchResponses[svc.searchCount] + svc.searchCount++ + return + } + + if svc.searchResponse != nil { + return svc.searchResponse, f, nil + } + + if f.RawFilter != "" { + // (rel_role=%d and (%s)) + combos := strings.Split(f.RawFilter, ") or (") + + for _, c := range combos { + roleRules := strings.Split(c, " and (") + + role := strings.Split(roleRules[0], "=")[1] + + for _, rule := range strings.Split(roleRules[1], " or ") { + rs := strings.Split(rule, "resource=")[1] + + out = append(out, &Rule{ + RoleID: cast.ToUint64(role), + Resource: rs[1 : len(rs)-1], + Operation: "read", + Access: svc.access, + }) + } + } + } else { + for _, r := range f.Resource { + out = append(out, &Rule{ + RoleID: f.RoleID, + Resource: r, + Operation: f.Operation, + Access: svc.access, + }) + } + } + + // Give a slight delay to better simulate what we'd expect to see in real-world + time.Sleep(time.Millisecond * 5) + return +} + +func (svc mockRuleStore) UpsertRbacRule(ctx context.Context, rr ...*Rule) (err error) { + return +} + +func (svc mockRuleStore) DeleteRbacRule(ctx context.Context, rr ...*Rule) (err error) { + return +} + +func (svc mockRuleStore) TruncateRbacRules(ctx context.Context) (err error) { + return +} + +func (svc mockRuleStore) SearchRoles(ctx context.Context, f types.RoleFilter) (out types.RoleSet, _ types.RoleFilter, err error) { + return +} diff --git a/server/pkg/rbac/roles.go b/server/pkg/rbac/roles.go index c4ab27e974..9cd243f066 100644 --- a/server/pkg/rbac/roles.go +++ b/server/pkg/rbac/roles.go @@ -115,8 +115,26 @@ func statRoles(rr ...*Role) (stats map[roleKind]int) { return } +func removedRoles(current []*Role, new ...*Role) (out []*Role) { + nx := map[uint64]bool{} + + for _, n := range new { + nx[n.id] = true + } + + for _, c := range current { + if nx[c.id] { + continue + } + + out = append(out, c) + } + + return +} + // compare list of session roles (ids) with preloaded roles and calculate the final list -func getContextRoles(s Session, res Resource, preloadedRoles []*Role) (out partRoles) { +func evalRoles(s Session, res Resource, preloadedRoles ...*Role) (out partRoles) { var ( mm = slice.ToUint64BoolMap(s.Roles()) scope = make(map[string]interface{}) diff --git a/server/pkg/rbac/roles_test.go b/server/pkg/rbac/roles_test.go index f6172cafdb..ad36513bb5 100644 --- a/server/pkg/rbac/roles_test.go +++ b/server/pkg/rbac/roles_test.go @@ -97,7 +97,7 @@ func Test_getContextRoles(t *testing.T) { req = require.New(t) ) - req.Equal(partitionRoles(tc.output...), getContextRoles(&session{rr: tc.sessionRoles}, tc.res, tc.preloadRoles)) + req.Equal(partitionRoles(tc.output...), evalRoles(&session{rr: tc.sessionRoles}, tc.res, tc.preloadRoles...)) }) } } diff --git a/server/pkg/rbac/rule_index.go b/server/pkg/rbac/rule_index.go index 2198f174d9..5116e56e86 100644 --- a/server/pkg/rbac/rule_index.go +++ b/server/pkg/rbac/rule_index.go @@ -2,24 +2,13 @@ package rbac import ( "sort" - "strings" ) type ( // ruleIndex indexes all given RBAC rules to optimize lookup times - // - // The algorithm is based on the standard trie structure. - // The max depth for a check operation is M+2 where M is the number of - // RBAC resource path elements + component + some meta. ruleIndex struct { - children map[uint64]*ruleIndexNode - } - - ruleIndexNode struct { - children map[string]*ruleIndexNode - isLeaf bool - access Access - rule *Rule + // children map[uint64]*ruleIndexNode + bits map[string]map[string]*Rule } ) @@ -27,35 +16,34 @@ type ( // // The build isn't that cleanned up but the lookup is good, I promise <3 func buildRuleIndex(rules []*Rule) (index *ruleIndex) { - index = &ruleIndex{ - children: make(map[uint64]*ruleIndexNode, 8), + index = &ruleIndex{} + index.add(rules...) + return index +} + +// add adds a new Rule to the index +func (index *ruleIndex) add(rules ...*Rule) { + if index.bits == nil { + index.bits = make(map[string]map[string]*Rule, len(rules)/2) } for _, r := range rules { - if _, ok := index.children[r.RoleID]; !ok { - index.children[r.RoleID] = &ruleIndexNode{ - children: make(map[string]*ruleIndexNode, 4), - } + if _, ok := index.bits[r.Operation]; !ok { + index.bits[r.Operation] = make(map[string]*Rule, 4) } - n := index.children[r.RoleID] - bits := append([]string{r.Operation}, strings.Split(r.Resource, "/")...) - for _, b := range bits { - if _, ok := n.children[b]; !ok { - n.children[b] = &ruleIndexNode{ - children: make(map[string]*ruleIndexNode, 4), - } - } - - n = n.children[b] - } - - n.isLeaf = true - n.access = r.Access - n.rule = r + index.bits[r.Operation][r.Resource] = r } +} - return index +// has checks if the rule is already in there +func (t *ruleIndex) has(r *Rule) bool { + return len(t.collect(true, r.Operation, r.Resource)) > 0 +} + +// get returns the matching rules +func (t *ruleIndex) get(op, res string) (out []*Rule) { + return t.collect(false, op, res) } // get returns all RBAC rules matching these constraints @@ -64,93 +52,31 @@ func buildRuleIndex(rules []*Rule) (index *ruleIndex) { // the operation + 1 for the role. // // Our longest bit will be 6 so this is essentially constant time. -func (t *ruleIndex) get(role uint64, op, res string) (out []*Rule) { - if t.children == nil { - return - } - - if _, ok := t.children[role]; !ok { - return - } - - // An edge case implied by the test suite - if op == "" && res == "" { - out = append(out, t.children[role].children[""].children[""].rule) - return - } - - // Pull out the nodes for the role - aux, ok := t.children[role] - if !ok { - return - } - - aux, ok = aux.children[op] - if !ok { - return - } - - return aux.get(res, 0) -} - -// get returns all of the rules matching these constraints -// -// Under the hood... -// We're avoiding string processing (concatenation, splitting, ...) as that can -// be a memory hog in scenarios where we're pounding this function. -// -// The from denotes the substring we've not yet processed. -func (n *ruleIndexNode) get(res string, from int) (out []*Rule) { - if n == nil || n.children == nil { +func (t *ruleIndex) collect(exact bool, op, res string) (out []*Rule) { + if t.bits[op] == nil { return } - // If we've reached the leaf node but haven't yet processed the entire resource, - // we've reached an invalid scenario since we can't go any deeper - to := len(res) - if n.isLeaf && from < to { - return - } - - // Once from passes to, we've processed the entire resource - if from >= to { - if n.isLeaf { - out = append(out, n.rule) - return + for _, res := range permuteResource(res) { + aux := t.bits[op][res] + if aux == nil { + continue } - } - - // Get the next / delimiter. - // Clamp the index to the length of the resource. - // Adjust the index to account the from (the start index of the remaining resource) - nextDelim := strings.Index(res[from:to], "/") - if nextDelim < 0 { - nextDelim = len(res) - } else { - nextDelim += from - } - - // Get RBAC rules down the actual path - pathBit := res[from:nextDelim] - if n.children[pathBit] != nil { - out = append(out, n.children[pathBit].get(res, nextDelim+1)...) - } - // Get RBAC rules down the wildcard path - if n.children[wildcard] != nil { - out = append(out, n.children[wildcard].get(res, nextDelim+1)...) + out = append(out, t.bits[op][res]) } - return + return out } // empty returns true if the index is empty func (t *ruleIndex) empty() bool { - return t == nil || t.children == nil || len(t.children) == 0 + return t == nil || t.bits == nil || len(t.bits) == 0 } -func (t *ruleIndex) matchingRule(role uint64, op, res string) (out *Rule) { - set := RuleSet(t.get(role, op, res)) +// matchingRule returns the first matching rule for the role, op, res +func (t *ruleIndex) matchingRule(op, res string) (out *Rule) { + set := RuleSet(t.get(op, res)) sort.Sort(set) for _, s := range set { diff --git a/server/pkg/rbac/rule_index_test.go b/server/pkg/rbac/rule_index_test.go index 3390673a93..6043fa5458 100644 --- a/server/pkg/rbac/rule_index_test.go +++ b/server/pkg/rbac/rule_index_test.go @@ -1,30 +1,30 @@ package rbac import ( + "math/rand" "sort" "testing" "github.com/stretchr/testify/require" ) -func TestIndex(t *testing.T) { +func TestIndexBuild(t *testing.T) { tcc := []struct { name string in []*Rule + add []*Rule out []int - role uint64 - op string - res string + op string + res string }{ { name: "empty", in: nil, out: nil, - role: 1, - op: "read", - res: "a:b/c/d", + op: "read", + res: "a:b/c/d", }, { name: "match", in: []*Rule{{ @@ -35,9 +35,8 @@ func TestIndex(t *testing.T) { }}, out: []int{0}, - role: 1, - op: "read", - res: "a:b/c/d", + op: "read", + res: "a:b/c/d", }, { name: "multiple matches", in: []*Rule{{ @@ -53,80 +52,63 @@ func TestIndex(t *testing.T) { }}, out: []int{0, 1}, - role: 1, - op: "read", - res: "a:b/c/d", + op: "read", + res: "a:b/c/d", }, { - name: "one match one role missmatch", + name: "path missmatch", in: []*Rule{{ - RoleID: 2, - Resource: "a:b/c/d", - Operation: "read", - Access: Allow, - }, { RoleID: 1, - Resource: "a:b/*/*", - Operation: "read", - Access: Inherit, - }}, - out: []int{1}, - - role: 1, - op: "read", - res: "a:b/c/d", - }, { - name: "role missmatch", - in: []*Rule{{ - RoleID: 2, - Resource: "a:b/c/d", + Resource: "a:b/c/e", Operation: "read", Access: Allow, - }, { - RoleID: 3, - Resource: "a:b/*/*", - Operation: "read", - Access: Inherit, }}, out: nil, - role: 1, - op: "read", - res: "a:b/c/d", + op: "read", + res: "a:b/c/d", }, { - name: "path missmatch", + name: "operation missmatch", in: []*Rule{{ RoleID: 1, - Resource: "a:b/c/e", - Operation: "read", + Resource: "a:b/c/d", + Operation: "write", Access: Allow, }}, out: nil, - role: 1, - op: "read", - res: "a:b/c/d", - }, { - name: "operation missmatch", + op: "read", + res: "a:b/c/d", + }, + { + name: "add new element", in: []*Rule{{ RoleID: 1, Resource: "a:b/c/d", Operation: "write", Access: Allow, }}, - out: nil, + add: []*Rule{{ + RoleID: 1, + Resource: "a:b/c/x", + Operation: "write", + Access: Allow, + }}, - role: 1, - op: "read", - res: "a:b/c/d", + out: []int{1}, + + op: "write", + res: "a:b/c/x", }} for _, tc := range tcc { t.Run(tc.name, func(t *testing.T) { ix := buildRuleIndex(tc.in) - out := RuleSet(ix.get(tc.role, tc.op, tc.res)) + ix.add(tc.add...) + + out := RuleSet(ix.get(tc.op, tc.res)) sort.Sort(out) - want := RuleSet(graby(tc.in, tc.out)) + want := RuleSet(grabIndexMatches(append(tc.in, tc.add...), tc.out)) sort.Sort(want) require.Len(t, out, len(want)) @@ -135,10 +117,40 @@ func TestIndex(t *testing.T) { } }) } +} + +func TestIndexHas(t *testing.T) { + ix := buildRuleIndex([]*Rule{{ + RoleID: 1, + Resource: "a:b/c/x", + Operation: "write", + Access: Allow, + }}) + + require.True(t, ix.has(&Rule{ + RoleID: 1, + Resource: "a:b/c/x", + Operation: "write", + Access: Allow, + })) + + require.False(t, ix.has(&Rule{ + RoleID: 1, + Resource: "a:b/c/*", + Operation: "write", + Access: Allow, + })) + + require.False(t, ix.has(&Rule{ + RoleID: 1, + Resource: "a:b/c/zz", + Operation: "write", + Access: Allow, + })) } -func graby(rr []*Rule, want []int) (out []*Rule) { +func grabIndexMatches(rr []*Rule, want []int) (out []*Rule) { out = make([]*Rule, 0, len(want)) for _, w := range want { @@ -148,14 +160,14 @@ func graby(rr []*Rule, want []int) (out []*Rule) { return } -// goos: linux -// goarch: amd64 +// goos: darwin +// goarch: arm64 // pkg: github.com/cortezaproject/corteza/server/pkg/rbac -// cpu: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz -// BenchmarkIndexBuild_100-12 10000 102361 ns/op 88064 B/op 1271 allocs/op -// BenchmarkIndexBuild_1000-12 1149 1024872 ns/op 755375 B/op 11183 allocs/op -// BenchmarkIndexBuild_10000-12 128 8986248 ns/op 4406477 B/op 82453 allocs/op -// BenchmarkIndexBuild_100000-12 14 81871407 ns/op 20627785 B/op 543568 allocs/op +// cpu: Apple M3 Pro +// BenchmarkIndexBuild_100-12 140071 7721 ns/op 10936 B/op 19 allocs/op +// BenchmarkIndexBuild_1000-12 14124 83847 ns/op 121899 B/op 45 allocs/op +// BenchmarkIndexBuild_10000-12 1161 944820 ns/op 1461061 B/op 429 allocs/op +// BenchmarkIndexBuild_100000-12 98 12371088 ns/op 13424712 B/op 3409 allocs/op func benchmarkIndexBuild(b *testing.B, rules []*Rule) { b.ResetTimer() @@ -179,3 +191,16 @@ func BenchmarkIndexBuild_10000(b *testing.B) { func BenchmarkIndexBuild_100000(b *testing.B) { benchmarkIndexBuild(b, makeRuleSet(100000, 10)) } + +func makeRuleSet(count int, roleCount int) (out RuleSet) { + for i := 0; i < count; i++ { + out = append(out, &Rule{ + RoleID: uint64(1000 + int(rand.Intn(roleCount))), + Resource: randomResource(), + Operation: randomOperation(), + Access: randomAccess(), + }) + } + + return +} diff --git a/server/pkg/rbac/ruleset_checks.go b/server/pkg/rbac/ruleset_checks.go deleted file mode 100644 index ba5554d1e1..0000000000 --- a/server/pkg/rbac/ruleset_checks.go +++ /dev/null @@ -1,116 +0,0 @@ -package rbac - -// function checks all given rules -// -// - indexRules are rules optimized for quick lookup. -// The trie data structure approach is used to optimize lookups and reduce -// memory consumption. -// -// - rolesByKind are roles optimized for quick lookup -// roles are grouped by kind and each kind contains fast-lookup (map[role-id]bool) -// -// - op and res represent operation and resource that are checked -// -// - trace is optional; when not nil, function will update trace struct -// with information as it traverses and checks the rules -func check(indexedRules *ruleIndex, rolesByKind partRoles, op, res string, trace *Trace) Access { - baseTraceInfo(trace, res, op, rolesByKind) - - if member(rolesByKind, AnonymousRole) && len(rolesByKind) > 1 { - // Integrity check; when user is member of anonymous role - // should not be member of any other type of role - return resolve(trace, Deny, failedIntegrityCheck) - } - - if member(rolesByKind, BypassRole) { - // if user has at least one bypass role, we allow access - return resolve(trace, Allow, bypassRoleMembership) - } - - if indexedRules.empty() { - // no rules to check - return resolve(trace, Inherit, noRules) - } - - var ( - match *Rule - allowed bool - ) - - // - if trace != nil { - // from this point on, there is a chance trace (if set) - // will contain some rules. - // - // Stable order needs to be ensured: there is no production - // code that relies on that but tests might fail and API - // response would be flaky. - defer sortTraceRules(trace) - } - - // Priority is important here. We want to have - // stable RBAC check behaviour and ability - // to override allow/deny depending on how niche the role (type) is: - // - context (eg owners) are more niche than common - // - rules for common roles are more important than authenticated and anonymous role types - // - // Note that bypass roles are intentionally ignored here; if user is member of - // bypass role there is no need to check any other rule - for _, kind := range []roleKind{ContextRole, CommonRole, AuthenticatedRole, AnonymousRole} { - // not a member of any role of this kind - if len(rolesByKind[kind]) == 0 { - continue - } - - // reset allowed to false - // for each role kind - allowed = false - - for r := range rolesByKind[kind] { - match = indexedRules.matchingRule(r, op, res) - - // check all rules for each role the security-context - if match == nil { - // no rules match - continue - } - - if trace != nil { - // if trace is enabled, append - // each matching rule - trace.Rules = append(trace.Rules, match) - } - - if match.Access == Deny { - // if we stumble upon Deny we short-circuit the check - return resolve(trace, Deny, "") - } - - if match.Access == Allow { - // allow rule found, we need to check rules on other roles - // before we allow it - allowed = true - } - } - - if allowed { - // at least one of the roles (per role type) in the security context - // allows operation on a resource - return resolve(trace, Allow, "") - } - } - - // No rule matched - return resolve(trace, Inherit, noMatch) -} - -// at least one of the roles must be set to true -func member(r partRoles, k roleKind) bool { - for _, is := range r[k] { - if is { - return true - } - } - - return false -} diff --git a/server/pkg/rbac/ruleset_checks_test.go b/server/pkg/rbac/ruleset_checks_test.go deleted file mode 100644 index f46962b08a..0000000000 --- a/server/pkg/rbac/ruleset_checks_test.go +++ /dev/null @@ -1,238 +0,0 @@ -package rbac - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func Test_check(t *testing.T) { - var ( - cc = []struct { - name string - exp Access - res string - op string - rr []*Role - set RuleSet - }{ - {"inherit when no roles or rules", - Inherit, "", "", nil, nil}, - { - "allow when checking with bypass roles", - Allow, - "", - "", - []*Role{ - {id: 1, kind: BypassRole}, - }, - nil, - }, - { - "inherit when no matching roles", - Inherit, - "", - "", - []*Role{ - {id: 1, kind: CommonRole}, - }, - []*Rule{ - {RoleID: 2, Access: Deny}, - }, - }, - { - "allow when matching rule", - Allow, - "", - "", - []*Role{ - {id: 1, kind: CommonRole}, - }, - []*Rule{ - {RoleID: 1, Access: Allow}, - {RoleID: 2, Access: Deny}, - }, - }, - { - "multiple matching roles of same kind with deny", - Deny, - "", - "", - []*Role{ - {id: 1, kind: CommonRole}, - {id: 2, kind: CommonRole}, - }, - []*Rule{ - {RoleID: 1, Access: Allow}, - {RoleID: 2, Access: Deny}, - }, - }, - { - "multiple matching matching roles of different with deny last", - Allow, - "", - "", - []*Role{ - {id: 1, kind: CommonRole}, - {id: 2, kind: AuthenticatedRole}, - }, - []*Rule{ - {RoleID: 1, Access: Allow}, - {RoleID: 2, Access: Deny}, - }, - }, - { - "complex inheritance", - Deny, - "test::test:test/1/2/3", - "", - []*Role{ - {id: 1, kind: CommonRole}, - {id: 2, kind: CommonRole}, - }, - []*Rule{ - {RoleID: 1, Operation: "", Resource: "test::test:test/1/*/*", Access: Allow}, - {RoleID: 2, Operation: "", Resource: "test::test:test/*/*/3", Access: Allow}, - {RoleID: 2, Operation: "", Resource: "test::test:test/1/2/3", Access: Deny}, - {RoleID: 1, Operation: "", Resource: "test::test:test/*/2/3", Access: Allow}, - }, - }, - } - ) - - for _, c := range cc { - t.Run(c.name, func(t *testing.T) { - require.Equal(t, c.exp.String(), check(buildRuleIndex(c.set), partitionRoles(c.rr...), c.op, c.res, nil).String()) - }) - } -} - -func Test_checkWithTrace(t *testing.T) { - var ( - trace *Trace - - cc = []struct { - name string - res string - exp Access - rr []*Role - set RuleSet - trace *Trace - }{ - { - "fail on integrity check (multiple anonymous roles)", - "res-trace", - Deny, - []*Role{ - {id: 1, kind: AnonymousRole}, - {id: 2, kind: CommonRole}, - {id: 3, kind: CommonRole}, - }, - nil, - &Trace{ - Resource: "res-trace", - Operation: "op-trace", - Access: Deny, - Roles: []uint64{1, 2, 3}, - Rules: nil, - Resolution: failedIntegrityCheck, - }, - }, - { - "allow when checking with bypass roles", - "res-trace", - Allow, - []*Role{ - {id: 1, kind: BypassRole}, - }, - nil, - &Trace{ - Resource: "res-trace", - Operation: "op-trace", - Access: Allow, - Roles: []uint64{1}, - Rules: nil, - Resolution: bypassRoleMembership, - }, - }, - { - "no rules", - "res-trace", - Allow, - []*Role{ - {id: 1, kind: CommonRole}, - }, - nil, - &Trace{ - Resource: "res-trace", - Operation: "op-trace", - Access: Inherit, - Roles: []uint64{1}, - Rules: nil, - Resolution: noRules, - }, - }, - { - "multi-role", - "res-trace", - Allow, - []*Role{ - {id: 1, kind: CommonRole}, - {id: 2, kind: CommonRole}, - {id: 3, kind: AuthenticatedRole}, - }, - RuleSet{ - AllowRule(1, "res-trace", "op-trace"), - AllowRule(2, "res-trace", "op-trace"), - AllowRule(3, "res-trace", "op-trace"), - AllowRule(1, "res-trace-2", "op-trace"), - AllowRule(2, "res-trace", "op-trace-2"), - }, - &Trace{ - Resource: "res-trace", - Operation: "op-trace", - Access: Allow, - Roles: []uint64{1, 2, 3}, - Rules: RuleSet{ - AllowRule(1, "res-trace", "op-trace"), - AllowRule(2, "res-trace", "op-trace"), - }, - }, - }, - { - "nested resource", - "res-trace/2", - Allow, - []*Role{ - {id: 1, kind: CommonRole}, - {id: 2, kind: CommonRole}, - {id: 3, kind: AuthenticatedRole}, - }, - RuleSet{ - AllowRule(1, "res-trace/*", "op-trace"), - AllowRule(2, "res-trace/*", "op-trace"), - AllowRule(2, "res-trace/1", "op-trace"), - }, - &Trace{ - Resource: "res-trace/2", - Operation: "op-trace", - Access: Allow, - Roles: []uint64{1, 2, 3}, - Rules: RuleSet{ - AllowRule(1, "res-trace/*", "op-trace"), - AllowRule(2, "res-trace/*", "op-trace"), - }, - }, - }, - } - ) - - for _, c := range cc { - t.Run(c.name, func(t *testing.T) { - trace = new(Trace) - check(buildRuleIndex(c.set), partitionRoles(c.rr...), "op-trace", c.res, trace) - require.Equal(t, c.trace, trace) - - }) - } -} diff --git a/server/pkg/rbac/ruleset_utils.go b/server/pkg/rbac/ruleset_utils.go index 0f8d5b055c..dcfd706daa 100644 --- a/server/pkg/rbac/ruleset_utils.go +++ b/server/pkg/rbac/ruleset_utils.go @@ -55,16 +55,6 @@ func eq(a, b *Rule) bool { a.Operation == b.Operation } -func ruleByRole(base RuleSet, roleID uint64) (out RuleSet) { - for _, r := range base { - if r.RoleID == roleID { - out = append(out, r) - } - } - - return -} - // Dirty returns list of deleted (Access==Inherit) and changed (dirty) rules func flushable(set RuleSet) (deletable, updatable, final RuleSet) { deletable, updatable, final = RuleSet{}, RuleSet{}, RuleSet{} diff --git a/server/pkg/rbac/service.go b/server/pkg/rbac/service.go index 8d58c83e99..ce3c3aa00f 100644 --- a/server/pkg/rbac/service.go +++ b/server/pkg/rbac/service.go @@ -2,33 +2,140 @@ package rbac import ( "context" + "fmt" + "math" + "sort" "strconv" + "strings" "sync" "time" - "github.com/cortezaproject/corteza/server/pkg/sentry" + "github.com/cortezaproject/corteza/server/pkg/filter" + "github.com/cortezaproject/corteza/server/system/types" + "github.com/spf13/cast" "go.uber.org/zap" ) type ( - service struct { - l *sync.RWMutex - logger *zap.Logger + Service struct { + mux sync.RWMutex + cfg Config + logger *zap.Logger + StatLogger *StatsLogger - // service will flush values on TRUE or just reload on FALSE - f chan bool + noop bool + noopAccess Access - rules RuleSet - indexed *ruleIndex + usageCounter *usageCounter[string] + roles []*Role - roles []*Role + indexes []*wrapperIndex + indexMappings map[uint64]int - store rbacRulesStore + RuleStorage rbacRulesStore + RoleStorage rbacRoleStore + } + + Config struct { + // MaxIndexSize limits the max size of the in memory index + // + // When set to -1, max size is used + // When set to 0, the in memory index is not used + MaxIndexSize int + + // Synchronous lets us make all the procedures synchronous for ease of testing + // This should always be false in production + Synchronous bool + + // ReindexStrategy specifies how the index needs to be recalcualted + // The default option is ReindexStrategyMemory + // + // If both speed and memory are needed, consider reducing MaxIndexSize + // while using ReindexStrategySpeed + ReindexStrategy ReindexStrategy + + // DecayFactor states how fast an indexed value looses it's score + DecayFactor float64 + // DecayInterval states how often we should decay indexed items + DecayInterval time.Duration + // CleanupInterval states how often stale or poor performers should be thrown out + CleanupInterval time.Duration + // ReindexInterval states how often we should reindex based on updated scores + ReindexInterval time.Duration + // IndexFlushInterval states how often the index state should be flushed to the database + IndexFlushInterval time.Duration + + // RuleStorage provides the methods to interact with rules + RuleStorage rbacRulesStore + // RoleStorage provides the methods to interact with roles + RoleStorage rbacRoleStore + + // PullInitialState provides the initial index state + // + // The string slice provides index keys which should then be further processed + // to determine the actual index state. + // + // When working with resource rule combos, the key will be `{roleID}:{resourceIdentifier}` + PullInitialState func(ctx context.Context, n int) ([]string, error) + // FlushIndexState takes the current index state and flushes it to the database + // @todo for now it's a noop; we should preserve + FlushIndexState func(context.Context, []string) error + } + + // evaluationState is a little helper to keep all the things we need in place + evaluationState struct { + unindexedRoles partRoles + indexedRoles partRoles + + unindexedRules [5]map[uint64][]*Rule + + res string + op string + } + + expCtrItem struct { + Key string `json:"key"` + Score float64 `json:"score"` + + // added denotes when the item was added to the counter + Added time.Time `json:"added"` + // lastScored denotes when the item was last scored (either via decay or access) + LastScored time.Time `json:"lastScored"` + // lastAccess denotes when the item was last accessed, needed + LastAccess time.Time `json:"lastAccess"` + } + + Stats struct { + CacheHits uint `json:"cacheHits"` + CacheMisses uint `json:"cacheMisses"` + CacheUpdates uint `json:"cacheUpdates"` + AvgDatabaseTiming time.Duration `json:"avgDatabaseTiming"` + MinDatabaseTiming time.Duration `json:"minDatabaseTiming"` + MaxDatabaseTiming time.Duration `json:"maxDatabaseTiming"` + AvgIndexTiming time.Duration `json:"avgIndexTiming"` + MinIndexTiming time.Duration `json:"minIndexTiming"` + MaxIndexTiming time.Duration `json:"maxIndexTiming"` + + IndexSize int `json:"indexSize"` + + LastHits []string `json:"lastHits"` + LastMisses []string `json:"lastMisses"` + LastDatabaseTimings []time.Duration `json:"lastDatabaseTimings"` + LastIndexTimings []time.Duration `json:"lastIndexTimings"` + + Counters []expCtrItem `json:"counters"` } - // RuleFilter is a dummy struct to satisfy store codegen RuleFilter struct { + RawFilter string + + Resource []string + Operation string + RoleID uint64 + Limit uint + + filter.Sorting } RoleSettings struct { @@ -36,79 +143,197 @@ type ( Authenticated []uint64 Anonymous []uint64 } -) -var ( - // Global RBAC service - gRBAC *service + ReindexStrategy string ) const ( - watchInterval = time.Hour + // ReindexStrategyDefault defaults to ReindexStrategyMemory + ReindexStrategyDefault ReindexStrategy = "" + // ReindexStrategyMemory prioritizes memory consumption over speed + // + // This mode firstly clears out stale values and then pulls in existing. + // The memory consumption should remain about the same through this process. + ReindexStrategyMemory ReindexStrategy = "memory" + // ReindexStrategySpeed prioritizes speed over memory + // + // This mode firstly builds the new index with the same (or larger) size as + // the current one (the new index falls under the upper limit). + // The memory consumption, worst case, will be 2x the upper limit. + ReindexStrategySpeed ReindexStrategy = "speed" RuleResourceType = "corteza::generic:rbac-rule" + + maxRuleFetchChunk = 10000 +) + +var ( + // Global RBAC service + gWrapper *Service ) // Global returns global RBAC service -func Global() *service { - return gRBAC +func Global() *Service { + return gWrapper } // SetGlobal re-sets global service -func SetGlobal(svc *service) { - gRBAC = svc +func SetGlobal(svc *Service) { + gWrapper = svc } -// NewService initializes service{} struct -// -// service{} struct preloads, checks, grants and flushes privileges to and from store -// It acts as a caching layer -func NewService(logger *zap.Logger, s rbacRulesStore) (svc *service) { - svc = &service{ - l: &sync.RWMutex{}, - f: make(chan bool), +// NoopSvc creates a blank RBAC service which always returns the stated access +func NoopSvc(access Access, cc Config) (svc *Service) { + return &Service{ + noop: true, + noopAccess: access, + logger: zap.NewNop(), + + RuleStorage: cc.RuleStorage, + RoleStorage: cc.RoleStorage, - store: s, + cfg: cc, + } +} + +// NewService initializes the wrapper service with all the required surrounding bits +func NewService(ctx context.Context, l *zap.Logger, store rbacRulesStore, cc Config) (svc *Service, err error) { + cc = defaultWrapperConfig(cc) + + uc := initUsageCounter(ctx, cc) + sl := initStatsLogger(ctx, l) + svc = initSvc(ctx, l, cc, sl, uc) + + // Init bits and pieces + svc.roles, err = svc.loadRoles(ctx) + if err != nil { + return } - if logger != nil { - svc.logger = logger.Named("rbac") + svc.indexes, svc.indexMappings, err = svc.loadIndex(ctx) + if err != nil { + return } return } -// Can function performs permission check for roles in context -// -// First extracts roles from context, then -// use Check() to test against permission rules and -// iterate over all fallback functions -// -// System user is always allowed to do everything -func (svc *service) Can(ses Session, op string, res Resource) bool { - return svc.Check(ses, op, res) == Allow +func NewServiceMust(ctx context.Context, l *zap.Logger, store rbacRulesStore, cc Config) (svc *Service) { + svc, err := NewService(ctx, l, store, cc) + if err != nil { + panic(fmt.Sprintf("NewServiceMust failed with: %v", err)) + } + + return } -// Check verifies if role has access to perform an operation on a resource -// -// See RuleSet's Check() func for details -func (svc *service) Check(ses Session, op string, res Resource) (a Access) { - var ( - fRoles = getContextRoles(ses, res, svc.roles) - ) +func initUsageCounter(ctx context.Context, cc Config) (svc *usageCounter[string]) { + svc = &usageCounter[string]{ + incChan: make(chan string, 1024), + + decayFactor: cc.DecayFactor, + decayInterval: cc.DecayInterval, + cleanupInterval: cc.CleanupInterval, + + checkKeyInclusion: func(k string, role uint64) bool { + return strings.HasPrefix(k, strconv.FormatUint(role, 10)) + }, + } + + svc.watch(ctx) + return +} + +func initStatsLogger(ctx context.Context, l *zap.Logger) (svc *StatsLogger) { + svc = &StatsLogger{ + log: l.Named("rbac stats logger"), + cacheHitChan: make(chan statsWrap, 1024), + cacheMissChan: make(chan statsWrap, 1024), + timingDatabaseChan: make(chan time.Duration, 1024), + timingIndexChan: make(chan time.Duration, 1024), + } + + svc.watch(ctx) + return +} + +func initSvc(ctx context.Context, l *zap.Logger, cc Config, sl *StatsLogger, uc *usageCounter[string]) (svc *Service) { + svc = &Service{ + logger: l, + + cfg: cc, + StatLogger: sl, + + usageCounter: uc, + + RuleStorage: cc.RuleStorage, + RoleStorage: cc.RoleStorage, + } + + svc.watch(ctx) + + return +} + +func defaultWrapperConfig(base Config) (out Config) { + out = base + + // -1 disables partitioning so everything is pulled in memory + if base.MaxIndexSize == 0 { + out.MaxIndexSize = -1 + } + + // Noop to avoid branching down the line + if base.FlushIndexState == nil { + out.FlushIndexState = func(ctx context.Context, s []string) error { return nil } + } + + if base.ReindexStrategy == ReindexStrategyDefault { + out.ReindexStrategy = ReindexStrategyMemory + } + + return out +} + +// Can returns true if the given resource can be accessed +func (svc *Service) Can(ses Session, op string, res Resource) (ok bool) { + ac, err := svc.Check(ses, op, res) + if err != nil { + svc.logger.Error("check failed with error", + zap.String("op", op), + zap.String("resource", res.RbacResource()), + zap.Error(err), + ) + return false + } + + return ac == Allow +} + +// Check returns the RBAC evaluation of the resource access +func (svc *Service) Check(ses Session, op string, res Resource) (a Access, err error) { + if svc.noop { + svc.logger.Debug(fmt.Sprintf("check bypass %v %v %v: %v", ses, op, res, svc.noopAccess)) + return svc.noopAccess, nil + } + + svc.logger.Debug(fmt.Sprintf("check %v %v %v", ses, op, res)) if hasWildcards(res.RbacResource()) { // prevent use of wildcard resources for checking permissions - return Inherit + return Inherit, nil } - a = check(svc.indexed, fRoles, op, res.RbacResource(), nil) + roles := evalRoles(ses, res, svc.roles...) - return + // @todo something more robust? + svc.incCounter(roles, res) + + return svc.check(ses.Context(), roles, op, res.RbacResource(), nil) } // Trace checks RBAC rules and returns all decision trace log -func (svc *service) Trace(ses Session, op string, res Resource) *Trace { +func (svc *Service) Trace(ses Session, op string, res Resource) (*Trace, error) { var ( t = new(Trace) ) @@ -145,116 +370,116 @@ func (svc *service) Trace(ses Session, op string, res Resource) *Trace { // there is no need to procede with RBAC check baseTraceInfo(t, res.RbacResource(), op, ctxRolesDebug) resolve(t, Inherit, unknownContext) - return t + return t, nil } } var ( - fRoles = getContextRoles(ses, res, svc.roles) + fRoles = evalRoles(ses, res, svc.roles...) ) - _ = check(svc.indexed, fRoles, op, res.RbacResource(), t) + _, err := svc.check(ses.Context(), fRoles, op, res.RbacResource(), nil) + if err != nil { + return nil, err + } - return t + return t, nil } // Grant appends and/or overwrites internal rules slice // // All rules with Inherit are removed -func (svc *service) Grant(ctx context.Context, rules ...*Rule) (err error) { - svc.l.Lock() - defer svc.l.Unlock() - +func (svc *Service) Grant(ctx context.Context, rules ...*Rule) (err error) { for _, r := range rules { + if svc.logger == nil { + continue + } + svc.logger.Debug(r.Access.String() + " " + r.Operation + " on " + r.Resource + " to " + strconv.FormatUint(r.RoleID, 10)) } - svc.grant(rules...) - return svc.flush(ctx) -} + if svc.isIndexEmpty() { + err = svc.flush(ctx, rules...) + if err != nil { + return + } -func (svc *service) grant(rules ...*Rule) { - svc.rules = merge(svc.rules, rules...) - svc.indexed = buildRuleIndex(svc.rules) -} + return + } -// Watch reloads RBAC rules in intervals and on request -func (svc *service) Watch(ctx context.Context) { - go func() { - defer sentry.Recover() + svc.mux.Lock() - var ticker = time.NewTicker(watchInterval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - svc.Reload(ctx) - case <-svc.f: - svc.Reload(ctx) - } + // @todo we might manage to optimize this a bit by grouping + for _, r := range rules { + // If this resource role combo isn't indexed, we don't care + if !svc.isRuleIndexed(r) { + continue } - }() - - svc.logger.Debug("watcher initialized") -} -// FindRulesByRoleID returns all RBAC rules that belong to a role -func (svc *service) FindRulesByRoleID(roleID uint64) (rr RuleSet) { - svc.l.RLock() - defer svc.l.RUnlock() + // If it is, we need to assure this thing is inside the index now + if !svc.indexRule(r) { + // Don't hit cache update in case nothing happened + continue + } - return ruleByRole(svc.rules, roleID) -} + svc.StatLogger.CacheUpdate(r) + } + svc.mux.Unlock() -// Rules return all roles -func (svc *service) Rules() (rr RuleSet) { - svc.l.RLock() - defer svc.l.RUnlock() - return svc.rules -} + // Flush changes to database :) -// Reload store rules -func (svc *service) Reload(ctx context.Context) { - svc.l.Lock() - defer svc.l.Unlock() - svc.reloadRules(ctx) -} + err = svc.flush(ctx, rules...) + if err != nil { + return + } -// Clear removes all access control rules -func (svc *service) Clear() { - svc.l.Lock() - defer svc.l.Unlock() - svc.rules = RuleSet{} - svc.indexed = &ruleIndex{} + return } -func (svc *service) reloadRules(ctx context.Context) { - rr, _, err := svc.store.SearchRbacRules(ctx, RuleFilter{}) - svc.logger.Debug( - "reloading rules", - zap.Error(err), - zap.Int("before", len(svc.rules)), - zap.Int("after", len(rr)), - ) +func (svc *Service) Stats() (out Stats, err error) { + svc.usageCounter.lock.RLock() + defer svc.usageCounter.lock.RUnlock() - if err == nil { - svc.setRules(rr) + for k, itm := range svc.usageCounter.index { + out.Counters = append(out.Counters, expCtrItem{ + Key: k, + Score: itm.score, + Added: itm.added, + LastScored: itm.lastScored, + LastAccess: itm.lastAccess, + }) } + + out.CacheHits, + out.CacheMisses, + out.CacheUpdates, + out.AvgDatabaseTiming, + out.MinDatabaseTiming, + out.MaxDatabaseTiming, + out.AvgIndexTiming, + out.MinIndexTiming, + out.MaxIndexTiming, + out.LastHits, + out.LastMisses, + out.LastDatabaseTimings, + out.LastIndexTimings = svc.StatLogger.Stats() + + out.IndexSize = svc.indexSize() + + return } -func (svc *service) setRules(rr RuleSet) { - svc.rules = rr - svc.indexed = buildRuleIndex(rr) +func (svc *Service) indexSize() (out int) { + for _, ix := range svc.indexes { + out += ix.getSize() + } + + return } -// UpdateRoles updates RBAC roles -// -// Warning: this REPLACES all existing roles that are recognized by RBAC subsystem -func (svc *service) UpdateRoles(rr ...*Role) { - svc.l.Lock() - defer svc.l.Unlock() +func (svc *Service) UpdateRoles(rr ...*Role) { + svc.mux.Lock() + defer svc.mux.Unlock() stats := statRoles(rr...) svc.logger.Debug( @@ -267,76 +492,966 @@ func (svc *service) UpdateRoles(rr ...*Role) { zap.Int("authenticated", stats[AuthenticatedRole]), zap.Int("anonymous", stats[AnonymousRole]), ) + + removed := removedRoles(svc.roles, rr...) + svc.cleanupCounter(removed...) + + // @todo log update stats? svc.roles = rr } -// flush pushes all changed rules to the store (if service is configured with one) -func (svc *service) flush(ctx context.Context) (err error) { - if svc.store == nil { - svc.logger.Debug("rule flushing disabled (no store)") +// FindRulesByRoleID returns all RBAC rules that belong to a role +func (svc *Service) FindRulesByRoleID(ctx context.Context, roleID uint64) (rr RuleSet, err error) { + aux, _, err := svc.RuleStorage.SearchRbacRules(ctx, RuleFilter{ + RoleID: roleID, + }) + if err != nil { return } - deletable, updatable, final := flushable(svc.rules) + for _, x := range aux { + rr = append(rr, &Rule{ + RoleID: x.RoleID, + Resource: x.Resource, + Operation: x.Operation, + Access: x.Access, + }) + } + + return +} + +// SignificantRoles returns two list of significant roles. +// +// See sigRoles on rules for more details +func (svc *Service) SignificantRoles(ctx context.Context, res Resource, op string) (aRR, dRR []uint64, err error) { + svc.mux.RLock() + defer svc.mux.RUnlock() - err = svc.store.DeleteRbacRule(ctx, deletable...) + aux, _, err := svc.RuleStorage.SearchRbacRules(ctx, RuleFilter{ + Resource: []string{res.RbacResource()}, + Operation: op, + }) if err != nil { return } - err = svc.store.UpsertRbacRule(ctx, updatable...) + aRR, dRR = aux.sigRoles(res.RbacResource(), op) + return +} + +func (svc *Service) Rules(ctx context.Context) (out RuleSet, err error) { + out, _, err = svc.RuleStorage.SearchRbacRules(ctx, RuleFilter{}) + return +} + +// Clear cleans out all the data +func (svc *Service) Clear() { + svc.usageCounter = nil + svc.indexes = nil + svc.indexMappings = nil + svc.roles = nil +} + +// // // // // // // // // // // // // // // // // // // // // // // // // +// Supporting + +func (svc *Service) check(ctx context.Context, rolesByKind partRoles, op, res string, trace *Trace) (a Access, err error) { + // Preflight to resolve some pre-known states which need to bypass the standard flow + a, resolved := svc.preflightCheck(rolesByKind) + if resolved { + return + } + + st := evaluationState{op: op, res: res} + st.indexedRoles, st.unindexedRoles, err = svc.segmentRoles(rolesByKind, res) + if err != nil { + return Inherit, err + } + + // @todo can we do something with this? + svc.logCachePerformance(st.indexedRoles, st.unindexedRoles, res, op) + + if trace != nil { + // from this point on, there is a chance trace (if set) + // will contain some rules. + // + // Stable order needs to be ensured: there is no production + // code that relies on that but tests might fail and API + // response would be flaky. + defer sortTraceRules(trace) + } + + // @todo should we cache this for n seconds? just in case it's going to happen again soon? + var timing time.Duration + st.unindexedRules, timing, err = svc.pullUnindexed(ctx, st.unindexedRoles, op, res) + if err != nil { + return Inherit, err + } + + svc.logDatabaseTiming(timing) + + a, err = svc.evaluate( + []roleKind{ContextRole, CommonRole, AuthenticatedRole, AnonymousRole}, + trace, + st, + rolesByKind, + ) if err != nil { return } - clear(final) - svc.rules = final - svc.logger.Debug( - "flushed rules", - zap.Int("deleted", len(deletable)), - zap.Int("updated", len(updatable)), - zap.Int("final", len(final)), + return +} + +func (svc *Service) evaluate(roleOrder []roleKind, trace *Trace, st evaluationState, rolesByKind partRoles) (a Access, err error) { + var ( + match *Rule + allowed bool ) + // Priority is important here. We want to have + // stable RBAC check behaviour and ability + // to override allow/deny depending on how niche the role (type) is: + // - context (eg owners) are more niche than common + // - rules for common roles are more important than authenticated and anonymous role types + // + // Note that bypass roles are intentionally ignored here; if user is member of + // bypass role there is no need to check any other rule + for _, kind := range roleOrder { + // not a member of any role of this kind + if len(rolesByKind[kind]) == 0 { + continue + } + + // reset allowed to false + // for each role kind + allowed = false + + for r := range rolesByKind[kind] { + match = svc.getMatchingRule(st, kind, r) + + // check all rules for each role the security-context + if match == nil { + // no rules match + continue + } + + if trace != nil { + // if trace is enabled, append + // each matching rule + trace.Rules = append(trace.Rules, match) + } + + if match.Access == Deny { + // if we stumble upon Deny we short-circuit the check + return resolve(nil, Deny, ""), nil + } + + if match.Access == Allow { + // allow rule found, we need to check rules on other roles + // before we allow it + allowed = true + } + } + + if allowed { + // at least one of the roles (per role type) in the security context + // allows operation on a resource + return resolve(nil, Allow, ""), nil + } + } + return } -// SignificantRoles returns two list of significant roles. -// -// See sigRoles on rules for more details -func (svc *service) SignificantRoles(res Resource, op string) (aRR, dRR []uint64) { - svc.l.Lock() - defer svc.l.Unlock() +// preflightCheck covers a few edge-case-esk scenarios +func (svc *Service) preflightCheck(roles partRoles) (a Access, resolved bool) { + if member(roles, AnonymousRole) && len(roles) > 1 { + // Integrity check; when user is member of anonymous role + // should not be member of any other type of role + return resolve(nil, Deny, failedIntegrityCheck), true + } + + if member(roles, BypassRole) { + // if user has at least one bypass role, we allow access + return resolve(nil, Allow, bypassRoleMembership), true + } - return svc.rules.sigRoles(res.RbacResource(), op) + return Inherit, false } -// CloneRulesByRoleID clone all rules of a Role S to a specific Role T by removing its existing rules -func (svc *service) CloneRulesByRoleID(ctx context.Context, fromRoleID uint64, toRoleID ...uint64) (err error) { - var ( - updatedRules RuleSet - ) +func (svc *Service) isRuleIndexed(r *Rule) bool { + ix := svc.getIndexForRole(r.RoleID) + if ix == nil { + return false + } - // Make sure rules of fromRoleID stays intact - rr := svc.FindRulesByRoleID(fromRoleID) + return ix.isIndexed(r.Resource) +} - for _, roleID := range toRoleID { - // Remove existing rules - existingRules := svc.FindRulesByRoleID(roleID) - for _, rule := range existingRules { - // Make sure to remove existing rules - rule.Access = Inherit +func (svc *Service) indexRule(r *Rule) (added bool) { + ix := svc.getIndexForRole(r.RoleID) + if ix == nil { + return + } + + if !ix.isIndexed(r.Resource) { + return + } + + return ix.add(r.Resource, r) +} + +func (svc *Service) getIndexedRules(roleID uint64, op string, resoirce string) (out []*Rule) { + ix := svc.getIndexForRole(roleID) + if ix == nil { + return + } + + return ix.get(op, resoirce) +} + +// at least one of the roles must be set to true +func member(r partRoles, k roleKind) bool { + for _, is := range r[k] { + if is { + return true } - updatedRules = append(updatedRules, existingRules...) + } - // Clone rules from role S to role T - for _, rule := range rr { - // Make sure everything is properly set - r := *rule - r.RoleID = roleID - updatedRules = append(updatedRules, &r) + return false +} + +// // // // // // // // // // // // // // // // // // // // // // // // // +// DB stuff + +func (svc *Service) flush(ctx context.Context, rules ...*Rule) (err error) { + // @todo is this stil valid? + // if svc.store == nil { + // svc.logger.Debug("rule flushing disabled (no store)") + // return + // } + + upsert, delete := upsertableDeletableRules(rules) + err = svc.RuleStorage.DeleteRbacRule(ctx, delete...) + if err != nil { + return + } + + err = svc.RuleStorage.UpsertRbacRule(ctx, upsert...) + if err != nil { + return + } + + if svc.logger != nil { + svc.logger.Debug( + "flushed rules", + zap.Int("deleted", len(delete)), + zap.Int("upserted", len(upsert)), + ) + } + + return +} + +func (svc *Service) pullUnindexed(ctx context.Context, unindexed partRoles, op, res string) (out [5]map[uint64][]*Rule, timing time.Duration, err error) { + // Get all the resource permissions + // @todo get permissions for parent resources; this will probs be some lookup table + now := time.Now() + defer func() { + timing = time.Since(now) + }() + + resPerm := permuteResource(res) + + for rk, rr := range unindexed { + for r := range rr { + var auxRr []*Rule + auxRr, _, err = svc.RuleStorage.SearchRbacRules(ctx, RuleFilter{ + RoleID: r, + Resource: resPerm, + Operation: op, + }) + if err != nil { + return + } + + if out[rk] == nil { + out[rk] = map[uint64][]*Rule{ + r: auxRr, + } + } else { + out[rk][r] = auxRr + } } } - return svc.Grant(ctx, updatedRules...) + return +} + +func (svc *Service) pullForRole(ctx context.Context, roleID uint64) (out []*Rule, err error) { + out, _, err = svc.RuleStorage.SearchRbacRules(ctx, RuleFilter{ + RoleID: roleID, + }) + if err != nil { + return + } + + return +} + +func (svc *Service) pullRules(ctx context.Context, role uint64, resource string) (rules []*Rule, err error) { + resPerm := permuteResource(resource) + + var aux RuleSet + aux, _, err = svc.RuleStorage.SearchRbacRules(ctx, RuleFilter{ + Resource: resPerm, + RoleID: role, + }) + + rules = append(rules, aux...) + + return +} + +func (svc *Service) ReloadRoles(ctx context.Context) (err error) { + svc.mux.Lock() + defer svc.mux.Unlock() + + crt := svc.roles + + svc.roles, err = svc.loadRoles(ctx) + if err != nil { + return + } + + rmd := removedRoles(crt, svc.roles...) + svc.cleanupCounter(rmd...) + + return +} + +func (svc *Service) loadRoles(ctx context.Context) (out []*Role, err error) { + auxRoles, _, err := svc.cfg.RoleStorage.SearchRoles(ctx, types.RoleFilter{}) + if err != nil { + return + } + + for _, ar := range auxRoles { + out = append(out, &Role{ + id: ar.ID, + handle: ar.Handle, + kind: CommonRole, + }) + } + + return +} + +// // // // // // // // // // // // // // // // // // // // // // // // // +// Utils + +// upsertableDeletableRules figures out what rules need to be upserted or deleted +func upsertableDeletableRules(rules []*Rule) (upsert, delete []*Rule) { + for _, r := range rules { + if r.Access == Inherit { + delete = append(delete, r) + } else { + upsert = append(upsert, r) + } + } + + return +} + +func (svc *Service) getMatchingRule(st evaluationState, kind roleKind, role uint64) (rule *Rule) { + var ( + aux []*Rule + rules RuleSet + ) + + // Indexed + now := time.Now() + aux = svc.getIndexedRules(role, st.op, st.res) + svc.logIndexTiming(time.Since(now)) + + rules = append(rules, aux...) + + // Unindexed + aux = st.unindexedRules[kind][role] + rules = append(rules, aux...) + + set := RuleSet(rules) + sort.Sort(set) + + for _, s := range set { + if s.Access == Inherit { + continue + } + + return s + } + + return nil +} + +// segmentRoles determines what roles are indexed and unindexed +func (svc *Service) segmentRoles(roles partRoles, resource string) (indexed, unindexed partRoles, err error) { + svc.mux.RLock() + defer svc.mux.RUnlock() + + unindexed = partRoles{} + indexed = partRoles{} + + if svc.isIndexEmpty() { + return indexed, roles, nil + } + + unindexed[CommonRole] = make(map[uint64]bool) + indexed[CommonRole] = make(map[uint64]bool) + + for k, rg := range roles { + for r := range rg { + ix := svc.getIndex(r, resource) + if ix != nil { + if indexed[k] == nil { + indexed[k] = make(map[uint64]bool) + } + + indexed[k][r] = true + continue + } + + if unindexed[k] == nil { + unindexed[k] = make(map[uint64]bool) + } + + unindexed[k][r] = true + } + } + + return +} + +func (svc *Service) getIndexForRole(role uint64) *wrapperIndex { + if _, ok := svc.indexMappings[role]; !ok { + return nil + } + + return svc.indexes[svc.indexMappings[role]] +} + +func (svc *Service) getIndex(role uint64, resource string) *wrapperIndex { + ix := svc.getIndexForRole(role) + if ix == nil { + return nil + } + + if !ix.isIndexed(resource) { + return nil + } + + return ix +} + +func (svc *Service) isIndexEmpty() bool { + if len(svc.indexes) == 0 { + return false + } + + out := false + + for _, ix := range svc.indexes { + out = out || ix.index.empty() + } + + return out +} + +// CloneRulesByRoleID clone all rules of a Role S to a specific Role T by removing its existing rules +func (svc *Service) CloneRulesByRoleID(ctx context.Context, fromRoleID uint64, toRoleID ...uint64) (err error) { + var ( + updatedRules RuleSet + ) + + // Make sure rules of fromRoleID stays intact + rr, err := svc.pullForRole(ctx, fromRoleID) + if err != nil { + return + } + + for _, roleID := range toRoleID { + // Remove existing rules + var existingRules []*Rule + existingRules, err = svc.pullForRole(ctx, roleID) + if err != nil { + return + } + + for _, rule := range existingRules { + // Make sure to remove existing rules + rule.Access = Inherit + } + updatedRules = append(updatedRules, existingRules...) + + // Clone rules from role S to role T + for _, rule := range rr { + // Make sure everything is properly set + r := *rule + r.RoleID = roleID + updatedRules = append(updatedRules, &r) + } + } + + return svc.Grant(ctx, updatedRules...) +} + +// incCounter sends some messages to the usage counter +func (svc *Service) incCounter(roles partRoles, res Resource) { + if svc.cfg.Synchronous { + svc.incCounterSync(roles, res) + } else { + svc.incCounterAsync(roles, res) + } +} + +func (svc *Service) cleanupCounter(roles ...*Role) { + if svc.cfg.Synchronous { + svc.cleanupCounterSync(roles...) + } else { + svc.cleanupCounterAsync(roles...) + } +} + +func (svc *Service) incCounterSync(roles partRoles, res Resource) { + for _, rr := range roles { + for r := range rr { + svc.usageCounter.inc(fmt.Sprintf("%d:%s", r, res.RbacResource())) + } + } +} + +func (svc *Service) incCounterAsync(roles partRoles, res Resource) { + if svc.usageCounter != nil && svc.usageCounter.incChan != nil { + for _, rr := range roles { + for r := range rr { + svc.usageCounter.incChan <- fmt.Sprintf("%d:%s", r, res.RbacResource()) + } + } + } +} + +func (svc *Service) cleanupCounterSync(roles ...*Role) { + for _, r := range roles { + gWrapper.usageCounter.cleanRoleKeys(r.id) + } +} + +func (svc *Service) cleanupCounterAsync(roles ...*Role) { + if svc.usageCounter != nil && svc.usageCounter.rmChan != nil { + for _, r := range roles { + svc.usageCounter.rmChan <- r.id + } + } +} + +func (svc *Service) updateWrapperIndex(ctx context.Context) (err error) { + switch svc.cfg.ReindexStrategy { + case ReindexStrategyMemory: + return svc.updateWrapperIndexMemFirst(ctx) + case ReindexStrategySpeed: + return svc.updateWrapperIndexSpeedFirst(ctx) + } + + return +} + +func (svc *Service) updateWrapperIndexMemFirst(ctx context.Context) (err error) { + auxIndex, auxMappings, err := svc.buildNewIndex(ctx) + if err != nil { + return + } + + svc.swapIndexes(auxIndex, auxMappings) + return +} + +func (svc *Service) updateWrapperIndexSpeedFirst(ctx context.Context) (err error) { + svc.mux.Lock() + defer svc.mux.Unlock() + + svc.indexes = nil + svc.indexMappings = nil + + auxIndex, auxMappings, err := svc.buildNewIndex(ctx) + if err != nil { + return + } + + svc.indexes = auxIndex + svc.indexMappings = auxMappings + return +} + +// // // // // // // // // // // // // // // // // // // // // // // // // // +// Boilerplate & state management stuff + +func (svc *Service) indexForResources(ctx context.Context, res ...string) (indexes []*wrapperIndex, mappings map[uint64]int, err error) { + permResMapping := make(map[string][]string, 64) + + queries := makeQueryStrings(permResMapping, res...) + rules := make(RuleSet, 0, len(queries)) + for _, q := range queries { + var aux RuleSet + + // @todo can this produce multiple pages? + aux, _, err = svc.RuleStorage.SearchRbacRules(ctx, RuleFilter{ + RawFilter: q, + Limit: maxRuleFetchChunk, + + // Sort here so we can optimally chunk up the rules based on roles + Sorting: filter.Sorting{ + Sort: filter.SortExprSet{{ + Column: "roleID", + Descending: false, + }}, + }, + }) + if err != nil { + return + } + + rules = append(rules, aux...) + } + + if len(rules) == 0 { + return + } + + // Fill up the indexes here so we can build up these chinks in parallel + mappings = make(map[uint64]int) + indexes = make([]*wrapperIndex, 0, 16) + for _, r := range rules { + if _, ok := mappings[r.RoleID]; !ok { + mappings[r.RoleID] = len(indexes) + indexes = append(indexes, nil) + } + } + + wg := sync.WaitGroup{} + walkRulesByRole(rules, func(rules RuleSet) { + wg.Add(1) + go func(rules RuleSet) { + defer wg.Done() + + index := &wrapperIndex{} + for _, r := range rules { + pp := strings.TrimRight(r.Resource, "/*") + for _, res := range permResMapping[pp] { + index.add(res, r) + } + } + + indexes[mappings[rules[0].RoleID]] = index + }(rules) + }) + wg.Wait() + + return +} + +// Assign resource to each resource permutation and set it to mapping +func assignPermutations(mapping map[string][]string, resource string) { + pp := strings.Split(resource, "/") + mapping[resource] = append(mapping[resource], resource) + + for i := 1; i < len(pp); i++ { + kk := strings.Join(pp[:i], "/") + + aux := mapping[kk] + aux = append(aux, resource) + + mapping[kk] = aux + } +} + +// @todo perhaps some query strings would improve +func makeQueryStrings(bits map[string][]string, resources ...string) (out []string) { + raw := make([]string, 0, len(resources)/maxRuleFetchChunk) + for i := 0; i < len(resources); i += maxRuleFetchChunk { + res := resources[i:int(math.Min(float64(len(resources)), float64(i+maxRuleFetchChunk)))] + + for _, b := range res { + pp := strings.SplitN(b, ":", 2) + role := cast.ToUint64(pp[0]) + resource := pp[1] + assignPermutations(bits, resource) + + resources := permuteResource(resource) + + rxs := make([]string, len(resources)) + for i, rx := range resources { + rxs[i] = fmt.Sprintf("resource='%s'", rx) + } + + raw = append(raw, fmt.Sprintf("(rel_role=%d and (%s))", role, strings.Join(rxs, " or "))) + } + + out = append(out, strings.Join(raw, " or ")) + } + + return +} + +func walkRulesByRole(rules RuleSet, fn func(rr RuleSet)) { + crtRole := rules[0].RoleID + startIx := 0 + + for i := 1; i < len(rules); i++ { + if rules[i].RoleID == crtRole { + continue + } + + fn(rules[startIx:i]) + + crtRole = rules[i].RoleID + startIx = i + } + + if startIx != len(rules) { + fn(rules[startIx:len(rules)]) + } +} + +func (svc *Service) loadIndex(ctx context.Context) (indexes []*wrapperIndex, mappings map[uint64]int, err error) { + // How do we figure out what resources we have? + // do we just start from empty? + + // I suppose this fnc would provide some assortment of resources... + // For now, we'll just yank out some list of records? + // At the end get some modules and stuff? + // Records would be those things that need max performance I suppose so it'd be a good starting point + + if svc.cfg.PullInitialState == nil { + return []*wrapperIndex{}, nil, nil + } + + rr, err := svc.cfg.PullInitialState(ctx, svc.cfg.MaxIndexSize) + if err != nil { + return + } + + return svc.indexForResources(ctx, rr...) +} + +func (svc *Service) buildNewIndex(ctx context.Context) (indexes []*wrapperIndex, mappings map[uint64]int, err error) { + svc.usageCounter.lock.RLock() + defer svc.usageCounter.lock.RUnlock() + + res := svc.usageCounter.bestPerformers(svc.cfg.MaxIndexSize) + return svc.indexForResources(ctx, res...) +} + +func (svc *Service) swapIndexes(auxIndex []*wrapperIndex, mappings map[uint64]int) { + if auxIndex == nil { + return + } + + svc.mux.Lock() + defer svc.mux.Unlock() + + svc.indexes = auxIndex + svc.indexMappings = mappings +} + +// Performance monitoring +func (svc *Service) logDatabaseTiming(timing time.Duration) { + if svc.cfg.Synchronous { + svc.logDatabaseSync(timing) + } else { + svc.logDatabaseAsync(timing) + } +} + +func (svc *Service) logDatabaseSync(timing time.Duration) { + svc.StatLogger.TimingDatabase(timing) +} + +func (svc *Service) logDatabaseAsync(timing time.Duration) { + if svc.StatLogger != nil && svc.StatLogger.timingDatabaseChan != nil { + svc.StatLogger.timingDatabaseChan <- timing + } +} + +func (svc *Service) logIndexTiming(timing time.Duration) { + if svc.cfg.Synchronous { + svc.logIndexSync(timing) + } else { + svc.logIndexAsync(timing) + } +} + +func (svc *Service) logIndexSync(timing time.Duration) { + svc.StatLogger.TimingIndex(timing) +} + +func (svc *Service) logIndexAsync(timing time.Duration) { + if svc.StatLogger != nil && svc.StatLogger.timingIndexChan != nil { + svc.StatLogger.timingIndexChan <- timing + } +} + +func (svc *Service) logCachePerformance(hits, misses partRoles, resource, op string) { + if svc.cfg.Synchronous { + svc.logCachePerformanceSync(hits, misses, resource, op) + } else { + svc.logCachePerformanceAsync(hits, misses, resource, op) + } +} + +func (svc *Service) logCachePerformanceSync(hits, misses partRoles, resource, op string) { + { + rls := make([]uint64, 0, 4) + + for _, rr := range hits { + for r := range rr { + rls = append(rls, r) + } + } + + if len(rls) > 0 { + svc.StatLogger.CacheHit(rls, resource, op) + } + } + + { + rls := make([]uint64, 0, 4) + + for _, rr := range misses { + for r := range rr { + rls = append(rls, r) + } + } + + if len(rls) > 0 { + svc.StatLogger.CacheMiss(rls, resource, op) + } + } +} + +func (svc *Service) logCachePerformanceAsync(hits, misses partRoles, resource, op string) { + // Hits + if svc.StatLogger != nil && svc.StatLogger.cacheHitChan != nil { + rls := make([]uint64, 0, 4) + + for _, rr := range hits { + for r := range rr { + rls = append(rls, r) + } + } + + if len(rls) > 0 { + svc.StatLogger.cacheHitChan <- statsWrap{ + roles: rls, + resource: resource, op: op, + } + } + } + + // Misses + if svc.StatLogger != nil && svc.StatLogger.cacheMissChan != nil { + rls := make([]uint64, 0, 4) + + for _, rr := range misses { + for r := range rr { + rls = append(rls, r) + } + } + + if len(rls) > 0 { + svc.StatLogger.cacheMissChan <- statsWrap{ + roles: rls, + resource: resource, op: op, + } + } + } +} + +// Debugger stuff + +func (svc *Service) DebuggerSetIndex(role uint64, resource string, rules ...*Rule) (err error) { + index := &wrapperIndex{} + + index.add(resource, rules...) + + svc.indexes = []*wrapperIndex{index} + svc.indexMappings = map[uint64]int{role: 0} + + return +} + +func (svc *Service) DebuggerAddIndex(role uint64, resource string, rules ...*Rule) (err error) { + ix := svc.getIndexForRole(role) + if ix == nil { + ix = &wrapperIndex{} + svc.indexes = append(svc.indexes, ix) + svc.indexMappings[role] = len(svc.indexes) - 1 + } + + ix.add(resource, rules...) + + return +} + +// // // // // // // // // // // // // // // // // // // // // // // // // +// Processing n stuff + +func (svc *Service) watch(ctx context.Context) { + tck := time.NewTicker(time.Minute * 5) + + flushInt := svc.cfg.IndexFlushInterval + if flushInt == 0 { + flushInt = time.Minute * 30 + } + flushTck := time.NewTicker(flushInt) + + rexInt := svc.cfg.ReindexInterval + if rexInt == 0 { + rexInt = time.Minute * 30 + } + rexTck := time.NewTicker(rexInt) + + lg := svc.logger.Named("rbac service wrapper") + go func() { + defer func() { + tck.Stop() + flushTck.Stop() + rexTck.Stop() + }() + + for { + select { + case <-tck.C: + lg.Info("tick") + + case <-rexTck.C: + lg.Info("reindex") + + err := svc.updateWrapperIndex(ctx) + if err != nil { + lg.Error("reindex failed", zap.Error(err)) + } + + // case <-flushTck.C: + // err := svc.cfg.FlushIndexState(ctx, svc.index.getIndexed()) + // if err != nil { + // lg.Error("failed to flush the index state", zap.Error(err)) + // } + + case <-ctx.Done(): + return + } + } + }() } diff --git a/server/pkg/rbac/service_test.go b/server/pkg/rbac/service_test.go index 2f5bb9455f..4b39a32a42 100644 --- a/server/pkg/rbac/service_test.go +++ b/server/pkg/rbac/service_test.go @@ -5,9 +5,12 @@ import ( "fmt" "math" "math/rand" + "strings" "testing" "github.com/cortezaproject/corteza/server/pkg/expr" + "github.com/spf13/cast" + "github.com/stretchr/testify/require" "go.uber.org/zap" ) @@ -18,194 +21,901 @@ type ( res Resource op string } + + stateCfg struct { + resources []string + searchResponses []*Rule + } ) -// goos: linux -// goarch: amd64 -// pkg: github.com/cortezaproject/corteza/server/pkg/rbac -// cpu: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz -// Benchmark_AccessCheck_role5_rule500-12 378988 3026 ns/op 615 B/op 16 allocs/op -// Benchmark_AccessCheck_role5_rule1000-12 253071 4087 ns/op 615 B/op 16 allocs/op -// Benchmark_AccessCheck_role10_rule10000-12 237085 5429 ns/op 1026 B/op 29 allocs/op -// Benchmark_AccessCheck_role20_rule50000-12 128914 9344 ns/op 2335 B/op 71 allocs/op -// Benchmark_AccessCheck_role30_rule100000-12 79963 20670 ns/op 3371 B/op 85 allocs/op -// Benchmark_AccessCheck_role100_rule500000-12 16927 79106 ns/op 12796 B/op 391 allocs/op -func benchmark_AccessCheck(b *testing.B, cfg matchBenchCfg) { - svc := NewService(zap.NewNop(), nil) - svc.UpdateRoles(cfg.roles...) - svc.setRules(cfg.rules) +func TestNoopSvc(t *testing.T) { + req := require.New(t) - ctx := context.Background() - b.ResetTimer() + svc := NoopSvc(Allow, Config{}) + a, err := svc.Check(session{ + id: 1, + rr: []uint64{1}, + ctx: context.Background(), + }, "read", NewResource("compose-record/1/2/3")) + req.NoError(err) + req.Equal(Allow, a) +} - for n := 0; n < b.N; n++ { - svc.Can(session{ - id: 90001, - rr: yankRandRoles(cfg.roles), +func TestStatePrepping(t *testing.T) { + ctx, + req, + svc, _ := prepState( + t, + stateCfg{ + resources: []string{ + "1:compose-record/1/2/3", + "2:compose-record/1/2/3", + "3:compose-record/1/2/3", + }, + searchResponses: []*Rule{{ + RoleID: 1, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Allow, + }, { + RoleID: 2, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Deny, + }}, + }, + ) + + a, err := svc.Check( + session{ + id: 1, + rr: []uint64{1}, ctx: ctx, - }, cfg.op, cfg.res) - } + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Allow, a) + + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{2}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Deny, a) } -func Benchmark_AccessCheck_role100_rule1000(b *testing.B) { - roles := 100 - rules := 1000 - benchmark_AccessCheck(b, matchBenchCfg{ - res: makeResource(), - op: randomOperation(), - rules: makeRuleSet(rules, roles), - roles: makeRoleSet(roles), +func TestCheck_index(t *testing.T) { + ctx, + req, + svc, _ := prepState( + t, + stateCfg{ + resources: []string{ + "1:compose-record/1/2/3", + + "2:compose-record/1/2/3", + // "2:compose-record/1/2/*", + + "3:compose-record/1/2/3", + // "3:compose-record/1/2/*", + // "3:compose-record/1/*/*", + + "4:compose-record/1/2/3", + }, + searchResponses: []*Rule{{ + RoleID: 1, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Allow, + }, + + { + RoleID: 2, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Inherit, + }, { + RoleID: 2, + Resource: "compose-record/1/2/*", + Operation: "read", + Access: Allow, + }, + + { + RoleID: 3, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Inherit, + }, { + RoleID: 3, + Resource: "compose-record/1/2/*", + Operation: "read", + Access: Allow, + }, { + RoleID: 3, + Resource: "compose-record/1/*/*", + Operation: "read", + Access: Deny, + }, + + { + RoleID: 4, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Deny, + }}, + }, + ) + + var a Access + var err error + + t.Run("single rule, allow", func(t *testing.T) { + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{1}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Allow, a) }) -} -func Benchmark_AccessCheck_role100_rule10000(b *testing.B) { - roles := 100 - rules := 10000 - benchmark_AccessCheck(b, matchBenchCfg{ - res: makeResource(), - op: randomOperation(), - rules: makeRuleSet(rules, roles), - roles: makeRoleSet(roles), + t.Run("multi rule, inherit -> allow", func(t *testing.T) { + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{2}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Allow, a) }) -} -func Benchmark_AccessCheck_role100_rule100000(b *testing.B) { - roles := 100 - rules := 100000 - benchmark_AccessCheck(b, matchBenchCfg{ - res: makeResource(), - op: randomOperation(), - rules: makeRuleSet(rules, roles), - roles: makeRoleSet(roles), + t.Run("multi rule, allow because deny is lower", func(t *testing.T) { + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{3}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Allow, a) }) -} -func Benchmark_AccessCheck_role100_rule1000000(b *testing.B) { - roles := 100 - rules := 1000000 - benchmark_AccessCheck(b, matchBenchCfg{ - res: makeResource(), - op: randomOperation(), - rules: makeRuleSet(rules, roles), - roles: makeRoleSet(roles), + t.Run("single rule, deny", func(t *testing.T) { + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{4}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Deny, a) }) -} -func Benchmark_AccessCheck_role100_rule10000000(b *testing.B) { - roles := 100 - rules := 10000000 - benchmark_AccessCheck(b, matchBenchCfg{ - res: makeResource(), - op: randomOperation(), - rules: makeRuleSet(rules, roles), - roles: makeRoleSet(roles), + t.Run("multi role, deny higher up, deny", func(t *testing.T) { + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{2, 4}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Deny, a) }) -} -func Benchmark_AccessCheck_role1000_rule1000(b *testing.B) { - roles := 1000 - rules := 1000 - benchmark_AccessCheck(b, matchBenchCfg{ - res: makeResource(), - op: randomOperation(), - rules: makeRuleSet(rules, roles), - roles: makeRoleSet(roles), + t.Run("multi role, same level, deny over takes, deny", func(t *testing.T) { + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{1, 4}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Deny, a) }) } -func Benchmark_AccessCheck_role1000_rule10000(b *testing.B) { - roles := 1000 - rules := 10000 - benchmark_AccessCheck(b, matchBenchCfg{ - res: makeResource(), - op: randomOperation(), - rules: makeRuleSet(rules, roles), - roles: makeRoleSet(roles), +func TestIndexSize(t *testing.T) { + t.Run("zero", func(t *testing.T) { + _, + req, + svc, _ := prepState( + t, + stateCfg{ + resources: []string{ + "1:compose-record/1/2/3", + "2:compose-record/1/2/3", + "3:compose-record/1/2/3", + "4:compose-record/1/2/3", + }, + searchResponses: []*Rule{}, + }, + ) + + req.Equal(0, svc.indexSize()) }) -} -func Benchmark_AccessCheck_role1000_rule100000(b *testing.B) { - roles := 1000 - rules := 100000 - benchmark_AccessCheck(b, matchBenchCfg{ - res: makeResource(), - op: randomOperation(), - rules: makeRuleSet(rules, roles), - roles: makeRoleSet(roles), + t.Run("all for same role", func(t *testing.T) { + _, + req, + svc, _ := prepState( + t, + stateCfg{ + resources: []string{ + "1:compose-record/1/2/3", + "2:compose-record/1/2/3", + "3:compose-record/1/2/3", + "4:compose-record/1/2/3", + }, + searchResponses: []*Rule{{ + RoleID: 1, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Allow, + }}, + }, + ) + + req.Equal(1, svc.indexSize()) }) -} -func Benchmark_AccessCheck_role1000_rule1000000(b *testing.B) { - roles := 1000 - rules := 1000000 - benchmark_AccessCheck(b, matchBenchCfg{ - res: makeResource(), - op: randomOperation(), - rules: makeRuleSet(rules, roles), - roles: makeRoleSet(roles), + t.Run("a bunch", func(t *testing.T) { + _, + req, + svc, _ := prepState( + t, + stateCfg{ + resources: []string{ + "1:compose-record/1/2/3", + "2:compose-record/1/2/3", + "3:compose-record/1/2/3", + "4:compose-record/1/2/3", + }, + searchResponses: []*Rule{{ + RoleID: 1, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Allow, + }, + + { + RoleID: 2, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Inherit, + }, { + RoleID: 2, + Resource: "compose-record/1/2/*", + Operation: "read", + Access: Allow, + }, + + { + RoleID: 3, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Inherit, + }, { + RoleID: 3, + Resource: "compose-record/1/2/*", + Operation: "read", + Access: Allow, + }, { + RoleID: 3, + Resource: "compose-record/1/*/*", + Operation: "read", + Access: Deny, + }, + }, + }, + ) + + // Counting resources here!! + req.Equal(3, svc.indexSize()) }) } -func Benchmark_AccessCheck_role1000_rule10000000(b *testing.B) { - roles := 1000 - rules := 10000000 - benchmark_AccessCheck(b, matchBenchCfg{ - res: makeResource(), - op: randomOperation(), - rules: makeRuleSet(rules, roles), - roles: makeRoleSet(roles), +func TestCheck_store(t *testing.T) { + ctx, + req, + svc, + store := prepState( + t, + stateCfg{ + resources: []string{ + // "1:compose-record/1/2/3", + + // "2:compose-record/1/2/3", + // // "2:compose-record/1/2/*", + + // "3:compose-record/1/2/3", + // // "3:compose-record/1/2/*", + // // "3:compose-record/1/*/*", + + // "4:compose-record/1/2/3", + }, + }, + ) + + svc.roles = append(svc.roles, + &Role{id: 1, handle: "1"}, + &Role{id: 2, handle: "2"}, + &Role{id: 3, handle: "3"}, + &Role{id: 4, handle: "4"}, + ) + + var a Access + var err error + + t.Run("single rule, allow", func(t *testing.T) { + store.searchCount = 0 + store.searchResponses = [][]*Rule{{{ + RoleID: 1, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Allow, + }}} + + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{1}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Allow, a) }) -} -func Benchmark_AccessCheck_role10000_rule1000(b *testing.B) { - roles := 10000 - rules := 1000 - benchmark_AccessCheck(b, matchBenchCfg{ - res: makeResource(), - op: randomOperation(), - rules: makeRuleSet(rules, roles), - roles: makeRoleSet(roles), + t.Run("multi rule, inherit -> allow", func(t *testing.T) { + store.searchCount = 0 + store.searchResponses = [][]*Rule{{{ + RoleID: 2, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Inherit, + }, { + RoleID: 2, + Resource: "compose-record/1/2/*", + Operation: "read", + Access: Allow, + }}} + + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{2}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Allow, a) }) -} -func Benchmark_AccessCheck_role10000_rule10000(b *testing.B) { - roles := 10000 - rules := 10000 - benchmark_AccessCheck(b, matchBenchCfg{ - res: makeResource(), - op: randomOperation(), - rules: makeRuleSet(rules, roles), - roles: makeRoleSet(roles), + + t.Run("multi rule, allow because deny is lower", func(t *testing.T) { + store.searchCount = 0 + store.searchResponses = [][]*Rule{{{ + RoleID: 3, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Inherit, + }, { + RoleID: 3, + Resource: "compose-record/1/2/*", + Operation: "read", + Access: Allow, + }, { + RoleID: 3, + Resource: "compose-record/1/*/*", + Operation: "read", + Access: Deny, + }}} + + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{3}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Allow, a) }) -} -func Benchmark_AccessCheck_role10000_rule100000(b *testing.B) { - roles := 10000 - rules := 100000 - benchmark_AccessCheck(b, matchBenchCfg{ - res: makeResource(), - op: randomOperation(), - rules: makeRuleSet(rules, roles), - roles: makeRoleSet(roles), + + t.Run("single rule, deny", func(t *testing.T) { + store.searchCount = 0 + store.searchResponses = [][]*Rule{{{ + RoleID: 4, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Deny, + }}} + + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{4}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Deny, a) }) -} -func Benchmark_AccessCheck_role10000_rule1000000(b *testing.B) { - roles := 10000 - rules := 1000000 - benchmark_AccessCheck(b, matchBenchCfg{ - res: makeResource(), - op: randomOperation(), - rules: makeRuleSet(rules, roles), - roles: makeRoleSet(roles), + + t.Run("multi role, deny higher up, deny", func(t *testing.T) { + store.searchCount = 0 + store.searchResponses = [][]*Rule{{{ + RoleID: 2, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Inherit, + }, { + RoleID: 2, + Resource: "compose-record/1/2/*", + Operation: "read", + Access: Allow, + }}, + + {{ + RoleID: 4, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Deny, + }}} + + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{2, 4}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Deny, a) + }) + + t.Run("multi role, same level, deny over takes, deny", func(t *testing.T) { + store.searchCount = 0 + store.searchResponses = [][]*Rule{{{ + RoleID: 1, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Allow, + }}, + + {{ + RoleID: 4, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Deny, + }}} + + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{1, 4}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Deny, a) }) } -func Benchmark_AccessCheck_role10000_rule10000000(b *testing.B) { - roles := 10000 - rules := 10000000 - benchmark_AccessCheck(b, matchBenchCfg{ - res: makeResource(), - op: randomOperation(), - rules: makeRuleSet(rules, roles), - roles: makeRoleSet(roles), + +func TestCheck_mix(t *testing.T) { + ctx, + req, + svc, + store := prepState( + t, + stateCfg{ + resources: []string{ + // allow compose-record/1/2/3 + "1:compose-record/1/2/3", + // deny compose-record/1/*/* + "2:compose-record/1/2/3", + }, + searchResponses: []*Rule{{ + RoleID: 1, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Allow, + }, + + { + RoleID: 2, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Deny, + }, + }, + }, + ) + + store.searchResponse = nil + + svc.roles = append(svc.roles, + &Role{id: 1, handle: "1"}, + &Role{id: 2, handle: "2"}, + &Role{id: 3, handle: "3"}, + &Role{id: 4, handle: "4"}, + + &Role{id: 11, handle: "11"}, + &Role{id: 22, handle: "22"}, + &Role{id: 33, handle: "33"}, + &Role{id: 44, handle: "44"}, + ) + + var a Access + var err error + + t.Run("allow overtake inherit", func(t *testing.T) { + store.searchCount = 0 + store.searchResponses = [][]*Rule{{{ + RoleID: 11, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Inherit, + }}} + + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{1, 11}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Allow, a) }) + + t.Run("store deny overtake index allow", func(t *testing.T) { + store.searchCount = 0 + store.searchResponses = [][]*Rule{{{ + RoleID: 11, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Deny, + }}} + + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{1, 11}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Deny, a) + }) + + t.Run("index deny overtakes db inherits", func(t *testing.T) { + store.searchCount = 0 + store.searchResponses = [][]*Rule{{{ + RoleID: 11, + Resource: "compose-record/1/2/3", + Operation: "read", + Access: Inherit, + }, { + RoleID: 11, + Resource: "compose-record/1/*/*", + Operation: "read", + Access: Inherit, + }, { + RoleID: 11, + Resource: "compose-record/*/*/*", + Operation: "read", + Access: Inherit, + }}} + + a, err = svc.Check( + session{ + id: 1, + rr: []uint64{2, 11}, + ctx: ctx, + }, + "read", + NewResource("compose-record/1/2/3"), + ) + req.NoError(err) + req.Equal(Deny, a) + }) +} + +func prepState(t *testing.T, cfg stateCfg) (ctx context.Context, req *require.Assertions, svc *Service, store *mockRuleStore) { + req = require.New(t) + ctx = context.Background() + + store = &mockRuleStore{ + searchResponse: cfg.searchResponses, + access: Allow, + } + + svc = &Service{ + RuleStorage: store, + logger: zap.NewNop(), + roles: rolesFromRes(cfg.resources...), + } + + var err error + svc.indexes, svc.indexMappings, err = svc.indexForResources(ctx, cfg.resources...) + require.NoError(t, err) + + return +} + +func rolesFromRes(resources ...string) (out []*Role) { + for _, r := range resources { + pp := strings.SplitN(r, ":", 2) + out = append(out, CommonRole.Make(cast.ToUint64(pp[0]), pp[0])) + } + return +} + +// goos: linux +// goarch: amd64 +// pkg: github.com/cortezaproject/corteza/server/pkg/rbac +// cpu: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz +// Benchmark_AccessCheck_role5_rule500-12 378988 3026 ns/op 615 B/op 16 allocs/op +// Benchmark_AccessCheck_role5_rule1000-12 253071 4087 ns/op 615 B/op 16 allocs/op +// Benchmark_AccessCheck_role10_rule10000-12 237085 5429 ns/op 1026 B/op 29 allocs/op +// Benchmark_AccessCheck_role20_rule50000-12 128914 9344 ns/op 2335 B/op 71 allocs/op +// Benchmark_AccessCheck_role30_rule100000-12 79963 20670 ns/op 3371 B/op 85 allocs/op +// Benchmark_AccessCheck_role100_rule500000-12 16927 79106 ns/op 12796 B/op 391 allocs/op +// func benchmark_AccessCheck(b *testing.B, cfg matchBenchCfg) { +// svc := NewService(zap.NewNop(), nil) +// svc.UpdateRoles(cfg.roles...) +// svc.setRules(cfg.rules) + +// ctx := context.Background() +// b.ResetTimer() + +// for n := 0; n < b.N; n++ { +// svc.Can(session{ +// id: 90001, +// rr: yankRandRoles(cfg.roles), +// ctx: ctx, +// }, cfg.op, cfg.res) +// } +// } + +// func Benchmark_AccessCheck_role100_rule1000(b *testing.B) { +// roles := 100 +// rules := 1000 +// benchmark_AccessCheck(b, matchBenchCfg{ +// res: makeResource(), +// op: randomOperation(), +// rules: makeRuleSet(rules, roles), +// roles: makeRoleSet(roles), +// }) +// } + +// func Benchmark_AccessCheck_role100_rule10000(b *testing.B) { +// roles := 100 +// rules := 10000 +// benchmark_AccessCheck(b, matchBenchCfg{ +// res: makeResource(), +// op: randomOperation(), +// rules: makeRuleSet(rules, roles), +// roles: makeRoleSet(roles), +// }) +// } + +// func Benchmark_AccessCheck_role100_rule100000(b *testing.B) { +// roles := 100 +// rules := 100000 +// benchmark_AccessCheck(b, matchBenchCfg{ +// res: makeResource(), +// op: randomOperation(), +// rules: makeRuleSet(rules, roles), +// roles: makeRoleSet(roles), +// }) +// } + +// func Benchmark_AccessCheck_role100_rule1000000(b *testing.B) { +// roles := 100 +// rules := 1000000 +// benchmark_AccessCheck(b, matchBenchCfg{ +// res: makeResource(), +// op: randomOperation(), +// rules: makeRuleSet(rules, roles), +// roles: makeRoleSet(roles), +// }) +// } + +// func Benchmark_AccessCheck_role100_rule10000000(b *testing.B) { +// roles := 100 +// rules := 10000000 +// benchmark_AccessCheck(b, matchBenchCfg{ +// res: makeResource(), +// op: randomOperation(), +// rules: makeRuleSet(rules, roles), +// roles: makeRoleSet(roles), +// }) +// } + +// func Benchmark_AccessCheck_role1000_rule1000(b *testing.B) { +// roles := 1000 +// rules := 1000 +// benchmark_AccessCheck(b, matchBenchCfg{ +// res: makeResource(), +// op: randomOperation(), +// rules: makeRuleSet(rules, roles), +// roles: makeRoleSet(roles), +// }) +// } + +// func Benchmark_AccessCheck_role1000_rule10000(b *testing.B) { +// roles := 1000 +// rules := 10000 +// benchmark_AccessCheck(b, matchBenchCfg{ +// res: makeResource(), +// op: randomOperation(), +// rules: makeRuleSet(rules, roles), +// roles: makeRoleSet(roles), +// }) +// } + +// func Benchmark_AccessCheck_role1000_rule100000(b *testing.B) { +// roles := 1000 +// rules := 100000 +// benchmark_AccessCheck(b, matchBenchCfg{ +// res: makeResource(), +// op: randomOperation(), +// rules: makeRuleSet(rules, roles), +// roles: makeRoleSet(roles), +// }) +// } + +// func Benchmark_AccessCheck_role1000_rule1000000(b *testing.B) { +// roles := 1000 +// rules := 1000000 +// benchmark_AccessCheck(b, matchBenchCfg{ +// res: makeResource(), +// op: randomOperation(), +// rules: makeRuleSet(rules, roles), +// roles: makeRoleSet(roles), +// }) +// } + +// func Benchmark_AccessCheck_role1000_rule10000000(b *testing.B) { +// roles := 1000 +// rules := 10000000 +// benchmark_AccessCheck(b, matchBenchCfg{ +// res: makeResource(), +// op: randomOperation(), +// rules: makeRuleSet(rules, roles), +// roles: makeRoleSet(roles), +// }) +// } + +// func Benchmark_AccessCheck_role10000_rule1000(b *testing.B) { +// roles := 10000 +// rules := 1000 +// benchmark_AccessCheck(b, matchBenchCfg{ +// res: makeResource(), +// op: randomOperation(), +// rules: makeRuleSet(rules, roles), +// roles: makeRoleSet(roles), +// }) +// } +// func Benchmark_AccessCheck_role10000_rule10000(b *testing.B) { +// roles := 10000 +// rules := 10000 +// benchmark_AccessCheck(b, matchBenchCfg{ +// res: makeResource(), +// op: randomOperation(), +// rules: makeRuleSet(rules, roles), +// roles: makeRoleSet(roles), +// }) +// } +// func Benchmark_AccessCheck_role10000_rule100000(b *testing.B) { +// roles := 10000 +// rules := 100000 +// benchmark_AccessCheck(b, matchBenchCfg{ +// res: makeResource(), +// op: randomOperation(), +// rules: makeRuleSet(rules, roles), +// roles: makeRoleSet(roles), +// }) +// } +// func Benchmark_AccessCheck_role10000_rule1000000(b *testing.B) { +// roles := 10000 +// rules := 1000000 +// benchmark_AccessCheck(b, matchBenchCfg{ +// res: makeResource(), +// op: randomOperation(), +// rules: makeRuleSet(rules, roles), +// roles: makeRoleSet(roles), +// }) +// } +// func Benchmark_AccessCheck_role10000_rule10000000(b *testing.B) { +// roles := 10000 +// rules := 10000000 +// benchmark_AccessCheck(b, matchBenchCfg{ +// res: makeResource(), +// op: randomOperation(), +// rules: makeRuleSet(rules, roles), +// roles: makeRoleSet(roles), +// }) +// } + +// goos: darwin +// goarch: arm64 +// pkg: github.com/cortezaproject/corteza/server/pkg/rbac +// cpu: Apple M3 Pro +// BenchmarkResourceBuild_100-12 180 6656197 ns/op 292020 B/op 4276 allocs/op +// BenchmarkResourceBuild_1000-12 126 9667167 ns/op 2846490 B/op 42264 allocs/op +// BenchmarkResourceBuild_10000-12 45 25771097 ns/op 32201567 B/op 422459 allocs/op +// BenchmarkResourceBuild_100000-12 2 693479625 ns/op 960572552 B/op 11028408 allocs/op +func BenchmarkResourceBuild_100(b *testing.B) { benchmarkResourceBuild(b, 100) } +func BenchmarkResourceBuild_1000(b *testing.B) { benchmarkResourceBuild(b, 1000) } +func BenchmarkResourceBuild_10000(b *testing.B) { benchmarkResourceBuild(b, 10000) } +func BenchmarkResourceBuild_100000(b *testing.B) { benchmarkResourceBuild(b, 100000) } + +func benchmarkResourceBuild(b *testing.B, n int) { + ctx := context.Background() + svc := &Service{ + RuleStorage: &mockRuleStore{}, + } + + var res []string + roleID := uint64(0) + for i := 0; i < n; i++ { + if i%2000 == 0 { + roleID++ + } + + res = append(res, fmt.Sprintf("%d:compose::record/%d/%d/%d", roleID, i, i, i)) + } + + b.ResetTimer() + for n := 0; n < b.N; n++ { + svc.indexForResources(ctx, res...) + } } func yankRandRoles(base []*Role) (out []uint64) { @@ -303,19 +1013,6 @@ func makeResource() (out Resource) { return resource(randomResource()) } -func makeRuleSet(count int, roleCount int) (out RuleSet) { - for i := 0; i < count; i++ { - out = append(out, &Rule{ - RoleID: uint64(1000 + int(rand.Intn(roleCount))), - Resource: randomResource(), - Operation: randomOperation(), - Access: randomAccess(), - }) - } - - return -} - func randomAccess() (out Access) { x := rand.Float64() if x < 0.7 { diff --git a/server/pkg/rbac/stats.go b/server/pkg/rbac/stats.go new file mode 100644 index 0000000000..6394cc2225 --- /dev/null +++ b/server/pkg/rbac/stats.go @@ -0,0 +1,232 @@ +package rbac + +import ( + "context" + "fmt" + "sort" + "sync" + "time" + + "github.com/cortezaproject/corteza/server/pkg/slice" + "go.uber.org/zap" +) + +type ( + StatsLogger struct { + lock sync.RWMutex + log *zap.Logger + + // Channels for async comms + cacheHitChan chan statsWrap + cacheMissChan chan statsWrap + timingDatabaseChan chan time.Duration + timingIndexChan chan time.Duration + + // Counters + cacheHits uint + cacheMisses uint + cacheUpdates uint + avgDatabaseTiming time.Duration + minDatabaseTiming time.Duration + maxDatabaseTiming time.Duration + avgIndexTiming time.Duration + minIndexTiming time.Duration + maxIndexTiming time.Duration + + // Track a limited set of things + // Using a circular buffer we can easily not consume too much data + lastHits *slice.Circular[string] + lastMisses *slice.Circular[string] + lastDatabaseTimings *slice.Circular[time.Duration] + lastIndexTimings *slice.Circular[time.Duration] + } + + // statsWrap wraps the state to log + statsWrap struct { + roles []uint64 + resource string + op string + } +) + +const ( + maxLastLogs = 500 +) + +// Stats returns the tracked stats +func (l *StatsLogger) Stats() ( + cacheHit uint, + cacheMiss uint, + cacheUpdates uint, + avgDbTiming, minDbTiming, maxDbTiming time.Duration, + avgIndexTiming, minIndexTiming, maxIndexTiming time.Duration, + lastHits []string, + lastMisses []string, + lastDbTimings []time.Duration, + lastIndexTimings []time.Duration, +) { + l.lock.RLock() + defer l.lock.RUnlock() + + return l.cacheHits, + l.cacheMisses, + l.cacheUpdates, + l.avgDatabaseTiming, + l.minDatabaseTiming, + l.maxDatabaseTiming, + l.avgIndexTiming, + l.minIndexTiming, + l.maxIndexTiming, + l.lastHits.Slice(), + l.lastMisses.Slice(), + l.lastDatabaseTimings.Slice(), + l.lastIndexTimings.Slice() +} + +// TimingDatabase logs the giving duration +func (l *StatsLogger) TimingDatabase(timing time.Duration) { + l.lock.Lock() + defer l.lock.Unlock() + + l.log.Info("record database timing", zap.Duration("timing", timing)) + + { + l.avgDatabaseTiming = (l.avgDatabaseTiming + timing) / 2 + } + + { + if l.minDatabaseTiming == 0 { + l.minDatabaseTiming = timing + } + if timing < l.minDatabaseTiming { + l.minDatabaseTiming = timing + } + } + + { + if l.maxDatabaseTiming == 0 { + l.maxDatabaseTiming = timing + } + if timing > l.maxDatabaseTiming { + l.maxDatabaseTiming = timing + } + } + + { + if l.lastDatabaseTimings == nil { + l.lastDatabaseTimings = slice.NewCircular[time.Duration](maxLastLogs) + } + + l.lastDatabaseTimings.Add(timing) + } +} + +// TimingIndex logs the giving duration +func (l *StatsLogger) TimingIndex(timing time.Duration) { + l.lock.Lock() + defer l.lock.Unlock() + + l.log.Info("record index timing", zap.Duration("timing", timing)) + + { + l.avgIndexTiming = (l.avgIndexTiming + timing) / 2 + } + + { + if l.minIndexTiming == 0 { + l.minIndexTiming = timing + } + if timing < l.minIndexTiming { + l.minIndexTiming = timing + } + } + + { + if l.maxIndexTiming == 0 { + l.maxIndexTiming = timing + } + if timing > l.maxIndexTiming { + l.maxIndexTiming = timing + } + } + + { + if l.lastIndexTimings == nil { + l.lastIndexTimings = slice.NewCircular[time.Duration](maxLastLogs) + } + + l.lastIndexTimings.Add(timing) + } +} + +func (l *StatsLogger) CacheHit(roles []uint64, resource string, op string) { + l.lock.Lock() + defer l.lock.Unlock() + + l.log.Info("cache hit", zap.Any("roles", roles), zap.String("resource", resource), zap.String("op", op)) + + l.cacheHits++ + if l.lastHits == nil { + l.lastHits = slice.NewCircular[string](maxLastLogs) + } + l.lastHits.Add(l.strfEntry(roles, resource, op)) +} + +func (l *StatsLogger) CacheMiss(roles []uint64, resource string, op string) { + l.lock.Lock() + defer l.lock.Unlock() + + l.log.Info("cache miss", zap.Any("roles", roles), zap.String("resource", resource), zap.String("op", op)) + + l.cacheMisses++ + if l.lastMisses == nil { + l.lastMisses = slice.NewCircular[string](maxLastLogs) + } + l.lastMisses.Add(l.strfEntry(roles, resource, op)) +} + +func (l *StatsLogger) CacheUpdate(in *Rule) { + l.lock.Lock() + defer l.lock.Unlock() + + l.log.Info("cache update", zap.Any("rule", in)) + + l.cacheUpdates++ +} + +// // // // // // // // // // // // // // // // // // // // // // // // // +// Utils + +func (l *StatsLogger) strfEntry(roles []uint64, resource string, op string) string { + sort.Slice(roles, func(i, j int) bool { return roles[i] < roles[j] }) + + return fmt.Sprintf("%v %s %s", roles, op, resource) +} + +func (l *StatsLogger) watch(ctx context.Context) { + t := time.NewTicker(time.Minute * 5) + + go func() { + for { + select { + case <-t.C: + l.log.Info("stats logger tick") + + case rs := <-l.cacheMissChan: + l.CacheMiss(rs.roles, rs.resource, rs.op) + + case rs := <-l.cacheHitChan: + l.CacheHit(rs.roles, rs.resource, rs.op) + + case tt := <-l.timingDatabaseChan: + l.TimingDatabase(tt) + + case tt := <-l.timingIndexChan: + l.TimingIndex(tt) + + case <-ctx.Done(): + l.log.Info("terminating watcher") + } + } + }() +} diff --git a/server/pkg/rbac/store_interface.go b/server/pkg/rbac/store_interface.go index e98ee22ba0..b1b01f52cc 100644 --- a/server/pkg/rbac/store_interface.go +++ b/server/pkg/rbac/store_interface.go @@ -2,6 +2,8 @@ package rbac import ( "context" + + "github.com/cortezaproject/corteza/server/system/types" ) type ( @@ -11,5 +13,12 @@ type ( UpsertRbacRule(ctx context.Context, rr ...*Rule) error DeleteRbacRule(ctx context.Context, rr ...*Rule) error TruncateRbacRules(ctx context.Context) error + + // @todo this isn't ok since we're referencing sys types + SearchRoles(ctx context.Context, f types.RoleFilter) (types.RoleSet, types.RoleFilter, error) + } + + rbacRoleStore interface { + SearchRoles(ctx context.Context, f types.RoleFilter) (types.RoleSet, types.RoleFilter, error) } ) diff --git a/server/pkg/rbac/svc_counter.go b/server/pkg/rbac/svc_counter.go new file mode 100644 index 0000000000..66882b89a1 --- /dev/null +++ b/server/pkg/rbac/svc_counter.go @@ -0,0 +1,201 @@ +package rbac + +import ( + "context" + "sort" + "sync" + "time" +) + +type ( + usageCounter[K comparable] struct { + lock sync.RWMutex + + // index keeps track of all the things we're counting + index map[K]counterItem[K] + + // sigEvictThreshold denotes when the usage counter should evict an item + sigEvictThreshold float64 + // decayFactor denotes how fast the score decays + // when 1 - it won't decay + // when 0 - it's barely preserved + decayFactor float64 + + // incChan sends instructions to the counter re. key K increment + incChan chan K + + // rmChan sends instructions to the counter re. key K removal + rmChan chan uint64 + + // checkKeyInclusion helps determine if an indexed thing should matches something + checkKeyInclusion func(k K, role uint64) bool + + // decayInterval denotes in what interval the decay factor should apply + decayInterval time.Duration + // cleanupInterval denotes in what interval counter evicts stuff + cleanupInterval time.Duration + } + + // counterItem wraps some metadata around each index + counterItem[K comparable] struct { + key K + score float64 + + // added denotes when the item was added to the counter + added time.Time + // lastScored denotes when the item was last scored (either via decay or access) + lastScored time.Time + // lastAccess denotes when the item was last accessed, needed + lastAccess time.Time + } + + MinHeap[K comparable] []counterItem[K] +) + +// inc updates key K +func (svc *usageCounter[K]) inc(key K) { + svc.lock.Lock() + defer svc.lock.Unlock() + + _, ok := svc.index[key] + if !ok { + svc.procNew(key) + } else { + svc.procExisting(key) + } +} + +// evict evicts the items below the specified threshold +func (svc *usageCounter[K]) evict() (out []K) { + svc.lock.Lock() + defer svc.lock.Unlock() + + // Firstly score them up + out = make([]K, 0, 4) + for k, v := range svc.index { + if v.score > float64(svc.sigEvictThreshold) { + continue + } + + out = append(out, k) + } + + // Then delete them + for _, r := range out { + delete(svc.index, r) + } + + return out +} + +func (svc *usageCounter[K]) cleanRoleKeys(role uint64) { + svc.lock.Lock() + defer svc.lock.Unlock() + + for k := range svc.index { + if !svc.checkKeyInclusion(k, role) { + continue + } + + delete(svc.index, k) + } +} + +// decay applies the specified decay factor to the cache items +func (svc *usageCounter[K]) decay() { + svc.lock.Lock() + defer svc.lock.Unlock() + + n := time.Now() + for k, v := range svc.index { + if n.Before(v.lastAccess.Add(svc.decayInterval)) { + continue + } + + v.score *= svc.decayFactor + svc.index[k] = v + } +} + +// bestPerformers returns the top n items based on their score +func (svc *usageCounter[K]) bestPerformers(n int) (out []K) { + svc.lock.RLock() + defer svc.lock.RUnlock() + + hh := make(MinHeap[K], 0, len(svc.index)) + for k, v := range svc.index { + hh = append(hh, counterItem[K]{key: k, score: v.score}) + } + + sort.Sort(hh) + + for i := len(hh) - 1; i >= 0; i-- { + if n >= 0 && len(out) >= n { + return + } + + out = append(out, hh[i].key) + } + return +} + +// procNew notes a new key in the thing, defaults and stuff +func (svc *usageCounter[K]) procNew(key K) { + n := time.Now() + if svc.index == nil { + svc.index = make(map[K]counterItem[K]) + } + + svc.index[key] = counterItem[K]{ + score: 1, + added: n, + lastScored: n, + lastAccess: n, + } +} + +// procExisting notes an access to an existing index element +func (svc *usageCounter[K]) procExisting(key K) { + n := time.Now() + + aux := svc.index[key] + aux.lastAccess = n + aux.lastScored = n + aux.score++ + + svc.index[key] = aux +} + +func (svc *usageCounter[K]) watch(ctx context.Context) { + if svc.decayInterval == 0 { + panic("svc.decayInterval can not be 0") + } + + if svc.cleanupInterval == 0 { + panic("svc.cleanupInterval can not be 0") + } + + decayT := time.NewTicker(svc.decayInterval) + + go func() { + for { + select { + case <-ctx.Done(): + return + + case <-decayT.C: + svc.decay() + + case key := <-svc.incChan: + svc.inc(key) + + case role := <-svc.rmChan: + svc.cleanRoleKeys(role) + } + } + }() +} + +func (h MinHeap[K]) Len() int { return len(h) } +func (h MinHeap[K]) Less(i, j int) bool { return h[i].score < h[j].score } +func (h MinHeap[K]) Swap(i, j int) { h[i], h[j] = h[j], h[i] } diff --git a/server/pkg/rbac/svc_counter_test.go b/server/pkg/rbac/svc_counter_test.go new file mode 100644 index 0000000000..e748306c14 --- /dev/null +++ b/server/pkg/rbac/svc_counter_test.go @@ -0,0 +1,149 @@ +package rbac + +import ( + "fmt" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWrapperCounter(t *testing.T) { + // @note since I'm leaving the decayInterval empty we don't need to fiddle + // with lastAccess timestamps + svc := &usageCounter[string]{ + index: map[string]counterItem[string]{}, + + sigEvictThreshold: 0.5, + decayFactor: 0.5, + } + + svc.inc("k1") + aux := svc.index["k1"] + require.Equal(t, 1.0, aux.score) + + svc.inc("k2") + aux = svc.index["k1"] + require.Equal(t, 1.0, aux.score) + aux = svc.index["k2"] + require.Equal(t, 1.0, aux.score) + + svc.inc("k1") + aux = svc.index["k1"] + require.Equal(t, 2.0, aux.score) + aux = svc.index["k2"] + require.Equal(t, 1.0, aux.score) + + svc.decay() + aux = svc.index["k1"] + require.Equal(t, 1.0, aux.score) + aux = svc.index["k2"] + require.Equal(t, 0.5, aux.score) + + cleaned := svc.evict() + require.Len(t, cleaned, 1) + aux, ok := svc.index["k1"] + require.True(t, ok) + + aux, ok = svc.index["k2"] + require.False(t, ok) + + svc.decay() + aux = svc.index["k1"] + require.Equal(t, 0.5, aux.score) + + cleaned = svc.evict() + require.Len(t, cleaned, 1) + aux, ok = svc.index["k1"] + require.False(t, ok) +} + +func TestCounterCleanRoleKeys(t *testing.T) { + req := require.New(t) + svc := &usageCounter[string]{ + index: map[string]counterItem[string]{}, + + sigEvictThreshold: 0.5, + decayFactor: 0.5, + + checkKeyInclusion: func(k string, role uint64) bool { + return strings.HasPrefix(k, fmt.Sprintf("%d", role)) + }, + } + + svc.inc("12:res/1/2/3") + svc.inc("12:res/2/2/3") + svc.inc("12:res/3/2/3") + svc.inc("13:res/1/2/3") + svc.inc("14:res/1/2/3") + + svc.cleanRoleKeys(12) + req.Len(svc.index, 2) + + svc.cleanRoleKeys(13) + req.Len(svc.index, 1) + + svc.cleanRoleKeys(14) + req.Len(svc.index, 0) +} + +func TestCounterBestPerformers(t *testing.T) { + req := require.New(t) + svc := &usageCounter[string]{ + index: map[string]counterItem[string]{}, + + sigEvictThreshold: 0.5, + decayFactor: 0.5, + } + + svc.inc("12:res/1/2/3") + svc.inc("12:res/2/2/3") + svc.inc("12:res/3/2/3") + svc.inc("13:res/1/2/3") + svc.inc("14:res/1/2/3") + + // -1 gets all + out := svc.bestPerformers(-1) + req.Len(out, 5) + + // 0 gets none + out = svc.bestPerformers(0) + req.Len(out, 0) + + // n gets some + out = svc.bestPerformers(2) + req.Len(out, 2) + + // too big n gets max + out = svc.bestPerformers(99) + req.Len(out, 5) +} + +func TestMinHeap(t *testing.T) { + hp := MinHeap[string]{} + + hp = append(hp, counterItem[string]{score: 4, key: "4"}) + hp = append(hp, counterItem[string]{score: 10, key: "10"}) + hp = append(hp, counterItem[string]{score: 1, key: "1"}) + hp = append(hp, counterItem[string]{score: 4, key: "4"}) + hp = append(hp, counterItem[string]{score: 2, key: "2"}) + hp = append(hp, counterItem[string]{score: 99, key: "99"}) + hp = append(hp, counterItem[string]{score: 12, key: "12"}) + hp = append(hp, counterItem[string]{score: 3, key: "3"}) + hp = append(hp, counterItem[string]{score: 4, key: "4"}) + hp = append(hp, counterItem[string]{score: 5, key: "5"}) + + sort.Sort(hp) + + require.Equal(t, "1", hp[0].key) + require.Equal(t, "2", hp[1].key) + require.Equal(t, "3", hp[2].key) + require.Equal(t, "4", hp[3].key) + require.Equal(t, "4", hp[4].key) + require.Equal(t, "4", hp[5].key) + require.Equal(t, "5", hp[6].key) + require.Equal(t, "10", hp[7].key) + require.Equal(t, "12", hp[8].key) + require.Equal(t, "99", hp[9].key) +} diff --git a/server/pkg/rbac/svc_index.go b/server/pkg/rbac/svc_index.go new file mode 100644 index 0000000000..ad0bdd0296 --- /dev/null +++ b/server/pkg/rbac/svc_index.go @@ -0,0 +1,100 @@ +package rbac + +import ( + "strings" +) + +type ( + wrapperIndex struct { + // indexed permits only max level identifiers + indexed map[string]bool + index *ruleIndex + } +) + +// add indexes the given rules +func (svc *wrapperIndex) add(resource string, rules ...*Rule) (added bool) { + if svc.indexed == nil { + svc.indexed = make(map[string]bool, 24) + } + + if svc.index == nil { + svc.index = &ruleIndex{} + } + + // Since we're only allowed to index under full resource identifiers + // we'll only optionally update indexes if something new comes in. + if strings.Contains(resource, "*") { + return svc.addWild(resource, rules...) + } else { + return svc.addPlain(resource, rules...) + } +} + +// addWild handles scenario where we would grant permissions for a wildcard +// +// In case of a wild card we need to check if any matching resource falls under it +// if so, add it to the index, if not, ignore. +// +// In no case should we add this to the indexed map since it only permits max lvl identifiers. +func (svc *wrapperIndex) addWild(resource string, rules ...*Rule) (added bool) { + give := false + pp := strings.SplitN(resource, "*", 2) + resource = strings.TrimRight(pp[0], "/") + + for r := range svc.indexed { + if strings.HasPrefix(r, resource) { + give = true + break + } + } + + if !give { + return false + } + + svc.index.add(rules...) + return true +} + +// addPlain handles scenario where we specify rules with a max level resource identifier (no wildcards) +func (svc *wrapperIndex) addPlain(resource string, rules ...*Rule) (added bool) { + svc.indexed[resource] = true + svc.index.add(rules...) + + return true +} + +// get returns the rules for the given role, operation and resource +func (svc *wrapperIndex) get(op string, res string) (out []*Rule) { + // @note we'll expect the state is good and no nil checks are needed + return svc.index.get(op, res) +} + +// getSize returns the number of indexed resources (may not match to number of rules) +func (svc *wrapperIndex) getSize() int { + return len(svc.indexed) +} + +// isIndexed returns true if the resource is either indexed or potentially indexed +// +// If we're providing a max level resource identifier, it must occur in the index +// If we're providing a wildcard, we always assume it's in there +// +// # Underlying functions need to respect this +// +// @todo consider keeping track of prefixes so we can know for a fact. +// It doesn't really matter at this point since referencing functions don't care about this +func (svc *wrapperIndex) isIndexed(resource string) (ok bool) { + // In case of wildcards, assume we have it indexed; further functions need + // to handle this properly + if strings.Contains(resource, "*") { + return true + } + + if svc.indexed == nil { + return false + } + + return svc.indexed[resource] +} diff --git a/server/pkg/rbac/svc_index_test.go b/server/pkg/rbac/svc_index_test.go new file mode 100644 index 0000000000..55faede056 --- /dev/null +++ b/server/pkg/rbac/svc_index_test.go @@ -0,0 +1,25 @@ +package rbac + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIndexing(t *testing.T) { + req := require.New(t) + + svc := wrapperIndex{} + req.True(svc.add("corteza::compose:module-field/1/2/3")) + req.True(svc.add("corteza::compose:module-field/1/4/6")) + + req.True(svc.add("corteza::compose:module-field/1/*/*")) + req.True(svc.add("corteza::compose:module-field/1/4/*")) + + // False since no resource matches this wildcard + req.False(svc.add("corteza::compose:module-field/1/5/*")) + req.False(svc.add("corteza::compose:module-field/2/*/*")) + + // False since it's a completely different resource + req.False(svc.add("corteza::compose:record/1/2/*")) +} diff --git a/server/pkg/rbac/utils.go b/server/pkg/rbac/utils.go new file mode 100644 index 0000000000..94a8635934 --- /dev/null +++ b/server/pkg/rbac/utils.go @@ -0,0 +1,19 @@ +package rbac + +import "strings" + +// permuteResource returns the given identifier lvl and all lower levels +func permuteResource(res string) (out []string) { + out = append(out, res) + rr := strings.Split(res, "/") + for i := len(rr) - 1; i >= 1; i-- { + if rr[i] == "*" { + continue + } + + rr[i] = "*" + out = append(out, strings.Join(rr, "/")) + } + + return +} diff --git a/server/pkg/rbac/utils_test.go b/server/pkg/rbac/utils_test.go new file mode 100644 index 0000000000..317fdc0330 --- /dev/null +++ b/server/pkg/rbac/utils_test.go @@ -0,0 +1,33 @@ +package rbac + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPermuteResource(t *testing.T) { + tcc := []struct { + in string + out []string + }{{ + in: "xx/1/2", + out: []string{"xx/1/2", "xx/1/*", "xx/*/*"}, + }, { + in: "xx/1/*", + out: []string{"xx/1/*", "xx/*/*"}, + }, { + in: "xx/*/*", + out: []string{"xx/*/*"}, + }, { + in: "xx", + out: []string{"xx"}, + }} + + req := require.New(t) + for _, tc := range tcc { + t.Run(tc.in, func(t *testing.T) { + req.Equal(tc.out, permuteResource(tc.in)) + }) + } +} diff --git a/server/pkg/slice/circular.go b/server/pkg/slice/circular.go new file mode 100644 index 0000000000..dcc0e7b7ee --- /dev/null +++ b/server/pkg/slice/circular.go @@ -0,0 +1,55 @@ +package slice + +type ( + Circular[V any] struct { + size int + slc []V + head int + cycled bool + } +) + +func NewCircular[V any](size int) *Circular[V] { + return &Circular[V]{ + slc: make([]V, size), + size: size, + } +} + +func (cs *Circular[V]) Add(v V) { + if cs.head >= cs.size { + cs.head = 0 + cs.cycled = true + } + + cs.slc[cs.head] = v + cs.head++ +} + +func (cs *Circular[V]) Slice() (out []V) { + if cs == nil { + return + } + + if !cs.cycled { + return cs.sliceUncycled() + } + return cs.sliceCycled() +} + +func (cs *Circular[V]) sliceUncycled() (out []V) { + for i := 0; i < cs.head; i++ { + out = append(out, cs.slc[i]) + } + + return +} + +func (cs *Circular[V]) sliceCycled() (out []V) { + for i := 0; i < cs.size; i++ { + xo := (cs.head + i) % cs.size + out = append(out, cs.slc[xo]) + } + + return +} diff --git a/server/pkg/slice/circular_test.go b/server/pkg/slice/circular_test.go new file mode 100644 index 0000000000..26d556c0fc --- /dev/null +++ b/server/pkg/slice/circular_test.go @@ -0,0 +1,37 @@ +package slice + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCycleSlice(t *testing.T) { + req := require.New(t) + + cc := NewCircular[int](5) + + cc.Add(1) + req.Equal([]int{1}, cc.Slice()) + + cc.Add(2) + cc.Add(3) + req.Equal([]int{1, 2, 3}, cc.Slice()) + + cc.Add(4) + cc.Add(5) + req.Equal([]int{1, 2, 3, 4, 5}, cc.Slice()) + + cc.Add(6) + req.Equal([]int{2, 3, 4, 5, 6}, cc.Slice()) + + cc.Add(7) + cc.Add(8) + cc.Add(9) + cc.Add(10) + req.Equal([]int{6, 7, 8, 9, 10}, cc.Slice()) + + cc.Add(11) + cc.Add(12) + req.Equal([]int{8, 9, 10, 11, 12}, cc.Slice()) +} diff --git a/server/store/adapters/rdbms/filters.gen.go b/server/store/adapters/rdbms/filters.gen.go index f35415d2cc..ffa9d26cc0 100644 --- a/server/store/adapters/rdbms/filters.gen.go +++ b/server/store/adapters/rdbms/filters.gen.go @@ -1145,6 +1145,24 @@ func QueueMessageFilter(d drivers.Dialect, f systemType.QueueMessageFilter) (ee // This function is auto-generated func RbacRuleFilter(d drivers.Dialect, f rbacType.RuleFilter) (ee []goqu.Expression, _ rbacType.RuleFilter, err error) { + if ss := trimStringSlice(f.Resource); len(ss) > 0 { + ee = append(ee, goqu.C("resource").In(ss)) + } + + if val := strings.TrimSpace(f.Operation); len(val) > 0 { + ee = append(ee, goqu.C("operation").Eq(f.Operation)) + } + + if f.RoleID > 0 { + ee = append(ee, goqu.C("rel_role").Eq(f.RoleID)) + } + + if f.RawFilter != "" { + // @note this is only acceptable for system-generated queries. + // This should be improved regardless. + ee = append(ee, goqu.Literal(f.RawFilter)) + } + return ee, f, err } diff --git a/server/store/adapters/rdbms/rdbms.gen.go b/server/store/adapters/rdbms/rdbms.gen.go index 717844f799..4f0635ec34 100644 --- a/server/store/adapters/rdbms/rdbms.gen.go +++ b/server/store/adapters/rdbms/rdbms.gen.go @@ -333,7 +333,7 @@ func (s *Store) LookupActionlogByID(ctx context.Context, id uint64) (_ *actionlo func (Store) sortableActionlogFields() map[string]string { return map[string]string{ "id": "id", - "timestamp": "timestamp", + "timestamp": "ts", } } @@ -916,7 +916,7 @@ func (Store) sortableApigwFilterFields() map[string]string { "enabled": "enabled", "id": "id", "kind": "kind", - "route": "route", + "route": "rel_route", "updated_at": "updated_at", "updatedat": "updated_at", "weight": "weight", @@ -1421,7 +1421,7 @@ func (s *Store) QueryApigwRoutes( // LookupApigwRouteByID searches for route by ID // -// It returns route even if deleted or suspended +// # It returns route even if deleted or suspended // // This function is auto-generated func (s *Store) LookupApigwRouteByID(ctx context.Context, id uint64) (_ *systemType.ApigwRoute, err error) { @@ -1463,7 +1463,7 @@ func (s *Store) LookupApigwRouteByID(ctx context.Context, id uint64) (_ *systemT // LookupApigwRouteByEndpoint searches for route by endpoint // -// It returns route even if deleted or suspended +// # It returns route even if deleted or suspended // // This function is auto-generated func (s *Store) LookupApigwRouteByEndpoint(ctx context.Context, endpoint string) (_ *systemType.ApigwRoute, err error) { @@ -1517,7 +1517,7 @@ func (Store) sortableApigwRouteFields() map[string]string { "deletedat": "deleted_at", "enabled": "enabled", "endpoint": "endpoint", - "group": "group", + "group": "rel_group", "id": "id", "method": "method", "updated_at": "updated_at", @@ -2023,7 +2023,7 @@ func (s *Store) QueryApplications( // LookupApplicationByID searches for role by ID // -// It returns role even if deleted or suspended +// # It returns role even if deleted or suspended // // This function is auto-generated func (s *Store) LookupApplicationByID(ctx context.Context, id uint64) (_ *systemType.Application, err error) { @@ -3132,7 +3132,7 @@ func (s *Store) QueryAuthClients( // LookupAuthClientByID searches for auth client by ID // -// It returns auth clint even if deleted +// It returns auth clint even if deleted // // This function is auto-generated func (s *Store) LookupAuthClientByID(ctx context.Context, id uint64) (_ *systemType.AuthClient, err error) { @@ -3174,7 +3174,7 @@ func (s *Store) LookupAuthClientByID(ctx context.Context, id uint64) (_ *systemT // LookupAuthClientByHandle searches for auth client by ID // -// It returns auth clint even if deleted +// # It returns auth clint even if deleted // // This function is auto-generated func (s *Store) LookupAuthClientByHandle(ctx context.Context, handle string) (_ *systemType.AuthClient, err error) { @@ -3599,12 +3599,12 @@ func (s *Store) LookupAuthConfirmedClientByUserIDClientID(ctx context.Context, u // This function is auto-generated func (Store) sortableAuthConfirmedClientFields() map[string]string { return map[string]string{ - "client_id": "client_id", - "clientid": "client_id", + "client_id": "rel_client", + "clientid": "rel_client", "confirmed_at": "confirmed_at", "confirmedat": "confirmed_at", - "user_id": "user_id", - "userid": "user_id", + "user_id": "rel_user", + "userid": "rel_user", } } @@ -4871,7 +4871,7 @@ func (s *Store) QueryAutomationSessions( // LookupAutomationSessionByID searches for session by ID // -// It returns session even if deleted +// # It returns session even if deleted // // This function is auto-generated func (s *Store) LookupAutomationSessionByID(ctx context.Context, id uint64) (_ *automationType.Session, err error) { @@ -4933,8 +4933,8 @@ func (Store) sortableAutomationSessionFields() map[string]string { "status": "status", "suspended_at": "suspended_at", "suspendedat": "suspended_at", - "workflow_id": "workflow_id", - "workflowid": "workflow_id", + "workflow_id": "rel_workflow", + "workflowid": "rel_workflow", } } @@ -5438,7 +5438,7 @@ func (s *Store) QueryAutomationTriggers( // LookupAutomationTriggerByID searches for trigger by ID // -// It returns trigger even if deleted +// # It returns trigger even if deleted // // This function is auto-generated func (s *Store) LookupAutomationTriggerByID(ctx context.Context, id uint64) (_ *automationType.Trigger, err error) { @@ -5498,8 +5498,8 @@ func (Store) sortableAutomationTriggerFields() map[string]string { "resourcetype": "resource_type", "updated_at": "updated_at", "updatedat": "updated_at", - "workflow_id": "workflow_id", - "workflowid": "workflow_id", + "workflow_id": "rel_workflow", + "workflowid": "rel_workflow", } } @@ -6001,7 +6001,7 @@ func (s *Store) QueryAutomationWorkflows( // LookupAutomationWorkflowByID searches for workflow by ID // -// It returns workflow even if deleted +// # It returns workflow even if deleted // // This function is auto-generated func (s *Store) LookupAutomationWorkflowByID(ctx context.Context, id uint64) (_ *automationType.Workflow, err error) { @@ -6043,7 +6043,7 @@ func (s *Store) LookupAutomationWorkflowByID(ctx context.Context, id uint64) (_ // LookupAutomationWorkflowByHandle searches for workflow by their handle // -// It returns only valid workflows +// # It returns only valid workflows // // This function is auto-generated func (s *Store) LookupAutomationWorkflowByHandle(ctx context.Context, handle string) (_ *automationType.Workflow, err error) { @@ -6679,8 +6679,8 @@ func (Store) sortableComposeAttachmentFields() map[string]string { "id": "id", "kind": "kind", "name": "name", - "owner_id": "owner_id", - "ownerid": "owner_id", + "owner_id": "rel_owner", + "ownerid": "rel_owner", "updated_at": "updated_at", "updatedat": "updated_at", } @@ -7182,7 +7182,7 @@ func (s *Store) QueryComposeCharts( // LookupComposeChartByID searches for compose chart by ID // -// It returns compose chart even if deleted +// # It returns compose chart even if deleted // // This function is auto-generated func (s *Store) LookupComposeChartByID(ctx context.Context, id uint64) (_ *composeType.Chart, err error) { @@ -7863,7 +7863,7 @@ func (s *Store) LookupComposeModuleByNamespaceIDName(ctx context.Context, namesp // LookupComposeModuleByID searches for compose module by ID // -// It returns compose module even if deleted +// # It returns compose module even if deleted // // This function is auto-generated func (s *Store) LookupComposeModuleByID(ctx context.Context, id uint64) (_ *composeType.Module, err error) { @@ -8906,7 +8906,7 @@ func (s *Store) LookupComposeNamespaceBySlug(ctx context.Context, slug string) ( // LookupComposeNamespaceByID searches for compose namespace by ID // -// It returns compose namespace even if deleted +// # It returns compose namespace even if deleted // // This function is auto-generated func (s *Store) LookupComposeNamespaceByID(ctx context.Context, id uint64) (_ *composeType.Namespace, err error) { @@ -9571,7 +9571,7 @@ func (s *Store) LookupComposePageByNamespaceIDModuleID(ctx context.Context, name // LookupComposePageByID searches for compose page by ID // -// It returns compose page even if deleted +// # It returns compose page even if deleted // // This function is auto-generated func (s *Store) LookupComposePageByID(ctx context.Context, id uint64) (_ *composeType.Page, err error) { @@ -10218,7 +10218,7 @@ func (s *Store) LookupComposePageLayoutByNamespaceIDPageIDHandle(ctx context.Con // LookupComposePageLayoutByID searches for compose page layour by ID // -// It returns compose page layour even if deleted +// # It returns compose page layour even if deleted // // This function is auto-generated func (s *Store) LookupComposePageLayoutByID(ctx context.Context, id uint64) (_ *composeType.PageLayout, err error) { @@ -10564,7 +10564,7 @@ func (s *Store) QueryCredentials( // LookupCredentialByID searches for credentials by ID // -// It returns credentials even if deleted +// # It returns credentials even if deleted // // This function is auto-generated func (s *Store) LookupCredentialByID(ctx context.Context, id uint64) (_ *systemType.Credential, err error) { @@ -11120,7 +11120,7 @@ func (s *Store) QueryDalConnections( // LookupDalConnectionByID searches for connection by ID // -// It returns connection even if deleted or suspended +// # It returns connection even if deleted or suspended // // This function is auto-generated func (s *Store) LookupDalConnectionByID(ctx context.Context, id uint64) (_ *systemType.DalConnection, err error) { @@ -12288,7 +12288,7 @@ func (s *Store) QueryDalSensitivityLevels( // LookupDalSensitivityLevelByID searches for user by ID // -// It returns user even if deleted or suspended +// # It returns user even if deleted or suspended // // This function is auto-generated func (s *Store) LookupDalSensitivityLevelByID(ctx context.Context, id uint64) (_ *systemType.DalSensitivityLevel, err error) { @@ -12843,7 +12843,7 @@ func (s *Store) QueryDataPrivacyRequests( // LookupDataPrivacyRequestByID searches for data privacy request by ID // -// It returns data privacy request even if deleted +// # It returns data privacy request even if deleted // // This function is auto-generated func (s *Store) LookupDataPrivacyRequestByID(ctx context.Context, id uint64) (_ *systemType.DataPrivacyRequest, err error) { @@ -13911,7 +13911,7 @@ func (s *Store) QueryFederationExposedModules( // LookupFederationExposedModuleByID searches for federation module by ID // -// It returns federation module +// # It returns federation module // // This function is auto-generated func (s *Store) LookupFederationExposedModuleByID(ctx context.Context, id uint64) (_ *federationType.ExposedModule, err error) { @@ -13966,8 +13966,8 @@ func (Store) sortableFederationExposedModuleFields() map[string]string { "handle": "handle", "id": "id", "name": "name", - "node_id": "node_id", - "nodeid": "node_id", + "node_id": "rel_node", + "nodeid": "rel_node", "updated_at": "updated_at", "updatedat": "updated_at", } @@ -14461,7 +14461,7 @@ func (s *Store) QueryFederationModuleMappings( // LookupFederationModuleMappingByFederationModuleIDComposeModuleIDComposeNamespaceID searches for module mapping by federation module id and compose module id // -// It returns module mapping +// # It returns module mapping // // This function is auto-generated func (s *Store) LookupFederationModuleMappingByFederationModuleIDComposeModuleIDComposeNamespaceID(ctx context.Context, federationModuleID uint64, composeModuleID uint64, composeNamespaceID uint64) (_ *federationType.ModuleMapping, err error) { @@ -14505,7 +14505,7 @@ func (s *Store) LookupFederationModuleMappingByFederationModuleIDComposeModuleID // LookupFederationModuleMappingByFederationModuleID searches for module mapping by federation module id // -// It returns module mapping +// # It returns module mapping // // This function is auto-generated func (s *Store) LookupFederationModuleMappingByFederationModuleID(ctx context.Context, federationModuleID uint64) (_ *federationType.ModuleMapping, err error) { @@ -14553,12 +14553,12 @@ func (s *Store) LookupFederationModuleMappingByFederationModuleID(ctx context.Co // This function is auto-generated func (Store) sortableFederationModuleMappingFields() map[string]string { return map[string]string{ - "compose_module_id": "compose_module_id", - "compose_namespace_id": "compose_namespace_id", - "composemoduleid": "compose_module_id", - "composenamespaceid": "compose_namespace_id", - "federation_module_id": "federation_module_id", - "federationmoduleid": "federation_module_id", + "compose_module_id": "rel_compose_module", + "compose_namespace_id": "rel_compose_namespace", + "composemoduleid": "rel_compose_module", + "composenamespaceid": "rel_compose_namespace", + "federation_module_id": "rel_federation_module", + "federationmoduleid": "rel_federation_module", "node_id": "node_id", "nodeid": "node_id", } @@ -15049,7 +15049,7 @@ func (s *Store) QueryFederationNodes( // LookupFederationNodeByID searches for federation node by ID // -// It returns federation node +// # It returns federation node // // This function is auto-generated func (s *Store) LookupFederationNodeByID(ctx context.Context, id uint64) (_ *federationType.Node, err error) { @@ -15695,7 +15695,7 @@ func (s *Store) QueryFederationNodeSyncs( // LookupFederationNodeSyncByNodeID searches for sync activity by node ID // -// It returns sync activity +// # It returns sync activity // // This function is auto-generated func (s *Store) LookupFederationNodeSyncByNodeID(ctx context.Context, nodeID uint64) (_ *federationType.NodeSync, err error) { @@ -15737,7 +15737,7 @@ func (s *Store) LookupFederationNodeSyncByNodeID(ctx context.Context, nodeID uin // LookupFederationNodeSyncByNodeIDModuleIDSyncTypeSyncStatus searches for activity by node, type and status // -// It returns sync activity +// # It returns sync activity // // This function is auto-generated func (s *Store) LookupFederationNodeSyncByNodeIDModuleIDSyncTypeSyncStatus(ctx context.Context, nodeID uint64, moduleID uint64, syncType string, syncStatus string) (_ *federationType.NodeSync, err error) { @@ -16293,7 +16293,7 @@ func (s *Store) QueryFederationSharedModules( // LookupFederationSharedModuleByID searches for shared federation module by ID // -// It returns shared federation module +// # It returns shared federation module // // This function is auto-generated func (s *Store) LookupFederationSharedModuleByID(ctx context.Context, id uint64) (_ *federationType.SharedModule, err error) { @@ -16345,13 +16345,13 @@ func (Store) sortableFederationSharedModuleFields() map[string]string { "createdat": "created_at", "deleted_at": "deleted_at", "deletedat": "deleted_at", - "external_federation_module_id": "external_federation_module_id", - "externalfederationmoduleid": "external_federation_module_id", + "external_federation_module_id": "xref_module", + "externalfederationmoduleid": "xref_module", "handle": "handle", "id": "id", "name": "name", - "node_id": "node_id", - "nodeid": "node_id", + "node_id": "rel_node", + "nodeid": "rel_node", "updated_at": "updated_at", "updatedat": "updated_at", } @@ -16695,8 +16695,8 @@ func (Store) sortableFlagFields() map[string]string { "name": "name", "owned_by": "owned_by", "ownedby": "owned_by", - "resource_id": "resource_id", - "resourceid": "resource_id", + "resource_id": "rel_resource", + "resourceid": "rel_resource", } } @@ -17040,8 +17040,8 @@ func (Store) sortableLabelFields() map[string]string { return map[string]string{ "kind": "kind", "name": "name", - "resource_id": "resource_id", - "resourceid": "resource_id", + "resource_id": "rel_resource", + "resourceid": "rel_resource", } } @@ -18342,6 +18342,8 @@ func (s *Store) QueryRbacRules( rows *sql.Rows count uint expr, tExpr []goqu.Expression + + sortExpr []exp.OrderedExpression ) if s.Filters.RbacRule != nil { @@ -18361,6 +18363,16 @@ func (s *Store) QueryRbacRules( query := rbacRuleSelectQuery(s.Dialect.GOQU()).Where(expr...) + // sorting feature is enabled + if sortExpr, err = order(f.Sort, s.sortableRbacRuleFields()); err != nil { + err = fmt.Errorf("could generate order expression for RbacRule: %w", err) + return + } + + if len(sortExpr) > 0 { + query = query.Order(sortExpr...) + } + if f.Limit > 0 { query = query.Limit(f.Limit) } @@ -18419,8 +18431,8 @@ func (Store) sortableRbacRuleFields() map[string]string { return map[string]string{ "operation": "operation", "resource": "resource", - "role_id": "role_id", - "roleid": "role_id", + "role_id": "rel_role", + "roleid": "rel_role", } } @@ -19483,7 +19495,7 @@ func (s *Store) QueryReports( // LookupReportByID searches for report by ID // -// It returns report even if deleted +// # It returns report even if deleted // // This function is auto-generated func (s *Store) LookupReportByID(ctx context.Context, id uint64) (_ *systemType.Report, err error) { @@ -19525,7 +19537,7 @@ func (s *Store) LookupReportByID(ctx context.Context, id uint64) (_ *systemType. // LookupReportByHandle searches for report by handle // -// It returns report if deleted +// # It returns report if deleted // // This function is auto-generated func (s *Store) LookupReportByHandle(ctx context.Context, handle string) (_ *systemType.Report, err error) { @@ -19895,7 +19907,7 @@ func (s *Store) QueryResourceActivitys( func (Store) sortableResourceActivityFields() map[string]string { return map[string]string{ "id": "id", - "timestamp": "timestamp", + "timestamp": "ts", } } @@ -20920,7 +20932,7 @@ func (s *Store) QueryRoles( // LookupRoleByID searches for role by ID // -// It returns role even if deleted or suspended +// # It returns role even if deleted or suspended // // This function is auto-generated func (s *Store) LookupRoleByID(ctx context.Context, id uint64) (_ *systemType.Role, err error) { @@ -21409,10 +21421,10 @@ func (s *Store) QueryRoleMembers( // This function is auto-generated func (Store) sortableRoleMemberFields() map[string]string { return map[string]string{ - "role_id": "role_id", - "roleid": "role_id", - "user_id": "user_id", - "userid": "user_id", + "role_id": "rel_role", + "roleid": "rel_role", + "user_id": "rel_user", + "userid": "rel_user", } } @@ -21739,8 +21751,8 @@ func (s *Store) LookupSettingValueByNameOwnedBy(ctx context.Context, name string func (Store) sortableSettingValueFields() map[string]string { return map[string]string{ "name": "name", - "owned_by": "owned_by", - "ownedby": "owned_by", + "owned_by": "rel_owner", + "ownedby": "rel_owner", "updated_at": "updated_at", "updatedat": "updated_at", } @@ -22874,7 +22886,7 @@ func (s *Store) QueryUsers( // LookupUserByID searches for user by ID // -// It returns user even if deleted or suspended +// # It returns user even if deleted or suspended // // This function is auto-generated func (s *Store) LookupUserByID(ctx context.Context, id uint64) (_ *systemType.User, err error) { diff --git a/server/system/model/corteza.gen.go b/server/system/model/corteza.gen.go index 94823da475..15077fd071 100644 --- a/server/system/model/corteza.gen.go +++ b/server/system/model/corteza.gen.go @@ -403,7 +403,7 @@ var Rule = &dal.Model{ Attributes: dal.AttributeSet{ &dal.Attribute{ - Ident: "RoleID", + Ident: "RoleID", Sortable: true, Type: &dal.TypeRef{ RefAttribute: "id", RefModel: &dal.ModelRef{ diff --git a/server/system/service/access_control.gen.go b/server/system/service/access_control.gen.go index c24d69cbf3..023fbb9f1f 100644 --- a/server/system/service/access_control.gen.go +++ b/server/system/service/access_control.gen.go @@ -22,9 +22,9 @@ import ( type ( rbacService interface { Can(rbac.Session, string, rbac.Resource) bool - Trace(rbac.Session, string, rbac.Resource) *rbac.Trace + Trace(rbac.Session, string, rbac.Resource) (*rbac.Trace, error) Grant(context.Context, ...*rbac.Rule) error - FindRulesByRoleID(roleID uint64) (rr rbac.RuleSet) + FindRulesByRoleID(ctx context.Context, roleID uint64) (rr rbac.RuleSet, err error) } accessControl struct { @@ -120,11 +120,16 @@ func (svc accessControl) Trace(ctx context.Context, userID uint64, roles []uint6 return nil, fmt.Errorf("no roles specified") } + var auxTrace *rbac.Trace session := rbac.ParamsToSession(ctx, userID, roles...) for _, res := range resources { r := res.RbacResource() for op := range rbacResourceOperations(r) { - ee = append(ee, svc.rbac.Trace(session, op, res)) + auxTrace, err = svc.rbac.Trace(session, op, res) + if err != nil { + return + } + ee = append(ee, auxTrace) } } @@ -604,7 +609,7 @@ func (svc accessControl) FindRulesByRoleID(ctx context.Context, roleID uint64) ( return nil, AccessControlErrNotAllowedToSetPermissions() } - return svc.rbac.FindRulesByRoleID(roleID), nil + return svc.rbac.FindRulesByRoleID(ctx, roleID) } // CanReadApplication checks if current user can read application diff --git a/server/system/service/statistics.go b/server/system/service/statistics.go index 98bc5bdf3c..9c58084252 100644 --- a/server/system/service/statistics.go +++ b/server/system/service/statistics.go @@ -6,6 +6,7 @@ import ( "github.com/cortezaproject/corteza/server/store" "github.com/cortezaproject/corteza/server/pkg/actionlog" + "github.com/cortezaproject/corteza/server/pkg/rbac" "github.com/cortezaproject/corteza/server/system/types" ) @@ -26,6 +27,8 @@ type ( Users *types.UserMetrics `json:"users"` Roles *types.RoleMetrics `json:"roles"` Applications *types.ApplicationMetrics `json:"applications"` + + Rbac rbac.Stats `json:"rbac"` } ) @@ -62,6 +65,12 @@ func (svc statistics) Metrics(ctx context.Context) (rval *StatisticsMetricsPaylo } } + // @todo rbac + rval.Rbac, err = rbac.Global().Stats() + if err != nil { + return err + } + return nil }() diff --git a/server/system/service/user_test.go b/server/system/service/user_test.go index 05b906a293..0465eb058c 100644 --- a/server/system/service/user_test.go +++ b/server/system/service/user_test.go @@ -3,6 +3,7 @@ package service import ( "context" "testing" + "time" "github.com/stretchr/testify/assert" @@ -38,8 +39,6 @@ func TestUser_ProtectedSearch(t *testing.T) { testUser = &types.User{ID: 42} - acRBAC = rbac.NewService(zap.NewNop(), nil) - s store.Storer ) @@ -49,6 +48,18 @@ func TestUser_ProtectedSearch(t *testing.T) { req.NoError(err) } + var ( + acRBAC = rbac.NewServiceMust(ctx, zap.NewNop(), s, rbac.Config{ + Synchronous: true, + DecayInterval: time.Hour * 2, + CleanupInterval: time.Hour * 2, + ReindexInterval: time.Hour * 2, + IndexFlushInterval: time.Hour * 2, + RuleStorage: s, + RoleStorage: s, + }) + ) + acRBAC.UpdateRoles(rbac.CommonRole.Make(testRoleID, "test-role")) req.NoError(acRBAC.Grant(ctx, rbac.AllowRule(testRoleID, types.ComponentRbacResource(), "users.search"), diff --git a/server/tests/automation/permissions_test.go b/server/tests/automation/permissions_test.go index f46e4dcf1b..7e52135636 100644 --- a/server/tests/automation/permissions_test.go +++ b/server/tests/automation/permissions_test.go @@ -1,6 +1,7 @@ package automation import ( + "context" "fmt" "net/http" "testing" @@ -8,7 +9,7 @@ import ( "github.com/cortezaproject/corteza/server/automation/types" "github.com/cortezaproject/corteza/server/pkg/rbac" "github.com/cortezaproject/corteza/server/tests/helpers" - "github.com/steinfletcher/apitest-jsonpath" + jsonpath "github.com/steinfletcher/apitest-jsonpath" ) func TestPermissionsEffective(t *testing.T) { @@ -68,6 +69,7 @@ func TestPermissionsUpdate(t *testing.T) { } func TestPermissionsDelete(t *testing.T) { + ctx := context.Background() h := newHelper(t) p := rbac.Global() @@ -77,12 +79,12 @@ func TestPermissionsDelete(t *testing.T) { // New role. permDelRole := h.roleID + 1 - h.a.Len(rbac.Global().FindRulesByRoleID(permDelRole), 0) + h.a.Len(mustFindRulesByRoleID(rbac.Global().FindRulesByRoleID(ctx, permDelRole)), 0) // Setup a few fake rules for new role helpers.Grant(rbac.AllowRule(permDelRole, types.ComponentRbacResource(), "workflow.create")) - h.a.Len(p.FindRulesByRoleID(permDelRole), 1) + h.a.Len(mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, permDelRole)), 1) h.apiInit(). Delete(fmt.Sprintf("/permissions/%d/rules", permDelRole)). @@ -93,7 +95,7 @@ func TestPermissionsDelete(t *testing.T) { End() // Make sure all rules for this role are deleted - for _, r := range p.FindRulesByRoleID(permDelRole) { + for _, r := range mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, permDelRole)) { h.a.True(r.Access == rbac.Inherit) } } @@ -113,3 +115,11 @@ func TestPermissionsTrace(t *testing.T) { Assert(jsonpath.Present(`$.response`)). End() } + +func mustFindRulesByRoleID(rr rbac.RuleSet, err error) rbac.RuleSet { + if err != nil { + panic(err) + } + + return rr +} diff --git a/server/tests/compose/module_test.go b/server/tests/compose/module_test.go index 46fad53b95..5b5119653b 100644 --- a/server/tests/compose/module_test.go +++ b/server/tests/compose/module_test.go @@ -160,7 +160,7 @@ func TestModuleList_filterForbidden(t *testing.T) { h.makeModule(ns, "module") f := h.makeModule(ns, "module_forbidden") - helpers.DenyMe(h, types.ModuleRbacResource(0, f.ID), "read") + helpers.DenyMe(h, types.ModuleRbacResource(f.NamespaceID, f.ID), "read") h.apiInit(). Get(fmt.Sprintf("/namespace/%d/module/", ns.ID)). diff --git a/server/tests/compose/permissions_test.go b/server/tests/compose/permissions_test.go index 39fa2b62c7..ef16aed81c 100644 --- a/server/tests/compose/permissions_test.go +++ b/server/tests/compose/permissions_test.go @@ -1,6 +1,7 @@ package compose import ( + "context" "fmt" "net/http" "testing" @@ -8,7 +9,7 @@ import ( "github.com/cortezaproject/corteza/server/compose/types" "github.com/cortezaproject/corteza/server/pkg/rbac" "github.com/cortezaproject/corteza/server/tests/helpers" - "github.com/steinfletcher/apitest-jsonpath" + jsonpath "github.com/steinfletcher/apitest-jsonpath" ) func TestPermissionsEffective(t *testing.T) { @@ -74,6 +75,7 @@ func TestPermissionsUpdate(t *testing.T) { } func TestPermissionsDelete(t *testing.T) { + ctx := context.Background() h := newHelper(t) p := rbac.Global() @@ -83,12 +85,12 @@ func TestPermissionsDelete(t *testing.T) { // New role. permDelRole := h.roleID + 1 - h.a.Len(rbac.Global().FindRulesByRoleID(permDelRole), 0) + h.a.Len(mustFindRulesByRoleID(rbac.Global().FindRulesByRoleID(ctx, permDelRole)), 0) // Setup a few fake rules for new role helpers.Grant(rbac.AllowRule(permDelRole, types.ComponentRbacResource(), "namespace.create")) - h.a.Len(p.FindRulesByRoleID(permDelRole), 1) + h.a.Len(mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, permDelRole)), 1) h.apiInit(). Delete(fmt.Sprintf("/permissions/%d/rules", permDelRole)). @@ -99,7 +101,7 @@ func TestPermissionsDelete(t *testing.T) { End() // Make sure all rules for this role are deleted - for _, r := range p.FindRulesByRoleID(permDelRole) { + for _, r := range mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, permDelRole)) { h.a.True(r.Access == rbac.Inherit) } } @@ -119,3 +121,11 @@ func TestPermissionsTrace(t *testing.T) { Assert(jsonpath.Present(`$.response`)). End() } + +func mustFindRulesByRoleID(rr rbac.RuleSet, err error) rbac.RuleSet { + if err != nil { + panic(err) + } + + return rr +} diff --git a/server/tests/compose/record_test.go b/server/tests/compose/record_test.go index 1e0d1ff6c2..ee434c664c 100644 --- a/server/tests/compose/record_test.go +++ b/server/tests/compose/record_test.go @@ -326,7 +326,7 @@ func TestRecordListForbiddenFields(t *testing.T) { module := h.repoMakeRecordModuleWithFields("record testing module") helpers.AllowMe(h, module.RbacResource(), "records.create", "records.search") - helpers.DenyMe(h, types.ModuleFieldRbacResource(0, 0, module.Fields[0].ID), "record.value.read") + helpers.DenyMe(h, types.ModuleFieldRbacResource(module.NamespaceID, module.ID, module.Fields[0].ID), "record.value.read") h.makeRecord(module, &types.RecordValue{Name: "name", Value: "v_name_0"}, &types.RecordValue{Name: "email", Value: "v_email_0"}) h.makeRecord(module, &types.RecordValue{Name: "name", Value: "v_name_1"}, &types.RecordValue{Name: "email", Value: "v_email_1"}) @@ -657,9 +657,9 @@ func TestRecordUpdate_forbiddenFields(t *testing.T) { &types.RecordValue{Name: "f-b-t-n", Value: "1"}, // no-value &types.RecordValue{Name: "f-b-t-v", Value: "1"}, // value ) - helpers.AllowMe(h, types.RecordRbacResource(0, 0, record.ID), "update") + helpers.AllowMe(h, types.RecordRbacResource(record.NamespaceID, record.ModuleID, record.ID), "update") helpers.AllowMe(h, module.Fields[0].RbacResource(), "record.value.update") - helpers.DenyMe(h, types.ModuleFieldRbacResource(0, record.ModuleID, 0), "record.value.update") + helpers.DenyMe(h, types.ModuleFieldRbacResource(record.NamespaceID, record.ModuleID, 0), "record.value.update") h.apiInit(). Post(fmt.Sprintf("/namespace/%d/module/%d/record/%d", module.NamespaceID, module.ID, record.ID)). diff --git a/server/tests/rbac/main_test.go b/server/tests/rbac/main_test.go new file mode 100644 index 0000000000..e0b3588191 --- /dev/null +++ b/server/tests/rbac/main_test.go @@ -0,0 +1,222 @@ +package rbac + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + automationEnvoy "github.com/cortezaproject/corteza/server/automation/envoy" + composeEnvoy "github.com/cortezaproject/corteza/server/compose/envoy" + systemEnvoy "github.com/cortezaproject/corteza/server/system/envoy" + "github.com/cortezaproject/corteza/server/system/types" + "github.com/stretchr/testify/require" + + "github.com/cortezaproject/corteza/server/pkg/cli" + "github.com/cortezaproject/corteza/server/pkg/envoyx" + "github.com/cortezaproject/corteza/server/pkg/id" + "github.com/cortezaproject/corteza/server/pkg/rbac" + "github.com/cortezaproject/corteza/server/store" + "github.com/cortezaproject/corteza/server/tests/helpers" + _ "github.com/joho/godotenv/autoload" + "go.uber.org/zap" +) + +type ( + sesWrap struct { + identity uint64 + roles []uint64 + context context.Context + } + + resWrap struct { + resource string + } + + testStorage struct { + upserts []*rbac.Rule + + returnRuleSearch []*rbac.Rule + } + + svcModFnc func(*rbac.Service) +) + +var ( + defaultEnvoy *envoyx.Service + defaultStore store.Storer +) + +func init() { + helpers.RecursiveDotEnvLoad() + id.Init(cli.Context()) +} + +func TestMain(m *testing.M) { + InitTestApp() + os.Exit(m.Run()) +} + +func InitTestApp() { + ctx := cli.Context() + + if defaultStore == nil { + initStore(ctx) + } + + if defaultEnvoy == nil { + initSvc(ctx) + } +} + +func initStore(ctx context.Context) { + var err error + // dsn := "postgres://corteza:corteza@127.0.0.1:3402/testing?sslmode=disable" + dsn := "sqlite3+debug://file::memory:?cache=shared&mode=memory" + defaultStore, err = store.Connect(ctx, zap.NewNop(), dsn, true) + if err != nil { + panic(err) + } + + err = store.Upgrade(ctx, zap.NewNop(), defaultStore) + if err != nil { + panic(err) + } +} + +func cleanup(b *testing.B) { + var ( + ctx = context.Background() + ) + + err := collect( + store.TruncateRbacRules(ctx, defaultStore), + store.TruncateRoles(ctx, defaultStore), + store.TruncateUsers(ctx, defaultStore), + ) + if err != nil { + b.Fatalf("failed to decode scenario data: %v", err) + } +} + +func collect(ee ...error) error { + for _, e := range ee { + if e != nil { + return e + } + } + return nil +} + +func initSvc(ctx context.Context) { + defaultEnvoy = envoyx.New() + defaultEnvoy.AddDecoder(envoyx.DecodeTypeURI, + composeEnvoy.YamlDecoder{}, + systemEnvoy.YamlDecoder{}, + automationEnvoy.YamlDecoder{}, + ) + + defaultEnvoy.AddEncoder(envoyx.EncodeTypeStore, + composeEnvoy.StoreEncoder{}, + systemEnvoy.StoreEncoder{}, + automationEnvoy.StoreEncoder{}, + ) +} + +func initState(t *testing.T, maxIndexSize int, things ...svcModFnc) (context.Context, *require.Assertions, *rbac.Service, *testStorage) { + var ( + ctx = context.Background() + req = require.New(t) + ) + + store := &testStorage{} + svc, err := rbac.NewService(ctx, zap.NewNop(), defaultStore, rbac.Config{ + Synchronous: true, + + MaxIndexSize: maxIndexSize, + DecayFactor: 1, + DecayInterval: time.Hour * 4, + CleanupInterval: time.Hour * 4, + + RuleStorage: store, + RoleStorage: store, + }) + req.NoError(err) + + for _, f := range things { + f(svc) + } + + return ctx, req, svc, store +} + +func mustStats(req *require.Assertions, svc *rbac.Service) rbac.Stats { + stats, err := svc.Stats() + req.NoError(err) + return stats +} + +func must(req *require.Assertions, err error) { + req.NoError(err) +} + +func checkHitRatios(req *require.Assertions, stats rbac.Stats, hits, misses uint, lastHitsLastMisses ...[][]uint64) { + req.Equal(hits, stats.CacheHits) + req.Equal(misses, stats.CacheMisses) + + if len(lastHitsLastMisses) > 0 { + for i := 0; i < len(lastHitsLastMisses[0]); i++ { + req.Contains(stats.LastHits[i], fmt.Sprintf("%v", lastHitsLastMisses[0][i])) + } + } + + if len(lastHitsLastMisses) > 1 { + for i := 0; i < len(lastHitsLastMisses[1]); i++ { + req.Contains(stats.LastMisses[i], fmt.Sprintf("%v", lastHitsLastMisses[1][i])) + } + } +} + +// Utils + +func (ts *testStorage) SearchRbacRules(ctx context.Context, f rbac.RuleFilter) (rs rbac.RuleSet, rf rbac.RuleFilter, er error) { + return ts.returnRuleSearch, f, nil +} + +func (ts *testStorage) UpsertRbacRule(ctx context.Context, rr ...*rbac.Rule) (err error) { + ts.upserts = append(ts.upserts, rr...) + return +} + +func (testStorage) DeleteRbacRule(ctx context.Context, rr ...*rbac.Rule) (err error) { + return +} + +func (testStorage) TruncateRbacRules(ctx context.Context) (err error) { + return +} + +func (testStorage) SearchRoles(ctx context.Context, f types.RoleFilter) (rs types.RoleSet, rf types.RoleFilter, err error) { + return +} + +func (sw sesWrap) Identity() uint64 { + return sw.identity +} +func (sw sesWrap) Roles() []uint64 { + return sw.roles +} +func (sw sesWrap) Context() context.Context { + return sw.context +} + +func (rw resWrap) RbacResource() string { + return rw.resource +} + +func svcWithRoles(roles ...*rbac.Role) svcModFnc { + return func(s *rbac.Service) { + s.UpdateRoles(roles...) + } +} diff --git a/server/tests/rbac/rbac_rules_test.go b/server/tests/rbac/rbac_rules_test.go new file mode 100644 index 0000000000..7ffcaff3f5 --- /dev/null +++ b/server/tests/rbac/rbac_rules_test.go @@ -0,0 +1,214 @@ +package rbac + +import ( + "context" + "testing" + + "github.com/cortezaproject/corteza/server/pkg/rbac" + "github.com/stretchr/testify/require" +) + +func TestGrant(t *testing.T) { + t.Run("completely empty index", func(t *testing.T) { + ctx, + req, + svc, + storage := initState(t, 0) + + svc.Grant(ctx, &rbac.Rule{ + RoleID: 1, + Resource: "smt/1/1/1", + Operation: "read", + Access: rbac.Allow, + }) + + // No cache update since resource not indexed + stats := mustStats(req, svc) + req.Len(storage.upserts, 1) + req.Equal(uint(0), stats.CacheUpdates) + }) + + t.Run("granting existing resource", func(t *testing.T) { + ctx, + req, + svc, + storage := initState(t, 0) + + must(req, svc.DebuggerSetIndex(1, "smt/1/1/1", &rbac.Rule{ + RoleID: 1, + Resource: "smt/1/1/1", + Operation: "write", + Access: rbac.Allow, + })) + + svc.Grant(ctx, &rbac.Rule{ + RoleID: 1, + Resource: "smt/1/1/1", + Operation: "read", + Access: rbac.Allow, + }) + + // Updated the index since resource indexed + stt := mustStats(req, svc) + req.Len(storage.upserts, 1) + req.Equal(uint(1), stt.CacheUpdates) + }) +} + +func TestCan(t *testing.T) { + t.Run("completely empty index", func(t *testing.T) { + ctx, + req, + svc, + storage := initState( + t, + 0, + svcWithRoles(rbac.CommonRole.Make(1, "")), + ) + + storage.returnRuleSearch = []*rbac.Rule{{ + RoleID: 1, + Resource: "smt/1/1/1", + Operation: "read", + Access: rbac.Allow, + }} + + req.True(svc.Can(sesWrap{ + identity: 1, + roles: []uint64{1}, + context: ctx, + }, "read", resWrap{resource: "smt/1/1/1"})) + + checkHitRatios(req, mustStats(req, svc), 0, 1) + }) + + t.Run("half index, half unindex", func(t *testing.T) { + ctx, + req, + svc, + storage := initState( + t, + 0, + svcWithRoles(rbac.CommonRole.Make(1, ""), rbac.CommonRole.Make(2, "")), + ) + + storage.returnRuleSearch = []*rbac.Rule{{ + RoleID: 2, + Resource: "smt/1/1/1", + Operation: "read", + Access: rbac.Allow, + }} + + must(req, svc.DebuggerSetIndex(1, "smt/1/1/1", &rbac.Rule{ + RoleID: 1, + Resource: "smt/1/1/1", + Operation: "read", + Access: rbac.Allow, + })) + + req.True(svc.Can(sesWrap{ + identity: 1, + roles: []uint64{1, 2}, + context: ctx, + }, "read", resWrap{resource: "smt/1/1/1"})) + + checkHitRatios(req, mustStats(req, svc), 1, 1, [][]uint64{{1}}, [][]uint64{{2}}) + }) + + t.Run("all hits", func(t *testing.T) { + ctx, + req, + svc, + _ := initState( + t, + 0, + svcWithRoles( + rbac.CommonRole.Make(1, ""), + rbac.CommonRole.Make(2, ""), + ), + ) + + must(req, svc.DebuggerSetIndex(1, "smt/1/1/1", &rbac.Rule{ + RoleID: 1, + Resource: "smt/1/1/1", + Operation: "read", + Access: rbac.Allow, + })) + + must(req, svc.DebuggerAddIndex(2, "smt/1/1/1", &rbac.Rule{ + RoleID: 2, + Resource: "smt/1/1/1", + Operation: "read", + Access: rbac.Allow, + })) + + req.True(svc.Can(sesWrap{ + identity: 1, + roles: []uint64{1, 2}, + context: ctx, + }, "read", resWrap{resource: "smt/1/1/1"})) + + checkHitRatios(req, mustStats(req, svc), 1, 0, [][]uint64{{1, 2}}) + }) +} + +func TestCheck_noop(t *testing.T) { + t.Run("noop allow", func(t *testing.T) { + ctx := context.Background() + req := require.New(t) + svc := rbac.NoopSvc(rbac.Allow, rbac.Config{}) + + a, err := svc.Check(sesWrap{ + identity: 1, + roles: []uint64{1, 2, 3}, + context: ctx, + }, "read", resWrap{resource: "res/1/2/3"}) + req.NoError(err) + req.Equal(rbac.Allow, a) + }) + + t.Run("noop deny", func(t *testing.T) { + ctx := context.Background() + req := require.New(t) + svc := rbac.NoopSvc(rbac.Deny, rbac.Config{}) + + a, err := svc.Check(sesWrap{ + identity: 1, + roles: []uint64{1, 2, 3}, + context: ctx, + }, "read", resWrap{resource: "res/1/2/3"}) + req.NoError(err) + req.Equal(rbac.Deny, a) + }) + + t.Run("noop inherit", func(t *testing.T) { + ctx := context.Background() + req := require.New(t) + svc := rbac.NoopSvc(rbac.Inherit, rbac.Config{}) + + a, err := svc.Check(sesWrap{ + identity: 1, + roles: []uint64{1, 2, 3}, + context: ctx, + }, "read", resWrap{resource: "res/1/2/3"}) + req.NoError(err) + req.Equal(rbac.Inherit, a) + }) +} + +func TestCheck_preventWildcards(t *testing.T) { + t.Run("prevent wildcard resources", func(t *testing.T) { + ctx, + req, + svc, + _ := initState(t, 0) + + a, err := svc.Check(sesWrap{ + identity: 1, + roles: []uint64{1, 2, 3}, + context: ctx, + }, "read", resWrap{resource: "res/1/2/*"}) + req.NoError(err) + req.Equal(rbac.Inherit, a) + }) +} diff --git a/server/tests/system/permissions_test.go b/server/tests/system/permissions_test.go index f39d8be660..adb0f00cf3 100644 --- a/server/tests/system/permissions_test.go +++ b/server/tests/system/permissions_test.go @@ -1,6 +1,7 @@ package system import ( + "context" "fmt" "net/http" "strconv" @@ -117,6 +118,8 @@ func TestPermissionsUpdate(t *testing.T) { } func TestPermissionsDelete(t *testing.T) { + ctx := context.Background() + h := newHelper(t) p := rbac.Global() @@ -126,12 +129,14 @@ func TestPermissionsDelete(t *testing.T) { // New role. permDelRole := h.roleID + 1 - h.a.Len(rbac.Global().FindRulesByRoleID(permDelRole), 0) + rr := mustFindRulesByRoleID(rbac.Global().FindRulesByRoleID(ctx, permDelRole)) + h.a.Len(rr, 0) // Setup a few fake rules for new role helpers.Grant(rbac.AllowRule(permDelRole, types.ComponentRbacResource(), "user.create")) - h.a.Len(p.FindRulesByRoleID(permDelRole), 1) + rr = mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, permDelRole)) + h.a.Len(rr, 1) h.apiInit(). Delete(fmt.Sprintf("/permissions/%d/rules", permDelRole)). @@ -142,7 +147,8 @@ func TestPermissionsDelete(t *testing.T) { End() // Make sure all rules for this role are deleted - for _, r := range p.FindRulesByRoleID(permDelRole) { + rr = mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, permDelRole)) + for _, r := range rr { h.a.True(r.Access == rbac.Inherit) } } @@ -166,6 +172,7 @@ func TestPermissionsTrace(t *testing.T) { func TestPermissionsCloneToSingleRole(t *testing.T) { h := newHelper(t) p := rbac.Global() + ctx := context.Background() // Make sure our user can grant helpers.AllowMe(h, types.ComponentRbacResource(), "grant") @@ -174,8 +181,8 @@ func TestPermissionsCloneToSingleRole(t *testing.T) { roleS := h.roleID + 1 roleT := h.roleID + 2 - h.a.Len(rbac.Global().FindRulesByRoleID(roleS), 0) - h.a.Len(rbac.Global().FindRulesByRoleID(roleT), 0) + h.a.Len(mustFindRulesByRoleID(rbac.Global().FindRulesByRoleID(ctx, roleS)), 0) + h.a.Len(mustFindRulesByRoleID(rbac.Global().FindRulesByRoleID(ctx, roleT)), 0) // Set up a few fake rules for new role helpers.Grant(rbac.AllowRule(roleS, types.ComponentRbacResource(), "user.create")) @@ -183,8 +190,8 @@ func TestPermissionsCloneToSingleRole(t *testing.T) { helpers.Grant(rbac.AllowRule(roleT, types.ComponentRbacResource(), "user.update")) helpers.Grant(rbac.AllowRule(roleT, types.ComponentRbacResource(), "user.delete")) - h.a.Len(p.FindRulesByRoleID(roleS), 1) - h.a.Len(p.FindRulesByRoleID(roleT), 2) + h.a.Len(mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, roleS)), 1) + h.a.Len(mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, roleT)), 2) h.apiInit(). Post(fmt.Sprintf("/roles/%d/rules/clone", roleS)). @@ -196,12 +203,13 @@ func TestPermissionsCloneToSingleRole(t *testing.T) { End() // Make sure all rules for role S are intact - h.a.Len(p.FindRulesByRoleID(roleS), 1) + h.a.Len(mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, roleS)), 1) // Make sure all rules for role T are cloned from role S - h.a.Len(p.FindRulesByRoleID(roleT), 1) + h.a.Len(mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, roleT)), 1) } func TestPermissionsCloneToMultipleRole(t *testing.T) { + ctx := context.Background() h := newHelper(t) p := rbac.Global() @@ -213,9 +221,9 @@ func TestPermissionsCloneToMultipleRole(t *testing.T) { roleT := h.roleID + 2 roleY := h.roleID + 3 - h.a.Len(rbac.Global().FindRulesByRoleID(roleS), 0) - h.a.Len(rbac.Global().FindRulesByRoleID(roleT), 0) - h.a.Len(rbac.Global().FindRulesByRoleID(roleY), 0) + h.a.Len(mustFindRulesByRoleID(rbac.Global().FindRulesByRoleID(ctx, roleS)), 0) + h.a.Len(mustFindRulesByRoleID(rbac.Global().FindRulesByRoleID(ctx, roleT)), 0) + h.a.Len(mustFindRulesByRoleID(rbac.Global().FindRulesByRoleID(ctx, roleY)), 0) // Set up a few fake rules for new role helpers.Grant(rbac.AllowRule(roleS, types.ComponentRbacResource(), "user.create")) @@ -227,9 +235,9 @@ func TestPermissionsCloneToMultipleRole(t *testing.T) { helpers.Grant(rbac.AllowRule(roleY, types.ComponentRbacResource(), "user.update")) helpers.Grant(rbac.AllowRule(roleY, types.ComponentRbacResource(), "user.delete")) - h.a.Len(p.FindRulesByRoleID(roleS), 1) - h.a.Len(p.FindRulesByRoleID(roleT), 2) - h.a.Len(p.FindRulesByRoleID(roleY), 3) + h.a.Len(mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, roleS)), 1) + h.a.Len(mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, roleT)), 2) + h.a.Len(mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, roleY)), 3) h.apiInit(). Post(fmt.Sprintf("/roles/%d/rules/clone", roleS)). @@ -242,14 +250,15 @@ func TestPermissionsCloneToMultipleRole(t *testing.T) { End() // Make sure all rules for role S are intact - h.a.Len(p.FindRulesByRoleID(roleS), 1) + h.a.Len(mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, roleS)), 1) // Make sure all rules for role T are cloned from role S - h.a.Len(p.FindRulesByRoleID(roleT), 1) + h.a.Len(mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, roleT)), 1) // Make sure all rules for role Y are cloned from role S - h.a.Len(p.FindRulesByRoleID(roleY), 1) + h.a.Len(mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, roleY)), 1) } func TestPermissionsCloneNotAllowed(t *testing.T) { + ctx := context.Background() h := newHelper(t) p := rbac.Global() @@ -257,8 +266,8 @@ func TestPermissionsCloneNotAllowed(t *testing.T) { roleS := h.roleID + 1 roleT := h.roleID + 2 - h.a.Len(rbac.Global().FindRulesByRoleID(roleS), 0) - h.a.Len(rbac.Global().FindRulesByRoleID(roleT), 0) + h.a.Len(mustFindRulesByRoleID(rbac.Global().FindRulesByRoleID(ctx, roleS)), 0) + h.a.Len(mustFindRulesByRoleID(rbac.Global().FindRulesByRoleID(ctx, roleT)), 0) // Set up a few fake rules for new role helpers.Grant(rbac.AllowRule(roleS, types.ComponentRbacResource(), "user.create")) @@ -266,8 +275,8 @@ func TestPermissionsCloneNotAllowed(t *testing.T) { helpers.Grant(rbac.AllowRule(roleT, types.ComponentRbacResource(), "user.update")) helpers.Grant(rbac.AllowRule(roleT, types.ComponentRbacResource(), "user.delete")) - h.a.Len(p.FindRulesByRoleID(roleS), 1) - h.a.Len(p.FindRulesByRoleID(roleT), 2) + h.a.Len(mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, roleS)), 1) + h.a.Len(mustFindRulesByRoleID(p.FindRulesByRoleID(ctx, roleT)), 2) h.apiInit(). Post(fmt.Sprintf("/roles/%d/rules/clone", roleS)). @@ -278,3 +287,11 @@ func TestPermissionsCloneNotAllowed(t *testing.T) { Assert(helpers.AssertError("role.errors.notAllowedToCloneRules")). End() } + +func mustFindRulesByRoleID(rr rbac.RuleSet, err error) rbac.RuleSet { + if err != nil { + panic(err) + } + + return rr +} diff --git a/server/tests/workflows/0002_rbac_fn_test.go b/server/tests/workflows/0002_rbac_fn_test.go index 44539c6600..f7853e3d31 100644 --- a/server/tests/workflows/0002_rbac_fn_test.go +++ b/server/tests/workflows/0002_rbac_fn_test.go @@ -17,7 +17,9 @@ func Test0002_rbac_fn(t *testing.T) { loadScenario(ctx, t) - req.Len(rbac.Global().Rules(), 0) + rr, err := rbac.Global().Rules(ctx) + req.NoError(err) + req.Len(rr, 0) var ( aux = struct { @@ -30,5 +32,7 @@ func Test0002_rbac_fn(t *testing.T) { req.NoError(vars.Decode(&aux)) req.Equal("y", aux.CanCurrentRead) req.Equal("n", aux.CanOtherRead) - req.Len(rbac.Global().Rules(), 1) + rr, err = rbac.Global().Rules(ctx) + req.NoError(err) + req.Len(rr, 1) } diff --git a/server/tests/workflows/exec_permissions_test.go b/server/tests/workflows/exec_permissions_test.go index ba6bff02e5..5d212e8d37 100644 --- a/server/tests/workflows/exec_permissions_test.go +++ b/server/tests/workflows/exec_permissions_test.go @@ -7,7 +7,6 @@ import ( "github.com/cortezaproject/corteza/server/automation/service" "github.com/cortezaproject/corteza/server/automation/types" "github.com/cortezaproject/corteza/server/pkg/auth" - "github.com/cortezaproject/corteza/server/pkg/rbac" "github.com/cortezaproject/corteza/server/tests/helpers" "github.com/stretchr/testify/require" ) @@ -46,8 +45,6 @@ func Test_exec_permissions(t *testing.T) { executors.ID, ) - rbac.Global().Reload(ctx) - t.Run("exec allowed", func(t *testing.T) { ctx = auth.SetIdentityToContext(ctx, execAllowed) _, _ = mustExecWorkflow(ctx, t, "wf", types.WorkflowExecParams{}) diff --git a/server/tests/workflows/invoker_and_runner_in_scope_test.go b/server/tests/workflows/invoker_and_runner_in_scope_test.go index 27569eeb91..1245d4b7db 100644 --- a/server/tests/workflows/invoker_and_runner_in_scope_test.go +++ b/server/tests/workflows/invoker_and_runner_in_scope_test.go @@ -6,7 +6,6 @@ import ( "github.com/cortezaproject/corteza/server/automation/types" "github.com/cortezaproject/corteza/server/pkg/auth" - "github.com/cortezaproject/corteza/server/pkg/rbac" sysTypes "github.com/cortezaproject/corteza/server/system/types" "github.com/cortezaproject/corteza/server/tests/helpers" "github.com/stretchr/testify/require" @@ -46,8 +45,6 @@ func Test_invoker_and_runner_in_scope(t *testing.T) { wfInvokers.ID, ) - rbac.Global().Reload(ctx) - t.Run("invoker set in scope", func(t *testing.T) { var ( req = require.New(t)