From 5afba0eb196fb8f71bddac964789d91cc21332e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Tue, 13 Aug 2024 15:00:10 +0200 Subject: [PATCH 01/22] update rule index to allow modifications --- server/pkg/rbac/rule_index.go | 91 +++++++++++++++-- server/pkg/rbac/rule_index_test.go | 156 ++++++++++++++++++++++++++++- 2 files changed, 233 insertions(+), 14 deletions(-) diff --git a/server/pkg/rbac/rule_index.go b/server/pkg/rbac/rule_index.go index 2198f174d9..07b8aae4de 100644 --- a/server/pkg/rbac/rule_index.go +++ b/server/pkg/rbac/rule_index.go @@ -20,6 +20,8 @@ type ( isLeaf bool access Access rule *Rule + + count int } ) @@ -27,16 +29,28 @@ 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 +} + +func (index *ruleIndex) add(rules ...*Rule) { + if index.children == nil { + index.children = make(map[uint64]*ruleIndexNode, len(rules)/2) } for _, r := range rules { + // skip duplicates + if index.has(r) { + continue + } + if _, ok := index.children[r.RoleID]; !ok { index.children[r.RoleID] = &ruleIndexNode{ children: make(map[string]*ruleIndexNode, 4), } } + index.children[r.RoleID].count++ n := index.children[r.RoleID] bits := append([]string{r.Operation}, strings.Split(r.Resource, "/")...) @@ -46,6 +60,7 @@ func buildRuleIndex(rules []*Rule) (index *ruleIndex) { children: make(map[string]*ruleIndexNode, 4), } } + n.children[b].count++ n = n.children[b] } @@ -54,8 +69,66 @@ func buildRuleIndex(rules []*Rule) (index *ruleIndex) { n.access = r.Access n.rule = r } +} - return index +func (index *ruleIndex) remove(rules ...*Rule) { + if len(rules) == 0 { + return + } + + for _, r := range rules { + if _, ok := index.children[r.RoleID]; !ok { + continue + } + + if !index.has(r) { + continue + } + + bits := append([]string{r.Operation}, strings.Split(r.Resource, "/")...) + index.removeRec(index.children[r.RoleID], bits) + + // Finishing touch cleanup + if len(index.children[r.RoleID].children[r.Operation].children) == 0 { + delete(index.children[r.RoleID].children, r.Operation) + } + if len(index.children[r.RoleID].children) == 0 { + delete(index.children, r.RoleID) + } + } +} + +func (index *ruleIndex) removeRec(n *ruleIndexNode, bits []string) { + // Recursive in; decrement counters + n.count-- + + if len(bits) == 0 { + return + } + + n = n.children[bits[0]] + index.removeRec(n, bits[1:]) + + // Recursive out; yoink out obsolete štuff + if len(bits) == 1 { + if len(n.children) > 0 { + n.children[bits[0]].isLeaf = false + n.children[bits[0]].rule = nil + } + return + } + + if n.children[bits[1]].count == 0 { + delete(n.children, bits[1]) + } +} + +func (t *ruleIndex) has(r *Rule) bool { + return len(t.collect(true, r.RoleID, r.Operation, r.Resource)) > 0 +} + +func (t *ruleIndex) get(role uint64, op, res string) (out []*Rule) { + return t.collect(false, role, op, res) } // get returns all RBAC rules matching these constraints @@ -64,7 +137,7 @@ 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) { +func (t *ruleIndex) collect(exact bool, role uint64, op, res string) (out []*Rule) { if t.children == nil { return } @@ -90,7 +163,7 @@ func (t *ruleIndex) get(role uint64, op, res string) (out []*Rule) { return } - return aux.get(res, 0) + return aux.get(exact, res, 0) } // get returns all of the rules matching these constraints @@ -100,7 +173,7 @@ func (t *ruleIndex) get(role uint64, op, res string) (out []*Rule) { // 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) { +func (n *ruleIndexNode) get(exact bool, res string, from int) (out []*Rule) { if n == nil || n.children == nil { return } @@ -133,12 +206,12 @@ func (n *ruleIndexNode) get(res string, from int) (out []*Rule) { // 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)...) + out = append(out, n.children[pathBit].get(exact, 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)...) + if !exact && n.children[wildcard] != nil { + out = append(out, n.children[wildcard].get(exact, res, nextDelim+1)...) } return diff --git a/server/pkg/rbac/rule_index_test.go b/server/pkg/rbac/rule_index_test.go index 3390673a93..e063b737fa 100644 --- a/server/pkg/rbac/rule_index_test.go +++ b/server/pkg/rbac/rule_index_test.go @@ -7,11 +7,13 @@ import ( "github.com/stretchr/testify/require" ) -func TestIndex(t *testing.T) { +func TestIndexBuild(t *testing.T) { tcc := []struct { - name string - in []*Rule - out []int + name string + in []*Rule + remove []*Rule + add []*Rule + out []int role uint64 op string @@ -118,15 +120,159 @@ func TestIndex(t *testing.T) { role: 1, op: "read", res: "a:b/c/d", + }, { + name: "removing the only element", + in: []*Rule{{ + RoleID: 1, + Resource: "a:b/c/d", + Operation: "write", + Access: Allow, + }}, + remove: []*Rule{{ + RoleID: 1, + Resource: "a:b/c/d", + Operation: "write", + Access: Allow, + }}, + out: nil, + + role: 1, + op: "write", + res: "a:b/c/d", + }, + { + name: "removing twice added thing", + in: []*Rule{{ + RoleID: 1, + Resource: "a:b/c/d", + Operation: "write", + Access: Allow, + }, { + RoleID: 1, + Resource: "a:b/c/d", + Operation: "write", + Access: Allow, + }}, + remove: []*Rule{{ + RoleID: 1, + Resource: "a:b/c/d", + Operation: "write", + Access: Allow, + }}, + + out: nil, + + role: 1, + op: "write", + res: "a:b/c/d", + }, + { + name: "two elements with no common root", + in: []*Rule{{ + RoleID: 1, + Resource: "a:b/c/d", + Operation: "write", + Access: Allow, + }, { + RoleID: 2, + Resource: "a:b/c/d", + Operation: "write", + Access: Allow, + }}, + remove: []*Rule{{ + RoleID: 1, + Resource: "a:b/c/d", + Operation: "write", + Access: Allow, + }}, + out: nil, + + role: 1, + op: "write", + res: "a:b/c/d", + }, + { + name: "two elements with common root (get removed)", + in: []*Rule{{ + RoleID: 1, + Resource: "a:b/c/d", + Operation: "write", + Access: Allow, + }, { + RoleID: 1, + Resource: "a:b/c/e", + Operation: "write", + Access: Allow, + }}, + remove: []*Rule{{ + RoleID: 1, + Resource: "a:b/c/d", + Operation: "write", + Access: Allow, + }}, + out: nil, + + role: 1, + op: "write", + res: "a:b/c/d", + }, + { + name: "two elements with common root (get not removed)", + in: []*Rule{{ + RoleID: 1, + Resource: "a:b/c/d", + Operation: "write", + Access: Allow, + }, { + RoleID: 1, + Resource: "a:b/c/e", + Operation: "write", + Access: Allow, + }}, + remove: []*Rule{{ + RoleID: 1, + Resource: "a:b/c/d", + Operation: "write", + Access: Allow, + }}, + out: []int{1}, + + role: 1, + op: "write", + res: "a:b/c/e", + }, + { + name: "add new element", + in: []*Rule{{ + RoleID: 1, + Resource: "a:b/c/d", + Operation: "write", + Access: Allow, + }}, + add: []*Rule{{ + RoleID: 1, + Resource: "a:b/c/x", + Operation: "write", + Access: Allow, + }}, + + out: []int{1}, + + role: 1, + op: "write", + res: "a:b/c/x", }} for _, tc := range tcc { t.Run(tc.name, func(t *testing.T) { ix := buildRuleIndex(tc.in) + ix.remove(tc.remove...) + ix.add(tc.add...) + out := RuleSet(ix.get(tc.role, tc.op, tc.res)) sort.Sort(out) - want := RuleSet(graby(tc.in, tc.out)) + want := RuleSet(graby(append(tc.in, tc.add...), tc.out)) sort.Sort(want) require.Len(t, out, len(want)) From 447cee2f55876993794c7119c2fbea0d32ac0d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Mon, 19 Aug 2024 13:12:51 +0200 Subject: [PATCH 02/22] Draft rbac index capping by role --- server/app/resources.cue | 10 + server/pkg/rbac/roles.go | 2 +- server/pkg/rbac/roles_test.go | 2 +- server/pkg/rbac/service.go | 8 +- server/pkg/rbac/store_interface.go | 5 + server/pkg/rbac/wrapper.go | 430 +++++++++++++++++++++ server/pkg/rbac/wrapper_counter.go | 99 +++++ server/pkg/rbac/wrapper_index.go | 40 ++ server/store/adapters/rdbms/filters.gen.go | 12 + 9 files changed, 604 insertions(+), 4 deletions(-) create mode 100644 server/pkg/rbac/wrapper.go create mode 100644 server/pkg/rbac/wrapper_counter.go create mode 100644 server/pkg/rbac/wrapper_index.go diff --git a/server/app/resources.cue b/server/app/resources.cue index 3a9be8a78a..001d945d29 100644 --- a/server/app/resources.cue +++ b/server/app/resources.cue @@ -51,6 +51,16 @@ 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"] + } + store: { ident: "rbacRule" diff --git a/server/pkg/rbac/roles.go b/server/pkg/rbac/roles.go index c4ab27e974..9267b37744 100644 --- a/server/pkg/rbac/roles.go +++ b/server/pkg/rbac/roles.go @@ -116,7 +116,7 @@ func statRoles(rr ...*Role) (stats map[roleKind]int) { } // 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 getContextRoles(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..e18b2edffb 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...), getContextRoles(&session{rr: tc.sessionRoles}, tc.res, tc.preloadRoles...)) }) } } diff --git a/server/pkg/rbac/service.go b/server/pkg/rbac/service.go index 8d58c83e99..d1bf92cb29 100644 --- a/server/pkg/rbac/service.go +++ b/server/pkg/rbac/service.go @@ -28,6 +28,10 @@ type ( // RuleFilter is a dummy struct to satisfy store codegen RuleFilter struct { + Resource []string + Operation string + RoleID uint64 + Limit uint } @@ -94,7 +98,7 @@ func (svc *service) Can(ses Session, op string, res Resource) bool { // 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) + fRoles = getContextRoles(ses, res, svc.roles...) ) if hasWildcards(res.RbacResource()) { @@ -150,7 +154,7 @@ func (svc *service) Trace(ses Session, op string, res Resource) *Trace { } var ( - fRoles = getContextRoles(ses, res, svc.roles) + fRoles = getContextRoles(ses, res, svc.roles...) ) _ = check(svc.indexed, fRoles, op, res.RbacResource(), t) diff --git a/server/pkg/rbac/store_interface.go b/server/pkg/rbac/store_interface.go index e98ee22ba0..75a417be3d 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,8 @@ 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) } ) diff --git a/server/pkg/rbac/wrapper.go b/server/pkg/rbac/wrapper.go new file mode 100644 index 0000000000..48ab7a7fa7 --- /dev/null +++ b/server/pkg/rbac/wrapper.go @@ -0,0 +1,430 @@ +package rbac + +import ( + "context" + "fmt" + "math" + "sort" + "strings" + "time" + + "github.com/cortezaproject/corteza/server/pkg/filter" + "github.com/cortezaproject/corteza/server/system/types" + "github.com/davecgh/go-spew/spew" +) + +type ( + WrapperConfig struct { + InitialIndexedRoles []uint64 + MaxIndexSize int + } + + wrapperService struct { + cfg WrapperConfig + + store rbacRulesStore + counter *usageCounter + index *wrapperIndex + roles []*Role + } +) + +func dftWrapperCfg(base WrapperConfig) (out WrapperConfig) { + out = base + + if base.MaxIndexSize == 0 { + out.MaxIndexSize = -1 + } + + return out +} + +func Wrapper(ctx context.Context, store rbacRulesStore, cc WrapperConfig) (x *wrapperService, err error) { + cc = dftWrapperCfg(cc) + + uc := &usageCounter{ + incChan: make(chan uint64, 256), + sigChan: make(chan counterEntry, 8), + } + + x = &wrapperService{ + cfg: cc, + + store: store, + counter: uc, + } + + x.roles, err = x.loadRoles(ctx, store) + if err != nil { + return + } + + x.index, err = x.loadIndex(ctx, store, x.roles) + if err != nil { + return + } + + uc.watch(ctx) + x.watch(ctx) + + return +} + +func (svc *wrapperService) Clear() { + svc.store = nil + svc.counter = nil + svc.index = nil + svc.roles = nil +} + +func (svc *wrapperService) Can(ses Session, op string, res Resource) (ok bool, err error) { + ac, err := svc.Check(ses, op, res) + if err != nil { + return + } + + return ac == Allow, nil +} + +func (svc *wrapperService) Check(ses Session, op string, res Resource) (a Access, err error) { + if hasWildcards(res.RbacResource()) { + // prevent use of wildcard resources for checking permissions + return Inherit, nil + } + + fRoles := getContextRoles(ses, res, svc.roles...) + + return svc.check(ses.Context(), fRoles, op, res.RbacResource()) +} + +func (svc *wrapperService) check(ctx context.Context, rolesByKind partRoles, op, res string) (a Access, err error) { + 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(nil, Deny, failedIntegrityCheck), nil + } + + if member(rolesByKind, BypassRole) { + // if user has at least one bypass role, we allow access + return resolve(nil, Allow, bypassRoleMembership), nil + } + + // if indexedRules.empty() { + // // no rules to check + // return resolve(nil, Inherit, noRules) + // } + + var ( + match *Rule + allowed bool + ) + + indexed, unindexed, err := svc.segmentRoles(ctx, rolesByKind) + if err != nil { + return Inherit, err + } + + // + // 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) + // } + + st := evlState{ + op: op, + res: res, + + unindexedRoles: unindexed, + indexedRoles: indexed, + } + + st.unindexedRules, err = svc.pullUnindexed(ctx, unindexed, op, res) + if err != nil { + return Inherit, err + } + + // 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 = svc.getMatching(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 + } + } + + // No rule matched + return resolve(nil, Inherit, noMatch), nil +} + +func (svc *wrapperService) segmentRoles(ctx context.Context, roles partRoles) (indexed, unindexed partRoles, err error) { + unindexed = partRoles{} + indexed = partRoles{} + + unindexed[CommonRole] = make(map[uint64]bool) + indexed[CommonRole] = make(map[uint64]bool) + + for k, rg := range roles { + for r := range rg { + if svc.index.hasRole(r) { + indexed[k][r] = true + continue + } + + unindexed[k][r] = true + } + } + + return +} + +type ( + evlState struct { + unindexedRoles partRoles + indexedRoles partRoles + + unindexedRules [5]map[uint64][]*Rule + + res string + op string + } +) + +func (svc *wrapperService) getMatching(st evlState, kind roleKind, role uint64) (rule *Rule) { + var ( + aux []*Rule + rules RuleSet + ) + + // Indexed + aux = svc.index.get(role, st.op, st.res) + 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 +} + +func (svc *wrapperService) pullUnindexed(ctx context.Context, unindexed partRoles, op, res string) (out [5]map[uint64][]*Rule, err error) { + resPerm := make([]string, 0, 8) + resPerm = append(resPerm, res) + + // Get all the resource permissions + // @todo get permissions for parent resources; this will probs be some lookup table + rr := strings.Split(res, "/") + for i := len(rr) - 1; i >= 0; i-- { + rr[i] = "*" + resPerm = append(resPerm, strings.Join(rr, "/")) + } + + for rk, rr := range unindexed { + for r := range rr { + auxRr := make([]*Rule, 0, 4) + auxRr, _, err = svc.store.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 +} + +func (svc *wrapperService) IndexRoleChange(ctx context.Context, roleID uint64) (err error) { + aux, _, err := svc.store.SearchRbacRules(ctx, RuleFilter{ + RoleID: roleID, + }) + if err != nil { + return + } + + // @todo cap this + if len(svc.index.rules.children) > svc.cfg.MaxIndexSize { + // @note probably remove a few extra just to avoid constantly doing this + // @todo is this a good idea? Not sure if worth it since all of this is behind the scene anyways + wp := svc.counter.worstPerformers(4) + svc.index.remove(wp...) + } + + svc.index.add(aux...) + return +} + +func (svc *wrapperService) watch(ctx context.Context) { + t := time.NewTicker(time.Minute * 5) + + go func() { + for { + select { + case <-t.C: + spew.Dump("ticking") + + case change := <-svc.counter.sigChan: + err := svc.IndexRoleChange(ctx, change.key) + if err != nil { + spew.Dump("wrapper watch change err", err) + } + + case <-ctx.Done(): + return + } + } + }() +} + +// // // // // // // // // // // // // // // // // // // // // // // // // // + +func makeKey(op, res string, role uint64) string { + return fmt.Sprintf("%d:%s:%s", role, op, res) +} + +// + +// // // // // // // // // // // // // // // // // // // // // // // // // // +// Boilerplate & state management stuff + +func (svc *wrapperService) loadRoles(ctx context.Context, s rbacRulesStore) (out []*Role, err error) { + auxRoles, _, err := s.SearchRoles(ctx, types.RoleFilter{ + Paging: filter.Paging{ + Limit: 0, + }, + }) + if err != nil { + return + } + + for _, ar := range auxRoles { + out = append(out, &Role{ + id: ar.ID, + handle: ar.Handle, + kind: CommonRole, + }) + } + + return +} + +func (svc *wrapperService) loadIndex(ctx context.Context, s rbacRulesStore, allRoles []*Role) (out *wrapperIndex, err error) { + // @todo smarter way to figure out what/how many roles we want to load up + roles := svc.getIndexRoles(allRoles) + + rules := make(RuleSet, 0, 1024) + var aux RuleSet + for _, role := range roles { + aux, _, err = s.SearchRbacRules(ctx, RuleFilter{ + RoleID: role.id, + Limit: 0, + }) + if err != nil { + return + } + + rules = append(rules, aux...) + } + + out = &wrapperIndex{ + rules: buildRuleIndex(rules), + } + + return +} + +func (svc *wrapperService) getIndexRoles(allRoles []*Role) (out []*Role) { + // User-specified what we want to index; respect that to the t + if len(svc.cfg.InitialIndexedRoles) > 0 { + for _, r := range allRoles { + for _, ir := range svc.cfg.InitialIndexedRoles { + if r.id == ir { + out = append(out, r) + } + } + } + + return + } + + // Straight up limit + // @todo add some counters to figure out which roles are most used from the start + if svc.cfg.MaxIndexSize == -1 { + return allRoles + } + + if svc.cfg.MaxIndexSize == 0 { + return nil + } + + // @todo smarter way to figure out what/how many roles we want to load up + return allRoles[:int(math.Min(float64(len(allRoles)), float64(svc.cfg.MaxIndexSize)))] +} diff --git a/server/pkg/rbac/wrapper_counter.go b/server/pkg/rbac/wrapper_counter.go new file mode 100644 index 0000000000..fe0fb340ed --- /dev/null +++ b/server/pkg/rbac/wrapper_counter.go @@ -0,0 +1,99 @@ +package rbac + +import ( + "context" + "sort" + "sync" + "time" +) + +type ( + usageCounter struct { + index map[uint64]uint + + lock sync.RWMutex + + sigThreshold uint + + incChan chan uint64 + sigChan chan counterEntry + } + + counterEntry struct { + key uint64 + count uint + } + + MinHeap []counterEntry +) + +func (svc *usageCounter) worstPerformers(n int) (out []uint64) { + svc.lock.RLock() + defer svc.lock.RUnlock() + + // Code to get n elements with the smallest count + + hh := make(MinHeap, 0, len(svc.index)) + for k, v := range svc.index { + hh = append(hh, counterEntry{key: k, count: v}) + } + + sort.Sort(hh) + + for _, x := range hh { + out = append(out, x.key) + + if len(out) >= n { + return + } + } + + return +} + +func (svc *usageCounter) inc(key uint64) { + svc.lock.Lock() + defer svc.lock.Unlock() + + count := svc.index[key] + 1 + svc.index[key] = count + + if count >= svc.sigThreshold { + delete(svc.index, key) + svc.sigChan <- counterEntry{key: key, count: count} + } +} + +func (svc *usageCounter) clean() { + svc.lock.Lock() + defer svc.lock.Unlock() + + for k, v := range svc.index { + if v < uint(float64(svc.sigThreshold)*0.05) { + delete(svc.index, k) + } + } +} + +func (svc *usageCounter) watch(ctx context.Context) { + cleanT := time.NewTicker(time.Minute * 10) + + go func() { + for { + select { + case <-ctx.Done(): + return + + case <-cleanT.C: + svc.clean() + + case key := <-svc.incChan: + svc.inc(key) + } + } + }() +} + +func (h MinHeap) Len() int { return len(h) } +func (h MinHeap) Less(i, j int) bool { return h[i].count < h[j].count } +func (h MinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } diff --git a/server/pkg/rbac/wrapper_index.go b/server/pkg/rbac/wrapper_index.go new file mode 100644 index 0000000000..3cde632c95 --- /dev/null +++ b/server/pkg/rbac/wrapper_index.go @@ -0,0 +1,40 @@ +package rbac + +import "sync" + +type ( + wrapperIndex struct { + mux sync.RWMutex + rules *ruleIndex + } +) + +func (svc *wrapperIndex) get(role uint64, op string, res string) (out []*Rule) { + svc.mux.RLock() + defer svc.mux.RUnlock() + + return svc.rules.get(role, op, res) +} + +func (svc *wrapperIndex) hasRole(role uint64) (ok bool) { + svc.mux.RLock() + defer svc.mux.RUnlock() + + _, ok = svc.rules.children[role] + return +} + +// @todo since it's like so, we might not need the trie to have deletable elements +func (svc *wrapperIndex) remove(roles ...uint64) { + svc.mux.Lock() + defer svc.mux.Unlock() + for _, r := range roles { + delete(svc.rules.children, r) + } +} + +func (svc *wrapperIndex) add(rules ...*Rule) { + svc.mux.Lock() + defer svc.mux.Unlock() + svc.rules.add(rules...) +} diff --git a/server/store/adapters/rdbms/filters.gen.go b/server/store/adapters/rdbms/filters.gen.go index f35415d2cc..bb28b60252 100644 --- a/server/store/adapters/rdbms/filters.gen.go +++ b/server/store/adapters/rdbms/filters.gen.go @@ -1145,6 +1145,18 @@ 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)) + } + return ee, f, err } From 27bdbf2ac3dfe2df1f6500990ccc7fa5291bd3e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Wed, 6 Nov 2024 15:22:25 +0100 Subject: [PATCH 03/22] Draft a counter for RBAC index --- server/pkg/rbac/wrapper_counter.go | 188 +++++++++++++++++++----- server/pkg/rbac/wrapper_counter_test.go | 57 +++++++ 2 files changed, 207 insertions(+), 38 deletions(-) create mode 100644 server/pkg/rbac/wrapper_counter_test.go diff --git a/server/pkg/rbac/wrapper_counter.go b/server/pkg/rbac/wrapper_counter.go index fe0fb340ed..0e137aa2c9 100644 --- a/server/pkg/rbac/wrapper_counter.go +++ b/server/pkg/rbac/wrapper_counter.go @@ -8,34 +8,132 @@ import ( ) type ( - usageCounter struct { - index map[uint64]uint - + usageCounter[K comparable] struct { lock sync.RWMutex - sigThreshold uint - - incChan chan uint64 - sigChan chan counterEntry + // 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 + // sigEvict lets the counter notify the manager what key K should be evicted + sigEvict chan K + // @todo remove + sigChan chan K + + // decayInterval denotes in what interval the decay factor should apply + decayInterval time.Duration + // cleanupInterval denotes in what interval counter evicts stuff + cleanupInterval time.Duration } - counterEntry struct { - key uint64 - count uint + // 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 []counterEntry + MinHeap[K comparable] []counterItem[K] ) -func (svc *usageCounter) worstPerformers(n int) (out []uint64) { +// 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 +} + +// 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-- { + out = append(out, hh[i].key) + + if len(out) >= n { + return + } + } + return +} + +// worstPerformers returns the bottom n items based on their score +func (svc *usageCounter[K]) worstPerformers(n int) (out []K) { svc.lock.RLock() defer svc.lock.RUnlock() // Code to get n elements with the smallest count - hh := make(MinHeap, 0, len(svc.index)) + hh := make(MinHeap[K], 0, len(svc.index)) for k, v := range svc.index { - hh = append(hh, counterEntry{key: k, count: v}) + hh = append(hh, counterItem[K]{key: k, score: v.score}) } sort.Sort(hh) @@ -51,32 +149,40 @@ func (svc *usageCounter) worstPerformers(n int) (out []uint64) { return } -func (svc *usageCounter) inc(key uint64) { - svc.lock.Lock() - defer svc.lock.Unlock() +// procNew notes a new key in the thing, defaults and stuff +func (svc *usageCounter[K]) procNew(key K) { + n := time.Now() + svc.index[key] = counterItem[K]{ + score: 1, + added: n, + lastScored: n, + lastAccess: n, + } +} - count := svc.index[key] + 1 - svc.index[key] = count +// procExisting notes an access to an existing index element +func (svc *usageCounter[K]) procExisting(key K) { + n := time.Now() - if count >= svc.sigThreshold { - delete(svc.index, key) - svc.sigChan <- counterEntry{key: key, count: count} - } + aux := svc.index[key] + aux.lastAccess = n + aux.lastScored = n + aux.score++ + + svc.index[key] = aux } -func (svc *usageCounter) clean() { - svc.lock.Lock() - defer svc.lock.Unlock() +func (svc *usageCounter[K]) watch(ctx context.Context) { + if svc.decayInterval == 0 { + panic("svc.decayInterval can not be 0") + } - for k, v := range svc.index { - if v < uint(float64(svc.sigThreshold)*0.05) { - delete(svc.index, k) - } + if svc.cleanupInterval == 0 { + panic("svc.cleanupInterval can not be 0") } -} -func (svc *usageCounter) watch(ctx context.Context) { - cleanT := time.NewTicker(time.Minute * 10) + decayT := time.NewTicker(svc.decayInterval) + evictT := time.NewTicker(svc.cleanupInterval) go func() { for { @@ -84,8 +190,14 @@ func (svc *usageCounter) watch(ctx context.Context) { case <-ctx.Done(): return - case <-cleanT.C: - svc.clean() + case <-evictT.C: + evicted := svc.evict() + for _, e := range evicted { + svc.sigEvict <- e + } + + case <-decayT.C: + svc.decay() case key := <-svc.incChan: svc.inc(key) @@ -94,6 +206,6 @@ func (svc *usageCounter) watch(ctx context.Context) { }() } -func (h MinHeap) Len() int { return len(h) } -func (h MinHeap) Less(i, j int) bool { return h[i].count < h[j].count } -func (h MinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +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/wrapper_counter_test.go b/server/pkg/rbac/wrapper_counter_test.go new file mode 100644 index 0000000000..2a9f3bb5d0 --- /dev/null +++ b/server/pkg/rbac/wrapper_counter_test.go @@ -0,0 +1,57 @@ +package rbac + +import ( + "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) +} From 7e2ba8c08f40fe855eb79721f768523fa8ed6334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Thu, 21 Nov 2024 14:42:09 +0100 Subject: [PATCH 04/22] V1 rbac rework --- server/pkg/rbac/index_stats.go | 9 + server/pkg/rbac/roles.go | 2 +- server/pkg/rbac/roles_test.go | 2 +- server/pkg/rbac/rule_index.go | 28 +- server/pkg/rbac/service.go | 917 +++++++++++++++--- server/pkg/rbac/stats.go | 28 + server/pkg/rbac/store_interface.go | 4 + .../{wrapper_counter.go => svc_counter.go} | 15 +- server/pkg/rbac/svc_index.go | 71 ++ server/pkg/rbac/wrapper.go | 430 -------- server/pkg/rbac/wrapper_index.go | 40 - 11 files changed, 888 insertions(+), 658 deletions(-) create mode 100644 server/pkg/rbac/index_stats.go create mode 100644 server/pkg/rbac/stats.go rename server/pkg/rbac/{wrapper_counter.go => svc_counter.go} (93%) create mode 100644 server/pkg/rbac/svc_index.go delete mode 100644 server/pkg/rbac/wrapper.go delete mode 100644 server/pkg/rbac/wrapper_index.go 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/roles.go b/server/pkg/rbac/roles.go index 9267b37744..fed12615ec 100644 --- a/server/pkg/rbac/roles.go +++ b/server/pkg/rbac/roles.go @@ -116,7 +116,7 @@ func statRoles(rr ...*Role) (stats map[roleKind]int) { } // 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 e18b2edffb..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 07b8aae4de..ce85da6a67 100644 --- a/server/pkg/rbac/rule_index.go +++ b/server/pkg/rbac/rule_index.go @@ -71,29 +71,29 @@ func (index *ruleIndex) add(rules ...*Rule) { } } -func (index *ruleIndex) remove(rules ...*Rule) { - if len(rules) == 0 { +func (index *ruleIndex) remove(role uint64, resource string, ops ...string) { + if _, ok := index.children[role]; !ok { return } - for _, r := range rules { - if _, ok := index.children[r.RoleID]; !ok { - continue - } + auxOps := ops - if !index.has(r) { - continue + if len(auxOps) == 0 { + for op := range index.children[role].children { + auxOps = append(auxOps, op) } + } - bits := append([]string{r.Operation}, strings.Split(r.Resource, "/")...) - index.removeRec(index.children[r.RoleID], bits) + for _, op := range auxOps { + bits := append([]string{op}, strings.Split(resource, "/")...) + index.removeRec(index.children[role], bits) // Finishing touch cleanup - if len(index.children[r.RoleID].children[r.Operation].children) == 0 { - delete(index.children[r.RoleID].children, r.Operation) + if len(index.children[role].children[op].children) == 0 { + delete(index.children[role].children, op) } - if len(index.children[r.RoleID].children) == 0 { - delete(index.children, r.RoleID) + if len(index.children[role].children) == 0 { + delete(index.children, role) } } } diff --git a/server/pkg/rbac/service.go b/server/pkg/rbac/service.go index d1bf92cb29..826cec11e3 100644 --- a/server/pkg/rbac/service.go +++ b/server/pkg/rbac/service.go @@ -2,31 +2,92 @@ package rbac import ( "context" + "fmt" + "sort" "strconv" + "strings" "sync" "time" - "github.com/cortezaproject/corteza/server/pkg/sentry" + "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 statLogger + + usageCounter *usageCounter[string] + index *wrapperIndex + roles []*Role + + RuleStorage rbacRulesStore + RoleStorage rbacRoleStore + } - // service will flush values on TRUE or just reload on FALSE - f chan bool + 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 + // IndexFlushInterval states how often the index state should be flushed to the database + IndexFlushInterval time.Duration + + // StatLogger provides the methods to log some performance metrices + StatLogger statLogger + // RuleStorage provides the methods to interact with rules + RuleStorage rbacRulesStore + // RoleStorage provides the methods to interact with roles + RoleStorage rbacRoleStore + + // PullInitialRoles provides the initial set of roles we can use + PullInitialRoles func(ctx context.Context) ([]*types.Role, error) + // 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 + } - rules RuleSet - indexed *ruleIndex + // evaluationState is a little helper to keep all the things we need in place + evaluationState struct { + unindexedRoles partRoles + indexedRoles partRoles - roles []*Role + unindexedRules [5]map[uint64][]*Rule - store rbacRulesStore + res string + op string } - // RuleFilter is a dummy struct to satisfy store codegen RuleFilter struct { Resource []string Operation string @@ -40,79 +101,134 @@ 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" ) +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), +// RbacService initializes the wrapper service with all the required surrounding bits +func RbacService(ctx context.Context, l *zap.Logger, store rbacRulesStore, cc Config) (svc *Service, err error) { + cc = defaultWrapperConfig(cc) - store: s, + usageCounter := &usageCounter[string]{ + incChan: make(chan string, 256), + + decayFactor: cc.DecayFactor, + decayInterval: cc.DecayInterval, + cleanupInterval: cc.CleanupInterval, + } + + svc = &Service{ + cfg: cc, + StatLogger: cc.StatLogger, + logger: l, + + usageCounter: usageCounter, + + RuleStorage: cc.RuleStorage, + RoleStorage: cc.RoleStorage, + } + + svc.roles, err = svc.loadRoles(ctx) + if err != nil { + return } - if logger != nil { - svc.logger = logger.Named("rbac") + svc.index, err = svc.loadIndex(ctx, store, svc.roles) + if err != nil { + return } + usageCounter.watch(ctx) + svc.watch(ctx) + 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 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 } + } + + // Noop to avoid branching down the line + if base.StatLogger == nil { + out.StatLogger = &noopStatLogger{} + } + + if base.ReindexStrategy == ReindexStrategyDefault { + out.ReindexStrategy = ReindexStrategyMemory + } + + return out } -// 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...) - ) +// Can returns true if the given resource can be accessed +func (svc *Service) Can(ses Session, op string, res Resource) (ok bool, err error) { + ac, err := svc.Check(ses, op, res) + if err != nil { + return + } + + return ac == Allow, nil +} +// Check returns the RBAC evaluation of the resource access +func (svc *Service) Check(ses Session, op string, res Resource) (a Access, err error) { 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) ) @@ -149,184 +265,433 @@ 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) -} + svc.mux.Lock() + if svc.index == nil { + svc.index = &wrapperIndex{} + } + // @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.index.isIndexed(r.RoleID, r.Resource) { + continue + } -func (svc *service) grant(rules ...*Rule) { - svc.rules = merge(svc.rules, rules...) - svc.indexed = buildRuleIndex(svc.rules) + // If it is, we need to assure this thing is inside the index now + svc.index.add(r.RoleID, r.Resource, r) + svc.StatLogger.CacheUpdate(r) + } + svc.mux.Unlock() + + // Flush changes to database :) + + err = svc.flush(ctx, rules...) + if err != nil { + return + } + + return } -// Watch reloads RBAC rules in intervals and on request -func (svc *service) Watch(ctx context.Context) { - go func() { - defer sentry.Recover() +// AddRole adds an additional role after the service was initialized +func (svc *Service) AddRole(r *Role) { + svc.mux.Lock() + defer svc.mux.Unlock() - 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) - } + svc.roles = append(svc.roles, r) +} + +// Remove role removes the role from the service +// +// @todo this won't clean out the removed rules until the next reload +func (svc *Service) RemoveRole(r *Role) { + svc.mux.Lock() + defer svc.mux.Unlock() + + for i, xr := range svc.roles { + if xr.id != r.id { + continue } - }() - svc.logger.Debug("watcher initialized") + svc.roles = append(svc.roles[:i], svc.roles[i+1:]...) + return + } } -// 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() +// IndexSize returns the number of indexed role/rule combos +func (svc *Service) IndexSize() int { + if svc.index == nil { + return 0 + } - return ruleByRole(svc.rules, roleID) + return svc.index.getSize() } -// Rules return all roles -func (svc *service) Rules() (rr RuleSet) { - svc.l.RLock() - defer svc.l.RUnlock() - return svc.rules +// Clear cleans out all the data +func (svc *Service) Clear() { + svc.usageCounter = nil + svc.index = nil + svc.roles = nil } -// Reload store rules -func (svc *service) Reload(ctx context.Context) { - svc.l.Lock() - defer svc.l.Unlock() - svc.reloadRules(ctx) -} +// // // // // // // // // // // // // // // // // // // // // // // // // +// 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? + st.unindexedRules, err = svc.pullUnindexed(ctx, st.unindexedRoles, op, res) + if err != nil { + return Inherit, err + } -// Clear removes all access control rules -func (svc *service) Clear() { - svc.l.Lock() - defer svc.l.Unlock() - svc.rules = RuleSet{} - svc.indexed = &ruleIndex{} + a, err = svc.evaluate( + []roleKind{ContextRole, CommonRole, AuthenticatedRole, AnonymousRole}, + trace, + st, + rolesByKind, + ) + if err != nil { + return + } + + 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) evaluate(roleOrder []roleKind, trace *Trace, st evaluationState, rolesByKind partRoles) (a Access, err error) { + var ( + match *Rule + allowed bool ) - if err == nil { - svc.setRules(rr) + // 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 + } } -} -func (svc *service) setRules(rr RuleSet) { - svc.rules = rr - svc.indexed = buildRuleIndex(rr) + 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() - - stats := statRoles(rr...) - svc.logger.Debug( - "updating roles", - zap.Int("before", len(svc.roles)), - zap.Int("after", len(rr)), - zap.Int("bypass", stats[BypassRole]), - zap.Int("context", stats[ContextRole]), - zap.Int("common", stats[CommonRole]), - zap.Int("authenticated", stats[AuthenticatedRole]), - zap.Int("anonymous", stats[AnonymousRole]), - ) - svc.roles = rr +// 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 Inherit, false } -// 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)") +// // // // // // // // // // // // // // // // // // // // // // // // // +// 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 } - deletable, updatable, final := flushable(svc.rules) + if svc.logger != nil { + svc.logger.Debug( + "flushed rules", + zap.Int("deleted", len(delete)), + zap.Int("upserted", len(upsert)), + ) + } - err = svc.store.DeleteRbacRule(ctx, deletable...) + return +} + +func (svc *Service) pullUnindexed(ctx context.Context, unindexed partRoles, op, res string) (out [5]map[uint64][]*Rule, err error) { + resPerm := make([]string, 0, 8) + resPerm = append(resPerm, res) + + // Get all the resource permissions + // @todo get permissions for parent resources; this will probs be some lookup table + rr := strings.Split(res, "/") + for i := len(rr) - 1; i >= 0; i-- { + rr[i] = "*" + resPerm = append(resPerm, strings.Join(rr, "/")) + } + + 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 +} + +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 } - err = svc.store.UpsertRbacRule(ctx, updatable...) + return +} + +func (svc *Service) pullRules(ctx context.Context, role uint64, resource string) (rules []*Rule, err error) { + resPerm := make([]string, 0, 8) + resPerm = append(resPerm, resource) + + // Get all the resource permissions + // @todo get permissions for parent resources; this will probs be some lookup table + rr := strings.Split(resource, "/") + for i := len(rr) - 1; i > 0; i-- { + rr[i] = "*" + resPerm = append(resPerm, strings.Join(rr, "/")) + } + + var aux RuleSet + aux, _, err = svc.RuleStorage.SearchRbacRules(ctx, RuleFilter{ + Resource: resPerm, + RoleID: role, + }) + + rules = append(rules, aux...) + + return +} + +func (svc *Service) loadRoles(ctx context.Context) (out []*Role, err error) { + auxRoles, err := svc.cfg.PullInitialRoles(ctx) 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)), - ) + for _, ar := range auxRoles { + out = append(out, &Role{ + id: ar.ID, + handle: ar.Handle, + kind: CommonRole, + }) + } 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() +// // // // // // // // // // // // // // // // // // // // // // // // // +// 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 + aux = svc.index.get(role, st.op, st.res) + 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 svc.rules.sigRoles(res.RbacResource(), op) + return nil +} + +// segmentRoles determines what roles are indexed and unindexed +func (svc *Service) segmentRoles(roles partRoles, resource string) (indexed, unindexed partRoles, err error) { + unindexed = partRoles{} + indexed = partRoles{} + + unindexed[CommonRole] = make(map[uint64]bool) + indexed[CommonRole] = make(map[uint64]bool) + + for k, rg := range roles { + for r := range rg { + if svc.index.isIndexed(r, resource) { + indexed[k][r] = true + continue + } + + unindexed[k][r] = true + } + } + + return } // 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) { +func (svc *Service) CloneRulesByRoleID(ctx context.Context, fromRoleID uint64, toRoleID ...uint64) (err error) { var ( updatedRules RuleSet ) // Make sure rules of fromRoleID stays intact - rr := svc.FindRulesByRoleID(fromRoleID) + rr, err := svc.pullForRole(ctx, fromRoleID) + if err != nil { + return + } for _, roleID := range toRoleID { // Remove existing rules - existingRules := svc.FindRulesByRoleID(roleID) + 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 @@ -344,3 +709,233 @@ func (svc *service) CloneRulesByRoleID(ctx context.Context, fromRoleID uint64, t 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) 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) { + for _, rr := range roles { + for r := range rr { + svc.usageCounter.incChan <- fmt.Sprintf("%d:%s", r, res.RbacResource()) + } + } +} + +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, err := svc.buildNewIndex(ctx) + if err != nil { + return + } + + svc.swapIndexes(ctx, auxIndex) + return +} + +func (svc *Service) updateWrapperIndexSpeedFirst(ctx context.Context) (err error) { + svc.mux.Lock() + defer svc.mux.Unlock() + + svc.index = nil + + auxIndex, err := svc.buildNewIndex(ctx) + if err != nil { + return + } + + svc.index = auxIndex + return +} + +// // // // // // // // // // // // // // // // // // // // // // // // // // +// Boilerplate & state management stuff + +func (svc *Service) indexForResources(ctx context.Context, res ...string) (index *wrapperIndex, err error) { + index = &wrapperIndex{} + var auxRules []*Rule + + for _, b := range res { + pp := strings.SplitN(b, ":", 2) + role := cast.ToUint64(pp[0]) + resource := pp[1] + + auxRules, err = svc.pullRules(ctx, role, resource) + if err != nil { + return + } + + index.add(role, resource, auxRules...) + } + + return +} + +func (svc *Service) loadIndex(ctx context.Context, s rbacRulesStore, allRoles []*Role) (out *wrapperIndex, 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 + } + + 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) (index *wrapperIndex, 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(ctx context.Context, auxIndex *wrapperIndex) { + if auxIndex == nil { + return + } + + svc.mux.Lock() + defer svc.mux.Unlock() + + svc.index = auxIndex +} + +// Performance monitoring + +func (svc *Service) logCachePerformance(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) + } + } +} + +// Debugger stuff + +func (svc *Service) DebuggerSetIndex(role uint64, resource string, rules ...*Rule) (err error) { + index := &wrapperIndex{} + + index.add(role, resource, rules...) + + svc.index = index + + return +} + +func (svc *Service) DebuggerAddIndex(role uint64, resource string, rules ...*Rule) (err error) { + index := svc.index + + index.add(role, resource, rules...) + + svc.index = index + + return +} + +// // // // // // // // // // // // // // // // // // // // // // // // // +// Processing n stuff + +func (svc *Service) watch(ctx context.Context) { + tInt := svc.cfg.IndexFlushInterval + if tInt == 0 { + tInt = time.Minute * 5 + } + + t := time.NewTicker(time.Minute * 5) + rexInt := svc.cfg.IndexFlushInterval + if rexInt == 0 { + rexInt = time.Minute * 30 + } + + rex := time.NewTicker(time.Minute * 30) + + flshInt := svc.cfg.IndexFlushInterval + if flshInt == 0 { + flshInt = time.Minute * 5 + } + tFlush := time.NewTicker(flshInt) + + lg := svc.logger.Named("rbac service wrapper") + + go func() { + for { + select { + case <-t.C: + lg.Info("tick") + + case <-rex.C: + lg.Info("reindex") + + err := svc.updateWrapperIndex(ctx) + if err != nil { + lg.Error("reindex failed", zap.Error(err)) + } + + case <-tFlush.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/stats.go b/server/pkg/rbac/stats.go new file mode 100644 index 0000000000..8b65eca7f8 --- /dev/null +++ b/server/pkg/rbac/stats.go @@ -0,0 +1,28 @@ +package rbac + +type ( + // @todo :) + stats struct { + cacheHitChan chan string + cacheMissChan chan string + + // cacheHits + + } + + noopStatLogger struct{} +) + +func Statser() { + +} + +func (l *stats) CacheHit([]uint64, string, string) {} +func (l *stats) CacheMiss([]uint64, string, string) {} +func (l *stats) CacheUpdate(in *Rule) {} + +// Noop + +func (l *noopStatLogger) CacheHit([]uint64, string, string) {} +func (l *noopStatLogger) CacheMiss([]uint64, string, string) {} +func (l *noopStatLogger) CacheUpdate(in *Rule) {} diff --git a/server/pkg/rbac/store_interface.go b/server/pkg/rbac/store_interface.go index 75a417be3d..b1b01f52cc 100644 --- a/server/pkg/rbac/store_interface.go +++ b/server/pkg/rbac/store_interface.go @@ -17,4 +17,8 @@ type ( // @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/wrapper_counter.go b/server/pkg/rbac/svc_counter.go similarity index 93% rename from server/pkg/rbac/wrapper_counter.go rename to server/pkg/rbac/svc_counter.go index 0e137aa2c9..e10f11c616 100644 --- a/server/pkg/rbac/wrapper_counter.go +++ b/server/pkg/rbac/svc_counter.go @@ -23,10 +23,6 @@ type ( // incChan sends instructions to the counter re. key K increment incChan chan K - // sigEvict lets the counter notify the manager what key K should be evicted - sigEvict chan K - // @todo remove - sigChan chan K // decayInterval denotes in what interval the decay factor should apply decayInterval time.Duration @@ -152,6 +148,10 @@ func (svc *usageCounter[K]) worstPerformers(n int) (out []K) { // 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, @@ -182,7 +182,6 @@ func (svc *usageCounter[K]) watch(ctx context.Context) { } decayT := time.NewTicker(svc.decayInterval) - evictT := time.NewTicker(svc.cleanupInterval) go func() { for { @@ -190,12 +189,6 @@ func (svc *usageCounter[K]) watch(ctx context.Context) { case <-ctx.Done(): return - case <-evictT.C: - evicted := svc.evict() - for _, e := range evicted { - svc.sigEvict <- e - } - case <-decayT.C: svc.decay() diff --git a/server/pkg/rbac/svc_index.go b/server/pkg/rbac/svc_index.go new file mode 100644 index 0000000000..e809db87fe --- /dev/null +++ b/server/pkg/rbac/svc_index.go @@ -0,0 +1,71 @@ +package rbac + +import ( + "fmt" + "sync" +) + +type ( + wrapperIndex struct { + mux sync.RWMutex + index *ruleIndex + indexed map[string]bool + } +) + +func (svc *wrapperIndex) add(role uint64, resource string, rules ...*Rule) { + svc.mux.Lock() + defer svc.mux.Unlock() + + if svc.indexed == nil { + svc.indexed = make(map[string]bool, 24) + } + + if svc.index == nil { + svc.index = &ruleIndex{} + } + + svc.indexed[svc.mkkey(role, resource)] = true + svc.index.add(rules...) +} + +func (svc *wrapperIndex) get(role uint64, op string, res string) (out []*Rule) { + svc.mux.RLock() + defer svc.mux.RUnlock() + + if svc.index == nil { + return + } + + return svc.index.get(role, op, res) +} + +func (svc *wrapperIndex) getIndexed() (out []string) { + for k := range svc.indexed { + out = append(out, k) + } + + return +} + +func (svc *wrapperIndex) getSize() int { + svc.mux.RLock() + defer svc.mux.RUnlock() + + return len(svc.indexed) +} + +func (svc *wrapperIndex) isIndexed(role uint64, resource string) (ok bool) { + svc.mux.RLock() + defer svc.mux.RUnlock() + + if svc.indexed == nil { + return false + } + + return svc.indexed[svc.mkkey(role, resource)] +} + +func (svc *wrapperIndex) mkkey(role uint64, resource string) string { + return fmt.Sprintf("%d:%s", role, resource) +} diff --git a/server/pkg/rbac/wrapper.go b/server/pkg/rbac/wrapper.go deleted file mode 100644 index 48ab7a7fa7..0000000000 --- a/server/pkg/rbac/wrapper.go +++ /dev/null @@ -1,430 +0,0 @@ -package rbac - -import ( - "context" - "fmt" - "math" - "sort" - "strings" - "time" - - "github.com/cortezaproject/corteza/server/pkg/filter" - "github.com/cortezaproject/corteza/server/system/types" - "github.com/davecgh/go-spew/spew" -) - -type ( - WrapperConfig struct { - InitialIndexedRoles []uint64 - MaxIndexSize int - } - - wrapperService struct { - cfg WrapperConfig - - store rbacRulesStore - counter *usageCounter - index *wrapperIndex - roles []*Role - } -) - -func dftWrapperCfg(base WrapperConfig) (out WrapperConfig) { - out = base - - if base.MaxIndexSize == 0 { - out.MaxIndexSize = -1 - } - - return out -} - -func Wrapper(ctx context.Context, store rbacRulesStore, cc WrapperConfig) (x *wrapperService, err error) { - cc = dftWrapperCfg(cc) - - uc := &usageCounter{ - incChan: make(chan uint64, 256), - sigChan: make(chan counterEntry, 8), - } - - x = &wrapperService{ - cfg: cc, - - store: store, - counter: uc, - } - - x.roles, err = x.loadRoles(ctx, store) - if err != nil { - return - } - - x.index, err = x.loadIndex(ctx, store, x.roles) - if err != nil { - return - } - - uc.watch(ctx) - x.watch(ctx) - - return -} - -func (svc *wrapperService) Clear() { - svc.store = nil - svc.counter = nil - svc.index = nil - svc.roles = nil -} - -func (svc *wrapperService) Can(ses Session, op string, res Resource) (ok bool, err error) { - ac, err := svc.Check(ses, op, res) - if err != nil { - return - } - - return ac == Allow, nil -} - -func (svc *wrapperService) Check(ses Session, op string, res Resource) (a Access, err error) { - if hasWildcards(res.RbacResource()) { - // prevent use of wildcard resources for checking permissions - return Inherit, nil - } - - fRoles := getContextRoles(ses, res, svc.roles...) - - return svc.check(ses.Context(), fRoles, op, res.RbacResource()) -} - -func (svc *wrapperService) check(ctx context.Context, rolesByKind partRoles, op, res string) (a Access, err error) { - 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(nil, Deny, failedIntegrityCheck), nil - } - - if member(rolesByKind, BypassRole) { - // if user has at least one bypass role, we allow access - return resolve(nil, Allow, bypassRoleMembership), nil - } - - // if indexedRules.empty() { - // // no rules to check - // return resolve(nil, Inherit, noRules) - // } - - var ( - match *Rule - allowed bool - ) - - indexed, unindexed, err := svc.segmentRoles(ctx, rolesByKind) - if err != nil { - return Inherit, err - } - - // - // 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) - // } - - st := evlState{ - op: op, - res: res, - - unindexedRoles: unindexed, - indexedRoles: indexed, - } - - st.unindexedRules, err = svc.pullUnindexed(ctx, unindexed, op, res) - if err != nil { - return Inherit, err - } - - // 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 = svc.getMatching(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 - } - } - - // No rule matched - return resolve(nil, Inherit, noMatch), nil -} - -func (svc *wrapperService) segmentRoles(ctx context.Context, roles partRoles) (indexed, unindexed partRoles, err error) { - unindexed = partRoles{} - indexed = partRoles{} - - unindexed[CommonRole] = make(map[uint64]bool) - indexed[CommonRole] = make(map[uint64]bool) - - for k, rg := range roles { - for r := range rg { - if svc.index.hasRole(r) { - indexed[k][r] = true - continue - } - - unindexed[k][r] = true - } - } - - return -} - -type ( - evlState struct { - unindexedRoles partRoles - indexedRoles partRoles - - unindexedRules [5]map[uint64][]*Rule - - res string - op string - } -) - -func (svc *wrapperService) getMatching(st evlState, kind roleKind, role uint64) (rule *Rule) { - var ( - aux []*Rule - rules RuleSet - ) - - // Indexed - aux = svc.index.get(role, st.op, st.res) - 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 -} - -func (svc *wrapperService) pullUnindexed(ctx context.Context, unindexed partRoles, op, res string) (out [5]map[uint64][]*Rule, err error) { - resPerm := make([]string, 0, 8) - resPerm = append(resPerm, res) - - // Get all the resource permissions - // @todo get permissions for parent resources; this will probs be some lookup table - rr := strings.Split(res, "/") - for i := len(rr) - 1; i >= 0; i-- { - rr[i] = "*" - resPerm = append(resPerm, strings.Join(rr, "/")) - } - - for rk, rr := range unindexed { - for r := range rr { - auxRr := make([]*Rule, 0, 4) - auxRr, _, err = svc.store.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 -} - -func (svc *wrapperService) IndexRoleChange(ctx context.Context, roleID uint64) (err error) { - aux, _, err := svc.store.SearchRbacRules(ctx, RuleFilter{ - RoleID: roleID, - }) - if err != nil { - return - } - - // @todo cap this - if len(svc.index.rules.children) > svc.cfg.MaxIndexSize { - // @note probably remove a few extra just to avoid constantly doing this - // @todo is this a good idea? Not sure if worth it since all of this is behind the scene anyways - wp := svc.counter.worstPerformers(4) - svc.index.remove(wp...) - } - - svc.index.add(aux...) - return -} - -func (svc *wrapperService) watch(ctx context.Context) { - t := time.NewTicker(time.Minute * 5) - - go func() { - for { - select { - case <-t.C: - spew.Dump("ticking") - - case change := <-svc.counter.sigChan: - err := svc.IndexRoleChange(ctx, change.key) - if err != nil { - spew.Dump("wrapper watch change err", err) - } - - case <-ctx.Done(): - return - } - } - }() -} - -// // // // // // // // // // // // // // // // // // // // // // // // // // - -func makeKey(op, res string, role uint64) string { - return fmt.Sprintf("%d:%s:%s", role, op, res) -} - -// - -// // // // // // // // // // // // // // // // // // // // // // // // // // -// Boilerplate & state management stuff - -func (svc *wrapperService) loadRoles(ctx context.Context, s rbacRulesStore) (out []*Role, err error) { - auxRoles, _, err := s.SearchRoles(ctx, types.RoleFilter{ - Paging: filter.Paging{ - Limit: 0, - }, - }) - if err != nil { - return - } - - for _, ar := range auxRoles { - out = append(out, &Role{ - id: ar.ID, - handle: ar.Handle, - kind: CommonRole, - }) - } - - return -} - -func (svc *wrapperService) loadIndex(ctx context.Context, s rbacRulesStore, allRoles []*Role) (out *wrapperIndex, err error) { - // @todo smarter way to figure out what/how many roles we want to load up - roles := svc.getIndexRoles(allRoles) - - rules := make(RuleSet, 0, 1024) - var aux RuleSet - for _, role := range roles { - aux, _, err = s.SearchRbacRules(ctx, RuleFilter{ - RoleID: role.id, - Limit: 0, - }) - if err != nil { - return - } - - rules = append(rules, aux...) - } - - out = &wrapperIndex{ - rules: buildRuleIndex(rules), - } - - return -} - -func (svc *wrapperService) getIndexRoles(allRoles []*Role) (out []*Role) { - // User-specified what we want to index; respect that to the t - if len(svc.cfg.InitialIndexedRoles) > 0 { - for _, r := range allRoles { - for _, ir := range svc.cfg.InitialIndexedRoles { - if r.id == ir { - out = append(out, r) - } - } - } - - return - } - - // Straight up limit - // @todo add some counters to figure out which roles are most used from the start - if svc.cfg.MaxIndexSize == -1 { - return allRoles - } - - if svc.cfg.MaxIndexSize == 0 { - return nil - } - - // @todo smarter way to figure out what/how many roles we want to load up - return allRoles[:int(math.Min(float64(len(allRoles)), float64(svc.cfg.MaxIndexSize)))] -} diff --git a/server/pkg/rbac/wrapper_index.go b/server/pkg/rbac/wrapper_index.go deleted file mode 100644 index 3cde632c95..0000000000 --- a/server/pkg/rbac/wrapper_index.go +++ /dev/null @@ -1,40 +0,0 @@ -package rbac - -import "sync" - -type ( - wrapperIndex struct { - mux sync.RWMutex - rules *ruleIndex - } -) - -func (svc *wrapperIndex) get(role uint64, op string, res string) (out []*Rule) { - svc.mux.RLock() - defer svc.mux.RUnlock() - - return svc.rules.get(role, op, res) -} - -func (svc *wrapperIndex) hasRole(role uint64) (ok bool) { - svc.mux.RLock() - defer svc.mux.RUnlock() - - _, ok = svc.rules.children[role] - return -} - -// @todo since it's like so, we might not need the trie to have deletable elements -func (svc *wrapperIndex) remove(roles ...uint64) { - svc.mux.Lock() - defer svc.mux.Unlock() - for _, r := range roles { - delete(svc.rules.children, r) - } -} - -func (svc *wrapperIndex) add(rules ...*Rule) { - svc.mux.Lock() - defer svc.mux.Unlock() - svc.rules.add(rules...) -} From 3fdaafd8a1f2920c26f3e1cff6824d511f330fdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Tue, 26 Nov 2024 12:50:24 +0100 Subject: [PATCH 05/22] Hook up reworked rbac svc --- server/.env.example | 54 +++++++ server/app/boot_levels.go | 137 ++++++++++-------- server/app/options/RBAC.cue | 69 +++++++++ .../automation/service/access_control.gen.go | 13 +- .../rbac/$component_access_control.go.tpl | 13 +- server/compose/rest/namespace.go | 5 +- server/compose/service/access_control.gen.go | 13 +- server/compose/service/namespace.go | 10 +- .../rest/internal/documents/compose.go | 17 ++- .../rest/internal/documents/system.go | 7 +- .../discovery/rest/internal/feed/resource.go | 5 +- .../federation/service/access_control.gen.go | 13 +- server/pkg/options/options.gen.go | 24 ++- server/system/service/access_control.gen.go | 13 +- 14 files changed, 293 insertions(+), 100 deletions(-) diff --git a/server/.env.example b/server/.env.example index d34888a1a4..5ac6b301c3 100644 --- a/server/.env.example +++ b/server/.env.example @@ -220,6 +220,60 @@ # 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= + +############################################################################### +# [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..44774189bb 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,7 @@ 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)) defer rbac.SetGlobal(nil) } @@ -355,10 +347,29 @@ 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, + 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 +515,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 +571,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..d129a7edcb 100644 --- a/server/app/options/RBAC.cue +++ b/server/app/options/RBAC.cue @@ -13,6 +13,75 @@ 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. + """ + } + + 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/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/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/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/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..00863324f3 100644 --- a/server/pkg/options/options.gen.go +++ b/server/pkg/options/options.gen.go @@ -52,11 +52,18 @@ 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"` + 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 +385,13 @@ 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, + IndexFlushInterval: time.Minute * 35, BypassRoles: "super-admin", AuthenticatedRoles: "authenticated", AnonymousRoles: "anonymous", 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 From a99bf12d87be074a1c1fb4bc90e659432a98666c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Wed, 27 Nov 2024 16:09:39 +0100 Subject: [PATCH 06/22] Tweak & plug in stat logging --- server/pkg/rbac/roles.go | 18 ++ server/pkg/rbac/ruleset_utils.go | 10 - server/pkg/rbac/service.go | 309 ++++++++++++++++++++++++++-- server/pkg/rbac/stats.go | 164 +++++++++++++-- server/pkg/rbac/svc_counter.go | 20 ++ server/pkg/slice/circular.go | 55 +++++ server/pkg/slice/circular_test.go | 37 ++++ server/system/service/statistics.go | 9 + 8 files changed, 576 insertions(+), 46 deletions(-) create mode 100644 server/pkg/slice/circular.go create mode 100644 server/pkg/slice/circular_test.go diff --git a/server/pkg/rbac/roles.go b/server/pkg/rbac/roles.go index fed12615ec..9cd243f066 100644 --- a/server/pkg/rbac/roles.go +++ b/server/pkg/rbac/roles.go @@ -115,6 +115,24 @@ 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 evalRoles(s Session, res Resource, preloadedRoles ...*Role) (out partRoles) { var ( 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 826cec11e3..850a92c4fa 100644 --- a/server/pkg/rbac/service.go +++ b/server/pkg/rbac/service.go @@ -19,7 +19,10 @@ type ( mux sync.RWMutex cfg Config logger *zap.Logger - StatLogger statLogger + StatLogger *statsLogger + + noop bool + noopAccess Access usageCounter *usageCounter[string] index *wrapperIndex @@ -56,15 +59,11 @@ type ( // IndexFlushInterval states how often the index state should be flushed to the database IndexFlushInterval time.Duration - // StatLogger provides the methods to log some performance metrices - StatLogger statLogger // RuleStorage provides the methods to interact with rules RuleStorage rbacRulesStore // RoleStorage provides the methods to interact with roles RoleStorage rbacRoleStore - // PullInitialRoles provides the initial set of roles we can use - PullInitialRoles func(ctx context.Context) ([]*types.Role, error) // PullInitialState provides the initial index state // // The string slice provides index keys which should then be further processed @@ -88,6 +87,35 @@ type ( 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"` + AvgTiming time.Duration `json:"avgTiming"` + MinTiming time.Duration `json:"minTiming"` + MaxTiming time.Duration `json:"maxTiming"` + + IndexSize int `json:"indexSize"` + + LastHits []string `json:"lastHits"` + LastMisses []string `json:"lastMisses"` + LastTimings []time.Duration `json:"lastTimings"` + + Counters []expCtrItem `json:"counters"` + } + RuleFilter struct { Resource []string Operation string @@ -138,21 +166,40 @@ func SetGlobal(svc *Service) { gWrapper = svc } -// RbacService initializes the wrapper service with all the required surrounding bits -func RbacService(ctx context.Context, l *zap.Logger, store rbacRulesStore, cc Config) (svc *Service, err error) { - cc = defaultWrapperConfig(cc) +// NoopSvc creates a blank RBAC service which always returns the stated access +func NoopSvc(access Access) (svc *Service) { + return &Service{ + noop: true, + noopAccess: access, + } +} + +// 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(l, cc) usageCounter := &usageCounter[string]{ - incChan: make(chan string, 256), + 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)) + }, + } + + sl := &statsLogger{ + log: l.Named("rbac stats logger"), + cacheHitChan: make(chan statsWrap, 1024), + cacheMissChan: make(chan statsWrap, 1024), + timingChan: make(chan time.Duration, 1024), } svc = &Service{ cfg: cc, - StatLogger: cc.StatLogger, + StatLogger: sl, logger: l, usageCounter: usageCounter, @@ -173,11 +220,12 @@ func RbacService(ctx context.Context, l *zap.Logger, store rbacRulesStore, cc Co usageCounter.watch(ctx) svc.watch(ctx) + sl.watch(ctx) return } -func defaultWrapperConfig(base Config) (out Config) { +func defaultWrapperConfig(l *zap.Logger, base Config) (out Config) { out = base // -1 disables partitioning so everything is pulled in memory @@ -190,11 +238,6 @@ func defaultWrapperConfig(base Config) (out Config) { out.FlushIndexState = func(ctx context.Context, s []string) error { return nil } } - // Noop to avoid branching down the line - if base.StatLogger == nil { - out.StatLogger = &noopStatLogger{} - } - if base.ReindexStrategy == ReindexStrategyDefault { out.ReindexStrategy = ReindexStrategyMemory } @@ -203,17 +246,29 @@ func defaultWrapperConfig(base Config) (out Config) { } // Can returns true if the given resource can be accessed -func (svc *Service) Can(ses Session, op string, res Resource) (ok bool, err error) { +func (svc *Service) Can(ses Session, op string, res Resource) (ok bool) { ac, err := svc.Check(ses, op, res) if err != nil { - return + svc.logger.Error("check failed with error", + zap.String("op", op), + zap.String("resource", res.RbacResource()), + zap.Error(err), + ) + return false } - return ac == Allow, nil + 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, nil @@ -320,6 +375,34 @@ func (svc *Service) Grant(ctx context.Context, rules ...*Rule) (err error) { return } +func (svc *Service) Stats() (out Stats, err error) { + svc.usageCounter.lock.RLock() + defer svc.usageCounter.lock.RUnlock() + + 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.AvgTiming, + out.MinTiming, + out.MaxTiming, + out.LastHits, + out.LastMisses, + out.LastTimings = svc.StatLogger.Stats() + + out.IndexSize = svc.index.getSize() + + return +} + // AddRole adds an additional role after the service was initialized func (svc *Service) AddRole(r *Role) { svc.mux.Lock() @@ -328,6 +411,50 @@ func (svc *Service) AddRole(r *Role) { svc.roles = append(svc.roles, r) } +func (svc *Service) UpdateRoles(rr ...*Role) { + svc.mux.Lock() + defer svc.mux.Unlock() + + stats := statRoles(rr...) + svc.logger.Debug( + "updating roles", + zap.Int("before", len(svc.roles)), + zap.Int("after", len(rr)), + zap.Int("bypass", stats[BypassRole]), + zap.Int("context", stats[ContextRole]), + zap.Int("common", stats[CommonRole]), + 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 +} + +// 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 + } + + for _, x := range aux { + rr = append(rr, &Rule{ + RoleID: x.RoleID, + Resource: x.Resource, + Operation: x.Operation, + Access: x.Access, + }) + } + + return +} + // Remove role removes the role from the service // // @todo this won't clean out the removed rules until the next reload @@ -354,6 +481,30 @@ func (svc *Service) IndexSize() int { return svc.index.getSize() } +// 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() + + aux, _, err := svc.RuleStorage.SearchRbacRules(ctx, RuleFilter{ + Resource: []string{res.RbacResource()}, + Operation: op, + }) + if err != nil { + return + } + + 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 @@ -391,11 +542,14 @@ func (svc *Service) check(ctx context.Context, rolesByKind partRoles, op, res st } // @todo should we cache this for n seconds? just in case it's going to happen again soon? - st.unindexedRules, err = svc.pullUnindexed(ctx, st.unindexedRoles, op, res) + var timing time.Duration + st.unindexedRules, timing, err = svc.pullUnindexed(ctx, st.unindexedRoles, op, res) if err != nil { return Inherit, err } + svc.logDbTiming(timing) + a, err = svc.evaluate( []roleKind{ContextRole, CommonRole, AuthenticatedRole, AnonymousRole}, trace, @@ -518,12 +672,17 @@ func (svc *Service) flush(ctx context.Context, rules ...*Rule) (err error) { return } -func (svc *Service) pullUnindexed(ctx context.Context, unindexed partRoles, op, res string) (out [5]map[uint64][]*Rule, err error) { +func (svc *Service) pullUnindexed(ctx context.Context, unindexed partRoles, op, res string) (out [5]map[uint64][]*Rule, timing time.Duration, err error) { resPerm := make([]string, 0, 8) resPerm = append(resPerm, res) // 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) + }() + rr := strings.Split(res, "/") for i := len(rr) - 1; i >= 0; i-- { rr[i] = "*" @@ -589,8 +748,25 @@ func (svc *Service) pullRules(ctx context.Context, role uint64, resource string) 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.PullInitialRoles(ctx) + auxRoles, _, err := svc.cfg.RoleStorage.SearchRoles(ctx, types.RoleFilter{}) if err != nil { return } @@ -661,10 +837,18 @@ func (svc *Service) segmentRoles(roles partRoles, resource string) (indexed, uni for k, rg := range roles { for r := range rg { if svc.index.isIndexed(r, resource) { + 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 } } @@ -719,6 +903,14 @@ func (svc *Service) incCounter(roles partRoles, res Resource) { } } +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 { @@ -735,6 +927,18 @@ func (svc *Service) incCounterAsync(roles partRoles, res Resource) { } } +func (svc *Service) cleanupCounterSync(roles ...*Role) { + for _, r := range roles { + gWrapper.usageCounter.cleanRoleKeys(r.id) + } +} + +func (svc *Service) cleanupCounterAsync(roles ...*Role) { + for _, r := range roles { + svc.usageCounter.rmChan <- r.id + } +} + func (svc *Service) updateWrapperIndex(ctx context.Context) (err error) { switch svc.cfg.ReindexStrategy { case ReindexStrategyMemory: @@ -835,8 +1039,31 @@ func (svc *Service) swapIndexes(ctx context.Context, auxIndex *wrapperIndex) { } // Performance monitoring +func (svc *Service) logDbTiming(timing time.Duration) { + if svc.cfg.Synchronous { + svc.logAccessSync(timing) + } else { + svc.logAccessAsync(timing) + } +} + +func (svc *Service) logAccessSync(timing time.Duration) { + svc.StatLogger.Timing(timing) +} + +func (svc *Service) logAccessAsync(timing time.Duration) { + svc.StatLogger.timingChan <- 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) @@ -866,6 +1093,44 @@ func (svc *Service) logCachePerformance(hits, misses partRoles, resource, op str } } +func (svc *Service) logCachePerformanceAsync(hits, misses partRoles, resource, op string) { + // Hits + { + 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 + { + 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) { diff --git a/server/pkg/rbac/stats.go b/server/pkg/rbac/stats.go index 8b65eca7f8..6ee6cbc9f8 100644 --- a/server/pkg/rbac/stats.go +++ b/server/pkg/rbac/stats.go @@ -1,28 +1,164 @@ package rbac +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/cortezaproject/corteza/server/pkg/slice" + "go.uber.org/zap" +) + type ( - // @todo :) - stats struct { - cacheHitChan chan string - cacheMissChan chan string + statsLogger struct { + lock sync.RWMutex + log *zap.Logger - // cacheHits + // Channels for async comms + cacheHitChan chan statsWrap + cacheMissChan chan statsWrap + timingChan chan time.Duration + // Counters + cacheHits uint + cacheMisses uint + cacheUpdates uint + avgTiming time.Duration + minTiming time.Duration + maxTiming 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] + lastTimings *slice.Circular[time.Duration] } - noopStatLogger struct{} + // statsWrap wraps the state to log + statsWrap struct { + roles []uint64 + resource string + op string + } ) -func Statser() { +// Stats returns the tracked stats +func (l *statsLogger) Stats() (cacheHit uint, cacheMiss uint, avgTiming, minTiming, maxTiming time.Duration, lastHits []string, lastMisses []string, lastTimings []time.Duration) { + l.lock.RLock() + defer l.lock.RUnlock() + + return l.cacheHits, + l.cacheMisses, + l.avgTiming, + l.minTiming, + l.maxTiming, + l.lastHits.Slice(), + l.lastMisses.Slice(), + l.lastTimings.Slice() +} + +// Timing logs the giving duration +func (l *statsLogger) Timing(timing time.Duration) { + l.lock.Lock() + defer l.lock.Unlock() + + l.log.Info("record timing", zap.Duration("timing", timing)) + + { + l.avgTiming = (l.avgTiming + timing) / 2 + } + + { + if l.minTiming == 0 { + l.minTiming = timing + } + if timing < l.minTiming { + l.minTiming = timing + } + } + + { + if l.maxTiming == 0 { + l.maxTiming = timing + } + if timing > l.maxTiming { + l.maxTiming = timing + } + } + + { + if l.lastTimings == nil { + l.lastTimings = slice.NewCircular[time.Duration](500) + } + + l.lastTimings.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](10000) + } + 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](10000) + } + l.lastMisses.Add(l.strfEntry(roles, resource, op)) } -func (l *stats) CacheHit([]uint64, string, string) {} -func (l *stats) CacheMiss([]uint64, string, string) {} -func (l *stats) CacheUpdate(in *Rule) {} +func (l *statsLogger) CacheUpdate(in *Rule) { + l.lock.Lock() + defer l.lock.Unlock() -// Noop + l.log.Info("cache update", zap.Any("rule", in)) + + l.cacheUpdates++ +} -func (l *noopStatLogger) CacheHit([]uint64, string, string) {} -func (l *noopStatLogger) CacheMiss([]uint64, string, string) {} -func (l *noopStatLogger) CacheUpdate(in *Rule) {} +// // // // // // // // // // // // // // // // // // // // // // // // // +// Utils + +func (l *statsLogger) strfEntry(roles []uint64, resource string, op string) string { + 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.timingChan: + l.Timing(tt) + + case <-ctx.Done(): + l.log.Info("terminating watcher") + } + } + }() +} diff --git a/server/pkg/rbac/svc_counter.go b/server/pkg/rbac/svc_counter.go index e10f11c616..20277fe1aa 100644 --- a/server/pkg/rbac/svc_counter.go +++ b/server/pkg/rbac/svc_counter.go @@ -24,6 +24,10 @@ type ( // incChan sends instructions to the counter re. key K increment incChan chan K + rmChan chan uint64 + + 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 @@ -82,6 +86,19 @@ func (svc *usageCounter[K]) evict() (out []K) { 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() @@ -194,6 +211,9 @@ func (svc *usageCounter[K]) watch(ctx context.Context) { case key := <-svc.incChan: svc.inc(key) + + case role := <-svc.rmChan: + svc.cleanRoleKeys(role) } } }() 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/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 }() From bc0e5a30ad2cf713e417fb42d5df81e587b68156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Thu, 28 Nov 2024 16:36:01 +0100 Subject: [PATCH 07/22] Add option to controll reindex interval --- server/.env.example | 6 +++++ server/app/boot_levels.go | 1 + server/app/options/RBAC.cue | 8 +++++++ server/pkg/options/options.gen.go | 2 ++ server/pkg/rbac/service.go | 37 +++++++++++++++++++++---------- 5 files changed, 42 insertions(+), 12 deletions(-) diff --git a/server/.env.example b/server/.env.example index 5ac6b301c3..48ea25d2a4 100644 --- a/server/.env.example +++ b/server/.env.example @@ -265,6 +265,12 @@ # Default: # RBAC_CLEANUP_INTERVAL= +############################################################################### +# Reindex interval controls when the index should be re-calculated. +# Type: time.Duration +# Default: +# RBAC_REINDEX_INTERVAL= + ############################################################################### # [IMPORTANT] # ==== diff --git a/server/app/boot_levels.go b/server/app/boot_levels.go index 44774189bb..c581538340 100644 --- a/server/app/boot_levels.go +++ b/server/app/boot_levels.go @@ -357,6 +357,7 @@ func (app *CortezaApp) InitServices(ctx context.Context) (err error) { 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, diff --git a/server/app/options/RBAC.cue b/server/app/options/RBAC.cue index d129a7edcb..e9fc24fe33 100644 --- a/server/app/options/RBAC.cue +++ b/server/app/options/RBAC.cue @@ -71,6 +71,14 @@ RBAC: schema.#optionsGroup & { """ } + 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" diff --git a/server/pkg/options/options.gen.go b/server/pkg/options/options.gen.go index 00863324f3..9755cbdaa9 100644 --- a/server/pkg/options/options.gen.go +++ b/server/pkg/options/options.gen.go @@ -59,6 +59,7 @@ type ( 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"` @@ -391,6 +392,7 @@ func Rbac() (o *RbacOpt) { DecayFactor: 0.9, DecayInterval: time.Minute * 30, CleanupInterval: time.Minute * 31, + ReindexInterval: time.Minute * 10, IndexFlushInterval: time.Minute * 35, BypassRoles: "super-admin", AuthenticatedRoles: "authenticated", diff --git a/server/pkg/rbac/service.go b/server/pkg/rbac/service.go index 850a92c4fa..a4dc96ff86 100644 --- a/server/pkg/rbac/service.go +++ b/server/pkg/rbac/service.go @@ -56,6 +56,8 @@ type ( 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 @@ -1157,34 +1159,45 @@ func (svc *Service) DebuggerAddIndex(role uint64, resource string, rules ...*Rul // Processing n stuff func (svc *Service) watch(ctx context.Context) { + tck := time.NewTicker(time.Minute * 5) + tInt := svc.cfg.IndexFlushInterval if tInt == 0 { tInt = time.Minute * 5 } + tTck := time.NewTicker(tInt) + _ = tTck + + flushInt := svc.cfg.IndexFlushInterval + if flushInt == 0 { + flushInt = time.Minute * 30 + } + flushTck := time.NewTicker(flushInt) + _ = flushTck - t := time.NewTicker(time.Minute * 5) - rexInt := svc.cfg.IndexFlushInterval + rexInt := svc.cfg.ReindexInterval if rexInt == 0 { rexInt = time.Minute * 30 } + rexTck := time.NewTicker(rexInt) + _ = rexTck - rex := time.NewTicker(time.Minute * 30) - - flshInt := svc.cfg.IndexFlushInterval - if flshInt == 0 { - flshInt = time.Minute * 5 - } - tFlush := time.NewTicker(flshInt) + defer func() { + tck.Stop() + tTck.Stop() + flushTck.Stop() + rexTck.Stop() + }() lg := svc.logger.Named("rbac service wrapper") go func() { for { select { - case <-t.C: + case <-tck.C: lg.Info("tick") - case <-rex.C: + case <-rexTck.C: lg.Info("reindex") err := svc.updateWrapperIndex(ctx) @@ -1192,7 +1205,7 @@ func (svc *Service) watch(ctx context.Context) { lg.Error("reindex failed", zap.Error(err)) } - case <-tFlush.C: + 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)) From 7fb75c92cb5c257c7ca0a5b9531a4e4bc92fc746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Thu, 28 Nov 2024 16:36:29 +0100 Subject: [PATCH 08/22] Fix resource counter not respecting unlimited size option --- server/pkg/rbac/svc_counter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/pkg/rbac/svc_counter.go b/server/pkg/rbac/svc_counter.go index 20277fe1aa..b5e6581b10 100644 --- a/server/pkg/rbac/svc_counter.go +++ b/server/pkg/rbac/svc_counter.go @@ -130,7 +130,7 @@ func (svc *usageCounter[K]) bestPerformers(n int) (out []K) { for i := len(hh) - 1; i >= 0; i-- { out = append(out, hh[i].key) - if len(out) >= n { + if n > 0 && len(out) >= n { return } } From 69dbfa9f661c4bb1a3ff370218ae1aacf58382e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Fri, 29 Nov 2024 10:09:38 +0100 Subject: [PATCH 09/22] Adding tests --- server/pkg/rbac/svc_counter.go | 31 +----- server/pkg/rbac/svc_counter_test.go | 149 ++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 27 deletions(-) create mode 100644 server/pkg/rbac/svc_counter_test.go diff --git a/server/pkg/rbac/svc_counter.go b/server/pkg/rbac/svc_counter.go index b5e6581b10..66882b89a1 100644 --- a/server/pkg/rbac/svc_counter.go +++ b/server/pkg/rbac/svc_counter.go @@ -24,8 +24,10 @@ type ( // 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 @@ -128,37 +130,12 @@ func (svc *usageCounter[K]) bestPerformers(n int) (out []K) { sort.Sort(hh) for i := len(hh) - 1; i >= 0; i-- { - out = append(out, hh[i].key) - - if n > 0 && len(out) >= n { + if n >= 0 && len(out) >= n { return } - } - return -} -// worstPerformers returns the bottom n items based on their score -func (svc *usageCounter[K]) worstPerformers(n int) (out []K) { - svc.lock.RLock() - defer svc.lock.RUnlock() - - // Code to get n elements with the smallest count - - 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 _, x := range hh { - out = append(out, x.key) - - if len(out) >= n { - return - } + out = append(out, hh[i].key) } - return } 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) +} From 1e79cd1d463bae28fdec207b854e7a7335553f6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Fri, 29 Nov 2024 11:59:56 +0100 Subject: [PATCH 10/22] More tests --- server/pkg/rbac/rule_index.go | 55 +--------- server/pkg/rbac/rule_index_test.go | 170 ++++++----------------------- 2 files changed, 38 insertions(+), 187 deletions(-) diff --git a/server/pkg/rbac/rule_index.go b/server/pkg/rbac/rule_index.go index ce85da6a67..f0f1678d89 100644 --- a/server/pkg/rbac/rule_index.go +++ b/server/pkg/rbac/rule_index.go @@ -34,6 +34,7 @@ func buildRuleIndex(rules []*Rule) (index *ruleIndex) { return index } +// add adds a new Rule to the index func (index *ruleIndex) add(rules ...*Rule) { if index.children == nil { index.children = make(map[uint64]*ruleIndexNode, len(rules)/2) @@ -71,62 +72,12 @@ func (index *ruleIndex) add(rules ...*Rule) { } } -func (index *ruleIndex) remove(role uint64, resource string, ops ...string) { - if _, ok := index.children[role]; !ok { - return - } - - auxOps := ops - - if len(auxOps) == 0 { - for op := range index.children[role].children { - auxOps = append(auxOps, op) - } - } - - for _, op := range auxOps { - bits := append([]string{op}, strings.Split(resource, "/")...) - index.removeRec(index.children[role], bits) - - // Finishing touch cleanup - if len(index.children[role].children[op].children) == 0 { - delete(index.children[role].children, op) - } - if len(index.children[role].children) == 0 { - delete(index.children, role) - } - } -} - -func (index *ruleIndex) removeRec(n *ruleIndexNode, bits []string) { - // Recursive in; decrement counters - n.count-- - - if len(bits) == 0 { - return - } - - n = n.children[bits[0]] - index.removeRec(n, bits[1:]) - - // Recursive out; yoink out obsolete štuff - if len(bits) == 1 { - if len(n.children) > 0 { - n.children[bits[0]].isLeaf = false - n.children[bits[0]].rule = nil - } - return - } - - if n.children[bits[1]].count == 0 { - delete(n.children, bits[1]) - } -} - +// has checks if the rule is already in there func (t *ruleIndex) has(r *Rule) bool { return len(t.collect(true, r.RoleID, r.Operation, r.Resource)) > 0 } +// get returns the matching rules func (t *ruleIndex) get(role uint64, op, res string) (out []*Rule) { return t.collect(false, role, op, res) } diff --git a/server/pkg/rbac/rule_index_test.go b/server/pkg/rbac/rule_index_test.go index e063b737fa..f8640d9232 100644 --- a/server/pkg/rbac/rule_index_test.go +++ b/server/pkg/rbac/rule_index_test.go @@ -9,11 +9,10 @@ import ( func TestIndexBuild(t *testing.T) { tcc := []struct { - name string - in []*Rule - remove []*Rule - add []*Rule - out []int + name string + in []*Rule + add []*Rule + out []int role uint64 op string @@ -120,126 +119,6 @@ func TestIndexBuild(t *testing.T) { role: 1, op: "read", res: "a:b/c/d", - }, { - name: "removing the only element", - in: []*Rule{{ - RoleID: 1, - Resource: "a:b/c/d", - Operation: "write", - Access: Allow, - }}, - remove: []*Rule{{ - RoleID: 1, - Resource: "a:b/c/d", - Operation: "write", - Access: Allow, - }}, - out: nil, - - role: 1, - op: "write", - res: "a:b/c/d", - }, - { - name: "removing twice added thing", - in: []*Rule{{ - RoleID: 1, - Resource: "a:b/c/d", - Operation: "write", - Access: Allow, - }, { - RoleID: 1, - Resource: "a:b/c/d", - Operation: "write", - Access: Allow, - }}, - remove: []*Rule{{ - RoleID: 1, - Resource: "a:b/c/d", - Operation: "write", - Access: Allow, - }}, - - out: nil, - - role: 1, - op: "write", - res: "a:b/c/d", - }, - { - name: "two elements with no common root", - in: []*Rule{{ - RoleID: 1, - Resource: "a:b/c/d", - Operation: "write", - Access: Allow, - }, { - RoleID: 2, - Resource: "a:b/c/d", - Operation: "write", - Access: Allow, - }}, - remove: []*Rule{{ - RoleID: 1, - Resource: "a:b/c/d", - Operation: "write", - Access: Allow, - }}, - out: nil, - - role: 1, - op: "write", - res: "a:b/c/d", - }, - { - name: "two elements with common root (get removed)", - in: []*Rule{{ - RoleID: 1, - Resource: "a:b/c/d", - Operation: "write", - Access: Allow, - }, { - RoleID: 1, - Resource: "a:b/c/e", - Operation: "write", - Access: Allow, - }}, - remove: []*Rule{{ - RoleID: 1, - Resource: "a:b/c/d", - Operation: "write", - Access: Allow, - }}, - out: nil, - - role: 1, - op: "write", - res: "a:b/c/d", - }, - { - name: "two elements with common root (get not removed)", - in: []*Rule{{ - RoleID: 1, - Resource: "a:b/c/d", - Operation: "write", - Access: Allow, - }, { - RoleID: 1, - Resource: "a:b/c/e", - Operation: "write", - Access: Allow, - }}, - remove: []*Rule{{ - RoleID: 1, - Resource: "a:b/c/d", - Operation: "write", - Access: Allow, - }}, - out: []int{1}, - - role: 1, - op: "write", - res: "a:b/c/e", }, { name: "add new element", @@ -266,13 +145,12 @@ func TestIndexBuild(t *testing.T) { for _, tc := range tcc { t.Run(tc.name, func(t *testing.T) { ix := buildRuleIndex(tc.in) - ix.remove(tc.remove...) ix.add(tc.add...) out := RuleSet(ix.get(tc.role, tc.op, tc.res)) sort.Sort(out) - want := RuleSet(graby(append(tc.in, tc.add...), tc.out)) + want := RuleSet(grabIndexMatches(append(tc.in, tc.add...), tc.out)) sort.Sort(want) require.Len(t, out, len(want)) @@ -281,10 +159,32 @@ func TestIndexBuild(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: 2, + Resource: "a:b/c/x", + 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 { @@ -294,14 +194,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 26077 43467 ns/op 94785 B/op 1119 allocs/op +// BenchmarkIndexBuild_1000-12 2316 505664 ns/op 939447 B/op 10219 allocs/op +// BenchmarkIndexBuild_10000-12 228 5301265 ns/op 9008425 B/op 98033 allocs/op +// BenchmarkIndexBuild_100000-12 19 68454059 ns/op 70832448 B/op 843270 allocs/op func benchmarkIndexBuild(b *testing.B, rules []*Rule) { b.ResetTimer() From 2cc865abd66c49ea2c200740f6550dcc932954d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Sat, 30 Nov 2024 11:50:26 +0100 Subject: [PATCH 11/22] Tweak svc, add tests --- server/pkg/rbac/rule_index.go | 4 + server/pkg/rbac/service.go | 67 ++++++---- server/pkg/rbac/wrapper_test.go | 213 ++++++++++++++++++++++++++++++++ 3 files changed, 263 insertions(+), 21 deletions(-) create mode 100644 server/pkg/rbac/wrapper_test.go diff --git a/server/pkg/rbac/rule_index.go b/server/pkg/rbac/rule_index.go index f0f1678d89..e2f00fd7c4 100644 --- a/server/pkg/rbac/rule_index.go +++ b/server/pkg/rbac/rule_index.go @@ -99,6 +99,10 @@ func (t *ruleIndex) collect(exact bool, role uint64, op, res string) (out []*Rul // An edge case implied by the test suite if op == "" && res == "" { + if t.children[role].children[""] == nil || t.children[role].children[""].children[""] == nil { + return + } + out = append(out, t.children[role].children[""].children[""].rule) return } diff --git a/server/pkg/rbac/service.go b/server/pkg/rbac/service.go index a4dc96ff86..95944a80f6 100644 --- a/server/pkg/rbac/service.go +++ b/server/pkg/rbac/service.go @@ -178,9 +178,28 @@ func NoopSvc(access Access) (svc *Service) { // 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(l, cc) + cc = defaultWrapperConfig(cc) - usageCounter := &usageCounter[string]{ + 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 + } + + svc.index, err = svc.loadIndex(ctx) + if err != nil { + return + } + + return +} + +func initUsageCounter(ctx context.Context, cc Config) (svc *usageCounter[string]) { + svc = &usageCounter[string]{ incChan: make(chan string, 1024), decayFactor: cc.DecayFactor, @@ -192,42 +211,41 @@ func NewService(ctx context.Context, l *zap.Logger, store rbacRulesStore, cc Con }, } - sl := &statsLogger{ + 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), timingChan: 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, - logger: l, - usageCounter: usageCounter, + usageCounter: uc, RuleStorage: cc.RuleStorage, RoleStorage: cc.RoleStorage, } - svc.roles, err = svc.loadRoles(ctx) - if err != nil { - return - } - - svc.index, err = svc.loadIndex(ctx, store, svc.roles) - if err != nil { - return - } - - usageCounter.watch(ctx) svc.watch(ctx) - sl.watch(ctx) return } -func defaultWrapperConfig(l *zap.Logger, base Config) (out Config) { +func defaultWrapperConfig(base Config) (out Config) { out = base // -1 disables partitioning so everything is pulled in memory @@ -830,9 +848,16 @@ func (svc *Service) getMatchingRule(st evaluationState, kind roleKind, role uint // 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.index.index.empty() { + return indexed, roles, nil + } + unindexed[CommonRole] = make(map[uint64]bool) indexed[CommonRole] = make(map[uint64]bool) @@ -958,7 +983,7 @@ func (svc *Service) updateWrapperIndexMemFirst(ctx context.Context) (err error) return } - svc.swapIndexes(ctx, auxIndex) + svc.swapIndexes(auxIndex) return } @@ -1000,7 +1025,7 @@ func (svc *Service) indexForResources(ctx context.Context, res ...string) (index return } -func (svc *Service) loadIndex(ctx context.Context, s rbacRulesStore, allRoles []*Role) (out *wrapperIndex, err error) { +func (svc *Service) loadIndex(ctx context.Context) (out *wrapperIndex, err error) { // How do we figure out what resources we have? // do we just start from empty? @@ -1029,7 +1054,7 @@ func (svc *Service) buildNewIndex(ctx context.Context) (index *wrapperIndex, err return svc.indexForResources(ctx, res...) } -func (svc *Service) swapIndexes(ctx context.Context, auxIndex *wrapperIndex) { +func (svc *Service) swapIndexes(auxIndex *wrapperIndex) { if auxIndex == nil { return } diff --git a/server/pkg/rbac/wrapper_test.go b/server/pkg/rbac/wrapper_test.go new file mode 100644 index 0000000000..68ac17f290 --- /dev/null +++ b/server/pkg/rbac/wrapper_test.go @@ -0,0 +1,213 @@ +package rbac + +import ( + "context" + "testing" + + "github.com/cortezaproject/corteza/server/system/types" + "github.com/stretchr/testify/require" +) + +func TestRoleSegmentation(t *testing.T) { + req := require.New(t) + + wx := &wrapperIndex{} + w := Service{ + index: wx, + } + + rl1 := uint64(1001) + rl2 := uint64(2001) + res1 := "abc/1/2/3" + res2 := "def/1/2/3" + + wx.add(rl1, res1, &Rule{ + RoleID: rl1, + Resource: res1, + Operation: "read", + Access: Allow, + }) + + rls := partRoles{} + rls[CommonRole] = map[uint64]bool{ + rl1: true, + rl2: true, + } + + indexed, unindexed, err := w.segmentRoles(rls, res1) + req.NoError(err) + + req.True(indexed[CommonRole][rl1]) + req.False(indexed[CommonRole][rl2]) + + req.True(unindexed[CommonRole][rl2]) + req.False(unindexed[CommonRole][rl1]) + + // + // + + indexed, unindexed, err = w.segmentRoles(rls, res2) + req.NoError(err) + + req.False(indexed[CommonRole][rl1]) + req.False(indexed[CommonRole][rl2]) + + req.True(unindexed[CommonRole][rl1]) + req.True(unindexed[CommonRole][rl2]) +} + +func TestRoleSegmentationEmpty(t *testing.T) { + req := require.New(t) + + wx := &wrapperIndex{} + w := Service{ + index: wx, + } + + rl1 := uint64(1001) + rl2 := uint64(2001) + res1 := "abc/1/2/3" + + rls := partRoles{} + rls[CommonRole] = map[uint64]bool{ + rl1: true, + rl2: true, + } + + _, unindexed, err := w.segmentRoles(rls, res1) + req.NoError(err) + + req.True(unindexed[CommonRole][rl1]) + req.True(unindexed[CommonRole][rl2]) +} + +type ( + tRuleStore struct { + searches []RuleFilter + } +) + +func TestPullRules(t *testing.T) { + req := require.New(t) + ruleS := &tRuleStore{} + ctx := context.Background() + + wx := &Service{ + RuleStorage: ruleS, + } + + wx.pullRules(ctx, 1, "res/1/2/3") + req.Len(ruleS.searches, 1) + req.Equal([]string{"res/1/2/3", "res/1/2/*", "res/1/*/*", "res/*/*/*"}, ruleS.searches[0].Resource) + req.Equal(uint64(1), ruleS.searches[0].RoleID) + + wx.pullRules(ctx, 1, "res/1") + req.Len(ruleS.searches, 2) + req.Equal([]string{"res/1", "res/*"}, ruleS.searches[1].Resource) + req.Equal(uint64(1), ruleS.searches[1].RoleID) + + wx.pullRules(ctx, 1, "res") + req.Len(ruleS.searches, 3) + req.Equal([]string{"res"}, ruleS.searches[2].Resource) + req.Equal(uint64(1), ruleS.searches[2].RoleID) +} + +func TestCombiningSources(t *testing.T) { + req := require.New(t) + wx := &Service{ + index: &wrapperIndex{}, + } + + wx.index.add(1, "res/1/2/3", &Rule{ + RoleID: 1, + Resource: "res/1/2/3", + Operation: "read", + Access: Inherit, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/*", + Operation: "read", + Access: Allow, + }, &Rule{ + RoleID: 2, + Resource: "res/1/2/3", + Operation: "read", + Access: Deny, + }) + + stt := evaluationState{ + res: "res/1/2/3", + op: "read", + + unindexedRoles: partRoles{CommonRole: map[uint64]bool{3: true}}, + indexedRoles: partRoles{CommonRole: map[uint64]bool{1: true}}, + unindexedRules: [5]map[uint64][]*Rule{CommonRole: { + 3: {{ + RoleID: 3, + Resource: "res/1/2/3", + Operation: "read", + Access: Inherit, + }, { + RoleID: 3, + Resource: "res/1/2/*", + Operation: "read", + Access: Deny, + }}, + }}, + } + + auxRule := wx.getMatchingRule(stt, CommonRole, 1) + req.Equal("res/1/2/*", auxRule.Resource) + req.Equal(Allow, auxRule.Access) + + auxRule = wx.getMatchingRule(stt, CommonRole, 3) + req.Equal("res/1/2/*", auxRule.Resource) + req.Equal(Deny, auxRule.Access) + + wx.index.add(3, "res/1/2/3", &Rule{ + RoleID: 3, + Resource: "res/1/2/3", + Operation: "read", + Access: Inherit, + }) + stt = evaluationState{ + res: "res/1/2/3", + op: "read", + + unindexedRoles: partRoles{CommonRole: map[uint64]bool{3: true}}, + indexedRoles: partRoles{CommonRole: map[uint64]bool{1: true}}, + unindexedRules: [5]map[uint64][]*Rule{CommonRole: { + 3: {{ + RoleID: 3, + Resource: "res/1/2/*", + Operation: "read", + Access: Deny, + }}, + }}, + } + + auxRule = wx.getMatchingRule(stt, CommonRole, 3) + req.Equal("res/1/2/*", auxRule.Resource) + req.Equal(Deny, auxRule.Access) +} + +func (tt *tRuleStore) SearchRbacRules(ctx context.Context, f RuleFilter) (rs RuleSet, rf RuleFilter, err error) { + tt.searches = append(tt.searches, f) + return +} + +func (tt *tRuleStore) UpsertRbacRule(ctx context.Context, rr ...*Rule) (err error) { + return +} + +func (tt *tRuleStore) DeleteRbacRule(ctx context.Context, rr ...*Rule) (err error) { + return +} + +func (tt *tRuleStore) TruncateRbacRules(ctx context.Context) (err error) { + return +} + +func (tt *tRuleStore) SearchRoles(ctx context.Context, f types.RoleFilter) (rs types.RoleSet, rf types.RoleFilter, err error) { + return +} From ea0f1eac47fe8241756fa30062632ec763b14dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Sun, 1 Dec 2024 21:21:55 +0100 Subject: [PATCH 12/22] Refactoring tests --- server/compose/service/chart_test.go | 2 +- server/compose/service/module_test.go | 21 +- server/compose/service/page_test.go | 2 +- server/compose/service/record.go | 2 + server/compose/service/record_test.go | 101 ++++---- server/pkg/rbac/service.go | 26 +- server/pkg/rbac/stats.go | 20 +- server/system/service/user_test.go | 13 +- server/tests/automation/permissions_test.go | 18 +- server/tests/compose/permissions_test.go | 18 +- server/tests/rbac/main_test.go | 223 ++++++++++++++++++ server/tests/rbac/rbac_rules_test.go | 151 ++++++++++++ server/tests/system/permissions_test.go | 61 +++-- server/tests/workflows/0002_rbac_fn_test.go | 8 +- .../tests/workflows/exec_permissions_test.go | 3 - .../invoker_and_runner_in_scope_test.go | 3 - 16 files changed, 543 insertions(+), 129 deletions(-) create mode 100644 server/tests/rbac/main_test.go create mode 100644 server/tests/rbac/rbac_rules_test.go diff --git a/server/compose/service/chart_test.go b/server/compose/service/chart_test.go index 736dfc29c4..ed90bf3079 100644 --- a/server/compose/service/chart_test.go +++ b/server/compose/service/chart_test.go @@ -48,7 +48,7 @@ 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)}, } 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..f0ed07b08d 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,18 @@ 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, + }) + require.NoError(t, err) + svc.ac = &accessControl{rbac: rc} } resourceMaker(ctx, t, svc.store, mods...) @@ -122,7 +129,7 @@ func TestModules(t *testing.T) { svc := makeTestModuleService(t, ns, - &rbac.ServiceAllowAll{}, + rbac.NoopSvc(rbac.Allow), ) res, err := svc.Create(ctx, &types.Module{Name: "My first module", NamespaceID: ns.ID}) @@ -167,7 +174,7 @@ func TestModule_LabelSearch(t *testing.T) { req = require.New(t) svc = makeTestModuleService(t, ns, - &rbac.ServiceAllowAll{}, + rbac.NoopSvc(rbac.Allow), ) ctx = context.Background() @@ -239,7 +246,7 @@ func TestModule_LabelCRUD(t *testing.T) { req = require.New(t) svc = makeTestModuleService(t, ns, - &rbac.ServiceAllowAll{}, + rbac.NoopSvc(rbac.Allow), ) findAndReturnLabel = func(id uint64) map[string]string { diff --git a/server/compose/service/page_test.go b/server/compose/service/page_test.go index 73ca659a82..b0485eaf7e 100644 --- a/server/compose/service/page_test.go +++ b/server/compose/service/page_test.go @@ -50,7 +50,7 @@ func TestPageDeleting(t *testing.T) { svc = &page{ store: s, - ac: &accessControl{rbac: &rbac.ServiceAllowAll{}}, + ac: &accessControl{rbac: rbac.NoopSvc(rbac.Allow)}, 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..0dd046134c 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,19 @@ 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, + }) + require.NoError(t, err) + svc.rbacSvc = rc + svc.ac = &accessControl{rbac: rc} } resourceMaker(ctx, t, svc.store, mods...) @@ -264,14 +272,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 +296,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 +384,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 +394,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 +435,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 +521,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 +548,6 @@ func TestRecord_refAccessControl(t *testing.T) { testerRole = &sysTypes.Role{Name: "tester", ID: nextID()} svc = makeTestRecordService(t, - rbacService, user, ns, mod1, @@ -581,7 +570,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 +585,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 +593,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 +613,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 +635,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 +653,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 +668,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 +682,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 +716,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 +732,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 +752,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 +774,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 +857,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 +875,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 +910,13 @@ 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, + }) ac = &accessControl{rbac: rbacService} invoker = &sysTypes.User{ID: 1001} diff --git a/server/pkg/rbac/service.go b/server/pkg/rbac/service.go index 95944a80f6..5757a9ca72 100644 --- a/server/pkg/rbac/service.go +++ b/server/pkg/rbac/service.go @@ -19,7 +19,7 @@ type ( mux sync.RWMutex cfg Config logger *zap.Logger - StatLogger *statsLogger + StatLogger *StatsLogger noop bool noopAccess Access @@ -198,6 +198,15 @@ func NewService(ctx context.Context, l *zap.Logger, store rbacRulesStore, cc Con return } +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 +} + func initUsageCounter(ctx context.Context, cc Config) (svc *usageCounter[string]) { svc = &usageCounter[string]{ incChan: make(chan string, 1024), @@ -215,8 +224,8 @@ func initUsageCounter(ctx context.Context, cc Config) (svc *usageCounter[string] return } -func initStatsLogger(ctx context.Context, l *zap.Logger) (svc *statsLogger) { - svc = &statsLogger{ +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), @@ -227,7 +236,7 @@ func initStatsLogger(ctx context.Context, l *zap.Logger) (svc *statsLogger) { return } -func initSvc(ctx context.Context, l *zap.Logger, cc Config, sl *statsLogger, uc *usageCounter[string]) (svc *Service) { +func initSvc(ctx context.Context, l *zap.Logger, cc Config, sl *StatsLogger, uc *usageCounter[string]) (svc *Service) { svc = &Service{ logger: l, @@ -411,6 +420,7 @@ func (svc *Service) Stats() (out Stats, err error) { out.CacheHits, out.CacheMisses, + out.CacheUpdates, out.AvgTiming, out.MinTiming, out.MaxTiming, @@ -423,14 +433,6 @@ func (svc *Service) Stats() (out Stats, err error) { return } -// AddRole adds an additional role after the service was initialized -func (svc *Service) AddRole(r *Role) { - svc.mux.Lock() - defer svc.mux.Unlock() - - svc.roles = append(svc.roles, r) -} - func (svc *Service) UpdateRoles(rr ...*Role) { svc.mux.Lock() defer svc.mux.Unlock() diff --git a/server/pkg/rbac/stats.go b/server/pkg/rbac/stats.go index 6ee6cbc9f8..73a4897207 100644 --- a/server/pkg/rbac/stats.go +++ b/server/pkg/rbac/stats.go @@ -3,6 +3,7 @@ package rbac import ( "context" "fmt" + "sort" "sync" "time" @@ -11,7 +12,7 @@ import ( ) type ( - statsLogger struct { + StatsLogger struct { lock sync.RWMutex log *zap.Logger @@ -44,12 +45,13 @@ type ( ) // Stats returns the tracked stats -func (l *statsLogger) Stats() (cacheHit uint, cacheMiss uint, avgTiming, minTiming, maxTiming time.Duration, lastHits []string, lastMisses []string, lastTimings []time.Duration) { +func (l *StatsLogger) Stats() (cacheHit uint, cacheMiss uint, cacheUpdates uint, avgTiming, minTiming, maxTiming time.Duration, lastHits []string, lastMisses []string, lastTimings []time.Duration) { l.lock.RLock() defer l.lock.RUnlock() return l.cacheHits, l.cacheMisses, + l.cacheUpdates, l.avgTiming, l.minTiming, l.maxTiming, @@ -59,7 +61,7 @@ func (l *statsLogger) Stats() (cacheHit uint, cacheMiss uint, avgTiming, minTimi } // Timing logs the giving duration -func (l *statsLogger) Timing(timing time.Duration) { +func (l *StatsLogger) Timing(timing time.Duration) { l.lock.Lock() defer l.lock.Unlock() @@ -96,7 +98,7 @@ func (l *statsLogger) Timing(timing time.Duration) { } } -func (l *statsLogger) CacheHit(roles []uint64, resource string, op string) { +func (l *StatsLogger) CacheHit(roles []uint64, resource string, op string) { l.lock.Lock() defer l.lock.Unlock() @@ -109,7 +111,7 @@ func (l *statsLogger) CacheHit(roles []uint64, resource string, op string) { l.lastHits.Add(l.strfEntry(roles, resource, op)) } -func (l *statsLogger) CacheMiss(roles []uint64, resource string, op string) { +func (l *StatsLogger) CacheMiss(roles []uint64, resource string, op string) { l.lock.Lock() defer l.lock.Unlock() @@ -122,7 +124,7 @@ func (l *statsLogger) CacheMiss(roles []uint64, resource string, op string) { l.lastMisses.Add(l.strfEntry(roles, resource, op)) } -func (l *statsLogger) CacheUpdate(in *Rule) { +func (l *StatsLogger) CacheUpdate(in *Rule) { l.lock.Lock() defer l.lock.Unlock() @@ -134,11 +136,13 @@ func (l *statsLogger) CacheUpdate(in *Rule) { // // // // // // // // // // // // // // // // // // // // // // // // // // Utils -func (l *statsLogger) strfEntry(roles []uint64, resource string, op string) string { +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) { +func (l *StatsLogger) watch(ctx context.Context) { t := time.NewTicker(time.Minute * 5) go func() { diff --git a/server/system/service/user_test.go b/server/system/service/user_test.go index 05b906a293..01f6823011 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,16 @@ 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, + }) + ) + 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/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/rbac/main_test.go b/server/tests/rbac/main_test.go new file mode 100644 index 0000000000..94870ee071 --- /dev/null +++ b/server/tests/rbac/main_test.go @@ -0,0 +1,223 @@ +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, + PullInitialState: intst, + 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..1bc14b6bfc --- /dev/null +++ b/server/tests/rbac/rbac_rules_test.go @@ -0,0 +1,151 @@ +package rbac + +import ( + "testing" + + "github.com/cortezaproject/corteza/server/pkg/rbac" +) + +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 TestCheck(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}}) + }) +} 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) From 08c8f29dcaac4c017b3225ded811eaf7d3f586df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Tue, 3 Dec 2024 10:42:53 +0100 Subject: [PATCH 13/22] Fix tests --- server/app/boot_levels.go | 5 ++++- server/compose/service/chart_test.go | 5 ++++- server/compose/service/module_test.go | 8 ++++--- server/compose/service/page_test.go | 7 ++++-- server/compose/service/record_test.go | 8 +++++-- server/pkg/rbac/service.go | 32 ++++++++++++++++++--------- server/pkg/rbac/svc_index.go | 4 ++++ server/system/service/user_test.go | 2 ++ server/tests/compose/module_test.go | 2 +- server/tests/compose/record_test.go | 6 ++--- 10 files changed, 56 insertions(+), 23 deletions(-) diff --git a/server/app/boot_levels.go b/server/app/boot_levels.go index c581538340..b5f8889fd9 100644 --- a/server/app/boot_levels.go +++ b/server/app/boot_levels.go @@ -258,7 +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 - rbac.SetGlobal(rbac.NoopSvc(rbac.Allow)) + rbac.SetGlobal(rbac.NoopSvc(rbac.Allow, rbac.Config{ + RuleStorage: app.Store, + RoleStorage: app.Store, + })) defer rbac.SetGlobal(nil) } diff --git a/server/compose/service/chart_test.go b/server/compose/service/chart_test.go index ed90bf3079..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.NoopSvc(rbac.Allow)}, + 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 f0ed07b08d..0c3892b7e0 100644 --- a/server/compose/service/module_test.go +++ b/server/compose/service/module_test.go @@ -83,6 +83,8 @@ func makeTestModuleService(t *testing.T, mods ...any) *module { 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} @@ -129,7 +131,7 @@ func TestModules(t *testing.T) { svc := makeTestModuleService(t, ns, - rbac.NoopSvc(rbac.Allow), + rbac.NoopSvc(rbac.Allow, rbac.Config{}), ) res, err := svc.Create(ctx, &types.Module{Name: "My first module", NamespaceID: ns.ID}) @@ -174,7 +176,7 @@ func TestModule_LabelSearch(t *testing.T) { req = require.New(t) svc = makeTestModuleService(t, ns, - rbac.NoopSvc(rbac.Allow), + rbac.NoopSvc(rbac.Allow, rbac.Config{}), ) ctx = context.Background() @@ -246,7 +248,7 @@ func TestModule_LabelCRUD(t *testing.T) { req = require.New(t) svc = makeTestModuleService(t, ns, - rbac.NoopSvc(rbac.Allow), + rbac.NoopSvc(rbac.Allow, rbac.Config{}), ) findAndReturnLabel = func(id uint64) map[string]string { diff --git a/server/compose/service/page_test.go b/server/compose/service/page_test.go index b0485eaf7e..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.NoopSvc(rbac.Allow)}, + 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_test.go b/server/compose/service/record_test.go index 0dd046134c..11b3c0b322 100644 --- a/server/compose/service/record_test.go +++ b/server/compose/service/record_test.go @@ -88,6 +88,8 @@ func makeTestRecordService(t *testing.T, mods ...any) *record { 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 @@ -263,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 @@ -916,6 +918,8 @@ func TestSetRecordOwner(t *testing.T) { CleanupInterval: time.Hour * 2, ReindexInterval: time.Hour * 2, IndexFlushInterval: time.Hour * 2, + RuleStorage: s, + RoleStorage: s, }) ac = &accessControl{rbac: rbacService} diff --git a/server/pkg/rbac/service.go b/server/pkg/rbac/service.go index 5757a9ca72..3d6dd93ece 100644 --- a/server/pkg/rbac/service.go +++ b/server/pkg/rbac/service.go @@ -169,10 +169,16 @@ func SetGlobal(svc *Service) { } // NoopSvc creates a blank RBAC service which always returns the stated access -func NoopSvc(access Access) (svc *Service) { +func NoopSvc(access Access, cc Config) (svc *Service) { return &Service{ noop: true, noopAccess: access, + logger: zap.NewNop(), + + RuleStorage: cc.RuleStorage, + RoleStorage: cc.RoleStorage, + + cfg: cc, } } @@ -856,7 +862,7 @@ func (svc *Service) segmentRoles(roles partRoles, resource string) (indexed, uni unindexed = partRoles{} indexed = partRoles{} - if svc.index.index.empty() { + if svc.index == nil || svc.index.index == nil || svc.index.index.empty() { return indexed, roles, nil } @@ -949,9 +955,11 @@ func (svc *Service) incCounterSync(roles partRoles, res Resource) { } func (svc *Service) incCounterAsync(roles partRoles, res Resource) { - for _, rr := range roles { - for r := range rr { - svc.usageCounter.incChan <- fmt.Sprintf("%d:%s", r, res.RbacResource()) + 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()) + } } } } @@ -963,8 +971,10 @@ func (svc *Service) cleanupCounterSync(roles ...*Role) { } func (svc *Service) cleanupCounterAsync(roles ...*Role) { - for _, r := range roles { - svc.usageCounter.rmChan <- r.id + if svc.usageCounter != nil && svc.usageCounter.rmChan != nil { + for _, r := range roles { + svc.usageCounter.rmChan <- r.id + } } } @@ -1081,7 +1091,9 @@ func (svc *Service) logAccessSync(timing time.Duration) { } func (svc *Service) logAccessAsync(timing time.Duration) { - svc.StatLogger.timingChan <- timing + if svc.StatLogger != nil && svc.StatLogger.timingChan != nil { + svc.StatLogger.timingChan <- timing + } } func (svc *Service) logCachePerformance(hits, misses partRoles, resource, op string) { @@ -1124,7 +1136,7 @@ func (svc *Service) logCachePerformanceSync(hits, misses partRoles, 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 { @@ -1142,7 +1154,7 @@ func (svc *Service) logCachePerformanceAsync(hits, misses partRoles, resource, o } // Misses - { + if svc.StatLogger != nil && svc.StatLogger.cacheMissChan != nil { rls := make([]uint64, 0, 4) for _, rr := range misses { diff --git a/server/pkg/rbac/svc_index.go b/server/pkg/rbac/svc_index.go index e809db87fe..f702a42314 100644 --- a/server/pkg/rbac/svc_index.go +++ b/server/pkg/rbac/svc_index.go @@ -30,6 +30,10 @@ func (svc *wrapperIndex) add(role uint64, resource string, rules ...*Rule) { } func (svc *wrapperIndex) get(role uint64, op string, res string) (out []*Rule) { + if svc == nil { + return + } + svc.mux.RLock() defer svc.mux.RUnlock() diff --git a/server/system/service/user_test.go b/server/system/service/user_test.go index 01f6823011..0465eb058c 100644 --- a/server/system/service/user_test.go +++ b/server/system/service/user_test.go @@ -55,6 +55,8 @@ func TestUser_ProtectedSearch(t *testing.T) { CleanupInterval: time.Hour * 2, ReindexInterval: time.Hour * 2, IndexFlushInterval: time.Hour * 2, + RuleStorage: s, + RoleStorage: s, }) ) 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/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)). From ac57c68fcf655ac4a2c4cbaf41cb5f0109a1874f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Tue, 3 Dec 2024 13:44:48 +0100 Subject: [PATCH 14/22] Tweakup performance monitoring --- server/pkg/rbac/service.go | 105 +++++++++++++++++------------- server/pkg/rbac/stats.go | 128 +++++++++++++++++++++++++++---------- 2 files changed, 156 insertions(+), 77 deletions(-) diff --git a/server/pkg/rbac/service.go b/server/pkg/rbac/service.go index 3d6dd93ece..f1eeeb0b5d 100644 --- a/server/pkg/rbac/service.go +++ b/server/pkg/rbac/service.go @@ -102,18 +102,22 @@ type ( } Stats struct { - CacheHits uint `json:"cacheHits"` - CacheMisses uint `json:"cacheMisses"` - CacheUpdates uint `json:"cacheUpdates"` - AvgTiming time.Duration `json:"avgTiming"` - MinTiming time.Duration `json:"minTiming"` - MaxTiming time.Duration `json:"maxTiming"` + CacheHits uint `json:"cacheHits"` + CacheMisses uint `json:"cacheMisses"` + CacheUpdates uint `json:"cacheUpdates"` + AvgDbTiming time.Duration `json:"avgDbTiming"` + MinDbTiming time.Duration `json:"minDbTiming"` + MaxDbTiming time.Duration `json:"maxDbTiming"` + 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"` - LastTimings []time.Duration `json:"lastTimings"` + LastHits []string `json:"lastHits"` + LastMisses []string `json:"lastMisses"` + LastDbTimings []time.Duration `json:"lastDbTimings"` + LastIndexTimings []time.Duration `json:"lastIndexTimings"` Counters []expCtrItem `json:"counters"` } @@ -232,10 +236,11 @@ func initUsageCounter(ctx context.Context, cc Config) (svc *usageCounter[string] 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), - timingChan: make(chan time.Duration, 1024), + 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) @@ -427,12 +432,16 @@ func (svc *Service) Stats() (out Stats, err error) { out.CacheHits, out.CacheMisses, out.CacheUpdates, - out.AvgTiming, - out.MinTiming, - out.MaxTiming, + out.AvgDbTiming, + out.MinDbTiming, + out.MaxDbTiming, + out.AvgIndexTiming, + out.MinIndexTiming, + out.MaxIndexTiming, out.LastHits, out.LastMisses, - out.LastTimings = svc.StatLogger.Stats() + out.LastDbTimings, + out.LastIndexTimings = svc.StatLogger.Stats() out.IndexSize = svc.index.getSize() @@ -576,7 +585,7 @@ func (svc *Service) check(ctx context.Context, rolesByKind partRoles, op, res st return Inherit, err } - svc.logDbTiming(timing) + svc.logDatabaseTiming(timing) a, err = svc.evaluate( []roleKind{ContextRole, CommonRole, AuthenticatedRole, AnonymousRole}, @@ -833,7 +842,10 @@ func (svc *Service) getMatchingRule(st evaluationState, kind roleKind, role uint ) // Indexed + now := time.Now() aux = svc.index.get(role, st.op, st.res) + svc.logIndexTiming(time.Since(now)) + rules = append(rules, aux...) // Unindexed @@ -1078,21 +1090,39 @@ func (svc *Service) swapIndexes(auxIndex *wrapperIndex) { } // Performance monitoring -func (svc *Service) logDbTiming(timing time.Duration) { +func (svc *Service) logDatabaseTiming(timing time.Duration) { if svc.cfg.Synchronous { - svc.logAccessSync(timing) + svc.logDatabaseSync(timing) } else { - svc.logAccessAsync(timing) + svc.logDatabaseAsync(timing) } } -func (svc *Service) logAccessSync(timing time.Duration) { - svc.StatLogger.Timing(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) logAccessAsync(timing time.Duration) { - if svc.StatLogger != nil && svc.StatLogger.timingChan != nil { - svc.StatLogger.timingChan <- 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 } } @@ -1200,37 +1230,26 @@ func (svc *Service) DebuggerAddIndex(role uint64, resource string, rules ...*Rul func (svc *Service) watch(ctx context.Context) { tck := time.NewTicker(time.Minute * 5) - tInt := svc.cfg.IndexFlushInterval - if tInt == 0 { - tInt = time.Minute * 5 - } - tTck := time.NewTicker(tInt) - _ = tTck - flushInt := svc.cfg.IndexFlushInterval if flushInt == 0 { flushInt = time.Minute * 30 } flushTck := time.NewTicker(flushInt) - _ = flushTck rexInt := svc.cfg.ReindexInterval if rexInt == 0 { rexInt = time.Minute * 30 } rexTck := time.NewTicker(rexInt) - _ = rexTck - - defer func() { - tck.Stop() - tTck.Stop() - flushTck.Stop() - rexTck.Stop() - }() lg := svc.logger.Named("rbac service wrapper") - go func() { + defer func() { + tck.Stop() + flushTck.Stop() + rexTck.Stop() + }() + for { select { case <-tck.C: diff --git a/server/pkg/rbac/stats.go b/server/pkg/rbac/stats.go index 73a4897207..e4adce6e5f 100644 --- a/server/pkg/rbac/stats.go +++ b/server/pkg/rbac/stats.go @@ -17,23 +17,28 @@ type ( log *zap.Logger // Channels for async comms - cacheHitChan chan statsWrap - cacheMissChan chan statsWrap - timingChan chan time.Duration + cacheHitChan chan statsWrap + cacheMissChan chan statsWrap + timingDatabaseChan chan time.Duration + timingIndexChan chan time.Duration // Counters - cacheHits uint - cacheMisses uint - cacheUpdates uint - avgTiming time.Duration - minTiming time.Duration - maxTiming time.Duration + 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] - lastTimings *slice.Circular[time.Duration] + lastHits *slice.Circular[string] + lastMisses *slice.Circular[string] + lastDatabaseTimings *slice.Circular[time.Duration] + lastIndexTimings *slice.Circular[time.Duration] } // statsWrap wraps the state to log @@ -45,56 +50,108 @@ type ( ) // Stats returns the tracked stats -func (l *StatsLogger) Stats() (cacheHit uint, cacheMiss uint, cacheUpdates uint, avgTiming, minTiming, maxTiming time.Duration, lastHits []string, lastMisses []string, lastTimings []time.Duration) { +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.avgTiming, - l.minTiming, - l.maxTiming, + l.avgDatabaseTiming, + l.minDatabaseTiming, + l.maxDatabaseTiming, + l.avgIndexTiming, + l.minIndexTiming, + l.maxIndexTiming, l.lastHits.Slice(), l.lastMisses.Slice(), - l.lastTimings.Slice() + l.lastDatabaseTimings.Slice(), + l.lastIndexTimings.Slice() } -// Timing logs the giving duration -func (l *StatsLogger) Timing(timing time.Duration) { +// TimingDatabase logs the giving duration +func (l *StatsLogger) TimingDatabase(timing time.Duration) { l.lock.Lock() defer l.lock.Unlock() - l.log.Info("record timing", zap.Duration("timing", timing)) + l.log.Info("record database timing", zap.Duration("timing", timing)) { - l.avgTiming = (l.avgTiming + timing) / 2 + l.avgDatabaseTiming = (l.avgDatabaseTiming + timing) / 2 } { - if l.minTiming == 0 { - l.minTiming = timing + if l.minDatabaseTiming == 0 { + l.minDatabaseTiming = timing } - if timing < l.minTiming { - l.minTiming = timing + if timing < l.minDatabaseTiming { + l.minDatabaseTiming = timing } } { - if l.maxTiming == 0 { - l.maxTiming = timing + if l.maxDatabaseTiming == 0 { + l.maxDatabaseTiming = timing } - if timing > l.maxTiming { - l.maxTiming = timing + if timing > l.maxDatabaseTiming { + l.maxDatabaseTiming = timing } } { - if l.lastTimings == nil { - l.lastTimings = slice.NewCircular[time.Duration](500) + if l.lastDatabaseTimings == nil { + l.lastDatabaseTimings = slice.NewCircular[time.Duration](500) } - l.lastTimings.Add(timing) + 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](500) + } + + l.lastIndexTimings.Add(timing) } } @@ -157,8 +214,11 @@ func (l *StatsLogger) watch(ctx context.Context) { case rs := <-l.cacheHitChan: l.CacheHit(rs.roles, rs.resource, rs.op) - case tt := <-l.timingChan: - l.Timing(tt) + case tt := <-l.timingDatabaseChan: + l.TimingDatabase(tt) + + case tt := <-l.timingIndexChan: + l.TimingIndex(tt) case <-ctx.Done(): l.log.Info("terminating watcher") From c3ef02903c5fd52f52d32a5b319b0980c575a577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Tue, 3 Dec 2024 13:45:02 +0100 Subject: [PATCH 15/22] Add extra tests --- server/tests/rbac/rbac_rules_test.go | 65 +++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/server/tests/rbac/rbac_rules_test.go b/server/tests/rbac/rbac_rules_test.go index 1bc14b6bfc..7ffcaff3f5 100644 --- a/server/tests/rbac/rbac_rules_test.go +++ b/server/tests/rbac/rbac_rules_test.go @@ -1,9 +1,11 @@ package rbac import ( + "context" "testing" "github.com/cortezaproject/corteza/server/pkg/rbac" + "github.com/stretchr/testify/require" ) func TestGrant(t *testing.T) { @@ -53,7 +55,7 @@ func TestGrant(t *testing.T) { }) } -func TestCheck(t *testing.T) { +func TestCan(t *testing.T) { t.Run("completely empty index", func(t *testing.T) { ctx, req, @@ -149,3 +151,64 @@ func TestCheck(t *testing.T) { 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) + }) +} From ee1e5e6983b10d34d58fc069a2e017684dcbf697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Tue, 3 Dec 2024 13:52:26 +0100 Subject: [PATCH 16/22] Tweak stats logging limits --- server/pkg/rbac/stats.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/pkg/rbac/stats.go b/server/pkg/rbac/stats.go index e4adce6e5f..6394cc2225 100644 --- a/server/pkg/rbac/stats.go +++ b/server/pkg/rbac/stats.go @@ -49,6 +49,10 @@ type ( } ) +const ( + maxLastLogs = 500 +) + // Stats returns the tracked stats func (l *StatsLogger) Stats() ( cacheHit uint, @@ -110,7 +114,7 @@ func (l *StatsLogger) TimingDatabase(timing time.Duration) { { if l.lastDatabaseTimings == nil { - l.lastDatabaseTimings = slice.NewCircular[time.Duration](500) + l.lastDatabaseTimings = slice.NewCircular[time.Duration](maxLastLogs) } l.lastDatabaseTimings.Add(timing) @@ -148,7 +152,7 @@ func (l *StatsLogger) TimingIndex(timing time.Duration) { { if l.lastIndexTimings == nil { - l.lastIndexTimings = slice.NewCircular[time.Duration](500) + l.lastIndexTimings = slice.NewCircular[time.Duration](maxLastLogs) } l.lastIndexTimings.Add(timing) @@ -163,7 +167,7 @@ func (l *StatsLogger) CacheHit(roles []uint64, resource string, op string) { l.cacheHits++ if l.lastHits == nil { - l.lastHits = slice.NewCircular[string](10000) + l.lastHits = slice.NewCircular[string](maxLastLogs) } l.lastHits.Add(l.strfEntry(roles, resource, op)) } @@ -176,7 +180,7 @@ func (l *StatsLogger) CacheMiss(roles []uint64, resource string, op string) { l.cacheMisses++ if l.lastMisses == nil { - l.lastMisses = slice.NewCircular[string](10000) + l.lastMisses = slice.NewCircular[string](maxLastLogs) } l.lastMisses.Add(l.strfEntry(roles, resource, op)) } From 6c88cef5b258a924b7b19f8fe19c7775048d8b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Tue, 3 Dec 2024 14:13:30 +0100 Subject: [PATCH 17/22] Tweak some namings --- server/pkg/rbac/service.go | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/server/pkg/rbac/service.go b/server/pkg/rbac/service.go index f1eeeb0b5d..2336b98f17 100644 --- a/server/pkg/rbac/service.go +++ b/server/pkg/rbac/service.go @@ -102,22 +102,22 @@ type ( } Stats struct { - CacheHits uint `json:"cacheHits"` - CacheMisses uint `json:"cacheMisses"` - CacheUpdates uint `json:"cacheUpdates"` - AvgDbTiming time.Duration `json:"avgDbTiming"` - MinDbTiming time.Duration `json:"minDbTiming"` - MaxDbTiming time.Duration `json:"maxDbTiming"` - AvgIndexTiming time.Duration `json:"avgIndexTiming"` - MinIndexTiming time.Duration `json:"minIndexTiming"` - MaxIndexTiming time.Duration `json:"maxIndexTiming"` + 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"` - LastDbTimings []time.Duration `json:"lastDbTimings"` - LastIndexTimings []time.Duration `json:"lastIndexTimings"` + LastHits []string `json:"lastHits"` + LastMisses []string `json:"lastMisses"` + LastDatabaseTimings []time.Duration `json:"lastDatabaseTimings"` + LastIndexTimings []time.Duration `json:"lastIndexTimings"` Counters []expCtrItem `json:"counters"` } @@ -432,15 +432,15 @@ func (svc *Service) Stats() (out Stats, err error) { out.CacheHits, out.CacheMisses, out.CacheUpdates, - out.AvgDbTiming, - out.MinDbTiming, - out.MaxDbTiming, + out.AvgDatabaseTiming, + out.MinDatabaseTiming, + out.MaxDatabaseTiming, out.AvgIndexTiming, out.MinIndexTiming, out.MaxIndexTiming, out.LastHits, out.LastMisses, - out.LastDbTimings, + out.LastDatabaseTimings, out.LastIndexTimings = svc.StatLogger.Stats() out.IndexSize = svc.index.getSize() From 5f116884476932fa5499fcc0eb1dfd9de9b80623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Wed, 4 Dec 2024 11:15:05 +0100 Subject: [PATCH 18/22] Fix tests --- server/pkg/rbac/service_test.go | 356 ++++++++++++------------ server/pkg/rbac/wrapper_counter_test.go | 57 ---- server/tests/rbac/main_test.go | 9 +- 3 files changed, 181 insertions(+), 241 deletions(-) delete mode 100644 server/pkg/rbac/wrapper_counter_test.go diff --git a/server/pkg/rbac/service_test.go b/server/pkg/rbac/service_test.go index 2f5bb9455f..9338af41d8 100644 --- a/server/pkg/rbac/service_test.go +++ b/server/pkg/rbac/service_test.go @@ -5,10 +5,8 @@ import ( "fmt" "math" "math/rand" - "testing" "github.com/cortezaproject/corteza/server/pkg/expr" - "go.uber.org/zap" ) type ( @@ -30,183 +28,183 @@ type ( // 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), - }) -} +// 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), +// }) +// } func yankRandRoles(base []*Role) (out []uint64) { count := rand.Intn(len(base)) diff --git a/server/pkg/rbac/wrapper_counter_test.go b/server/pkg/rbac/wrapper_counter_test.go deleted file mode 100644 index 2a9f3bb5d0..0000000000 --- a/server/pkg/rbac/wrapper_counter_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package rbac - -import ( - "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) -} diff --git a/server/tests/rbac/main_test.go b/server/tests/rbac/main_test.go index 94870ee071..e0b3588191 100644 --- a/server/tests/rbac/main_test.go +++ b/server/tests/rbac/main_test.go @@ -134,11 +134,10 @@ func initState(t *testing.T, maxIndexSize int, things ...svcModFnc) (context.Con svc, err := rbac.NewService(ctx, zap.NewNop(), defaultStore, rbac.Config{ Synchronous: true, - MaxIndexSize: maxIndexSize, - PullInitialState: intst, - DecayFactor: 1, - DecayInterval: time.Hour * 4, - CleanupInterval: time.Hour * 4, + MaxIndexSize: maxIndexSize, + DecayFactor: 1, + DecayInterval: time.Hour * 4, + CleanupInterval: time.Hour * 4, RuleStorage: store, RoleStorage: store, From 88151ec58007cd785ee54235c1302a064f034887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Tue, 21 Jan 2025 15:51:27 +0100 Subject: [PATCH 19/22] Fix granting rules with wildcard resource --- server/pkg/rbac/service.go | 26 +-- server/pkg/rbac/svc_index.go | 67 ++++++- server/pkg/rbac/svc_index_test.go | 54 ++++++ server/pkg/rbac/utils.go | 19 ++ server/pkg/rbac/utils_test.go | 33 ++++ server/pkg/rbac/wrapper_test.go | 306 ++++++++++++++++++++++++++++++ 6 files changed, 480 insertions(+), 25 deletions(-) create mode 100644 server/pkg/rbac/svc_index_test.go create mode 100644 server/pkg/rbac/utils.go create mode 100644 server/pkg/rbac/utils_test.go diff --git a/server/pkg/rbac/service.go b/server/pkg/rbac/service.go index 2336b98f17..b460ec40a5 100644 --- a/server/pkg/rbac/service.go +++ b/server/pkg/rbac/service.go @@ -400,7 +400,11 @@ func (svc *Service) Grant(ctx context.Context, rules ...*Rule) (err error) { } // If it is, we need to assure this thing is inside the index now - svc.index.add(r.RoleID, r.Resource, r) + if !svc.index.add(r.RoleID, r.Resource, r) { + // Don't hit cache update in case nothing happened + continue + } + svc.StatLogger.CacheUpdate(r) } svc.mux.Unlock() @@ -710,9 +714,6 @@ func (svc *Service) flush(ctx context.Context, rules ...*Rule) (err error) { } func (svc *Service) pullUnindexed(ctx context.Context, unindexed partRoles, op, res string) (out [5]map[uint64][]*Rule, timing time.Duration, err error) { - resPerm := make([]string, 0, 8) - resPerm = append(resPerm, res) - // Get all the resource permissions // @todo get permissions for parent resources; this will probs be some lookup table now := time.Now() @@ -720,11 +721,7 @@ func (svc *Service) pullUnindexed(ctx context.Context, unindexed partRoles, op, timing = time.Since(now) }() - rr := strings.Split(res, "/") - for i := len(rr) - 1; i >= 0; i-- { - rr[i] = "*" - resPerm = append(resPerm, strings.Join(rr, "/")) - } + resPerm := permuteResource(res) for rk, rr := range unindexed { for r := range rr { @@ -763,16 +760,7 @@ func (svc *Service) pullForRole(ctx context.Context, roleID uint64) (out []*Rule } func (svc *Service) pullRules(ctx context.Context, role uint64, resource string) (rules []*Rule, err error) { - resPerm := make([]string, 0, 8) - resPerm = append(resPerm, resource) - - // Get all the resource permissions - // @todo get permissions for parent resources; this will probs be some lookup table - rr := strings.Split(resource, "/") - for i := len(rr) - 1; i > 0; i-- { - rr[i] = "*" - resPerm = append(resPerm, strings.Join(rr, "/")) - } + resPerm := permuteResource(resource) var aux RuleSet aux, _, err = svc.RuleStorage.SearchRbacRules(ctx, RuleFilter{ diff --git a/server/pkg/rbac/svc_index.go b/server/pkg/rbac/svc_index.go index f702a42314..160b0cb8a1 100644 --- a/server/pkg/rbac/svc_index.go +++ b/server/pkg/rbac/svc_index.go @@ -2,18 +2,21 @@ package rbac import ( "fmt" + "strings" "sync" ) type ( wrapperIndex struct { - mux sync.RWMutex - index *ruleIndex + mux sync.RWMutex + index *ruleIndex + + // indexed permits only max level identifiers indexed map[string]bool } ) -func (svc *wrapperIndex) add(role uint64, resource string, rules ...*Rule) { +func (svc *wrapperIndex) add(role uint64, resource string, rules ...*Rule) (added bool) { svc.mux.Lock() defer svc.mux.Unlock() @@ -25,8 +28,42 @@ func (svc *wrapperIndex) add(role uint64, resource string, rules ...*Rule) { svc.index = &ruleIndex{} } - svc.indexed[svc.mkkey(role, resource)] = true + // 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(role, resource, rules...) + } else { + return svc.addPlain(role, 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(role uint64, resource string, rules ...*Rule) (added bool) { + give := false + rKey := svc.makeKey(role, resource) + + for k := range svc.indexed { + give = give || strings.HasPrefix(k, rKey) + } + + if !give { + return false + } + svc.index.add(rules...) + return true +} + +func (svc *wrapperIndex) addPlain(role uint64, resource string, rules ...*Rule) (added bool) { + svc.indexed[svc.makeKey(role, resource)] = true + svc.index.add(rules...) + + return true } func (svc *wrapperIndex) get(role uint64, op string, res string) (out []*Rule) { @@ -59,7 +96,22 @@ 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(role uint64, 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 + } + svc.mux.RLock() defer svc.mux.RUnlock() @@ -67,9 +119,12 @@ func (svc *wrapperIndex) isIndexed(role uint64, resource string) (ok bool) { return false } - return svc.indexed[svc.mkkey(role, resource)] + return svc.indexed[svc.makeKey(role, resource)] } -func (svc *wrapperIndex) mkkey(role uint64, resource string) string { +func (svc *wrapperIndex) makeKey(role uint64, resource string) string { + pp := strings.SplitN(resource, "*", 2) + resource = strings.TrimRight(pp[0], "/") + return fmt.Sprintf("%d:%s", role, 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..a8c5509054 --- /dev/null +++ b/server/pkg/rbac/svc_index_test.go @@ -0,0 +1,54 @@ +package rbac + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMakeKey(t *testing.T) { + role := uint64(10) + tcc := []struct { + in string + out string + }{{ + in: "corteza::compose:module-field/*/*/*", + out: fmt.Sprintf("%d:corteza::compose:module-field", role), + }, { + in: "corteza::compose:module-field/1/*/*", + out: fmt.Sprintf("%d:corteza::compose:module-field/1", role), + }, { + in: "corteza::compose:module-field/1/2/*", + out: fmt.Sprintf("%d:corteza::compose:module-field/1/2", role), + }, { + in: "corteza::compose:module-field/1/2/3", + out: fmt.Sprintf("%d:corteza::compose:module-field/1/2/3", role), + }} + + wx := wrapperIndex{} + for _, tc := range tcc { + t.Run(tc.in, func(t *testing.T) { + wx.makeKey(role, tc.in) + }) + } +} + +func TestIndexing(t *testing.T) { + role := uint64(10) + req := require.New(t) + + svc := wrapperIndex{} + req.True(svc.add(role, "corteza::compose:module-field/1/2/3")) + req.True(svc.add(role, "corteza::compose:module-field/1/4/6")) + + req.True(svc.add(role, "corteza::compose:module-field/1/*/*")) + req.True(svc.add(role, "corteza::compose:module-field/1/4/*")) + + // False since no resource matches this wildcard + req.False(svc.add(role, "corteza::compose:module-field/1/5/*")) + req.False(svc.add(role, "corteza::compose:module-field/2/*/*")) + + // False since it's a completely different resource + req.False(svc.add(role, "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/rbac/wrapper_test.go b/server/pkg/rbac/wrapper_test.go index 68ac17f290..ab61939cea 100644 --- a/server/pkg/rbac/wrapper_test.go +++ b/server/pkg/rbac/wrapper_test.go @@ -112,6 +112,312 @@ func TestPullRules(t *testing.T) { req.Equal(uint64(1), ruleS.searches[2].RoleID) } +func TestGetMatchingRule_pureIndex(t *testing.T) { + req := require.New(t) + + stt := evaluationState{ + res: "res/1/2/3", + op: "read", + + unindexedRoles: partRoles{CommonRole: map[uint64]bool{}}, + indexedRoles: partRoles{CommonRole: map[uint64]bool{1: true}}, + unindexedRules: [5]map[uint64][]*Rule{}, + } + + t.Run("1", func(t *testing.T) { + wx := &Service{ + index: &wrapperIndex{}, + } + + wx.index.add(1, "res/1/2/3", &Rule{ + RoleID: 1, + Resource: "res/1/*/*", + Operation: "read", + Access: Deny, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/3", + Operation: "read", + Access: Inherit, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/*", + Operation: "read", + Access: Allow, + }) + + auxRule := wx.getMatchingRule(stt, CommonRole, 1) + req.Equal("res/1/2/*", auxRule.Resource) + req.Equal(Allow, auxRule.Access) + }) + + t.Run("2", func(t *testing.T) { + wx := &Service{ + index: &wrapperIndex{}, + } + + wx.index.add(1, "res/1/2/3", &Rule{ + RoleID: 1, + Resource: "res/1/*/*", + Operation: "read", + Access: Deny, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/3", + Operation: "read", + Access: Deny, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/*", + Operation: "read", + Access: Deny, + }) + + auxRule := wx.getMatchingRule(stt, CommonRole, 1) + req.Equal("res/1/2/3", auxRule.Resource) + req.Equal(Deny, auxRule.Access) + }) + + t.Run("3", func(t *testing.T) { + wx := &Service{ + index: &wrapperIndex{}, + } + + wx.index.add(1, "res/1/2/3", &Rule{ + RoleID: 1, + Resource: "res/1/*/*", + Operation: "read", + Access: Inherit, + }, &Rule{ + RoleID: 1, + Resource: "res/*/*/*", + Operation: "read", + Access: Inherit, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/3", + Operation: "read", + Access: Inherit, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/*", + Operation: "read", + Access: Inherit, + }) + + auxRule := wx.getMatchingRule(stt, CommonRole, 1) + req.Nil(auxRule) + }) +} + +func TestGetMatchingRule_pureStored(t *testing.T) { + req := require.New(t) + + stt := evaluationState{ + res: "res/1/2/3", + op: "read", + + unindexedRoles: partRoles{CommonRole: map[uint64]bool{1: true}}, + indexedRoles: partRoles{CommonRole: map[uint64]bool{}}, + unindexedRules: [5]map[uint64][]*Rule{}, + } + + t.Run("1", func(t *testing.T) { + wx := &Service{ + index: &wrapperIndex{}, + } + + stt.unindexedRules = [5]map[uint64][]*Rule{CommonRole: { + 1: {&Rule{ + RoleID: 1, + Resource: "res/1/*/*", + Operation: "read", + Access: Deny, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/3", + Operation: "read", + Access: Inherit, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/*", + Operation: "read", + Access: Allow, + }}, + }} + + auxRule := wx.getMatchingRule(stt, CommonRole, 1) + req.Equal("res/1/2/*", auxRule.Resource) + req.Equal(Allow, auxRule.Access) + }) + + t.Run("2", func(t *testing.T) { + wx := &Service{ + index: &wrapperIndex{}, + } + + stt.unindexedRules = [5]map[uint64][]*Rule{CommonRole: { + 1: {&Rule{ + RoleID: 1, + Resource: "res/1/*/*", + Operation: "read", + Access: Deny, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/3", + Operation: "read", + Access: Deny, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/*", + Operation: "read", + Access: Deny, + }}, + }} + + auxRule := wx.getMatchingRule(stt, CommonRole, 1) + req.Equal("res/1/2/3", auxRule.Resource) + req.Equal(Deny, auxRule.Access) + }) + + t.Run("3", func(t *testing.T) { + wx := &Service{ + index: &wrapperIndex{}, + } + + stt.unindexedRules = [5]map[uint64][]*Rule{CommonRole: { + 3: {&Rule{ + RoleID: 1, + Resource: "res/1/*/*", + Operation: "read", + Access: Inherit, + }, &Rule{ + RoleID: 1, + Resource: "res/*/*/*", + Operation: "read", + Access: Inherit, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/3", + Operation: "read", + Access: Inherit, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/*", + Operation: "read", + Access: Inherit, + }}, + }} + + auxRule := wx.getMatchingRule(stt, CommonRole, 1) + req.Nil(auxRule) + }) +} + +func TestGetMatchingRule_mixed(t *testing.T) { + req := require.New(t) + + stt := evaluationState{ + res: "res/1/2/3", + op: "read", + + unindexedRoles: partRoles{CommonRole: map[uint64]bool{1: true}}, + indexedRoles: partRoles{CommonRole: map[uint64]bool{}}, + unindexedRules: [5]map[uint64][]*Rule{}, + } + + t.Run("1", func(t *testing.T) { + wx := &Service{ + index: &wrapperIndex{}, + } + + stt.unindexedRules = [5]map[uint64][]*Rule{CommonRole: { + 1: {&Rule{ + RoleID: 1, + Resource: "res/1/*/*", + Operation: "read", + Access: Deny, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/3", + Operation: "read", + Access: Inherit, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/*", + Operation: "read", + Access: Allow, + }}, + }} + + auxRule := wx.getMatchingRule(stt, CommonRole, 1) + req.Equal("res/1/2/*", auxRule.Resource) + req.Equal(Allow, auxRule.Access) + }) + + t.Run("2", func(t *testing.T) { + wx := &Service{ + index: &wrapperIndex{}, + } + + stt.unindexedRules = [5]map[uint64][]*Rule{CommonRole: { + 1: {&Rule{ + RoleID: 1, + Resource: "res/1/*/*", + Operation: "read", + Access: Deny, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/3", + Operation: "read", + Access: Deny, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/*", + Operation: "read", + Access: Deny, + }}, + }} + + auxRule := wx.getMatchingRule(stt, CommonRole, 1) + req.Equal("res/1/2/3", auxRule.Resource) + req.Equal(Deny, auxRule.Access) + }) + + t.Run("3", func(t *testing.T) { + wx := &Service{ + index: &wrapperIndex{}, + } + + stt.unindexedRules = [5]map[uint64][]*Rule{CommonRole: { + 3: {&Rule{ + RoleID: 1, + Resource: "res/1/*/*", + Operation: "read", + Access: Inherit, + }, &Rule{ + RoleID: 1, + Resource: "res/*/*/*", + Operation: "read", + Access: Inherit, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/3", + Operation: "read", + Access: Inherit, + }, &Rule{ + RoleID: 1, + Resource: "res/1/2/*", + Operation: "read", + Access: Inherit, + }}, + }} + + auxRule := wx.getMatchingRule(stt, CommonRole, 1) + req.Nil(auxRule) + }) +} + func TestCombiningSources(t *testing.T) { req := require.New(t) wx := &Service{ From 743cdd87915bafea0179d54db2e686e7b68c7753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Wed, 22 Jan 2025 12:08:12 +0100 Subject: [PATCH 20/22] Fix rbac index not permitting rule updates --- server/pkg/rbac/rule_index.go | 5 ---- server/pkg/rbac/rule_index_test.go | 42 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/server/pkg/rbac/rule_index.go b/server/pkg/rbac/rule_index.go index e2f00fd7c4..5b68f6a688 100644 --- a/server/pkg/rbac/rule_index.go +++ b/server/pkg/rbac/rule_index.go @@ -41,11 +41,6 @@ func (index *ruleIndex) add(rules ...*Rule) { } for _, r := range rules { - // skip duplicates - if index.has(r) { - continue - } - if _, ok := index.children[r.RoleID]; !ok { index.children[r.RoleID] = &ruleIndexNode{ children: make(map[string]*ruleIndexNode, 4), diff --git a/server/pkg/rbac/rule_index_test.go b/server/pkg/rbac/rule_index_test.go index f8640d9232..af9d3eab76 100644 --- a/server/pkg/rbac/rule_index_test.go +++ b/server/pkg/rbac/rule_index_test.go @@ -161,6 +161,48 @@ func TestIndexBuild(t *testing.T) { } } +func TestIndexUpsert(t *testing.T) { + var ( + ix = &ruleIndex{} + req = require.New(t) + ) + + t.Run("add specific item", func(t *testing.T) { + ix.add(&Rule{ + RoleID: 1, + Resource: "corteza::compose:namespace/5", + Operation: "read", + Access: Allow, + }) + + req.Equal(Allow, ix.children[1].children["read"].children["corteza::compose:namespace"].children["5"].access) + }) + + t.Run("add wildcard item", func(t *testing.T) { + ix.add(&Rule{ + RoleID: 1, + Resource: "corteza::compose:namespace/*", + Operation: "read", + Access: Deny, + }) + + req.Equal(Allow, ix.children[1].children["read"].children["corteza::compose:namespace"].children["5"].access) + req.Equal(Deny, ix.children[1].children["read"].children["corteza::compose:namespace"].children["*"].access) + }) + + t.Run("add existing item (update)", func(t *testing.T) { + ix.add(&Rule{ + RoleID: 1, + Resource: "corteza::compose:namespace/*", + Operation: "read", + Access: Allow, + }) + + req.Equal(Allow, ix.children[1].children["read"].children["corteza::compose:namespace"].children["5"].access) + req.Equal(Allow, ix.children[1].children["read"].children["corteza::compose:namespace"].children["*"].access) + }) +} + func TestIndexHas(t *testing.T) { ix := buildRuleIndex([]*Rule{{ RoleID: 1, From 5132ef0b35855836c7e09788a0106c6e7d2fbf5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Thu, 27 Feb 2025 13:55:18 +0100 Subject: [PATCH 21/22] Change RBAC index alg --- server/app/resources.cue | 8 +- .../gocode/store/rdbms/filters.go.tpl | 8 + server/codegen/schema/resource.cue | 3 + server/codegen/server.store.cue | 2 + server/pkg/rbac/mocks_test.go | 87 +++ server/pkg/rbac/rule_index.go | 141 +--- server/pkg/rbac/rule_index_test.go | 149 ++-- server/pkg/rbac/ruleset_checks.go | 116 --- server/pkg/rbac/ruleset_checks_test.go | 238 ------ server/pkg/rbac/service.go | 332 ++++++-- server/pkg/rbac/service_test.go | 725 +++++++++++++++++- server/pkg/rbac/svc_index.go | 76 +- server/pkg/rbac/svc_index_test.go | 43 +- server/pkg/rbac/wrapper_test.go | 519 ------------- server/store/adapters/rdbms/filters.gen.go | 6 + server/store/adapters/rdbms/rdbms.gen.go | 12 + 16 files changed, 1194 insertions(+), 1271 deletions(-) create mode 100644 server/pkg/rbac/mocks_test.go delete mode 100644 server/pkg/rbac/ruleset_checks.go delete mode 100644 server/pkg/rbac/ruleset_checks_test.go delete mode 100644 server/pkg/rbac/wrapper_test.go diff --git a/server/app/resources.cue b/server/app/resources.cue index 001d945d29..5beef2cb8b 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: { @@ -59,6 +64,7 @@ resources: { [key=_]: {"handle": key, "component": "system", "platform": "cortez } byValue: ["resource", "operation", "role_id"] + rawFilter: true } store: { 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..e65babcc66 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)" 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/rule_index.go b/server/pkg/rbac/rule_index.go index 5b68f6a688..5116e56e86 100644 --- a/server/pkg/rbac/rule_index.go +++ b/server/pkg/rbac/rule_index.go @@ -2,26 +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 - - count int + // children map[uint64]*ruleIndexNode + bits map[string]map[string]*Rule } ) @@ -36,45 +23,27 @@ func buildRuleIndex(rules []*Rule) (index *ruleIndex) { // add adds a new Rule to the index func (index *ruleIndex) add(rules ...*Rule) { - if index.children == nil { - index.children = make(map[uint64]*ruleIndexNode, len(rules)/2) + 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), - } - } - index.children[r.RoleID].count++ - 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.children[b].count++ - - n = n.children[b] + if _, ok := index.bits[r.Operation]; !ok { + index.bits[r.Operation] = make(map[string]*Rule, 4) } - n.isLeaf = true - n.access = r.Access - n.rule = r + index.bits[r.Operation][r.Resource] = r } } // has checks if the rule is already in there func (t *ruleIndex) has(r *Rule) bool { - return len(t.collect(true, r.RoleID, r.Operation, r.Resource)) > 0 + return len(t.collect(true, r.Operation, r.Resource)) > 0 } // get returns the matching rules -func (t *ruleIndex) get(role uint64, op, res string) (out []*Rule) { - return t.collect(false, role, op, res) +func (t *ruleIndex) get(op, res string) (out []*Rule) { + return t.collect(false, op, res) } // get returns all RBAC rules matching these constraints @@ -83,97 +52,31 @@ func (t *ruleIndex) get(role uint64, op, res string) (out []*Rule) { // the operation + 1 for the role. // // Our longest bit will be 6 so this is essentially constant time. -func (t *ruleIndex) collect(exact bool, 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 == "" { - if t.children[role].children[""] == nil || t.children[role].children[""].children[""] == nil { - return - } - - 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 { +func (t *ruleIndex) collect(exact bool, op, res string) (out []*Rule) { + if t.bits[op] == nil { return } - return aux.get(exact, 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(exact bool, res string, from int) (out []*Rule) { - if n == nil || n.children == 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(exact, res, nextDelim+1)...) - } - // Get RBAC rules down the wildcard path - if !exact && n.children[wildcard] != nil { - out = append(out, n.children[wildcard].get(exact, 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 af9d3eab76..6043fa5458 100644 --- a/server/pkg/rbac/rule_index_test.go +++ b/server/pkg/rbac/rule_index_test.go @@ -1,6 +1,7 @@ package rbac import ( + "math/rand" "sort" "testing" @@ -14,18 +15,16 @@ func TestIndexBuild(t *testing.T) { 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{{ @@ -36,9 +35,8 @@ func TestIndexBuild(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{{ @@ -54,45 +52,8 @@ func TestIndexBuild(t *testing.T) { }}, out: []int{0, 1}, - role: 1, - op: "read", - res: "a:b/c/d", - }, { - name: "one match one role 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", - 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", in: []*Rule{{ @@ -103,9 +64,8 @@ func TestIndexBuild(t *testing.T) { }}, out: nil, - role: 1, - op: "read", - res: "a:b/c/d", + op: "read", + res: "a:b/c/d", }, { name: "operation missmatch", in: []*Rule{{ @@ -116,9 +76,8 @@ func TestIndexBuild(t *testing.T) { }}, out: nil, - role: 1, - op: "read", - res: "a:b/c/d", + op: "read", + res: "a:b/c/d", }, { name: "add new element", @@ -137,9 +96,8 @@ func TestIndexBuild(t *testing.T) { out: []int{1}, - role: 1, - op: "write", - res: "a:b/c/x", + op: "write", + res: "a:b/c/x", }} for _, tc := range tcc { @@ -147,7 +105,7 @@ func TestIndexBuild(t *testing.T) { ix := buildRuleIndex(tc.in) ix.add(tc.add...) - out := RuleSet(ix.get(tc.role, tc.op, tc.res)) + out := RuleSet(ix.get(tc.op, tc.res)) sort.Sort(out) want := RuleSet(grabIndexMatches(append(tc.in, tc.add...), tc.out)) @@ -161,48 +119,6 @@ func TestIndexBuild(t *testing.T) { } } -func TestIndexUpsert(t *testing.T) { - var ( - ix = &ruleIndex{} - req = require.New(t) - ) - - t.Run("add specific item", func(t *testing.T) { - ix.add(&Rule{ - RoleID: 1, - Resource: "corteza::compose:namespace/5", - Operation: "read", - Access: Allow, - }) - - req.Equal(Allow, ix.children[1].children["read"].children["corteza::compose:namespace"].children["5"].access) - }) - - t.Run("add wildcard item", func(t *testing.T) { - ix.add(&Rule{ - RoleID: 1, - Resource: "corteza::compose:namespace/*", - Operation: "read", - Access: Deny, - }) - - req.Equal(Allow, ix.children[1].children["read"].children["corteza::compose:namespace"].children["5"].access) - req.Equal(Deny, ix.children[1].children["read"].children["corteza::compose:namespace"].children["*"].access) - }) - - t.Run("add existing item (update)", func(t *testing.T) { - ix.add(&Rule{ - RoleID: 1, - Resource: "corteza::compose:namespace/*", - Operation: "read", - Access: Allow, - }) - - req.Equal(Allow, ix.children[1].children["read"].children["corteza::compose:namespace"].children["5"].access) - req.Equal(Allow, ix.children[1].children["read"].children["corteza::compose:namespace"].children["*"].access) - }) -} - func TestIndexHas(t *testing.T) { ix := buildRuleIndex([]*Rule{{ RoleID: 1, @@ -219,11 +135,19 @@ func TestIndexHas(t *testing.T) { })) require.False(t, ix.has(&Rule{ - RoleID: 2, - Resource: "a:b/c/x", + 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 grabIndexMatches(rr []*Rule, want []int) (out []*Rule) { @@ -240,10 +164,10 @@ func grabIndexMatches(rr []*Rule, want []int) (out []*Rule) { // goarch: arm64 // pkg: github.com/cortezaproject/corteza/server/pkg/rbac // cpu: Apple M3 Pro -// BenchmarkIndexBuild_100-12 26077 43467 ns/op 94785 B/op 1119 allocs/op -// BenchmarkIndexBuild_1000-12 2316 505664 ns/op 939447 B/op 10219 allocs/op -// BenchmarkIndexBuild_10000-12 228 5301265 ns/op 9008425 B/op 98033 allocs/op -// BenchmarkIndexBuild_100000-12 19 68454059 ns/op 70832448 B/op 843270 allocs/op +// 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() @@ -267,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/service.go b/server/pkg/rbac/service.go index b460ec40a5..ce3c3aa00f 100644 --- a/server/pkg/rbac/service.go +++ b/server/pkg/rbac/service.go @@ -3,12 +3,14 @@ package rbac import ( "context" "fmt" + "math" "sort" "strconv" "strings" "sync" "time" + "github.com/cortezaproject/corteza/server/pkg/filter" "github.com/cortezaproject/corteza/server/system/types" "github.com/spf13/cast" "go.uber.org/zap" @@ -25,9 +27,11 @@ type ( noopAccess Access usageCounter *usageCounter[string] - index *wrapperIndex roles []*Role + indexes []*wrapperIndex + indexMappings map[uint64]int + RuleStorage rbacRulesStore RoleStorage rbacRoleStore } @@ -123,11 +127,15 @@ type ( } RuleFilter struct { + RawFilter string + Resource []string Operation string RoleID uint64 Limit uint + + filter.Sorting } RoleSettings struct { @@ -155,6 +163,8 @@ const ( ReindexStrategySpeed ReindexStrategy = "speed" RuleResourceType = "corteza::generic:rbac-rule" + + maxRuleFetchChunk = 10000 ) var ( @@ -200,7 +210,7 @@ func NewService(ctx context.Context, l *zap.Logger, store rbacRulesStore, cc Con return } - svc.index, err = svc.loadIndex(ctx) + svc.indexes, svc.indexMappings, err = svc.loadIndex(ctx) if err != nil { return } @@ -388,19 +398,26 @@ func (svc *Service) Grant(ctx context.Context, rules ...*Rule) (err error) { svc.logger.Debug(r.Access.String() + " " + r.Operation + " on " + r.Resource + " to " + strconv.FormatUint(r.RoleID, 10)) } - svc.mux.Lock() - if svc.index == nil { - svc.index = &wrapperIndex{} + if svc.isIndexEmpty() { + err = svc.flush(ctx, rules...) + if err != nil { + return + } + + return } + + svc.mux.Lock() + // @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.index.isIndexed(r.RoleID, r.Resource) { + if !svc.isRuleIndexed(r) { continue } // If it is, we need to assure this thing is inside the index now - if !svc.index.add(r.RoleID, r.Resource, r) { + if !svc.indexRule(r) { // Don't hit cache update in case nothing happened continue } @@ -447,7 +464,15 @@ func (svc *Service) Stats() (out Stats, err error) { out.LastDatabaseTimings, out.LastIndexTimings = svc.StatLogger.Stats() - out.IndexSize = svc.index.getSize() + out.IndexSize = svc.indexSize() + + return +} + +func (svc *Service) indexSize() (out int) { + for _, ix := range svc.indexes { + out += ix.getSize() + } return } @@ -496,32 +521,6 @@ func (svc *Service) FindRulesByRoleID(ctx context.Context, roleID uint64) (rr Ru return } -// Remove role removes the role from the service -// -// @todo this won't clean out the removed rules until the next reload -func (svc *Service) RemoveRole(r *Role) { - svc.mux.Lock() - defer svc.mux.Unlock() - - for i, xr := range svc.roles { - if xr.id != r.id { - continue - } - - svc.roles = append(svc.roles[:i], svc.roles[i+1:]...) - return - } -} - -// IndexSize returns the number of indexed role/rule combos -func (svc *Service) IndexSize() int { - if svc.index == nil { - return 0 - } - - return svc.index.getSize() -} - // SignificantRoles returns two list of significant roles. // // See sigRoles on rules for more details @@ -549,7 +548,8 @@ func (svc *Service) Rules(ctx context.Context) (out RuleSet, err error) { // Clear cleans out all the data func (svc *Service) Clear() { svc.usageCounter = nil - svc.index = nil + svc.indexes = nil + svc.indexMappings = nil svc.roles = nil } @@ -681,6 +681,48 @@ func (svc *Service) preflightCheck(roles partRoles) (a Access, resolved bool) { return Inherit, false } +func (svc *Service) isRuleIndexed(r *Rule) bool { + ix := svc.getIndexForRole(r.RoleID) + if ix == nil { + return false + } + + return ix.isIndexed(r.Resource) +} + +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 + } + } + + return false +} + // // // // // // // // // // // // // // // // // // // // // // // // // // DB stuff @@ -831,7 +873,7 @@ func (svc *Service) getMatchingRule(st evaluationState, kind roleKind, role uint // Indexed now := time.Now() - aux = svc.index.get(role, st.op, st.res) + aux = svc.getIndexedRules(role, st.op, st.res) svc.logIndexTiming(time.Since(now)) rules = append(rules, aux...) @@ -862,7 +904,7 @@ func (svc *Service) segmentRoles(roles partRoles, resource string) (indexed, uni unindexed = partRoles{} indexed = partRoles{} - if svc.index == nil || svc.index.index == nil || svc.index.index.empty() { + if svc.isIndexEmpty() { return indexed, roles, nil } @@ -871,7 +913,8 @@ func (svc *Service) segmentRoles(roles partRoles, resource string) (indexed, uni for k, rg := range roles { for r := range rg { - if svc.index.isIndexed(r, resource) { + ix := svc.getIndex(r, resource) + if ix != nil { if indexed[k] == nil { indexed[k] = make(map[uint64]bool) } @@ -891,6 +934,41 @@ func (svc *Service) segmentRoles(roles partRoles, resource string) (indexed, uni 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 ( @@ -990,12 +1068,12 @@ func (svc *Service) updateWrapperIndex(ctx context.Context) (err error) { } func (svc *Service) updateWrapperIndexMemFirst(ctx context.Context) (err error) { - auxIndex, err := svc.buildNewIndex(ctx) + auxIndex, auxMappings, err := svc.buildNewIndex(ctx) if err != nil { return } - svc.swapIndexes(auxIndex) + svc.swapIndexes(auxIndex, auxMappings) return } @@ -1003,41 +1081,150 @@ func (svc *Service) updateWrapperIndexSpeedFirst(ctx context.Context) (err error svc.mux.Lock() defer svc.mux.Unlock() - svc.index = nil + svc.indexes = nil + svc.indexMappings = nil - auxIndex, err := svc.buildNewIndex(ctx) + auxIndex, auxMappings, err := svc.buildNewIndex(ctx) if err != nil { return } - svc.index = auxIndex + svc.indexes = auxIndex + svc.indexMappings = auxMappings return } // // // // // // // // // // // // // // // // // // // // // // // // // // // Boilerplate & state management stuff -func (svc *Service) indexForResources(ctx context.Context, res ...string) (index *wrapperIndex, err error) { - index = &wrapperIndex{} - var auxRules []*Rule - - for _, b := range res { - pp := strings.SplitN(b, ":", 2) - role := cast.ToUint64(pp[0]) - resource := pp[1] - - auxRules, err = svc.pullRules(ctx, role, resource) +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 } - index.add(role, resource, auxRules...) + 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 } -func (svc *Service) loadIndex(ctx context.Context) (out *wrapperIndex, err error) { +// 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? @@ -1047,7 +1234,7 @@ func (svc *Service) loadIndex(ctx context.Context) (out *wrapperIndex, err error // 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 + return []*wrapperIndex{}, nil, nil } rr, err := svc.cfg.PullInitialState(ctx, svc.cfg.MaxIndexSize) @@ -1058,7 +1245,7 @@ func (svc *Service) loadIndex(ctx context.Context) (out *wrapperIndex, err error return svc.indexForResources(ctx, rr...) } -func (svc *Service) buildNewIndex(ctx context.Context) (index *wrapperIndex, err error) { +func (svc *Service) buildNewIndex(ctx context.Context) (indexes []*wrapperIndex, mappings map[uint64]int, err error) { svc.usageCounter.lock.RLock() defer svc.usageCounter.lock.RUnlock() @@ -1066,7 +1253,7 @@ func (svc *Service) buildNewIndex(ctx context.Context) (index *wrapperIndex, err return svc.indexForResources(ctx, res...) } -func (svc *Service) swapIndexes(auxIndex *wrapperIndex) { +func (svc *Service) swapIndexes(auxIndex []*wrapperIndex, mappings map[uint64]int) { if auxIndex == nil { return } @@ -1074,7 +1261,8 @@ func (svc *Service) swapIndexes(auxIndex *wrapperIndex) { svc.mux.Lock() defer svc.mux.Unlock() - svc.index = auxIndex + svc.indexes = auxIndex + svc.indexMappings = mappings } // Performance monitoring @@ -1195,19 +1383,23 @@ func (svc *Service) logCachePerformanceAsync(hits, misses partRoles, resource, o func (svc *Service) DebuggerSetIndex(role uint64, resource string, rules ...*Rule) (err error) { index := &wrapperIndex{} - index.add(role, resource, rules...) + index.add(resource, rules...) - svc.index = index + svc.indexes = []*wrapperIndex{index} + svc.indexMappings = map[uint64]int{role: 0} return } func (svc *Service) DebuggerAddIndex(role uint64, resource string, rules ...*Rule) (err error) { - index := svc.index - - index.add(role, resource, rules...) + ix := svc.getIndexForRole(role) + if ix == nil { + ix = &wrapperIndex{} + svc.indexes = append(svc.indexes, ix) + svc.indexMappings[role] = len(svc.indexes) - 1 + } - svc.index = index + ix.add(resource, rules...) return } @@ -1251,11 +1443,11 @@ func (svc *Service) watch(ctx context.Context) { 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 <-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 9338af41d8..4b39a32a42 100644 --- a/server/pkg/rbac/service_test.go +++ b/server/pkg/rbac/service_test.go @@ -5,8 +5,13 @@ 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" ) type ( @@ -16,8 +21,680 @@ type ( res Resource op string } + + stateCfg struct { + resources []string + searchResponses []*Rule + } ) +func TestNoopSvc(t *testing.T) { + req := require.New(t) + + 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) +} + +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, + }, + "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 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) + }) + + 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) + }) + + 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) + }) + + 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) + }) + + 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) + }) + + 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 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()) + }) + + 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()) + }) + + 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 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) + }) + + 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) + }) + + 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) + }) + + 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) + }) + + 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 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 @@ -206,6 +883,41 @@ type ( // }) // } +// 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) { count := rand.Intn(len(base)) if count <= 0 { @@ -301,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/svc_index.go b/server/pkg/rbac/svc_index.go index 160b0cb8a1..ad0bdd0296 100644 --- a/server/pkg/rbac/svc_index.go +++ b/server/pkg/rbac/svc_index.go @@ -1,25 +1,19 @@ package rbac import ( - "fmt" "strings" - "sync" ) type ( wrapperIndex struct { - mux sync.RWMutex - index *ruleIndex - // indexed permits only max level identifiers indexed map[string]bool + index *ruleIndex } ) -func (svc *wrapperIndex) add(role uint64, resource string, rules ...*Rule) (added bool) { - svc.mux.Lock() - defer svc.mux.Unlock() - +// 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) } @@ -31,9 +25,9 @@ func (svc *wrapperIndex) add(role uint64, resource string, rules ...*Rule) (adde // 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(role, resource, rules...) + return svc.addWild(resource, rules...) } else { - return svc.addPlain(role, resource, rules...) + return svc.addPlain(resource, rules...) } } @@ -43,12 +37,16 @@ func (svc *wrapperIndex) add(role uint64, resource string, rules ...*Rule) (adde // 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(role uint64, resource string, rules ...*Rule) (added bool) { +func (svc *wrapperIndex) addWild(resource string, rules ...*Rule) (added bool) { give := false - rKey := svc.makeKey(role, resource) + pp := strings.SplitN(resource, "*", 2) + resource = strings.TrimRight(pp[0], "/") - for k := range svc.indexed { - give = give || strings.HasPrefix(k, rKey) + for r := range svc.indexed { + if strings.HasPrefix(r, resource) { + give = true + break + } } if !give { @@ -59,40 +57,22 @@ func (svc *wrapperIndex) addWild(role uint64, resource string, rules ...*Rule) ( return true } -func (svc *wrapperIndex) addPlain(role uint64, resource string, rules ...*Rule) (added bool) { - svc.indexed[svc.makeKey(role, resource)] = 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 } -func (svc *wrapperIndex) get(role uint64, op string, res string) (out []*Rule) { - if svc == nil { - return - } - - svc.mux.RLock() - defer svc.mux.RUnlock() - - if svc.index == nil { - return - } - - return svc.index.get(role, op, res) -} - -func (svc *wrapperIndex) getIndexed() (out []string) { - for k := range svc.indexed { - out = append(out, k) - } - - return +// 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 { - svc.mux.RLock() - defer svc.mux.RUnlock() - return len(svc.indexed) } @@ -105,26 +85,16 @@ func (svc *wrapperIndex) getSize() int { // // @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(role uint64, resource string) (ok bool) { +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 } - svc.mux.RLock() - defer svc.mux.RUnlock() - if svc.indexed == nil { return false } - return svc.indexed[svc.makeKey(role, resource)] -} - -func (svc *wrapperIndex) makeKey(role uint64, resource string) string { - pp := strings.SplitN(resource, "*", 2) - resource = strings.TrimRight(pp[0], "/") - - return fmt.Sprintf("%d:%s", role, resource) + return svc.indexed[resource] } diff --git a/server/pkg/rbac/svc_index_test.go b/server/pkg/rbac/svc_index_test.go index a8c5509054..55faede056 100644 --- a/server/pkg/rbac/svc_index_test.go +++ b/server/pkg/rbac/svc_index_test.go @@ -1,54 +1,25 @@ package rbac import ( - "fmt" "testing" "github.com/stretchr/testify/require" ) -func TestMakeKey(t *testing.T) { - role := uint64(10) - tcc := []struct { - in string - out string - }{{ - in: "corteza::compose:module-field/*/*/*", - out: fmt.Sprintf("%d:corteza::compose:module-field", role), - }, { - in: "corteza::compose:module-field/1/*/*", - out: fmt.Sprintf("%d:corteza::compose:module-field/1", role), - }, { - in: "corteza::compose:module-field/1/2/*", - out: fmt.Sprintf("%d:corteza::compose:module-field/1/2", role), - }, { - in: "corteza::compose:module-field/1/2/3", - out: fmt.Sprintf("%d:corteza::compose:module-field/1/2/3", role), - }} - - wx := wrapperIndex{} - for _, tc := range tcc { - t.Run(tc.in, func(t *testing.T) { - wx.makeKey(role, tc.in) - }) - } -} - func TestIndexing(t *testing.T) { - role := uint64(10) req := require.New(t) svc := wrapperIndex{} - req.True(svc.add(role, "corteza::compose:module-field/1/2/3")) - req.True(svc.add(role, "corteza::compose:module-field/1/4/6")) + 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(role, "corteza::compose:module-field/1/*/*")) - req.True(svc.add(role, "corteza::compose:module-field/1/4/*")) + 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(role, "corteza::compose:module-field/1/5/*")) - req.False(svc.add(role, "corteza::compose:module-field/2/*/*")) + 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(role, "corteza::compose:record/1/2/*")) + req.False(svc.add("corteza::compose:record/1/2/*")) } diff --git a/server/pkg/rbac/wrapper_test.go b/server/pkg/rbac/wrapper_test.go deleted file mode 100644 index ab61939cea..0000000000 --- a/server/pkg/rbac/wrapper_test.go +++ /dev/null @@ -1,519 +0,0 @@ -package rbac - -import ( - "context" - "testing" - - "github.com/cortezaproject/corteza/server/system/types" - "github.com/stretchr/testify/require" -) - -func TestRoleSegmentation(t *testing.T) { - req := require.New(t) - - wx := &wrapperIndex{} - w := Service{ - index: wx, - } - - rl1 := uint64(1001) - rl2 := uint64(2001) - res1 := "abc/1/2/3" - res2 := "def/1/2/3" - - wx.add(rl1, res1, &Rule{ - RoleID: rl1, - Resource: res1, - Operation: "read", - Access: Allow, - }) - - rls := partRoles{} - rls[CommonRole] = map[uint64]bool{ - rl1: true, - rl2: true, - } - - indexed, unindexed, err := w.segmentRoles(rls, res1) - req.NoError(err) - - req.True(indexed[CommonRole][rl1]) - req.False(indexed[CommonRole][rl2]) - - req.True(unindexed[CommonRole][rl2]) - req.False(unindexed[CommonRole][rl1]) - - // - // - - indexed, unindexed, err = w.segmentRoles(rls, res2) - req.NoError(err) - - req.False(indexed[CommonRole][rl1]) - req.False(indexed[CommonRole][rl2]) - - req.True(unindexed[CommonRole][rl1]) - req.True(unindexed[CommonRole][rl2]) -} - -func TestRoleSegmentationEmpty(t *testing.T) { - req := require.New(t) - - wx := &wrapperIndex{} - w := Service{ - index: wx, - } - - rl1 := uint64(1001) - rl2 := uint64(2001) - res1 := "abc/1/2/3" - - rls := partRoles{} - rls[CommonRole] = map[uint64]bool{ - rl1: true, - rl2: true, - } - - _, unindexed, err := w.segmentRoles(rls, res1) - req.NoError(err) - - req.True(unindexed[CommonRole][rl1]) - req.True(unindexed[CommonRole][rl2]) -} - -type ( - tRuleStore struct { - searches []RuleFilter - } -) - -func TestPullRules(t *testing.T) { - req := require.New(t) - ruleS := &tRuleStore{} - ctx := context.Background() - - wx := &Service{ - RuleStorage: ruleS, - } - - wx.pullRules(ctx, 1, "res/1/2/3") - req.Len(ruleS.searches, 1) - req.Equal([]string{"res/1/2/3", "res/1/2/*", "res/1/*/*", "res/*/*/*"}, ruleS.searches[0].Resource) - req.Equal(uint64(1), ruleS.searches[0].RoleID) - - wx.pullRules(ctx, 1, "res/1") - req.Len(ruleS.searches, 2) - req.Equal([]string{"res/1", "res/*"}, ruleS.searches[1].Resource) - req.Equal(uint64(1), ruleS.searches[1].RoleID) - - wx.pullRules(ctx, 1, "res") - req.Len(ruleS.searches, 3) - req.Equal([]string{"res"}, ruleS.searches[2].Resource) - req.Equal(uint64(1), ruleS.searches[2].RoleID) -} - -func TestGetMatchingRule_pureIndex(t *testing.T) { - req := require.New(t) - - stt := evaluationState{ - res: "res/1/2/3", - op: "read", - - unindexedRoles: partRoles{CommonRole: map[uint64]bool{}}, - indexedRoles: partRoles{CommonRole: map[uint64]bool{1: true}}, - unindexedRules: [5]map[uint64][]*Rule{}, - } - - t.Run("1", func(t *testing.T) { - wx := &Service{ - index: &wrapperIndex{}, - } - - wx.index.add(1, "res/1/2/3", &Rule{ - RoleID: 1, - Resource: "res/1/*/*", - Operation: "read", - Access: Deny, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/3", - Operation: "read", - Access: Inherit, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/*", - Operation: "read", - Access: Allow, - }) - - auxRule := wx.getMatchingRule(stt, CommonRole, 1) - req.Equal("res/1/2/*", auxRule.Resource) - req.Equal(Allow, auxRule.Access) - }) - - t.Run("2", func(t *testing.T) { - wx := &Service{ - index: &wrapperIndex{}, - } - - wx.index.add(1, "res/1/2/3", &Rule{ - RoleID: 1, - Resource: "res/1/*/*", - Operation: "read", - Access: Deny, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/3", - Operation: "read", - Access: Deny, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/*", - Operation: "read", - Access: Deny, - }) - - auxRule := wx.getMatchingRule(stt, CommonRole, 1) - req.Equal("res/1/2/3", auxRule.Resource) - req.Equal(Deny, auxRule.Access) - }) - - t.Run("3", func(t *testing.T) { - wx := &Service{ - index: &wrapperIndex{}, - } - - wx.index.add(1, "res/1/2/3", &Rule{ - RoleID: 1, - Resource: "res/1/*/*", - Operation: "read", - Access: Inherit, - }, &Rule{ - RoleID: 1, - Resource: "res/*/*/*", - Operation: "read", - Access: Inherit, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/3", - Operation: "read", - Access: Inherit, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/*", - Operation: "read", - Access: Inherit, - }) - - auxRule := wx.getMatchingRule(stt, CommonRole, 1) - req.Nil(auxRule) - }) -} - -func TestGetMatchingRule_pureStored(t *testing.T) { - req := require.New(t) - - stt := evaluationState{ - res: "res/1/2/3", - op: "read", - - unindexedRoles: partRoles{CommonRole: map[uint64]bool{1: true}}, - indexedRoles: partRoles{CommonRole: map[uint64]bool{}}, - unindexedRules: [5]map[uint64][]*Rule{}, - } - - t.Run("1", func(t *testing.T) { - wx := &Service{ - index: &wrapperIndex{}, - } - - stt.unindexedRules = [5]map[uint64][]*Rule{CommonRole: { - 1: {&Rule{ - RoleID: 1, - Resource: "res/1/*/*", - Operation: "read", - Access: Deny, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/3", - Operation: "read", - Access: Inherit, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/*", - Operation: "read", - Access: Allow, - }}, - }} - - auxRule := wx.getMatchingRule(stt, CommonRole, 1) - req.Equal("res/1/2/*", auxRule.Resource) - req.Equal(Allow, auxRule.Access) - }) - - t.Run("2", func(t *testing.T) { - wx := &Service{ - index: &wrapperIndex{}, - } - - stt.unindexedRules = [5]map[uint64][]*Rule{CommonRole: { - 1: {&Rule{ - RoleID: 1, - Resource: "res/1/*/*", - Operation: "read", - Access: Deny, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/3", - Operation: "read", - Access: Deny, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/*", - Operation: "read", - Access: Deny, - }}, - }} - - auxRule := wx.getMatchingRule(stt, CommonRole, 1) - req.Equal("res/1/2/3", auxRule.Resource) - req.Equal(Deny, auxRule.Access) - }) - - t.Run("3", func(t *testing.T) { - wx := &Service{ - index: &wrapperIndex{}, - } - - stt.unindexedRules = [5]map[uint64][]*Rule{CommonRole: { - 3: {&Rule{ - RoleID: 1, - Resource: "res/1/*/*", - Operation: "read", - Access: Inherit, - }, &Rule{ - RoleID: 1, - Resource: "res/*/*/*", - Operation: "read", - Access: Inherit, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/3", - Operation: "read", - Access: Inherit, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/*", - Operation: "read", - Access: Inherit, - }}, - }} - - auxRule := wx.getMatchingRule(stt, CommonRole, 1) - req.Nil(auxRule) - }) -} - -func TestGetMatchingRule_mixed(t *testing.T) { - req := require.New(t) - - stt := evaluationState{ - res: "res/1/2/3", - op: "read", - - unindexedRoles: partRoles{CommonRole: map[uint64]bool{1: true}}, - indexedRoles: partRoles{CommonRole: map[uint64]bool{}}, - unindexedRules: [5]map[uint64][]*Rule{}, - } - - t.Run("1", func(t *testing.T) { - wx := &Service{ - index: &wrapperIndex{}, - } - - stt.unindexedRules = [5]map[uint64][]*Rule{CommonRole: { - 1: {&Rule{ - RoleID: 1, - Resource: "res/1/*/*", - Operation: "read", - Access: Deny, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/3", - Operation: "read", - Access: Inherit, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/*", - Operation: "read", - Access: Allow, - }}, - }} - - auxRule := wx.getMatchingRule(stt, CommonRole, 1) - req.Equal("res/1/2/*", auxRule.Resource) - req.Equal(Allow, auxRule.Access) - }) - - t.Run("2", func(t *testing.T) { - wx := &Service{ - index: &wrapperIndex{}, - } - - stt.unindexedRules = [5]map[uint64][]*Rule{CommonRole: { - 1: {&Rule{ - RoleID: 1, - Resource: "res/1/*/*", - Operation: "read", - Access: Deny, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/3", - Operation: "read", - Access: Deny, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/*", - Operation: "read", - Access: Deny, - }}, - }} - - auxRule := wx.getMatchingRule(stt, CommonRole, 1) - req.Equal("res/1/2/3", auxRule.Resource) - req.Equal(Deny, auxRule.Access) - }) - - t.Run("3", func(t *testing.T) { - wx := &Service{ - index: &wrapperIndex{}, - } - - stt.unindexedRules = [5]map[uint64][]*Rule{CommonRole: { - 3: {&Rule{ - RoleID: 1, - Resource: "res/1/*/*", - Operation: "read", - Access: Inherit, - }, &Rule{ - RoleID: 1, - Resource: "res/*/*/*", - Operation: "read", - Access: Inherit, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/3", - Operation: "read", - Access: Inherit, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/*", - Operation: "read", - Access: Inherit, - }}, - }} - - auxRule := wx.getMatchingRule(stt, CommonRole, 1) - req.Nil(auxRule) - }) -} - -func TestCombiningSources(t *testing.T) { - req := require.New(t) - wx := &Service{ - index: &wrapperIndex{}, - } - - wx.index.add(1, "res/1/2/3", &Rule{ - RoleID: 1, - Resource: "res/1/2/3", - Operation: "read", - Access: Inherit, - }, &Rule{ - RoleID: 1, - Resource: "res/1/2/*", - Operation: "read", - Access: Allow, - }, &Rule{ - RoleID: 2, - Resource: "res/1/2/3", - Operation: "read", - Access: Deny, - }) - - stt := evaluationState{ - res: "res/1/2/3", - op: "read", - - unindexedRoles: partRoles{CommonRole: map[uint64]bool{3: true}}, - indexedRoles: partRoles{CommonRole: map[uint64]bool{1: true}}, - unindexedRules: [5]map[uint64][]*Rule{CommonRole: { - 3: {{ - RoleID: 3, - Resource: "res/1/2/3", - Operation: "read", - Access: Inherit, - }, { - RoleID: 3, - Resource: "res/1/2/*", - Operation: "read", - Access: Deny, - }}, - }}, - } - - auxRule := wx.getMatchingRule(stt, CommonRole, 1) - req.Equal("res/1/2/*", auxRule.Resource) - req.Equal(Allow, auxRule.Access) - - auxRule = wx.getMatchingRule(stt, CommonRole, 3) - req.Equal("res/1/2/*", auxRule.Resource) - req.Equal(Deny, auxRule.Access) - - wx.index.add(3, "res/1/2/3", &Rule{ - RoleID: 3, - Resource: "res/1/2/3", - Operation: "read", - Access: Inherit, - }) - stt = evaluationState{ - res: "res/1/2/3", - op: "read", - - unindexedRoles: partRoles{CommonRole: map[uint64]bool{3: true}}, - indexedRoles: partRoles{CommonRole: map[uint64]bool{1: true}}, - unindexedRules: [5]map[uint64][]*Rule{CommonRole: { - 3: {{ - RoleID: 3, - Resource: "res/1/2/*", - Operation: "read", - Access: Deny, - }}, - }}, - } - - auxRule = wx.getMatchingRule(stt, CommonRole, 3) - req.Equal("res/1/2/*", auxRule.Resource) - req.Equal(Deny, auxRule.Access) -} - -func (tt *tRuleStore) SearchRbacRules(ctx context.Context, f RuleFilter) (rs RuleSet, rf RuleFilter, err error) { - tt.searches = append(tt.searches, f) - return -} - -func (tt *tRuleStore) UpsertRbacRule(ctx context.Context, rr ...*Rule) (err error) { - return -} - -func (tt *tRuleStore) DeleteRbacRule(ctx context.Context, rr ...*Rule) (err error) { - return -} - -func (tt *tRuleStore) TruncateRbacRules(ctx context.Context) (err error) { - return -} - -func (tt *tRuleStore) SearchRoles(ctx context.Context, f types.RoleFilter) (rs types.RoleSet, rf types.RoleFilter, err error) { - return -} diff --git a/server/store/adapters/rdbms/filters.gen.go b/server/store/adapters/rdbms/filters.gen.go index bb28b60252..ffa9d26cc0 100644 --- a/server/store/adapters/rdbms/filters.gen.go +++ b/server/store/adapters/rdbms/filters.gen.go @@ -1157,6 +1157,12 @@ func RbacRuleFilter(d drivers.Dialect, f rbacType.RuleFilter) (ee []goqu.Express 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..5a01441ea9 100644 --- a/server/store/adapters/rdbms/rdbms.gen.go +++ b/server/store/adapters/rdbms/rdbms.gen.go @@ -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) } From 02acaaa8e4d4ab48c2a4a623c418e683ecfb7eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toma=C5=BE=20Jerman?= Date: Thu, 27 Feb 2025 15:09:49 +0100 Subject: [PATCH 22/22] Fix codegen lapsus causing incorrect sort field mapping --- server/app/resources.cue | 1 + server/codegen/server.store.cue | 4 +- server/store/adapters/rdbms/rdbms.gen.go | 134 +++++++++++------------ server/system/model/corteza.gen.go | 2 +- 4 files changed, 71 insertions(+), 70 deletions(-) diff --git a/server/app/resources.cue b/server/app/resources.cue index 5beef2cb8b..3131cb24d3 100644 --- a/server/app/resources.cue +++ b/server/app/resources.cue @@ -37,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: { diff --git a/server/codegen/server.store.cue b/server/codegen/server.store.cue index e65babcc66..6c5916d8f9 100644 --- a/server/codegen/server.store.cue +++ b/server/codegen/server.store.cue @@ -148,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/store/adapters/rdbms/rdbms.gen.go b/server/store/adapters/rdbms/rdbms.gen.go index 5a01441ea9..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", } } @@ -18431,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", } } @@ -19495,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) { @@ -19537,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) { @@ -19907,7 +19907,7 @@ func (s *Store) QueryResourceActivitys( func (Store) sortableResourceActivityFields() map[string]string { return map[string]string{ "id": "id", - "timestamp": "timestamp", + "timestamp": "ts", } } @@ -20932,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) { @@ -21421,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", } } @@ -21751,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", } @@ -22886,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{