snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s

This commit is contained in:
upstream-mirror
2026-09-18 01:22:23 +05:00
commit 7e385fc126
8391 changed files with 1766236 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
package rbac
type (
effective struct {
Resource string `json:"resource"`
Operation string `json:"operation"`
Allow bool `json:"allow"`
}
EffectiveSet []effective
)
func (ee *EffectiveSet) Push(res, op string, allow bool) {
*ee = append(*ee, effective{
Resource: res,
Operation: op,
Allow: allow,
})
}

View File

@@ -0,0 +1,96 @@
package rbac
import (
"reflect"
"github.com/PaesslerAG/gval"
"github.com/cortezaproject/corteza/server/pkg/id"
"github.com/spf13/cast"
)
var (
isAboveChecker = func(owner id.ID, user id.ID, paths ...string) bool {
return gRBAC.orgTree.IsAbove(owner, user, paths...)
}
)
func AllFunctions() []gval.Language {
return []gval.Language{
gval.Function("isDescendantOf", isDescendantOf),
gval.Function("isDescendantOfW", isDescendantOfW),
gval.Function("isDescendantOfC", isDescendantOfC),
gval.Function("isDescendantOfR", isDescendantOfR),
gval.Function("isDescendantOfU", isDescendantOfU),
gval.Function("isDescendantOfD", isDescendantOfD),
}
}
func isDescendantOf(userID any, resourceOwner any, paths ...string) bool {
if gRBAC == nil {
return false
}
owners := toUint64Slice(resourceOwner)
if len(owners) == 0 {
return false
}
user := id.MustNumID(cast.ToUint64(cast.ToString(userID)))
if user.IsZero() {
return false
}
for _, owner := range owners {
if isAboveChecker(id.MustNumID(owner), user, paths...) {
return true
}
}
return false
}
func isDescendantOfR(userID any, resourceOwner any) bool {
return isDescendantOf(userID, resourceOwner, "read")
}
func isDescendantOfC(userID any, resourceOwner any) bool {
return isDescendantOf(userID, resourceOwner, "create")
}
func isDescendantOfW(userID any, resourceOwner any) bool {
return isDescendantOf(userID, resourceOwner, "create", "update", "delete")
}
func isDescendantOfU(userID any, resourceOwner any) bool {
return isDescendantOf(userID, resourceOwner, "update")
}
func isDescendantOfD(userID any, resourceOwner any) bool {
return isDescendantOf(userID, resourceOwner, "delete")
}
func toUint64Slice(v any) []uint64 {
switch x := v.(type) {
case uint64:
return []uint64{x}
case []uint64:
return x
case [1]uint64:
return x[:]
case [2]uint64:
return x[:]
case [3]uint64:
return x[:]
default:
rv := reflect.ValueOf(v)
if rv.Kind() == reflect.Array && rv.Type().Elem().Kind() == reflect.Uint64 {
out := make([]uint64, rv.Len())
for i := 0; i < rv.Len(); i++ {
out[i] = rv.Index(i).Uint()
}
return out
}
}
return nil
}

View File

@@ -0,0 +1,62 @@
package rbac
import (
"testing"
"github.com/cortezaproject/corteza/server/pkg/id"
"github.com/stretchr/testify/require"
)
func TestIsDescendantOf(t *testing.T) {
t.Run("single values", func(t *testing.T) {
var (
ownerL []id.ID
userL []id.ID
pathsL [][]string
)
isAboveChecker = func(owner, user id.ID, paths ...string) bool {
ownerL = append(ownerL, owner)
userL = append(userL, user)
pathsL = append(pathsL, paths)
return true
}
gRBAC = &service{}
isDescendantOf(uint64(101), uint64(102))
require.Len(t, ownerL, 1)
require.Len(t, userL, 1)
require.Equal(t, id.MustNumID(102), ownerL[0])
require.Equal(t, id.MustNumID(101), userL[0])
})
t.Run("multiple owners", func(t *testing.T) {
var (
ownerL []id.ID
userL []id.ID
pathsL [][]string
)
isAboveChecker = func(owner, user id.ID, paths ...string) bool {
ownerL = append(ownerL, owner)
userL = append(userL, user)
pathsL = append(pathsL, paths)
return false
}
gRBAC = &service{}
isDescendantOf(uint64(101), []uint64{uint64(102), uint64(103)})
require.Len(t, ownerL, 2)
require.Len(t, userL, 2)
require.Equal(t, id.MustNumID(102), ownerL[0])
require.Equal(t, id.MustNumID(103), ownerL[1])
require.Equal(t, id.MustNumID(101), userL[0])
require.Equal(t, id.MustNumID(101), userL[1])
})
}

View File

@@ -0,0 +1,56 @@
package rbac
// General permission stuff, types, constants
type (
Access int
// CheckAccessFunc function.
CheckAccessFunc func() Access
)
const (
// Allow - Operation over a resource is allowed
Allow Access = 1
// Deny - Operation over a resource is denied
Deny Access = 0
// Inherit - Operation over a resource is not defined, inherit
Inherit Access = -1
)
func (a Access) String() string {
switch a {
case Allow:
return "allow"
case Deny:
return "deny"
default:
return "inherit"
}
}
func (a *Access) UnmarshalJSON(data []byte) error {
switch string(data) {
case "allow":
*a = Allow
case "deny":
*a = Deny
default:
*a = Inherit
}
return nil
}
func (a Access) MarshalJSON() ([]byte, error) {
return []byte(`"` + a.String() + `"`), nil
}
func Allowed() Access {
return Allow
}
func Denied() Access {
return Deny
}

163
server/pkg/rbac/resource.go Normal file
View File

@@ -0,0 +1,163 @@
package rbac
import (
"path"
"strings"
"github.com/spf13/cast"
)
type (
resource string
Resource interface {
RbacResource() string
}
resourceDicter interface {
Dict() map[string]interface{}
}
)
const (
nsSep = "::"
cmpSep = ":"
pathSep = "/"
wildcard = "*"
)
// NewResource constructs untyped resource from the given string
//
// This is a utility method that should not be used for standard permission checking and granting
// it's intended to be used for testing end permission evaluation where we do not have access to the resource struct
func NewResource(s string) Resource {
return resource(s)
}
// RbacResource returns string of an untyped resource
func (t resource) RbacResource() string {
return string(t)
}
// HasWildcards returns true if the given resource has wildcards
func (t resource) HasWildcards() bool {
return hasWildcards(string(t))
}
// ResourceType extracts 1st part of the resource
//
// ns::cmp:res/c returns ns::cmp:res
// ns::cmp:res/ returns ns::cmp:res
// ns::cmp:res returns ns::cmp:res
func ResourceType(r string) string {
if p := strings.Index(r, pathSep); p > 0 {
return r[:p]
} else {
return r
}
}
func ResourceComponent(r string) string {
var (
t = ResourceType(r)
ns = strings.Index(t, nsSep)
c = strings.LastIndex(t, cmpSep)
)
// make sure that we have both namespace + component separators
if c > ns+1 && ns > -1 {
return t[:c]
} else {
return t
}
}
func ParseResourceID(r string) (string, []uint64) {
const sep = "/"
var (
pp = strings.Split(r, sep)
ids = make([]uint64, 0)
)
for i := 1; i < len(pp); i++ {
ids = append(ids, cast.ToUint64(pp[i]))
}
return pp[0], ids
}
// match returns true if the given resource matches the given pattern
func matchResource(matcher, resource string) (m bool) {
if matcher == resource {
return true
}
m, _ = path.Match(matcher, resource)
return
}
func hasWildcards(resource string) bool {
return strings.Contains(resource, wildcard)
}
// returns level for the given resource match
// In a nutshell, level indicates number of wildcard characters
//
// More defined resources use less wildcards and are on a lower level
func level(r string) (score int) {
var nl bool
for l := len(r) - 1; l > strings.Index(r, pathSep); l-- {
switch r[l] {
case wildcard[0]:
// nop
case pathSep[0]:
// found next resource reference level
score *= 10
nl = false
default:
if !nl {
score += 1
nl = true
}
}
}
return
}
// isSpecific will return true if rule relates to a specific resource
//
// ns::cmp:res/xx/* returns true
// ns::cmp:res/xx/xx returns true
// ns::cmp:res/ returns false
// ns::cmp:res/*/* returns false
func isSpecific(r string) (out bool) {
out = false
parts := strings.Split(r, pathSep)
// remove resource name part
parts = parts[1:]
// check parts for wildcard if it's not empty otherwise return false
for _, p := range parts {
if len(p) == 0 {
continue
}
if p != wildcard && !out {
out = true
}
}
return
}
func ResourceID(res string) uint64 {
pp := strings.Split(res, pathSep)
if len(pp) < 2 {
return 0
}
if pp[len(pp)-1] == wildcard {
return 0
}
return cast.ToUint64(pp[len(pp)-1])
}

View File

@@ -0,0 +1,141 @@
package rbac
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestResourceType(t *testing.T) {
var (
tcc = []struct {
in string
exp string
}{
{"a:b/c/d", "a:b"},
{"a:b/c", "a:b"},
{"a:b/", "a:b"},
{"a:b", "a:b"},
{"a/", "a"},
{"a", "a"},
{"", ""},
}
)
for _, tc := range tcc {
t.Run(tc.in, func(t *testing.T) {
require.Equal(t, tc.exp, ResourceType(tc.in))
})
}
}
func TestResourceComponent(t *testing.T) {
var (
tcc = []struct {
in string
exp string
}{
{"ns::cmp:r/1/2/3", "ns::cmp"},
{"ns::cmp:r", "ns::cmp"},
{"ns::cmp/", "ns::cmp"},
{"ns::cmp", "ns::cmp"},
{"cmp", "cmp"},
}
)
for _, tc := range tcc {
t.Run(tc.in, func(t *testing.T) {
require.Equal(t, tc.exp, ResourceComponent(tc.in))
})
}
}
func TestResourceMatch(t *testing.T) {
var (
tcc = []struct {
m string
r string
e bool
}{
{"::corteza:test/a/b/c", "::corteza:test/a/b/c", true},
{"::corteza:test/a/b/*", "::corteza:test/a/b/c", true},
{"::corteza:test/a/*/*", "::corteza:test/a/b/c", true},
{"::corteza:test/*/*/*", "::corteza:test/a/b/c", true},
{"::corteza:test/a/*/*", "::corteza:test/1/2/3", false},
}
)
for _, tc := range tcc {
t.Run(tc.m, func(t *testing.T) {
require.Equal(t, tc.e, matchResource(tc.m, tc.r))
})
}
}
func TestLevel(t *testing.T) {
var (
tcc = []struct {
r string
l int
}{
{"corteza::test/a/b/c", 111},
{"corteza::test/a/b/*", 11},
{"corteza::test/a/*/*", 1},
{"corteza::test/*/*/*", 0},
{"corteza::test/a/*/123", 101},
}
)
for _, tc := range tcc {
t.Run(tc.r, func(t *testing.T) {
require.Equal(t, tc.l, level(tc.r))
})
}
}
func TestIsSpecific(t *testing.T) {
var (
tcc = []struct {
r string
e bool
}{
{"corteza::test/a/b/c", true},
{"corteza::test/a/b/*", true},
{"corteza::test/a/*/*", true},
{"corteza::test/a/*/123", true},
{"corteza::test/*/*/*", false},
{"corteza::test/*/*", false},
{"corteza::test/*", false},
{"corteza::test/", false},
}
)
for _, tc := range tcc {
t.Run(tc.r, func(t *testing.T) {
require.Equal(t, tc.e, isSpecific(tc.r))
})
}
}
func TestParseResourceID(t *testing.T) {
var (
tcc = []struct {
in string
outType string
outIDs []uint64
}{
{"corteza::test/*/*/*", "corteza::test", []uint64{0, 0, 0}},
{"corteza::test/234/*/123", "corteza::test", []uint64{234, 0, 123}},
{"corteza::test/3/2/1", "corteza::test", []uint64{3, 2, 1}},
}
)
for _, tc := range tcc {
t.Run(tc.in, func(t *testing.T) {
outType, outIDs := ParseResourceID(tc.in)
require.Equal(t, tc.outType, outType)
require.Equal(t, tc.outIDs, outIDs)
})
}
}

209
server/pkg/rbac/roles.go Normal file
View File

@@ -0,0 +1,209 @@
package rbac
import (
"github.com/cortezaproject/corteza/server/pkg/logger"
"github.com/cortezaproject/corteza/server/pkg/slice"
"go.uber.org/zap"
)
const (
roleKinds = 5
)
type (
ctxRoleCheckFn func(map[string]interface{}) bool
// role information, adapted for the needs of RBAC package
Role struct {
// all RBAC rules refer to role ID
id uint64
// for debugging and logging
handle string
// role type that will allow us
kind roleKind
check ctxRoleCheckFn
// compatible resource types
crtypes map[string]bool
}
roleKind int
partRoles [roleKinds]map[uint64]bool
)
const (
CommonRole roleKind = iota
AnonymousRole
AuthenticatedRole
ContextRole
BypassRole
)
func (k roleKind) String() string {
switch k {
case BypassRole:
return "bypass"
case ContextRole:
return "context"
case CommonRole:
return "common"
case AuthenticatedRole:
return "authenticated"
case AnonymousRole:
return "anonymous"
default:
panic("unknown role kind")
}
}
func (k roleKind) Make(id uint64, handle string) *Role {
return &Role{
kind: k,
id: id,
handle: handle,
}
}
func MakeContextRole(id uint64, handle string, fn ctxRoleCheckFn, tt ...string) *Role {
return &Role{
kind: ContextRole,
id: id,
handle: handle,
check: fn,
crtypes: slice.ToStringBoolMap(tt),
}
}
// partitions roles by kind
func partitionRoles(rr ...*Role) partRoles {
out := [roleKinds]map[uint64]bool{}
for _, r := range rr {
if out[r.kind] == nil {
out[r.kind] = make(map[uint64]bool)
}
out[r.kind][r.id] = true
}
return out
}
func (p partRoles) LogFields() (ff []zap.Field) {
for _, k := range []roleKind{BypassRole, ContextRole, CommonRole, AuthenticatedRole, AnonymousRole} {
ii := make([]uint64, 0, len(p[k]))
for r := range p[k] {
ii = append(ii, r)
}
ff = append(ff, logger.Uint64s(k.String(), ii))
}
return
}
// counts roles per type
func statRoles(rr ...*Role) (stats map[roleKind]int) {
stats = make(map[roleKind]int)
for _, r := range rr {
stats[r.kind]++
}
return
}
// compare list of session roles (ids) with preloaded roles and calculate the final list
func getSessionRoles(s Session, res Resource, preloadedRoles []*Role, resolveUser UserResolver) (out partRoles) {
var (
mm = slice.ToUint64BoolMap(s.Roles())
scope = make(map[string]interface{})
)
out = [roleKinds]map[uint64]bool{}
{
// if this is an anonymous user (has one role of kind anonymous)
// ensure role-kind integrity and skip complex checks
for _, r := range preloadedRoles {
if !mm[r.id] {
// skip roles that are not in the security session
// skip all other types of roles that user from session is not member of
continue
}
if r.kind != AnonymousRole {
continue
}
if out[AnonymousRole] == nil {
out[AnonymousRole] = make(map[uint64]bool)
}
out[AnonymousRole][r.id] = true
}
if len(out[AnonymousRole]) > 0 {
return
}
}
if d, ok := res.(resourceDicter); ok {
// if resource implements Dict() fn, we can use it to
// collect attributes, used for expression evaluation and contextual role gathering
scope["resource"] = d.Dict()
}
scope["userID"] = s.Identity()
// Resolve user properties (email, username, handle, name, labels)
// and inject them into the expression scope as "user"
if resolveUser != nil {
if userData := resolveUser(s.Identity()); userData != nil {
scope["user"] = userData
}
}
for _, r := range preloadedRoles {
if r.kind == ContextRole {
if hasWildcards(res.RbacResource()) {
// if resource has wildcards, we can't use it for contextual role evaluation
//
// this exception causes RBAC trace requests that can have wildcard
// resources to ignore this role
//
// without skipping contextual roles like this
// check function on role is highly likely to fail to evaluate properly
continue
}
if len(r.crtypes) == 0 || !r.crtypes[ResourceType(res.RbacResource())] {
// resource type not compatible with this contextual role
continue
}
if r.check == nil {
// expression not defined, skip contextual role
continue
}
if !r.check(scope) {
// add role to the list ONLY of expression evaluated true
continue
}
} else if !mm[r.id] {
// skip all other types of roles that user from session is not member of
continue
}
if out[r.kind] == nil {
out[r.kind] = make(map[uint64]bool)
}
out[r.kind][r.id] = true
}
return
}

View File

@@ -0,0 +1,195 @@
package rbac
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test_partitionRoles(t *testing.T) {
var (
req = require.New(t)
pr = partitionRoles(
&Role{id: 1, kind: BypassRole},
&Role{id: 2, kind: BypassRole},
&Role{id: 3, kind: BypassRole},
&Role{id: 4, kind: ContextRole},
&Role{id: 5, kind: CommonRole},
)
)
req.Nil(pr[AuthenticatedRole])
req.Nil(pr[AnonymousRole])
req.NotNil(pr[BypassRole])
req.NotNil(pr[ContextRole])
req.NotNil(pr[CommonRole])
req.Len(pr[BypassRole], 3)
req.True(pr[BypassRole][1])
req.True(pr[BypassRole][2])
req.True(pr[BypassRole][3])
req.Len(pr[ContextRole], 1)
req.True(pr[ContextRole][4])
req.Len(pr[CommonRole], 1)
req.True(pr[CommonRole][5])
}
func Test_getContextRoles(t *testing.T) {
var (
dyCheck = func(r bool) ctxRoleCheckFn {
return func(map[string]interface{}) bool {
return r
}
}
tres = NewResource("testResource")
tcc = []struct {
name string
sessionRoles []uint64
res Resource
preloadRoles []*Role
output []*Role
}{
{
"existing role",
[]uint64{1},
tres,
[]*Role{{id: 1, kind: BypassRole}},
[]*Role{{id: 1, kind: BypassRole}},
},
{
"missing role",
[]uint64{2},
tres,
[]*Role{{id: 1, kind: BypassRole}},
[]*Role{},
},
{
"dynamic role",
[]uint64{1, 2},
tres,
[]*Role{
{id: 1, kind: BypassRole},
{id: 2, kind: ContextRole, check: dyCheck(true), crtypes: map[string]bool{tres.RbacResource(): true}},
{id: 3, kind: ContextRole, check: dyCheck(false), crtypes: map[string]bool{tres.RbacResource(): true}},
{id: 4, kind: ContextRole, check: dyCheck(true)},
},
[]*Role{{id: 1, kind: BypassRole}, {id: 2, kind: ContextRole}},
},
{
"anonymous role mixed with matching contextual",
[]uint64{1, 2},
tres,
[]*Role{
{id: 1, kind: AnonymousRole},
{id: 2, kind: ContextRole, check: dyCheck(true), crtypes: map[string]bool{tres.RbacResource(): true}},
{id: 3, kind: ContextRole, check: dyCheck(false), crtypes: map[string]bool{tres.RbacResource(): true}},
{id: 4, kind: ContextRole, check: dyCheck(true)},
},
[]*Role{{id: 1, kind: AnonymousRole}},
},
}
)
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
var (
req = require.New(t)
)
req.Equal(partitionRoles(tc.output...), getSessionRoles(&session{rr: tc.sessionRoles}, tc.res, tc.preloadRoles, nil))
})
}
}
func Test_getContextRolesWithUserResolver(t *testing.T) {
var (
tres = NewResource("corteza::compose:record/123/456")
// A check function that verifies user labels are accessible in scope
labelCheck = func(scope map[string]interface{}) bool {
user, ok := scope["user"].(map[string]interface{})
if !ok {
return false
}
labels, ok := user["labels"].(map[string]interface{})
if !ok {
return false
}
client, ok := labels["client"].(string)
if !ok {
return false
}
return client == "acme"
}
// A check function that verifies user email is accessible
emailCheck = func(scope map[string]interface{}) bool {
user, ok := scope["user"].(map[string]interface{})
if !ok {
return false
}
email, ok := user["email"].(string)
if !ok {
return false
}
return email == "test@example.com"
}
// Mock user resolver that returns user data
mockResolver = func(userID uint64) map[string]interface{} {
if userID == 42 {
return map[string]interface{}{
"email": "test@example.com",
"username": "testuser",
"handle": "test-handle",
"name": "Test User",
"labels": map[string]interface{}{
"client": "acme",
"region": "eu",
},
}
}
return nil
}
)
t.Run("context role with matching user labels", func(t *testing.T) {
req := require.New(t)
roles := []*Role{
{id: 1, kind: ContextRole, check: labelCheck, crtypes: map[string]bool{"corteza::compose:record": true}},
}
result := getSessionRoles(&session{id: 42, rr: []uint64{1}}, tres, roles, mockResolver)
req.NotNil(result[ContextRole])
req.True(result[ContextRole][1], "context role should be active when user labels match")
})
t.Run("context role with non-matching user (no resolver data)", func(t *testing.T) {
req := require.New(t)
roles := []*Role{
{id: 1, kind: ContextRole, check: labelCheck, crtypes: map[string]bool{"corteza::compose:record": true}},
}
// User 999 has no data from the resolver
result := getSessionRoles(&session{id: 999, rr: []uint64{1}}, tres, roles, mockResolver)
req.Nil(result[ContextRole], "context role should not be active when user has no resolver data")
})
t.Run("context role checking user email", func(t *testing.T) {
req := require.New(t)
roles := []*Role{
{id: 1, kind: ContextRole, check: emailCheck, crtypes: map[string]bool{"corteza::compose:record": true}},
}
result := getSessionRoles(&session{id: 42, rr: []uint64{1}}, tres, roles, mockResolver)
req.NotNil(result[ContextRole])
req.True(result[ContextRole][1], "context role should be active when user email matches")
})
t.Run("nil resolver falls back to no user data", func(t *testing.T) {
req := require.New(t)
roles := []*Role{
{id: 1, kind: ContextRole, check: labelCheck, crtypes: map[string]bool{"corteza::compose:record": true}},
}
result := getSessionRoles(&session{id: 42, rr: []uint64{1}}, tres, roles, nil)
req.Nil(result[ContextRole], "context role should not be active when no resolver is provided")
})
}

146
server/pkg/rbac/rule.go Normal file
View File

@@ -0,0 +1,146 @@
package rbac
import (
"fmt"
"strconv"
"strings"
"github.com/cortezaproject/corteza/server/pkg/cast2"
)
type (
Rule struct {
RoleID uint64 `json:"roleID,string"`
Resource string `json:"resource"`
Operation string `json:"operation"`
Access Access `json:"access,string"`
// Do we need to flush it to storage?
dirty bool
}
RuleSet []*Rule
)
func (r Rule) String() string {
return fmt.Sprintf("%s %d to %s on %s", r.Access, r.RoleID, r.Operation, r.Resource)
}
func (set RuleSet) Len() int { return len(set) }
func (set RuleSet) Swap(i, j int) { set[i], set[j] = set[j], set[i] }
func (set RuleSet) Less(i, j int) bool {
return level(set[i].Resource) > level(set[j].Resource)
}
func (set RuleSet) FilterAccess(a Access) (out RuleSet) {
out = make(RuleSet, 0, len(set))
for _, s := range set {
if s.Access == a {
out = append(out, s)
}
}
return out
}
func (set RuleSet) FilterOperation(op string) (out RuleSet) {
out = make(RuleSet, 0, len(set))
for _, s := range set {
if s.Operation == op {
out = append(out, s)
}
}
return out
}
// FilterResource returns rules that match given list of resources
// Wildcards are not used!
//
// Note that empty resource list will return ALL rules!
func (set RuleSet) FilterResource(rr ...Resource) (out RuleSet) {
if len(rr) == 0 {
return set
}
out = RuleSet{}
for _, rule := range set {
for _, res := range rr {
if res.RbacResource() != rule.Resource {
continue
}
out = append(out, rule)
}
}
return
}
// AllowRule helper func to create allow rule
func AllowRule(id uint64, r, o string) *Rule {
return &Rule{id, r, o, Allow, false}
}
// DenyRule helper func to create deny rule
func DenyRule(id uint64, r, o string) *Rule {
return &Rule{id, r, o, Deny, false}
}
// InheritRule helper func to create inherit rule
func InheritRule(id uint64, r, o string) *Rule {
return &Rule{id, r, o, Inherit, false}
}
func (u *Rule) SetValue(name string, pos uint, v any) (err error) {
switch name {
case "roleID", "RoleID":
return cast2.Uint64(v, &u.RoleID)
case "resource", "Resource":
return cast2.String(v, &u.Resource)
default:
return u.setValue(name, pos, v)
}
}
func (u *Rule) setValue(name string, pos uint, v any) (err error) {
pp := strings.Split(name, ".")
switch pp[0] {
case "resource", "Resource", "Path", "path":
ix, err := strconv.ParseUint(pp[1], 10, 64)
if err != nil {
return err
}
res := strings.Split(u.Resource, "/")
aux := ""
err = cast2.String(v, &aux)
// +1 bacause the first bit is the resource
res[ix+1] = aux
u.Resource = strings.Join(res, "/")
return err
}
return
}
func (u *Rule) GetValue(name string, pos uint) (v any, err error) {
switch name {
case "roleID", "RoleID":
return u.RoleID, nil
case "resource", "Resource":
return u.Resource, nil
}
return
}
func (r Rule) GetID() uint64 {
// The resource does not define an ID field
return 0
}

View File

@@ -0,0 +1,165 @@
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
}
)
// buildRuleIndex indexes the given rules for optimal lookups
//
// 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),
}
for _, r := range rules {
if _, ok := index.children[r.RoleID]; !ok {
index.children[r.RoleID] = &ruleIndexNode{
children: make(map[string]*ruleIndexNode, 4),
}
}
n := index.children[r.RoleID]
bits := append([]string{r.Operation}, strings.Split(r.Resource, "/")...)
for _, b := range bits {
if _, ok := n.children[b]; !ok {
n.children[b] = &ruleIndexNode{
children: make(map[string]*ruleIndexNode, 4),
}
}
n = n.children[b]
}
n.isLeaf = true
n.access = r.Access
n.rule = r
}
return index
}
// get returns all RBAC rules matching these constraints
//
// The get operation's lookup complexity is the longest RBAC key + 1 for
// the operation + 1 for the role.
//
// Our longest bit will be 6 so this is essentially constant time.
func (t *ruleIndex) get(role uint64, op, res string) (out []*Rule) {
if t.children == nil {
return
}
if _, ok := t.children[role]; !ok {
return
}
// An edge case implied by the test suite
if op == "" && res == "" {
out = append(out, t.children[role].children[""].children[""].rule)
return
}
// Pull out the nodes for the role
aux, ok := t.children[role]
if !ok {
return
}
aux, ok = aux.children[op]
if !ok {
return
}
return aux.get(res, 0)
}
// get returns all of the rules matching these constraints
//
// Under the hood...
// We're avoiding string processing (concatenation, splitting, ...) as that can
// be a memory hog in scenarios where we're pounding this function.
//
// The from denotes the substring we've not yet processed.
func (n *ruleIndexNode) get(res string, from int) (out []*Rule) {
if n == nil || n.children == nil {
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
}
}
// Get the next / delimiter.
// Clamp the index to the length of the resource.
// Adjust the index to account the from (the start index of the remaining resource)
nextDelim := strings.Index(res[from:to], "/")
if nextDelim < 0 {
nextDelim = len(res)
} else {
nextDelim += from
}
// Get RBAC rules down the actual path
pathBit := res[from:nextDelim]
if n.children[pathBit] != nil {
out = append(out, n.children[pathBit].get(res, nextDelim+1)...)
}
// Get RBAC rules down the wildcard path
if n.children[wildcard] != nil {
out = append(out, n.children[wildcard].get(res, nextDelim+1)...)
}
return
}
// empty returns true if the index is empty
func (t *ruleIndex) empty() bool {
return t == nil || t.children == nil || len(t.children) == 0
}
func (t *ruleIndex) matchingRule(role uint64, op, res string) (out *Rule) {
set := RuleSet(t.get(role, op, res))
sort.Sort(set)
for _, s := range set {
if s.Access == Inherit {
continue
}
return s
}
return nil
}

View File

@@ -0,0 +1,181 @@
package rbac
import (
"sort"
"testing"
"github.com/stretchr/testify/require"
)
func TestIndex(t *testing.T) {
tcc := []struct {
name string
in []*Rule
out []int
role uint64
op string
res string
}{
{
name: "empty",
in: nil,
out: nil,
role: 1,
op: "read",
res: "a:b/c/d",
}, {
name: "match",
in: []*Rule{{
RoleID: 1,
Resource: "a:b/c/d",
Operation: "read",
Access: Allow,
}},
out: []int{0},
role: 1,
op: "read",
res: "a:b/c/d",
}, {
name: "multiple matches",
in: []*Rule{{
RoleID: 1,
Resource: "a:b/c/d",
Operation: "read",
Access: Allow,
}, {
RoleID: 1,
Resource: "a:b/*/*",
Operation: "read",
Access: Inherit,
}},
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",
}, {
name: "path missmatch",
in: []*Rule{{
RoleID: 1,
Resource: "a:b/c/e",
Operation: "read",
Access: Allow,
}},
out: nil,
role: 1,
op: "read",
res: "a:b/c/d",
}, {
name: "operation missmatch",
in: []*Rule{{
RoleID: 1,
Resource: "a:b/c/d",
Operation: "write",
Access: Allow,
}},
out: nil,
role: 1,
op: "read",
res: "a:b/c/d",
}}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
ix := buildRuleIndex(tc.in)
out := RuleSet(ix.get(tc.role, tc.op, tc.res))
sort.Sort(out)
want := RuleSet(graby(tc.in, tc.out))
sort.Sort(want)
require.Len(t, out, len(want))
for i, o := range out {
require.Equal(t, want[i], o)
}
})
}
}
func graby(rr []*Rule, want []int) (out []*Rule) {
out = make([]*Rule, 0, len(want))
for _, w := range want {
out = append(out, rr[w])
}
return
}
// goos: linux
// goarch: amd64
// 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
func benchmarkIndexBuild(b *testing.B, rules []*Rule) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
buildRuleIndex(rules)
}
}
func BenchmarkIndexBuild_100(b *testing.B) {
benchmarkIndexBuild(b, makeRuleSet(100, 10))
}
func BenchmarkIndexBuild_1000(b *testing.B) {
benchmarkIndexBuild(b, makeRuleSet(1000, 10))
}
func BenchmarkIndexBuild_10000(b *testing.B) {
benchmarkIndexBuild(b, makeRuleSet(10000, 10))
}
func BenchmarkIndexBuild_100000(b *testing.B) {
benchmarkIndexBuild(b, makeRuleSet(100000, 10))
}

View File

@@ -0,0 +1,54 @@
package rbac
import (
"sort"
"testing"
"github.com/stretchr/testify/require"
)
func TestRuleSetSort(t *testing.T) {
var (
req = require.New(t)
rr = RuleSet{
{Resource: ":::/*/*/*"},
{Resource: ":::/1/2/3"},
{Resource: ":::/1/2/*"},
{Resource: ":::/1/*/*"},
{Resource: ":::/1/*/3"},
{Resource: ":::/*/*/3"},
{Resource: ":::/*/2/*"},
}
c int = 0
i = func() int {
c++
return c - 1
}
)
req.NotNil(rr)
sort.Sort(rr)
c = i()
req.Equal(":::/1/2/3", rr[i()].Resource)
req.Equal(":::/1/*/3", rr[i()].Resource)
req.Equal(":::/*/*/3", rr[i()].Resource)
req.Equal(":::/1/2/*", rr[i()].Resource)
req.Equal(":::/*/2/*", rr[i()].Resource)
req.Equal(":::/1/*/*", rr[i()].Resource)
req.Equal(":::/*/*/*", rr[i()].Resource)
}
func TestRuleSet_FilterResource(t *testing.T) {
var (
req = require.New(t)
rr = RuleSet{
{Resource: ":::/*/*/*"},
{Resource: ":::/1/2/3"},
}
)
req.Len(rr.FilterResource(NewResource(":::/1/2/3")), 1)
req.Len(rr.FilterResource(NewResource(":::/*/*/*")), 1)
req.Len(rr.FilterResource(NewResource(":::/*")), 0)
}

View File

@@ -0,0 +1,116 @@
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
}

View File

@@ -0,0 +1,238 @@
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)
})
}
}

View File

@@ -0,0 +1,184 @@
package rbac
import (
"github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/pkg/slice"
)
func merge(base RuleSet, new ...*Rule) (out RuleSet) {
var (
o int
blen = len(base)
)
if blen == 0 {
// Nothing exists yet, mark all as dirty
for r := range new {
new[r].dirty = true
}
return new
} else {
out = base
newRules:
for _, rule := range new {
// Never go beyond the last base rule (blen)
for o = 0; o < blen; o++ {
if eq(out[o], rule) {
out[o].dirty = out[o].Access != rule.Access
out[o].Access = rule.Access
// only one rule can match so proceed with next new rule
continue newRules
}
}
// none of the base new matched, append
var c = *rule
c.dirty = true
out = append(out, &c)
}
}
return
}
func eq(a, b *Rule) bool {
if a == nil || b == nil {
return false
}
return a.RoleID == b.RoleID &&
a.Resource == b.Resource &&
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{}
for _, r := range set {
var c = *r
if r.Access == Inherit {
deletable = append(deletable, &c)
continue
}
if r.dirty {
updatable = append(updatable, &c)
}
final = append(final, &c)
}
return
}
// Significant roles (sigRoles) returns two list of significant roles.
//
// First slice are roles that are allowed to perform an operation on a specific resource (directly or indirectly),
// 2nd slice are roles that are denied the op.
func (set RuleSet) sigRoles(res string, op string) (aRR, dRR []uint64) {
if hasWildcards(res) {
// nothing to do here, we need a direct resource id (level=0)
return
}
const (
dirRes = 0
indRes = 1
)
var (
rr = map[Access]map[int][]uint64{
Allow: {
dirRes: make([]uint64, 0),
indRes: make([]uint64, 0),
},
Deny: {
dirRes: make([]uint64, 0),
indRes: make([]uint64, 0),
},
}
)
// Extract all relevant rules (by op and resource) and group them by
// access and distance (rules for direct resources and rules for indirect resources)
for _, r := range set {
if r.Operation != op {
continue
}
if r.Resource == res {
// direct rules
rr[r.Access][dirRes] = append(rr[r.Access][dirRes], r.RoleID)
}
if matchResource(r.Resource, res) {
// rules on all resources of this type
rr[r.Access][indRes] = append(rr[r.Access][indRes], r.RoleID)
}
}
// Process all extracted roles and make sure that are filtered and ordered by relevance:
// 1. list of roles with denied operation directly on the resource
dRoles := slice.ToUint64BoolMap(rr[Deny][dirRes])
// 2. list of roles with allowed operation directly on the resource
aRoles := make(map[uint64]bool)
for _, r := range rr[Allow][dirRes] {
aRoles[r] = !dRoles[r]
}
// 3. list of roles with denied operation indirectly on the resource
for _, r := range rr[Deny][indRes] {
dRoles[r] = true
}
// 4. list of roles with allowed operation indirectly on the resource
for _, r := range rr[Allow][indRes] {
aRoles[r] = !dRoles[r]
}
for r, chk := range aRoles {
if chk {
aRR = append(aRR, r)
}
}
for r, chk := range dRoles {
if chk {
dRR = append(dRR, r)
}
}
// add bypass roles
for _, r := range auth.BypassRoles() {
if r == nil {
continue
}
aRR = append(aRR, r.ID)
}
return
}
func clear(set []*Rule) {
for _, r := range set {
r.dirty = false
}
}

View File

@@ -0,0 +1,188 @@
package rbac
import (
"sort"
"testing"
"github.com/stretchr/testify/require"
)
// Test role inheritance
func TestRuleSet_merge(t *testing.T) {
var (
role1 uint64 = 1
role2 uint64 = 2
role3 uint64 = 3
resService1 = "res1"
resService2 = "res2"
opAccess = "access"
resThing42 = "42"
resThingWc = "*"
sCases = []struct {
old RuleSet
new RuleSet
deletable RuleSet
updatable RuleSet
final RuleSet
}{
{
RuleSet{AllowRule(role1, resService1, opAccess)},
RuleSet{AllowRule(role1, resService1, opAccess)},
RuleSet{},
RuleSet{},
RuleSet{AllowRule(role1, resService1, opAccess)},
},
{
RuleSet{AllowRule(role1, resService1, opAccess)},
RuleSet{DenyRule(role1, resService1, opAccess)},
RuleSet{},
RuleSet{DenyRule(role1, resService1, opAccess)},
RuleSet{DenyRule(role1, resService1, opAccess)},
},
{
RuleSet{AllowRule(role1, resService1, opAccess)},
RuleSet{InheritRule(role1, resService1, opAccess)},
RuleSet{InheritRule(role1, resService1, opAccess)},
RuleSet{},
RuleSet{},
},
{
RuleSet{AllowRule(role1, resService1, opAccess)},
RuleSet{AllowRule(role1, resService1, opAccess)},
RuleSet{},
RuleSet{},
RuleSet{AllowRule(role1, resService1, opAccess)},
},
{
RuleSet{
AllowRule(role1, resService1, opAccess),
DenyRule(role2, resService1, opAccess),
DenyRule(role3, resService2, opAccess),
AllowRule(role1, resService2, opAccess),
AllowRule(role2, resThing42, opAccess),
},
RuleSet{
DenyRule(role3, resThingWc, opAccess),
AllowRule(role1, resService2, opAccess),
AllowRule(role1, resThing42, opAccess),
InheritRule(role2, resThing42, opAccess),
},
RuleSet{
InheritRule(role2, resThing42, opAccess),
},
RuleSet{
DenyRule(role3, resThingWc, opAccess),
AllowRule(role1, resThing42, opAccess),
},
RuleSet{
AllowRule(role1, resService1, opAccess),
DenyRule(role2, resService1, opAccess),
DenyRule(role3, resService2, opAccess),
AllowRule(role1, resService2, opAccess),
DenyRule(role3, resThingWc, opAccess),
AllowRule(role1, resThing42, opAccess),
},
},
}
)
for _, sc := range sCases {
t.Run("", func(t *testing.T) {
var (
req = require.New(t)
// Apply changed and get update candidates
mrg = merge(sc.old, sc.new...)
deletable, updatable, final = flushable(mrg)
)
req.Equal(len(sc.deletable), len(deletable), "rule count for deletable do not match")
req.Equal(len(sc.updatable), len(updatable), "rule count for updatable do not match")
req.Equal(len(sc.final), len(final), "rule count for final do not match")
// Clear dirty flag so that we do not confuse comparison test
clear(deletable)
clear(updatable)
clear(final)
req.Equal(sc.deletable, deletable, "deletable rules do not match")
req.Equal(sc.updatable, updatable, "updatable rules do not match")
req.Equal(sc.final, final, "final rules do not match")
})
}
}
// Test role inheritance
func TestRuleSet_sigRoles(t *testing.T) {
const (
roleA uint64 = 1
roleB uint64 = 2
roleC uint64 = 3
roleD uint64 = 4
roleE uint64 = 5
opRead = "read"
opWrite = "write"
resD = "res:foo:1"
resI = "res:foo:*"
)
var (
rr = func(rr ...uint64) []uint64 { return rr }
sCases = []struct {
set RuleSet
arr []uint64
drr []uint64
}{
{
RuleSet{
AllowRule(roleA, resD, opRead),
DenyRule(roleA, resD, opWrite),
AllowRule(roleB, resI, opRead),
DenyRule(roleA, resI, opRead),
DenyRule(roleC, resI, opRead),
DenyRule(roleD, resI, opRead),
DenyRule(roleE, resI, opRead),
DenyRule(roleE, resI, opWrite),
},
rr(roleB),
rr(roleA, roleC, roleD, roleE),
},
{
RuleSet{
AllowRule(roleA, resD, opRead),
DenyRule(roleA, resD, opRead),
},
rr(),
rr(roleA),
},
{
RuleSet{
AllowRule(roleA, resD, opRead),
DenyRule(roleA, resD, opRead),
AllowRule(roleA, resI, opRead),
DenyRule(roleA, resI, opRead),
},
rr(),
rr(roleA),
},
}
)
for _, sc := range sCases {
t.Run("a", func(t *testing.T) {
req := require.New(t)
arr, drr := sc.set.sigRoles(resD, opRead)
sort.Slice(sc.arr, func(i, j int) bool { return sc.arr[i] < sc.arr[j] })
sort.Slice(sc.drr, func(i, j int) bool { return sc.drr[i] < sc.drr[j] })
sort.Slice(arr, func(i, j int) bool { return arr[i] < arr[j] })
sort.Slice(drr, func(i, j int) bool { return drr[i] < drr[j] })
req.Equal(sc.arr, arr)
req.Equal(sc.drr, drr)
})
}
}

531
server/pkg/rbac/service.go Normal file
View File

@@ -0,0 +1,531 @@
package rbac
import (
"context"
"strconv"
"sync"
"time"
"github.com/cortezaproject/corteza/server/pkg/id"
"github.com/cortezaproject/corteza/server/pkg/sentry"
"go.uber.org/zap"
)
type (
// UserResolver is a callback that loads user properties by ID.
// Used during contextual role expression evaluation to populate
// the "user" variable in expression scope.
UserResolver func(userID uint64) map[string]interface{}
service struct {
l *sync.RWMutex
logger *zap.Logger
// service will flush values on TRUE or just reload on FALSE
f chan bool
rules RuleSet
indexed *ruleIndex
roles []*Role
orgTree *orgTree
store rbacRulesStore
userResolver UserResolver
userCache userCache
}
// RuleFilter is a dummy struct to satisfy store codegen
RuleFilter struct {
Limit uint
}
RoleSettings struct {
Bypass []uint64
Authenticated []uint64
Anonymous []uint64
}
)
var (
// Global RBAC service
gRBAC *service
)
const (
watchInterval = time.Hour
debugWatchInterval = time.Minute * 5
RuleResourceType = "corteza::generic:rbac-rule"
)
// Global returns global RBAC service
func Global() *service {
return gRBAC
}
// SetGlobal re-sets global service
func SetGlobal(svc *service) {
gRBAC = 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),
store: s,
userCache: newUserCache(),
}
if logger != nil {
svc.logger = logger.Named("rbac")
}
return
}
// @todo consider a more graceful update
func (svc *service) UpdateUserGroups(gm ...GroupMembers) (err error) {
svc.orgTree, err = OrgTree(
svc.logger.Named("org tree"),
gm...,
)
return
}
// SetUserResolver registers a callback that resolves user properties by user ID.
// The resolved user data is injected into the expression scope as "user" when
// evaluating contextual role expressions.
func (svc *service) SetUserResolver(fn UserResolver) {
svc.userResolver = fn
}
// InvalidateUser removes a single user entry from the resolver cache.
func (svc *service) InvalidateUser(userID uint64) {
svc.userCache.delete(userID)
}
// InvalidateAllUsers clears the entire user resolver cache.
func (svc *service) InvalidateAllUsers() {
svc.userCache.purge()
}
// cachedUserResolver wraps userResolver with the userPropertyCache.
// Returns nil when no resolver is configured.
func (svc *service) cachedUserResolver() UserResolver {
if svc.userResolver == nil {
return nil
}
return func(userID uint64) map[string]interface{} {
if v, ok := svc.userCache.load(userID); ok {
return v
}
result := svc.userResolver(userID)
svc.userCache.store(userID, result)
return result
}
}
// 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
}
// 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) (userAccess Access) {
if !svc.checkValidity(ses, op, res) {
return Inherit
}
sesRoles := getSessionRoles(ses, res, svc.roles, svc.cachedUserResolver())
// @todo probably move this somewhere
if member(sesRoles, BypassRole) {
return Allow
}
treeAccess := svc.checkMemberBranch(ses, op, res)
userAccess = check(svc.indexed, sesRoles, op, res.RbacResource(), nil)
out := svc.mergeAccess(treeAccess, userAccess)
return out
}
func (svc *service) checkValidity(ses Session, op string, res Resource) bool {
// prevent use of wildcard resources for checking permissions
if hasWildcards(res.RbacResource()) {
return false
}
return true
}
func (svc *service) checkMemberBranch(ses Session, op string, res Resource) Access {
mm, err := svc.orgTree.MemberBranch(id.MustNumID(ses.Identity()))
if err != nil {
return Deny
}
return svc.evalSubBranch(mm, op, res.RbacResource())
}
// @note being ery explicit with branches for the sake of clarity
func (svc *service) mergeAccess(treeAccess, userAccess Access) Access {
if userAccess == Deny {
return Deny
}
if userAccess == Allow {
return Allow
}
// @note userAccess will always be Inherit here; adding a little guard
// just in case we ever expand this
if userAccess != Inherit {
panic("new edge case; to address")
}
if treeAccess == Allow {
return Allow
}
if treeAccess == Deny {
return Deny
}
return Inherit
}
func (svc *service) evalSubBranch(nn []*groupNode, op, res string) (a Access) {
aux := make([]Access, len(nn))
wg := sync.WaitGroup{}
wg.Add(len(nn))
for i, n := range nn {
go func() {
aux[i] = svc.evalNode(n, op, res)
wg.Done()
}()
}
wg.Wait()
for _, x := range aux {
if x == Allow {
return Allow
}
}
return Inherit
}
func (svc *service) evalNode(n *groupNode, op, res string) (a Access) {
thingy := make(map[uint64]bool, len(n.roles))
for _, r := range n.roles {
thingy[r.Number()] = true
}
return check(svc.indexed, partRoles{
CommonRole: thingy,
}, op, res, nil)
}
// func (svc *service) grabCtxRoles(ses Session, res Resource)
// Trace checks RBAC rules and returns all decision trace log
func (svc *service) Trace(ses Session, op string, res Resource) *Trace {
var (
t = new(Trace)
)
if hasWildcards(res.RbacResource()) {
// a special case for when user has contextual roles
// AND trace is done on a resource with wildcards
ctxRolesDebug := partRoles{ContextRole: make(map[uint64]bool)}
for _, memberOf := range ses.Roles() {
for _, role := range svc.roles {
if role.kind != ContextRole {
continue
}
if role.id != memberOf {
continue
}
// member of contextual role
//
// this is a tricky situation:
// when doing regular check this is an unlikely scenario since
// check can not be done on a resource with wildcards
//
// all contextual roles we're members off will be collected
ctxRolesDebug[ContextRole][memberOf] = true
}
}
if len(ctxRolesDebug[ContextRole]) > 0 {
// session has at least one contextual role
// and since we're checking this on a wildcard resource
// there is no need to procede with RBAC check
baseTraceInfo(t, res.RbacResource(), op, ctxRolesDebug)
resolve(t, Inherit, unknownContext)
return t
}
}
var (
fRoles = getSessionRoles(ses, res, svc.roles, svc.cachedUserResolver())
)
_ = check(svc.indexed, fRoles, op, res.RbacResource(), t)
return t
}
// 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()
for _, r := range rules {
svc.logger.Debug(r.Access.String() + " " + r.Operation + " on " + r.Resource + " to " + strconv.FormatUint(r.RoleID, 10))
}
svc.grant(rules...)
return svc.flush(ctx)
}
func (svc *service) grant(rules ...*Rule) {
svc.rules = merge(svc.rules, rules...)
svc.indexed = buildRuleIndex(svc.rules)
}
// Watch reloads RBAC rules in intervals and on request
func (svc *service) Watch(ctx context.Context) {
go func() {
defer sentry.Recover()
var ticker = time.NewTicker(watchInterval)
var debugTicker = time.NewTicker(debugWatchInterval)
defer ticker.Stop()
defer debugTicker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
svc.Reload(ctx)
case <-svc.f:
svc.Reload(ctx)
case <-debugTicker.C:
// @todo truncate and bind to a level
// svc.LogDebug(ctx)
}
}
}()
svc.logger.Debug("watcher initialized")
}
func (svc *service) AddGroupRole(group id.ID, roles ...id.ID) (err error) {
return svc.orgTree.AddGroupRole(group, roles...)
}
func (svc *service) RemoveGroupRole(group id.ID, roles ...id.ID) (err error) {
return svc.orgTree.RemoveGroupRole(group, roles...)
}
func (svc *service) AssignGroupMembers(group id.ID, members ...id.ID) (err error) {
return svc.orgTree.AssignGroupMembers(group, members...)
}
func (svc *service) RemoveGroupMembers(group id.ID, members ...id.ID) (err error) {
return svc.orgTree.RemoveGroupMembers(group, members...)
}
func (svc *service) AddNode(id id.ID, handle string, paths ...GroupNodePath) (err error) {
return svc.orgTree.AddNode(id, handle, paths...)
}
func (svc *service) UpdateNode(id id.ID, handle string, paths ...GroupNodePath) (err error) {
return svc.orgTree.UpdateNode(id, handle, paths...)
}
func (svc *service) RemoveNode(id id.ID) (err error) {
return svc.orgTree.RemoveNode(id)
}
// 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()
return ruleByRole(svc.rules, roleID)
}
// Rules return all roles
func (svc *service) Rules() (rr RuleSet) {
svc.l.RLock()
defer svc.l.RUnlock()
return svc.rules
}
// Reload store rules
func (svc *service) Reload(ctx context.Context) {
svc.l.Lock()
defer svc.l.Unlock()
svc.reloadRules(ctx)
}
// Clear removes all access control rules
func (svc *service) Clear() {
svc.l.Lock()
defer svc.l.Unlock()
svc.rules = RuleSet{}
svc.indexed = &ruleIndex{}
}
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)),
)
if err == nil {
svc.setRules(rr)
}
}
func (svc *service) setRules(rr RuleSet) {
svc.rules = rr
svc.indexed = buildRuleIndex(rr)
}
// 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
}
// 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)")
return
}
deletable, updatable, final := flushable(svc.rules)
err = svc.store.DeleteRbacRule(ctx, deletable...)
if err != nil {
return
}
err = svc.store.UpsertRbacRule(ctx, updatable...)
if err != nil {
return
}
clear(final)
svc.rules = final
svc.logger.Debug(
"flushed rules",
zap.Int("deleted", len(deletable)),
zap.Int("updated", len(updatable)),
zap.Int("final", len(final)),
)
return
}
// 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()
return svc.rules.sigRoles(res.RbacResource(), op)
}
// CloneRulesByRoleID clone all rules of a Role S to a specific Role T by removing its existing rules
func (svc *service) CloneRulesByRoleID(ctx context.Context, fromRoleID uint64, toRoleID ...uint64) (err error) {
var (
updatedRules RuleSet
)
// Make sure rules of fromRoleID stays intact
rr := svc.FindRulesByRoleID(fromRoleID)
for _, roleID := range toRoleID {
// Remove existing rules
existingRules := svc.FindRulesByRoleID(roleID)
for _, rule := range existingRules {
// Make sure to remove existing rules
rule.Access = Inherit
}
updatedRules = append(updatedRules, existingRules...)
// Clone rules from role S to role T
for _, rule := range rr {
// Make sure everything is properly set
r := *rule
r.RoleID = roleID
updatedRules = append(updatedRules, &r)
}
}
return svc.Grant(ctx, updatedRules...)
}
func (svc *service) LogDebug(ctx context.Context) {
nodes := make([]map[string]any, 0, 4)
for _, n := range svc.orgTree.root.inline() {
nodes = append(nodes, n.format())
}
children := make(map[string]string, 4)
for u, g := range svc.orgTree.memberGroupIndex {
children[u.Value()] = g.id.Value()
}
svc.logger.Debug("internal org tree state", zap.Any("nodes", nodes), zap.Any("role membership", children))
}

View File

@@ -0,0 +1,34 @@
package rbac
import (
"context"
"fmt"
)
type (
// ServiceAllowAll constructs not-for-production RBAC service
ServiceAllowAll struct{}
)
func (ServiceAllowAll) Can(Session, string, Resource) bool {
return true
}
func (ServiceAllowAll) Check([]uint64, string, Resource) (v Access) {
return Allow
}
func (ServiceAllowAll) FindRulesByRoleID(uint64) (rr RuleSet) {
return
}
func (ServiceAllowAll) Grant(context.Context, ...*Rule) error {
return nil
}
func (ServiceAllowAll) Trace(Session, string, Resource) *Trace {
return nil
}
func (ServiceAllowAll) CloneRulesByRoleID(context.Context, uint64, ...uint64) error {
return fmt.Errorf(" ServiceAllowAll does not support rule clonning")
}

View File

@@ -0,0 +1,345 @@
package rbac
import (
"context"
"fmt"
"math"
"math/rand"
"testing"
"github.com/cortezaproject/corteza/server/pkg/expr"
"go.uber.org/zap"
)
type (
matchBenchCfg struct {
rules RuleSet
roles []*Role
res Resource
op string
}
)
// goos: linux
// goarch: amd64
// pkg: github.com/cortezaproject/corteza/server/pkg/rbac
// cpu: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz
// Benchmark_AccessCheck_role5_rule500-12 378988 3026 ns/op 615 B/op 16 allocs/op
// Benchmark_AccessCheck_role5_rule1000-12 253071 4087 ns/op 615 B/op 16 allocs/op
// Benchmark_AccessCheck_role10_rule10000-12 237085 5429 ns/op 1026 B/op 29 allocs/op
// Benchmark_AccessCheck_role20_rule50000-12 128914 9344 ns/op 2335 B/op 71 allocs/op
// Benchmark_AccessCheck_role30_rule100000-12 79963 20670 ns/op 3371 B/op 85 allocs/op
// Benchmark_AccessCheck_role100_rule500000-12 16927 79106 ns/op 12796 B/op 391 allocs/op
func benchmark_AccessCheck(b *testing.B, cfg matchBenchCfg) {
svc := NewService(zap.NewNop(), nil)
svc.UpdateRoles(cfg.roles...)
svc.setRules(cfg.rules)
ctx := context.Background()
b.ResetTimer()
for n := 0; n < b.N; n++ {
svc.Can(session{
id: 90001,
rr: yankRandRoles(cfg.roles),
ctx: ctx,
}, cfg.op, cfg.res)
}
}
func Benchmark_AccessCheck_role100_rule1000(b *testing.B) {
roles := 100
rules := 1000
benchmark_AccessCheck(b, matchBenchCfg{
res: makeResource(),
op: randomOperation(),
rules: makeRuleSet(rules, roles),
roles: makeRoleSet(roles),
})
}
func Benchmark_AccessCheck_role100_rule10000(b *testing.B) {
roles := 100
rules := 10000
benchmark_AccessCheck(b, matchBenchCfg{
res: makeResource(),
op: randomOperation(),
rules: makeRuleSet(rules, roles),
roles: makeRoleSet(roles),
})
}
func Benchmark_AccessCheck_role100_rule100000(b *testing.B) {
roles := 100
rules := 100000
benchmark_AccessCheck(b, matchBenchCfg{
res: makeResource(),
op: randomOperation(),
rules: makeRuleSet(rules, roles),
roles: makeRoleSet(roles),
})
}
func Benchmark_AccessCheck_role100_rule1000000(b *testing.B) {
roles := 100
rules := 1000000
benchmark_AccessCheck(b, matchBenchCfg{
res: makeResource(),
op: randomOperation(),
rules: makeRuleSet(rules, roles),
roles: makeRoleSet(roles),
})
}
func Benchmark_AccessCheck_role100_rule10000000(b *testing.B) {
roles := 100
rules := 10000000
benchmark_AccessCheck(b, matchBenchCfg{
res: makeResource(),
op: randomOperation(),
rules: makeRuleSet(rules, roles),
roles: makeRoleSet(roles),
})
}
func Benchmark_AccessCheck_role1000_rule1000(b *testing.B) {
roles := 1000
rules := 1000
benchmark_AccessCheck(b, matchBenchCfg{
res: makeResource(),
op: randomOperation(),
rules: makeRuleSet(rules, roles),
roles: makeRoleSet(roles),
})
}
func Benchmark_AccessCheck_role1000_rule10000(b *testing.B) {
roles := 1000
rules := 10000
benchmark_AccessCheck(b, matchBenchCfg{
res: makeResource(),
op: randomOperation(),
rules: makeRuleSet(rules, roles),
roles: makeRoleSet(roles),
})
}
func Benchmark_AccessCheck_role1000_rule100000(b *testing.B) {
roles := 1000
rules := 100000
benchmark_AccessCheck(b, matchBenchCfg{
res: makeResource(),
op: randomOperation(),
rules: makeRuleSet(rules, roles),
roles: makeRoleSet(roles),
})
}
func Benchmark_AccessCheck_role1000_rule1000000(b *testing.B) {
roles := 1000
rules := 1000000
benchmark_AccessCheck(b, matchBenchCfg{
res: makeResource(),
op: randomOperation(),
rules: makeRuleSet(rules, roles),
roles: makeRoleSet(roles),
})
}
func Benchmark_AccessCheck_role1000_rule10000000(b *testing.B) {
roles := 1000
rules := 10000000
benchmark_AccessCheck(b, matchBenchCfg{
res: makeResource(),
op: randomOperation(),
rules: makeRuleSet(rules, roles),
roles: makeRoleSet(roles),
})
}
func Benchmark_AccessCheck_role10000_rule1000(b *testing.B) {
roles := 10000
rules := 1000
benchmark_AccessCheck(b, matchBenchCfg{
res: makeResource(),
op: randomOperation(),
rules: makeRuleSet(rules, roles),
roles: makeRoleSet(roles),
})
}
func Benchmark_AccessCheck_role10000_rule10000(b *testing.B) {
roles := 10000
rules := 10000
benchmark_AccessCheck(b, matchBenchCfg{
res: makeResource(),
op: randomOperation(),
rules: makeRuleSet(rules, roles),
roles: makeRoleSet(roles),
})
}
func Benchmark_AccessCheck_role10000_rule100000(b *testing.B) {
roles := 10000
rules := 100000
benchmark_AccessCheck(b, matchBenchCfg{
res: makeResource(),
op: randomOperation(),
rules: makeRuleSet(rules, roles),
roles: makeRoleSet(roles),
})
}
func Benchmark_AccessCheck_role10000_rule1000000(b *testing.B) {
roles := 10000
rules := 1000000
benchmark_AccessCheck(b, matchBenchCfg{
res: makeResource(),
op: randomOperation(),
rules: makeRuleSet(rules, roles),
roles: makeRoleSet(roles),
})
}
func Benchmark_AccessCheck_role10000_rule10000000(b *testing.B) {
roles := 10000
rules := 10000000
benchmark_AccessCheck(b, matchBenchCfg{
res: makeResource(),
op: randomOperation(),
rules: makeRuleSet(rules, roles),
roles: makeRoleSet(roles),
})
}
func yankRandRoles(base []*Role) (out []uint64) {
count := rand.Intn(len(base))
if count <= 0 {
count = int(math.Ceil(float64(len(base)) / 2))
}
out = make([]uint64, count)
for i := 0; i < count; i++ {
out[i] = base[i].id
}
return
}
func makeRoleSet(count int) (out []*Role) {
for i := 0; i < count; i++ {
out = append(out, makeRole(uint64(1000+i), fmt.Sprintf("rl_%d", 1000+i)))
}
return
}
func makeRole(id uint64, handle string) *Role {
rx := rand.Float64()
if rx <= 1 {
return CommonRole.Make(id, handle)
}
return makeContextualRole(id, handle)
}
func makeContextualRole(id uint64, handle string) *Role {
rx := rand.Float64()
if rx < 0.7 {
return makeContextualRolePassing(id, handle)
}
return makeContextualRoleFailing(id, handle)
}
func makeContextualRolePassing(id uint64, handle string) *Role {
p := expr.NewParser()
eval, err := p.Parse("true == true && true == true && 1 <= 1")
if err != nil {
panic(err)
}
check := func(scope map[string]interface{}) bool {
vars, err := expr.NewVars(scope)
if err != nil {
return false
}
ctx := context.Background()
test, err := eval.Test(ctx, vars)
if err != nil {
return false
}
return test
}
return MakeContextRole(id, handle, check, "corteza::compose:record")
}
func makeContextualRoleFailing(id uint64, handle string) *Role {
p := expr.NewParser()
eval, err := p.Parse("false == false || false == false || 1 > 1")
if err != nil {
panic(err)
}
check := func(scope map[string]interface{}) bool {
vars, err := expr.NewVars(scope)
if err != nil {
return false
}
ctx := context.Background()
test, err := eval.Test(ctx, vars)
if err != nil {
return false
}
return test
}
return MakeContextRole(id, handle, check, "corteza::compose:record")
}
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 {
return Inherit
}
return Inherit
}
func randomOperation() (out string) {
ops := []string{"read", "write", "delete"}
return ops[rand.Intn(len(ops))]
}
func randomResource() (out string) {
return fmt.Sprintf("%s:%s/%s/%s", RandStringRunes(1), RandStringRunes(1), RandStringRunes(1), RandStringRunes(1))
}
// var letterRunes = []rune("abcdefghijklmnoprst")
var letterRunes = []rune("abcdefghijklmnoprstuvzxyABCDEFGHIJKLMNOPRSTXY")
func RandStringRunes(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}

View File

@@ -0,0 +1,58 @@
package rbac
import (
"context"
"github.com/cortezaproject/corteza/server/pkg/auth"
)
type (
// Security/RBAC session
Session interface {
// Identity of the subject
Identity() uint64
// Roles returns all subject's roles for the session
Roles() []uint64
// Context used for expr evaluation
Context() context.Context
}
session struct {
// identity
id uint64
// roles
rr []uint64
// context
ctx context.Context
}
)
func (s session) Identity() uint64 { return s.id }
func (s session) Roles() []uint64 { return s.rr }
func (s session) Context() context.Context { return s.ctx }
var _ Session = &session{}
func ContextToSession(ctx context.Context) *session {
return NewSession(ctx, auth.GetIdentityFromContext(ctx))
}
func ParamsToSession(ctx context.Context, user uint64, roles ...uint64) *session {
return &session{
id: user,
rr: roles,
ctx: ctx,
}
}
func NewSession(ctx context.Context, i auth.Identifiable) *session {
return &session{
id: i.Identity(),
rr: i.Roles(),
ctx: ctx,
}
}

View File

@@ -0,0 +1,15 @@
package rbac
import (
"context"
)
type (
// All
rbacRulesStore interface {
SearchRbacRules(ctx context.Context, f RuleFilter) (RuleSet, RuleFilter, error)
UpsertRbacRule(ctx context.Context, rr ...*Rule) error
DeleteRbacRule(ctx context.Context, rr ...*Rule) error
TruncateRbacRules(ctx context.Context) error
}
)

78
server/pkg/rbac/trace.go Normal file
View File

@@ -0,0 +1,78 @@
package rbac
import (
"github.com/cortezaproject/corteza/server/pkg/slice"
"sort"
)
type (
resolution string
Trace struct {
Resource string `json:"resource"`
Operation string `json:"operation"`
Access Access `json:"access"`
Roles slice.UInt64s `json:"roles"`
Rules []*Rule `json:"rules,omitempty"`
Resolution resolution `json:"resolution,omitempty"`
}
)
const (
failedIntegrityCheck resolution = "failed-integrity-check"
bypassRoleMembership resolution = "bypass-role-membership"
noRules resolution = "no-rules"
noMatch resolution = "no-match"
unknownContext resolution = "unknown-context"
)
// baseTraceInfo updates given check trace struct
//
// If Trace is nil, function terminates early and does not panic
func baseTraceInfo(t *Trace, res, op string, rolesByKind partRoles) {
if t == nil {
return
}
t.Resource = res
t.Operation = op
for _, rr := range rolesByKind {
for r := range rr {
t.Roles = append(t.Roles, r)
}
}
sort.Slice(t.Roles, func(i, j int) bool {
return t.Roles[i] < t.Roles[j]
})
}
// resolve updates access and resolution and returns access info unmodified
// if nil is passed for Trace, function silently ignores that
func resolve(t *Trace, access Access, res resolution) Access {
if t != nil {
t.Access = access
t.Resolution = res
}
return access
}
// ensure stable order of trace rules
func sortTraceRules(trace *Trace) {
// using custom sorting (and not RulSet implementation) because
// we might have multiple roles per resource.
sort.Slice(trace.Rules, func(i, j int) bool {
var (
rli = level(trace.Rules[i].Resource)
rlj = level(trace.Rules[j].Resource)
)
if rli != rlj {
return rli < rlj
}
return trace.Rules[i].RoleID < trace.Rules[j].RoleID
})
}

View File

@@ -0,0 +1,60 @@
package rbac
import (
"testing"
)
func TestEvaluate(t *testing.T) {
//var (
// rC1 = &Role{id: 1, kind: CommonRole}
// rAnon = &Role{id: 100, kind: AnonymousRole}
// rAuth = &Role{id: 200, kind: AuthenticatedRole}
// rCtx1 = &Role{id: 300, kind: ContextRole}
// rBps1 = &Role{id: 400, kind: BypassRole}
//
// allRoles = []*Role{rC1, rAnon, rAuth, rCtx1, rBps1}
// _ = allRoles
//
// res1 = "corteza::test:resource1"
//
// tcc = []struct {
// name string
// op string
// res string
// should Access
// roles []*Role
// rules RuleSet
// }{
// {
// name: "base",
// op: "READ",
// res: res1,
// roles: []*Role{rAuth},
// rules: RuleSet{
// &Rule{RoleID: rAuth.id, Resource: res1, Operation: "READ", Access: Allow},
// },
//
// should: Allow,
// },
// }
//)
//
//for _, tc := range tcc {
// t.Run(tc.name, func(t *testing.T) {
// var (
// req = require.New(t)
//
// partitionedRoles = partitionRoles(tc.roles...)
// indexedRules = indexRules(tc.rules)
//
// _access, _rule, _expl = evaluate(indexedRules, partitionedRoles, tc.op, tc.res, false)
// )
//
// _ = _access
// _ = _rule
// _ = _expl
//
// req.Equal(tc.should, _access)
// })
//}
}

View File

@@ -0,0 +1,50 @@
package rbac
import "sync"
const userCacheMaxSize = 1024
// userCache is a bounded, goroutine-safe cache of user property maps
// used by the RBAC user resolver to avoid repeated DB lookups per Check() call.
//
// When the cache reaches userCacheMaxSize entries, it is cleared before inserting
// the new entry. This simple eviction strategy avoids unbounded memory growth while
// keeping implementation complexity minimal.
type userCache struct {
mu sync.RWMutex
entries map[uint64]map[string]interface{}
}
func newUserCache() userCache {
return userCache{
entries: make(map[uint64]map[string]interface{}),
}
}
func (c *userCache) load(userID uint64) (map[string]interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.entries[userID]
return v, ok
}
func (c *userCache) store(userID uint64, data map[string]interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.entries) >= userCacheMaxSize {
c.entries = make(map[uint64]map[string]interface{})
}
c.entries[userID] = data
}
func (c *userCache) delete(userID uint64) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, userID)
}
func (c *userCache) purge() {
c.mu.Lock()
defer c.mu.Unlock()
c.entries = make(map[uint64]map[string]interface{})
}

View File

@@ -0,0 +1,629 @@
package rbac
import (
"errors"
"fmt"
"github.com/cortezaproject/corteza/server/pkg/id"
"github.com/cortezaproject/corteza/server/pkg/stack"
"go.uber.org/zap"
)
type (
orgTree struct {
logger *zap.Logger
root *groupNode
// branchIndex provides all of the available sub-trees in the given orgTree
//
// This is primarily meant to speed up the process of determining what
// permissions should be included in access evaluation
//
// @todo benchmark with/without to see if it makes sense
branchIndex map[id.ID]*groupNode
// memberGroupIndex holds what user group a given member belongs to
memberGroupIndex map[id.ID]*groupNode
// groupMemberIndex holds what members a specific user group holds
groupMemberIndex map[id.ID][]id.ID
}
groupNode struct {
id id.ID
handle string
// Nodes this one is parent of
children []groupNodeConnection
// Nodes this one is child of
//
// Used to simplify lookup
parents []groupNodeConnection
// Slice of paths this node was provided (this node -> parents)
paths []GroupNodePath
roles []id.ID
}
GroupNodePath struct {
SelfID id.ID
Name string
}
groupNodeConnection struct {
node *groupNode
name string
}
GroupMembers struct {
group *groupNode
members []id.ID
}
)
// ConvUserGroup creates internal structures for the org tree
func ConvUserGroup(id id.ID, handle string, members, roles []id.ID, paths []GroupNodePath) GroupMembers {
return GroupMembers{
group: &groupNode{
id: id,
handle: handle,
roles: roles,
paths: paths,
},
members: members,
}
}
// OrgTree prepares the organization tree subservice
func OrgTree(log *zap.Logger, gm ...GroupMembers) (svc *orgTree, err error) {
svc, err = mkOrgTree(gm...)
if err != nil {
return
}
svc.logger = log
err = svc.Rebuild(gm...)
return
}
func mkOrgTree(gm ...GroupMembers) (svc *orgTree, err error) {
svc = &orgTree{
logger: zap.NewNop(),
}
err = svc.Rebuild(gm...)
return
}
// Rebuild rebuilds the current organization tree dropping all currently indexed data
func (svc *orgTree) Rebuild(gm ...GroupMembers) (err error) {
svc.root = nil
svc.branchIndex = nil
svc.memberGroupIndex = nil
svc.groupMemberIndex = nil
gg := make([]*groupNode, 0, len(gm))
for _, m := range gm {
gg = append(gg, m.group)
}
svc.root, svc.branchIndex, err = buildOrgTree(gg...)
if err != nil {
return
}
for _, m := range gm {
err = svc.AssignGroupMembers(m.group.id, m.members...)
if err != nil {
return
}
}
return
}
func (svc *orgTree) AddNode(id id.ID, handle string, paths ...GroupNodePath) (err error) {
svc.logger.Debug("adding node", zap.Any("id", id.Value()), zap.String("handle", handle), zap.Any("paths", formatPaths(paths)))
n := &groupNode{
id: id,
handle: handle,
paths: paths,
}
err = svc.addNode(n)
if err != nil {
return
}
return
}
func (svc *orgTree) addNode(node *groupNode) (err error) {
if svc.root == nil {
svc.root = node
if svc.branchIndex == nil {
svc.branchIndex = make(map[id.ID]*groupNode)
}
svc.branchIndex[node.id] = node
if svc.groupMemberIndex == nil {
svc.groupMemberIndex = make(map[id.ID][]id.ID)
}
svc.groupMemberIndex[node.id] = []id.ID{}
return
}
for _, p := range node.paths {
i, n := svc.findNode(p.SelfID)
if i < 0 {
return fmt.Errorf("cannot add node path: parent node %v not found", p.SelfID)
}
n.children = append(n.children, groupNodeConnection{
node: node,
name: p.Name,
})
node.parents = append(node.parents, groupNodeConnection{
node: n,
name: p.Name,
})
}
svc.branchIndex[node.id] = node
svc.groupMemberIndex[node.id] = []id.ID{}
return
}
func (svc *orgTree) UpdateNode(idx id.ID, handle string, paths ...GroupNodePath) (err error) {
svc.logger.Debug("updating node", zap.Any("id", idx.Value()), zap.String("handle", handle), zap.Any("paths", formatPaths(paths)))
i, n := svc.findNode(idx)
if i < 0 {
return fmt.Errorf("cannot update node %v (%s): not indexed", idx.Value(), handle)
}
oldPaths := n.paths
n.handle = handle
n.paths = paths
if svc.branchIndex == nil {
svc.branchIndex = make(map[id.ID]*groupNode)
}
for _, op := range oldPaths {
i = svc.isInConnections(svc.branchIndex[op.SelfID].children, idx)
svc.branchIndex[op.SelfID].children = append(svc.branchIndex[op.SelfID].children[:i], svc.branchIndex[op.SelfID].children[i+1:]...)
i = svc.isInConnections(svc.branchIndex[idx].parents, op.SelfID)
svc.branchIndex[idx].parents = append(svc.branchIndex[idx].parents[:i], svc.branchIndex[idx].parents[i+1:]...)
}
for _, p := range paths {
svc.branchIndex[p.SelfID].children = append(svc.branchIndex[p.SelfID].children, groupNodeConnection{
node: n,
name: p.Name,
})
svc.branchIndex[idx].parents = append(svc.branchIndex[idx].parents, groupNodeConnection{
node: svc.branchIndex[p.SelfID],
name: p.Name,
})
}
return
}
func (svc *orgTree) RemoveNode(id id.ID) (err error) {
svc.logger.Debug("removing node", zap.Any("id", id.Value()))
i, n := svc.findNode(id)
if i < 0 {
return fmt.Errorf("cannot delete node %v: not indexed", id.Value())
}
if len(svc.groupMemberIndex[id]) > 0 {
return fmt.Errorf("cannot remove node %v: node has members", id)
}
if len(n.children) > 0 {
return fmt.Errorf("cannot remove node %v: node is above a node ", id)
}
for _, n := range svc.root.inline() {
i := svc.isInConnections(n.children, id)
if i < 0 {
continue
}
n.children = append(n.children[:i], n.children[i+1:]...)
}
delete(svc.branchIndex, id)
delete(svc.groupMemberIndex, id)
return
}
// GroupMembers returns all of the members belonging to the specific group
func (svc *orgTree) GroupMembers(group id.ID) (members []id.ID) {
if svc.memberGroupIndex == nil {
return
}
return svc.groupMemberIndex[group]
}
// IsAbove checks if parentUser is above the childUser
func (svc *orgTree) IsAbove(childUser, parentUser id.ID, paths ...string) bool {
childGroup := svc.memberGroupIndex[childUser]
parentGroup := svc.memberGroupIndex[parentUser]
return svc.isAbove(parentGroup, childGroup, paths...)
}
// isAbove checks if parent is above child based on the hierarchy
//
// The check goes bottom up as the number of paths will (in most cases) always be lower.
func (svc *orgTree) isAbove(parent, child *groupNode, paths ...string) (ok bool) {
checkConnection := func(c groupNodeConnection) bool {
if len(paths) == 0 {
return true
}
if c.name == "" {
return true
}
for _, pth := range paths {
if c.name == pth {
return true
}
}
return false
}
stack := stack.Stack[*groupNode]{}
for _, p := range child.parents {
if !checkConnection(p) {
continue
}
stack.Push(p.node)
}
for !stack.Empty() {
n := stack.Pop()
if n.id.Equal(parent.id) {
return true
}
for _, c := range n.parents {
if !checkConnection(c) {
continue
}
stack.Push(c.node)
}
}
return false
}
// MemberBranch returns the branch belonging to the user
func (svc *orgTree) MemberBranch(user id.ID, paths ...string) (group []*groupNode, err error) {
if svc == nil {
return
}
if svc.memberGroupIndex == nil {
return
}
aux, ok := svc.memberGroupIndex[user]
if !ok {
return nil, fmt.Errorf("user %s is not a member of any group", user.Value())
}
ss, ok := svc.branchIndex[aux.id]
if !ok {
return nil, fmt.Errorf("group %s not found in subtree index", aux.id.Value())
}
return ss.inline(paths...), nil
}
func (svc *orgTree) AddGroupRole(group id.ID, roles ...id.ID) (err error) {
svc.logger.Debug("adding group roles", zap.Any("group", group.Value()), zap.Any("roles", id.StringifySlice(roles...)))
i, n := svc.findNode(group)
if i < 0 {
return fmt.Errorf("node %v not found", group)
}
for _, r := range roles {
if id.InSlice(r, n.roles...) {
continue
}
n.roles = append(n.roles, r)
}
return
}
func (svc *orgTree) RemoveGroupRole(group id.ID, roles ...id.ID) (err error) {
svc.logger.Debug("removing group roles", zap.Any("group", group.Value()), zap.Any("roles", id.StringifySlice(roles...)))
i, n := svc.findNode(group)
if i < 0 {
return fmt.Errorf("node %v not found", group)
}
for _, r := range roles {
n.roles = id.RemoveFromSlice(r, n.roles...)
}
return
}
// AssignGroupMembers assigns the members to the specified group
func (svc *orgTree) AssignGroupMembers(group id.ID, members ...id.ID) (err error) {
if group.IsZero() {
return
}
svc.logger.Debug("assigning group members", zap.Any("group", group.Value()), zap.Any("roles", id.StringifySlice(members...)))
if svc.memberGroupIndex == nil {
svc.memberGroupIndex = make(map[id.ID]*groupNode)
}
if svc.groupMemberIndex == nil {
svc.groupMemberIndex = make(map[id.ID][]id.ID)
}
g := svc.branchIndex[group]
if g == nil {
return fmt.Errorf("group %s not found", group.Value())
}
for _, u := range members {
oldGroup := svc.memberGroupIndex[u]
// Cleanup previous state
if oldGroup != nil {
svc.groupMemberIndex[oldGroup.id] = id.RemoveFromSlice(u, svc.groupMemberIndex[oldGroup.id]...)
}
// Handle new state
svc.memberGroupIndex[u] = g
if !id.InSlice(u, svc.groupMemberIndex[group]...) {
svc.groupMemberIndex[group] = append(svc.groupMemberIndex[group], u)
}
}
return
}
// RemoveGroupMembers removes the members from the given user group
func (svc *orgTree) RemoveGroupMembers(group id.ID, members ...id.ID) (err error) {
if svc == nil {
return
}
svc.logger.Debug("assigning group members", zap.Any("group", group.Value()), zap.Any("roles", id.StringifySlice(members...)))
if svc.memberGroupIndex == nil {
return
}
for _, m := range members {
g, ok := svc.memberGroupIndex[m]
if !ok {
err = fmt.Errorf("user %s is not a member of any group", m.Value())
return
}
if !g.id.Equal(group) {
err = fmt.Errorf("user %s is not a member of group %s", m.Value(), group.Value())
return
}
// Cleanup
delete(svc.memberGroupIndex, m)
svc.groupMemberIndex[group] = id.RemoveFromSlice(m, svc.groupMemberIndex[group]...)
}
return
}
func buildOrgTree(gg ...*groupNode) (root *groupNode, index map[id.ID]*groupNode, err error) {
mapped := make(map[id.ID]*groupNode, len(gg))
for _, g := range gg {
mapped[g.id] = g
if len(g.paths) == 0 {
if root != nil {
return nil, nil, errors.New("multiple root nodes detected")
}
root = g
}
}
for _, g := range gg {
if len(g.paths) == 0 {
continue
}
for _, p := range g.paths {
parent, ok := mapped[p.SelfID]
if !ok {
return nil, nil, fmt.Errorf("node %s parent %s not found", g.id.Value(), p.SelfID.Value())
}
parent.children = append(parent.children, groupNodeConnection{
name: p.Name,
node: g,
})
g.parents = append(g.parents, groupNodeConnection{
node: parent,
name: p.Name,
})
}
}
index = make(map[id.ID]*groupNode, len(gg))
for _, g := range gg {
index[g.id] = g
}
return root, index, nil
}
// inline converts the subtree in a slice using BFS
func (root *groupNode) inline(paths ...string) []*groupNode {
if root == nil {
return nil
}
// Helper to check if a connection should be included based on path
//
// - It we don't specify any path, all nodes are used
// - If a connection name is empty, the node is included regardless of defined paths
// - If a connection defines a name, it must occur in the requested paths slice
inclConnection := func(c groupNodeConnection) bool {
if len(paths) == 0 {
return true
}
if c.name == "" {
return true
}
for _, pth := range paths {
if c.name == pth {
return true
}
}
return false
}
// outSeen helps us filter out duplicates
outSeen := make(map[id.ID]bool, 4)
out := make([]*groupNode, 0)
fifo := make([]*groupNode, 0)
fifo = append(fifo, root)
i := 0
for {
n := fifo[i]
if outSeen[n.id] {
if i+1 >= len(fifo) {
break
}
i++
continue
}
for _, c := range n.children {
if !inclConnection(c) {
continue
}
fifo = append(fifo, c.node)
}
out = append(out, n)
outSeen[n.id] = true
if i+1 >= len(fifo) {
break
}
i++
}
return out
}
func formatPaths(paths []GroupNodePath) []map[string]any {
out := make([]map[string]any, 0, len(paths))
for _, p := range paths {
out = append(out, map[string]any{
"selfID": p.SelfID,
"name": p.Name,
})
}
return out
}
func (n *groupNode) format() map[string]any {
out := map[string]any{
"id": n.id.Value(),
"handle": n.handle,
"paths": formatPaths(n.paths),
"roles": id.StringifySlice(n.roles...),
}
cc := make([]id.ID, 0, len(n.children))
for _, c := range n.children {
cc = append(cc, c.node.id)
}
out["children"] = id.StringifySlice(cc...)
return out
}
func (svc *orgTree) findNode(id id.ID) (i int, n *groupNode) {
nn := svc.root.inline()
i = 0
found := false
for i = range nn {
n = nn[i]
if n.id.Equal(id) {
found = true
break
}
}
if !found {
n = nil
i = -1
}
return
}
func (svc *orgTree) isInConnections(ss []groupNodeConnection, id id.ID) int {
for i, s := range ss {
if s.node.id.Equal(id) {
return i
}
}
return -1
}

View File

@@ -0,0 +1,173 @@
package rbac
import (
"fmt"
"math/rand"
"testing"
"github.com/cortezaproject/corteza/server/pkg/id"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
// goos: darwin
// goarch: arm64
// pkg: github.com/cortezaproject/corteza/server/pkg/rbac
// cpu: Apple M3 Pro
// BenchmarkAccess_100_10_1000-12 1552434 755.3 ns/op 320 B/op 11 allocs/op
// BenchmarkAccess_1000_100_10000-12 1314985 1053 ns/op 320 B/op 11 allocs/op
// BenchmarkAccess_10000_1000_10000-12 246471 4589 ns/op 780 B/op 13 allocs/op
// BenchmarkAccess_100000_10000_100000-12 2286 509592 ns/op 1119 B/op 16 allocs/op
func BenchmarkAccess_100_10_1000(b *testing.B) {
benchmarkAccess(b, 100, 10, 1000)
}
func BenchmarkAccess_1000_100_10000(b *testing.B) {
benchmarkAccess(b, 1000, 100, 10000)
}
func BenchmarkAccess_10000_1000_10000(b *testing.B) {
benchmarkAccess(b, 10000, 1000, 10000)
}
func BenchmarkAccess_100000_10000_100000(b *testing.B) {
benchmarkAccess(b, 100000, 10000, 10000)
}
func benchmarkAccess(b *testing.B, l1n, l2n, l3n int) {
r := &groupNode{
id: id.MustNumID(1),
handle: "root",
}
l1 := makeNodeSet(1, l1n)
l2 := makeNodeSet(2, l2n)
l3 := makeNodeSet(3, l3n)
connectNodeSets([]*groupNode{r}, l1)
connectNodeSets(l1, l2)
connectNodeSets(l2, l3)
connectNodeSets(l1, l3)
x := append([]*groupNode{r}, l1...)
x = append(x, l2...)
x = append(x, l3...)
root, _, err := buildOrgTree(x...)
require.NoError(b, err)
_ = root
mm := []GroupMembers{}
for _, x := range x {
mm = append(mm, GroupMembers{
group: x,
members: []id.ID{x.id},
})
}
svc := &orgTree{logger: zap.NewNop()}
err = svc.Rebuild(mm...)
if err != nil {
panic(err)
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
svc.IsAbove(pickRandomID(l2), pickRandomID(l1))
svc.IsAbove(pickRandomID(l3), pickRandomID(l1))
svc.IsAbove(pickRandomID(l3), pickRandomID(l2))
svc.IsAbove(pickRandomID(l3), root.id)
}
}
func pickRandomID(slice []*groupNode) id.ID {
return slice[rand.Intn(len(slice))].id
}
func makeNodeSet(lvl int, n int) (out []*groupNode) {
for i := 0; i < n; i++ {
out = append(out, &groupNode{
id: id.MustNumID(uint64(lvl*1000 + i)),
handle: fmt.Sprintf("%d_%d", lvl, i),
})
}
return
}
func connectNodeSets(parents, children []*groupNode) {
for i, c := range children {
c.paths = append(c.paths, mkPp(parents[i%len(parents)].id)...)
}
}
func BenchmarkBuildOrgTree(b *testing.B) {
bits := []GroupMembers{
{
group: &groupNode{
id: id.MustNumID(1),
handle: "1",
},
},
{
group: &groupNode{
id: id.MustNumID(2),
handle: "2",
paths: mkPp(id.MustNumID(1)),
},
},
{
group: &groupNode{
id: id.MustNumID(3),
handle: "3",
paths: mkPp(id.MustNumID(1)),
},
},
{
group: &groupNode{
id: id.MustNumID(4),
handle: "4",
paths: mkPp(id.MustNumID(1)),
},
},
{
group: &groupNode{
id: id.MustNumID(5),
handle: "5",
paths: mkPp(id.MustNumID(1)),
},
},
{
group: &groupNode{
id: id.MustNumID(6),
handle: "6",
paths: mkPp(id.MustNumID(1)),
},
},
{
group: &groupNode{
id: id.MustNumID(7),
handle: "7",
paths: mkPp(id.MustNumID(2)),
},
},
{
group: &groupNode{
id: id.MustNumID(8),
handle: "8",
paths: mkPp(id.MustNumID(3)),
},
},
}
svc := &orgTree{logger: zap.NewNop()}
b.ResetTimer()
for n := 0; n < b.N; n++ {
err := svc.Rebuild(bits...)
if err != nil {
panic(err)
}
}
}

View File

@@ -0,0 +1,807 @@
package rbac
import (
"testing"
"github.com/cortezaproject/corteza/server/pkg/id"
"github.com/stretchr/testify/require"
)
func TestGroupMembership(t *testing.T) {
svc, err := testPrepState(t)
require.NoError(t, err)
require.NotNil(t, svc)
mm := svc.GroupMembers(id.MustNumID(1))
require.Len(t, mm, 2)
require.Contains(t, mm, id.MustNumID(101))
require.Contains(t, mm, id.MustNumID(102))
mm = svc.GroupMembers(id.MustNumID(2))
require.Len(t, mm, 3)
require.Contains(t, mm, id.MustNumID(121))
require.Contains(t, mm, id.MustNumID(122))
require.Contains(t, mm, id.MustNumID(123))
}
func TestMemberBranch(t *testing.T) {
// @todo add some test cases for named paths; they use the same underlying fnc so I'm skipping for now
t.Run("unnamed paths", func(t *testing.T) {
svc, err := testPrepState(t)
require.NoError(t, err)
require.NotNil(t, svc)
mm, err := svc.MemberBranch(id.MustNumID(102))
require.NoError(t, err)
require.Len(t, mm, 4)
require.Equal(t, id.MustNumID(1), mm[0].id)
require.Equal(t, id.MustNumID(2), mm[1].id)
require.Equal(t, id.MustNumID(3), mm[2].id)
require.Equal(t, id.MustNumID(4), mm[3].id)
})
}
func TestIsAbove(t *testing.T) {
t.Run("unnamed", func(t *testing.T) {
svc, err := testPrepState(t)
require.NoError(t, err)
t.Run("yes, child", func(t *testing.T) {
require.True(t, svc.IsAbove(id.MustNumID(121), id.MustNumID(101)))
})
t.Run("yes, grandchild", func(t *testing.T) {
require.True(t, svc.IsAbove(id.MustNumID(141), id.MustNumID(101)))
})
t.Run("no, same lvl", func(t *testing.T) {
require.False(t, svc.IsAbove(id.MustNumID(101), id.MustNumID(102)))
})
t.Run("no, below", func(t *testing.T) {
require.False(t, svc.IsAbove(id.MustNumID(101), id.MustNumID(121)))
})
})
t.Run("named", func(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
err = svc.AssignGroupMembers(id.MustNumID(101), id.MustNumID(101))
require.NoError(t, err)
//
err = svc.AddNode(id.MustNumID(201), "c1", mkPpn(id.MustNumID(101), "a")...)
require.NoError(t, err)
err = svc.AssignGroupMembers(id.MustNumID(201), id.MustNumID(201))
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(202), "c2", mkPpn(id.MustNumID(101), "a", id.MustNumID(101), "b")...)
require.NoError(t, err)
err = svc.AssignGroupMembers(id.MustNumID(202), id.MustNumID(202))
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(203), "c3", mkPp(id.MustNumID(101))...)
require.NoError(t, err)
err = svc.AssignGroupMembers(id.MustNumID(203), id.MustNumID(203))
require.NoError(t, err)
//
err = svc.AddNode(id.MustNumID(301), "cc1", mkPpn(id.MustNumID(201), "a")...)
require.NoError(t, err)
err = svc.AssignGroupMembers(id.MustNumID(301), id.MustNumID(301))
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(302), "cc2", mkPpn(id.MustNumID(202), "a")...)
require.NoError(t, err)
err = svc.AssignGroupMembers(id.MustNumID(302), id.MustNumID(302))
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(303), "cc3", mkPpn(id.MustNumID(101), "a")...)
require.NoError(t, err)
err = svc.AssignGroupMembers(id.MustNumID(303), id.MustNumID(303))
require.NoError(t, err)
// ---
t.Run("yes, child, labeled path", func(t *testing.T) {
require.True(t, svc.IsAbove(id.MustNumID(201), id.MustNumID(101), "a"))
})
t.Run("yes, child, has labeled path but looking generic", func(t *testing.T) {
require.True(t, svc.IsAbove(id.MustNumID(201), id.MustNumID(101)))
})
t.Run("no, child, wrong labeled path", func(t *testing.T) {
require.False(t, svc.IsAbove(id.MustNumID(201), id.MustNumID(101), "b"))
})
t.Run("yes, grandchild, labeled path", func(t *testing.T) {
require.True(t, svc.IsAbove(id.MustNumID(302), id.MustNumID(101), "a"))
})
})
}
func TestAddGroupRole(t *testing.T) {
t.Run("add role", func(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(201), "c1", mkPp(id.MustNumID(101))...)
require.NoError(t, err)
err = svc.AddGroupRole(id.MustNumID(101), id.MustNumID(991))
require.NoError(t, err)
_, n := svc.findNode(id.MustNumID(101))
require.NotNil(t, n)
require.Len(t, n.roles, 1)
require.Equal(t, id.MustNumID(991), n.roles[0])
})
t.Run("add duplicate role", func(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(201), "c1", mkPp(id.MustNumID(101))...)
require.NoError(t, err)
err = svc.AddGroupRole(id.MustNumID(101), id.MustNumID(991), id.MustNumID(991))
require.NoError(t, err)
_, n := svc.findNode(id.MustNumID(101))
require.NotNil(t, n)
require.Len(t, n.roles, 1)
require.Equal(t, id.MustNumID(991), n.roles[0])
})
t.Run("append role", func(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(201), "c1", mkPp(id.MustNumID(101))...)
require.NoError(t, err)
err = svc.AddGroupRole(id.MustNumID(101), id.MustNumID(991))
require.NoError(t, err)
err = svc.AddGroupRole(id.MustNumID(101), id.MustNumID(992), id.MustNumID(993))
require.NoError(t, err)
_, n := svc.findNode(id.MustNumID(101))
require.NotNil(t, n)
require.Len(t, n.roles, 3)
require.Equal(t, id.MustNumID(991), n.roles[0])
require.Equal(t, id.MustNumID(992), n.roles[1])
require.Equal(t, id.MustNumID(993), n.roles[2])
})
}
func TestRemoveGroupRole(t *testing.T) {
t.Run("remove role", func(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(201), "c1", mkPp(id.MustNumID(101))...)
require.NoError(t, err)
err = svc.AddGroupRole(id.MustNumID(101), id.MustNumID(991))
require.NoError(t, err)
err = svc.RemoveGroupRole(id.MustNumID(101), id.MustNumID(991))
require.NoError(t, err)
_, n := svc.findNode(id.MustNumID(101))
require.NotNil(t, n)
require.Len(t, n.roles, 0)
})
t.Run("remove roles", func(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(201), "c1", mkPp(id.MustNumID(101))...)
require.NoError(t, err)
err = svc.AddGroupRole(id.MustNumID(101), id.MustNumID(991), id.MustNumID(992), id.MustNumID(993))
require.NoError(t, err)
err = svc.RemoveGroupRole(id.MustNumID(101), id.MustNumID(991), id.MustNumID(993))
require.NoError(t, err)
_, n := svc.findNode(id.MustNumID(101))
require.NotNil(t, n)
require.Len(t, n.roles, 1)
require.Equal(t, id.MustNumID(992), n.roles[0])
})
}
func TestAddNode(t *testing.T) {
t.Run("empty svc", func(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(1), "root")
require.NoError(t, err)
ii := svc.root.inline()
require.Len(t, ii, 1)
require.Equal(t, id.MustNumID(1), ii[0].id)
})
t.Run("add children", func(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(201), "c1", mkPp(id.MustNumID(101))...)
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(202), "c2", mkPp(id.MustNumID(101))...)
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(301), "c3", mkPp(id.MustNumID(201))...)
require.NoError(t, err)
// ---
checkInline(t, svc.root, id.MustNumID(101), id.MustNumID(201), id.MustNumID(202), id.MustNumID(301))
checkInline(t, svc.branchIndex[id.MustNumID(101)], id.MustNumID(101), id.MustNumID(201), id.MustNumID(202), id.MustNumID(301))
checkInline(t, svc.branchIndex[id.MustNumID(201)], id.MustNumID(201), id.MustNumID(301))
checkInline(t, svc.branchIndex[id.MustNumID(202)], id.MustNumID(202))
checkInline(t, svc.branchIndex[id.MustNumID(301)], id.MustNumID(301))
})
t.Run("check path management", func(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
// Add initial stuff
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(201), "c1", GroupNodePath{SelfID: id.MustNumID(101)})
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(202), "c2", GroupNodePath{SelfID: id.MustNumID(101)})
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(301), "cc1", GroupNodePath{SelfID: id.MustNumID(201)})
require.NoError(t, err)
require.Len(t, svc.branchIndex[id.MustNumID(101)].parents, 0)
require.Len(t, svc.branchIndex[id.MustNumID(101)].children, 2)
require.Equal(t, id.MustNumID(201), svc.branchIndex[id.MustNumID(101)].children[0].node.id)
require.Equal(t, id.MustNumID(202), svc.branchIndex[id.MustNumID(101)].children[1].node.id)
require.Len(t, svc.branchIndex[id.MustNumID(201)].parents, 1)
require.Len(t, svc.branchIndex[id.MustNumID(201)].children, 1)
require.Equal(t, id.MustNumID(301), svc.branchIndex[id.MustNumID(201)].children[0].node.id)
require.Equal(t, id.MustNumID(101), svc.branchIndex[id.MustNumID(201)].parents[0].node.id)
// Change c2 connection
err = svc.UpdateNode(id.MustNumID(202), "ccc2 edit", GroupNodePath{SelfID: id.MustNumID(301)})
require.NoError(t, err)
require.Len(t, svc.branchIndex[id.MustNumID(101)].parents, 0)
require.Len(t, svc.branchIndex[id.MustNumID(101)].children, 1)
require.Equal(t, id.MustNumID(201), svc.branchIndex[id.MustNumID(101)].children[0].node.id)
require.Len(t, svc.branchIndex[id.MustNumID(301)].parents, 1)
require.Len(t, svc.branchIndex[id.MustNumID(301)].children, 1)
require.Equal(t, id.MustNumID(201), svc.branchIndex[id.MustNumID(301)].parents[0].node.id)
require.Equal(t, id.MustNumID(202), svc.branchIndex[id.MustNumID(301)].children[0].node.id)
require.Len(t, svc.branchIndex[id.MustNumID(202)].parents, 1)
require.Len(t, svc.branchIndex[id.MustNumID(202)].children, 0)
require.Equal(t, id.MustNumID(301), svc.branchIndex[id.MustNumID(202)].parents[0].node.id)
// Remove c2 node
err = svc.RemoveNode(id.MustNumID(202))
require.NoError(t, err)
require.Len(t, svc.branchIndex[id.MustNumID(301)].parents, 1)
require.Len(t, svc.branchIndex[id.MustNumID(301)].children, 0)
require.Equal(t, id.MustNumID(201), svc.branchIndex[id.MustNumID(301)].parents[0].node.id)
})
}
func TestMultiPath(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
// lvl 2 roots
err = svc.AddNode(id.MustNumID(201), "c1", mkPp(id.MustNumID(101))...)
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(202), "c2", mkPp(id.MustNumID(101))...)
require.NoError(t, err)
// Multi path child
err = svc.AddNode(id.MustNumID(301), "cc1", mkPp(id.MustNumID(201), id.MustNumID(202))...)
require.NoError(t, err)
// Check
gg := svc.branchIndex[id.MustNumID(201)].inline()
require.Len(t, gg, 2)
require.Equal(t, id.MustNumID(201), gg[0].id)
require.Equal(t, id.MustNumID(301), gg[1].id)
gg = svc.branchIndex[id.MustNumID(202)].inline()
require.Len(t, gg, 2)
require.Equal(t, id.MustNumID(202), gg[0].id)
require.Equal(t, id.MustNumID(301), gg[1].id)
}
func TestNamedPaths(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
//
err = svc.AddNode(id.MustNumID(201), "c1", mkPpn(id.MustNumID(101), "a")...)
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(202), "c2", mkPpn(id.MustNumID(101), "b")...)
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(203), "c3", mkPpn(id.MustNumID(101), "")...)
require.NoError(t, err)
//
err = svc.AddNode(id.MustNumID(311), "cc11", mkPpn(id.MustNumID(201), "a", id.MustNumID(201), "b")...)
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(312), "cc12", mkPpn(id.MustNumID(201), "")...)
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(321), "cc21", mkPpn(id.MustNumID(202), "")...)
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(331), "cc31", mkPpn(id.MustNumID(203), "a")...)
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(332), "cc32", mkPpn(id.MustNumID(203), "b")...)
require.NoError(t, err)
//
err = svc.AddNode(id.MustNumID(401), "ccc01", mkPpn(id.MustNumID(311), "")...)
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(402), "ccc02", mkPpn(id.MustNumID(312), "b")...)
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(403), "ccc03", mkPpn(id.MustNumID(332), "")...)
require.NoError(t, err)
//
//
//
t.Run("201 any path", func(t *testing.T) {
bb := svc.branchIndex[id.MustNumID(201)].inline()
require.Len(t, bb, 5)
require.Equal(t, id.MustNumID(201), bb[0].id)
require.Equal(t, id.MustNumID(311), bb[1].id)
require.Equal(t, id.MustNumID(312), bb[2].id)
require.Equal(t, id.MustNumID(401), bb[3].id)
require.Equal(t, id.MustNumID(402), bb[4].id)
})
t.Run("201 a path", func(t *testing.T) {
bb := svc.branchIndex[id.MustNumID(201)].inline("a")
require.Len(t, bb, 4)
require.Equal(t, id.MustNumID(201), bb[0].id)
require.Equal(t, id.MustNumID(311), bb[1].id)
require.Equal(t, id.MustNumID(312), bb[2].id)
require.Equal(t, id.MustNumID(401), bb[3].id)
})
}
func TestUpdateNode(t *testing.T) {
t.Run("update without move", func(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
err = svc.UpdateNode(id.MustNumID(101), "root edited")
require.NoError(t, err)
require.Equal(t, id.MustNumID(101), svc.root.id)
require.Equal(t, "root edited", svc.root.handle)
})
t.Run("change parent", func(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(201), "c1", mkPp(id.MustNumID(101))...)
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(202), "c2", mkPp(id.MustNumID(101))...)
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(301), "c3", mkPp(id.MustNumID(201))...)
require.NoError(t, err)
err = svc.UpdateNode(id.MustNumID(301), "c3 edited", mkPp(id.MustNumID(202))...)
require.NoError(t, err)
checkInline(t, svc.branchIndex[id.MustNumID(201)], id.MustNumID(201))
checkInline(t, svc.branchIndex[id.MustNumID(202)], id.MustNumID(202), id.MustNumID(301))
})
t.Run("update non existing node", func(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
err = svc.UpdateNode(id.MustNumID(999), "root edited", mkPp(id.MustNumID(0))...)
require.Error(t, err)
})
}
func TestRemoveNode(t *testing.T) {
t.Run("remove leaf node", func(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(201), "c1", mkPp(id.MustNumID(101))...)
require.NoError(t, err)
err = svc.RemoveNode(id.MustNumID(201))
require.NoError(t, err)
checkInline(t, svc.branchIndex[id.MustNumID(101)], id.MustNumID(101))
checkInline(t, svc.branchIndex[id.MustNumID(201)])
})
t.Run("can not remove non-lief nodes", func(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(201), "c1", mkPp(id.MustNumID(101))...)
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(301), "c2", mkPp(id.MustNumID(201))...)
require.NoError(t, err)
err = svc.RemoveNode(id.MustNumID(201))
require.Error(t, err)
})
t.Run("can not remove non-existing nodes", func(t *testing.T) {
svc, err := mkOrgTree()
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(101), "root")
require.NoError(t, err)
err = svc.AddNode(id.MustNumID(201), "c1", mkPp(id.MustNumID(101))...)
require.NoError(t, err)
err = svc.RemoveNode(id.MustNumID(999))
require.Error(t, err)
})
}
func TestAssignGroupMembers(t *testing.T) {
t.Run("new", func(t *testing.T) {
svc, err := mkOrgTree(
GroupMembers{
group: &groupNode{
id: id.MustNumID(1),
handle: "root",
},
members: nil,
},
)
require.NoError(t, err)
require.NotNil(t, svc)
err = svc.AssignGroupMembers(id.MustNumID(1), id.MustNumID(101), id.MustNumID(102))
require.NoError(t, err)
members := svc.GroupMembers(id.MustNumID(1))
require.Len(t, members, 2)
require.Equal(t, id.MustNumID(101), members[0])
require.Equal(t, id.MustNumID(102), members[1])
})
t.Run("update", func(t *testing.T) {
svc, err := mkOrgTree(
GroupMembers{
group: &groupNode{
id: id.MustNumID(1),
handle: "root",
},
members: []id.ID{id.MustNumID(991), id.MustNumID(102)},
},
GroupMembers{
group: &groupNode{
id: id.MustNumID(2),
handle: "subgroup",
paths: mkPp(id.MustNumID(1)),
},
},
)
require.NoError(t, err)
require.NotNil(t, svc)
err = svc.AssignGroupMembers(id.MustNumID(2), id.MustNumID(991))
require.NoError(t, err)
members := svc.GroupMembers(id.MustNumID(1))
require.Len(t, members, 1)
require.Equal(t, id.MustNumID(102), members[0])
members = svc.GroupMembers(id.MustNumID(2))
require.Len(t, members, 1)
require.Equal(t, id.MustNumID(991), members[0])
})
}
func TestRemoveGroupMembers(t *testing.T) {
svc, err := mkOrgTree(
GroupMembers{
group: &groupNode{
id: id.MustNumID(1),
handle: "root",
},
members: []id.ID{id.MustNumID(101), id.MustNumID(102), id.MustNumID(103)},
},
)
require.NoError(t, err)
require.NotNil(t, svc)
err = svc.RemoveGroupMembers(id.MustNumID(1), id.MustNumID(102))
require.NoError(t, err)
members := svc.GroupMembers(id.MustNumID(1))
require.Len(t, members, 2)
require.Equal(t, id.MustNumID(101), members[0])
require.Equal(t, id.MustNumID(103), members[1])
}
func TestRebuild(t *testing.T) {
svc, err := testPrepState(t)
require.NoError(t, err)
require.NotNil(t, svc)
err = svc.Rebuild(GroupMembers{
group: &groupNode{
id: id.MustNumID(999),
handle: "newRoot",
},
members: []id.ID{id.MustNumID(991), id.MustNumID(992), id.MustNumID(993)},
})
require.NoError(t, err)
members := svc.GroupMembers(id.MustNumID(1))
require.Len(t, members, 0)
members = svc.GroupMembers(id.MustNumID(999))
require.Len(t, members, 3)
}
func TestBuildOrgTree(t *testing.T) {
t.Run("empty", func(t *testing.T) {
out, _, err := buildOrgTree()
require.NoError(t, err)
require.Nil(t, out)
})
t.Run("just root", func(t *testing.T) {
out, _, err := buildOrgTree(&groupNode{id: id.MustNumID(1), handle: "root"})
require.NoError(t, err)
nodes := out.inline()
require.Len(t, nodes, 1)
require.Equal(t, "root", nodes[0].handle)
})
t.Run("multiple root error", func(t *testing.T) {
_, _, err := buildOrgTree(
&groupNode{id: id.MustNumID(1), handle: "root1"},
&groupNode{id: id.MustNumID(2), handle: "root2"},
)
require.Error(t, err)
require.EqualError(t, err, "multiple root nodes detected")
})
t.Run("missing parent error", func(t *testing.T) {
_, _, err := buildOrgTree(
&groupNode{id: id.MustNumID(1), handle: "root1"},
&groupNode{id: id.MustNumID(2), handle: "child1", paths: mkPp(id.MustNumID(99))},
)
require.Error(t, err)
require.EqualError(t, err, `node "2" parent "99" not found`)
})
t.Run("build tree", func(t *testing.T) {
out, _, err := buildOrgTree(
&groupNode{id: id.MustNumID(1), handle: "root"},
&groupNode{id: id.MustNumID(2), handle: "child1", paths: mkPp(id.MustNumID(1))},
&groupNode{id: id.MustNumID(3), handle: "child2", paths: mkPp(id.MustNumID(1))},
&groupNode{id: id.MustNumID(4), handle: "child1_grandchild1", paths: mkPp(id.MustNumID(2))},
&groupNode{id: id.MustNumID(5), handle: "child1_grandchild2", paths: mkPp(id.MustNumID(2))},
&groupNode{id: id.MustNumID(6), handle: "child2_grandchild1", paths: mkPp(id.MustNumID(3))},
&groupNode{id: id.MustNumID(7), handle: "child1_grandchild2_ggrandchild1", paths: mkPp(id.MustNumID(5))},
)
require.NoError(t, err)
nn := out.inline()
require.Len(t, nn, 7)
require.Equal(t, "root", nn[0].handle)
require.Equal(t, "child1", nn[1].handle)
require.Equal(t, "child2", nn[2].handle)
require.Equal(t, "child1_grandchild1", nn[3].handle)
require.Equal(t, "child1_grandchild2", nn[4].handle)
require.Equal(t, "child2_grandchild1", nn[5].handle)
require.Equal(t, "child1_grandchild2_ggrandchild1", nn[6].handle)
})
}
func TestFindNode(t *testing.T) {
svc, err := testPrepState(t)
require.NoError(t, err)
t.Run("first", func(t *testing.T) {
i, n := svc.findNode(id.MustNumID(1))
require.Equal(t, 0, i)
require.NotNil(t, n)
})
t.Run("last", func(t *testing.T) {
i, n := svc.findNode(id.MustNumID(4))
require.Equal(t, 3, i)
require.NotNil(t, n)
})
t.Run("middle", func(t *testing.T) {
i, n := svc.findNode(id.MustNumID(3))
require.Equal(t, 2, i)
require.NotNil(t, n)
})
t.Run("not existing", func(t *testing.T) {
i, n := svc.findNode(id.MustNumID(999))
require.Equal(t, -1, i)
require.Nil(t, n)
})
}
func testPrepState(t *testing.T) (svc *orgTree, err error) {
return mkOrgTree(GroupMembers{
group: &groupNode{
id: id.MustNumID(1),
handle: "root",
},
members: []id.ID{id.MustNumID(101), id.MustNumID(102)},
}, GroupMembers{
group: &groupNode{
id: id.MustNumID(2),
handle: "subgroup1",
paths: mkPp(id.MustNumID(1)),
},
members: []id.ID{id.MustNumID(121), id.MustNumID(122), id.MustNumID(123)},
}, GroupMembers{
group: &groupNode{
id: id.MustNumID(3),
handle: "subgroup2",
paths: mkPp(id.MustNumID(1)),
},
members: []id.ID{id.MustNumID(131)},
}, GroupMembers{
group: &groupNode{
id: id.MustNumID(4),
handle: "subgroup1_subgroup1",
paths: mkPp(id.MustNumID(2)),
},
members: []id.ID{id.MustNumID(141), id.MustNumID(142)},
})
}
func mkPp(ii ...id.ID) []GroupNodePath {
out := make([]GroupNodePath, len(ii))
for i, x := range ii {
out[i] = GroupNodePath{
SelfID: x,
}
}
return out
}
func mkPpn(ii ...any) []GroupNodePath {
if len(ii)%2 != 0 {
panic("number of things not even")
}
out := make([]GroupNodePath, 0, len(ii))
for i := 0; i < len(ii); i += 2 {
id := ii[i].(id.ID)
label := ii[i+1].(string)
out = append(out, GroupNodePath{
SelfID: id,
Name: label,
})
}
return out
}
func checkInline(t *testing.T, node *groupNode, ii ...id.ID) {
nn := node.inline()
require.Len(t, nn, len(ii))
for i, n := range nn {
require.Equal(t, ii[i], n.id)
}
}