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
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:
19
server/pkg/rbac/effective.go
Normal file
19
server/pkg/rbac/effective.go
Normal 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,
|
||||
})
|
||||
}
|
||||
96
server/pkg/rbac/func_expr.go
Normal file
96
server/pkg/rbac/func_expr.go
Normal 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
|
||||
}
|
||||
62
server/pkg/rbac/func_expr_test.go
Normal file
62
server/pkg/rbac/func_expr_test.go
Normal 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])
|
||||
})
|
||||
}
|
||||
56
server/pkg/rbac/permissions.go
Normal file
56
server/pkg/rbac/permissions.go
Normal 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
163
server/pkg/rbac/resource.go
Normal 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])
|
||||
}
|
||||
141
server/pkg/rbac/resource_test.go
Normal file
141
server/pkg/rbac/resource_test.go
Normal 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
209
server/pkg/rbac/roles.go
Normal 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
|
||||
}
|
||||
195
server/pkg/rbac/roles_test.go
Normal file
195
server/pkg/rbac/roles_test.go
Normal 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
146
server/pkg/rbac/rule.go
Normal 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
|
||||
}
|
||||
165
server/pkg/rbac/rule_index.go
Normal file
165
server/pkg/rbac/rule_index.go
Normal 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
|
||||
}
|
||||
181
server/pkg/rbac/rule_index_test.go
Normal file
181
server/pkg/rbac/rule_index_test.go
Normal 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))
|
||||
}
|
||||
54
server/pkg/rbac/rule_test.go
Normal file
54
server/pkg/rbac/rule_test.go
Normal 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)
|
||||
}
|
||||
116
server/pkg/rbac/ruleset_checks.go
Normal file
116
server/pkg/rbac/ruleset_checks.go
Normal 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
|
||||
}
|
||||
238
server/pkg/rbac/ruleset_checks_test.go
Normal file
238
server/pkg/rbac/ruleset_checks_test.go
Normal 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)
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
184
server/pkg/rbac/ruleset_utils.go
Normal file
184
server/pkg/rbac/ruleset_utils.go
Normal 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
|
||||
}
|
||||
}
|
||||
188
server/pkg/rbac/ruleset_utils_test.go
Normal file
188
server/pkg/rbac/ruleset_utils_test.go
Normal 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
531
server/pkg/rbac/service.go
Normal 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))
|
||||
}
|
||||
34
server/pkg/rbac/service_alt.go
Normal file
34
server/pkg/rbac/service_alt.go
Normal 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")
|
||||
}
|
||||
345
server/pkg/rbac/service_test.go
Normal file
345
server/pkg/rbac/service_test.go
Normal 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)
|
||||
}
|
||||
58
server/pkg/rbac/session.go
Normal file
58
server/pkg/rbac/session.go
Normal 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,
|
||||
}
|
||||
}
|
||||
15
server/pkg/rbac/store_interface.go
Normal file
15
server/pkg/rbac/store_interface.go
Normal 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
78
server/pkg/rbac/trace.go
Normal 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
|
||||
})
|
||||
}
|
||||
60
server/pkg/rbac/trace_test.go
Normal file
60
server/pkg/rbac/trace_test.go
Normal 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)
|
||||
// })
|
||||
//}
|
||||
}
|
||||
50
server/pkg/rbac/user_cache.go
Normal file
50
server/pkg/rbac/user_cache.go
Normal 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{})
|
||||
}
|
||||
629
server/pkg/rbac/user_groups.go
Normal file
629
server/pkg/rbac/user_groups.go
Normal 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
|
||||
}
|
||||
173
server/pkg/rbac/user_groups_bench_test.go
Normal file
173
server/pkg/rbac/user_groups_bench_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
807
server/pkg/rbac/user_groups_test.go
Normal file
807
server/pkg/rbac/user_groups_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user