3
0

RBAC refactored (pkg renamed, init improved)

This commit is contained in:
Denis Arh
2020-09-18 07:56:10 +02:00
parent d21cd72f7c
commit 7508659165
120 changed files with 730 additions and 828 deletions

19
pkg/rbac/effective.go Normal file
View File

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

149
pkg/rbac/importer.go Normal file
View File

@@ -0,0 +1,149 @@
package rbac
import (
"context"
"sort"
"strings"
"github.com/pkg/errors"
"github.com/cortezaproject/corteza-server/pkg/deinterfacer"
)
type (
Importer struct {
whitelist whitelistChecker
// Rules per role
rules map[string]RuleSet
}
whitelistChecker interface {
Check(*Rule) bool
}
ImportKeeper interface {
Grant(ctx context.Context, rr ...*Rule) error
}
)
func NewImporter(wl whitelistChecker) *Importer {
return &Importer{
whitelist: wl,
}
}
// CastSet - resolves permission rules for specific resource:
// <role>: [<operation>, ...]
func (imp *Importer) CastSet(resource, accessStr string, roles interface{}) (err error) {
if !deinterfacer.IsMap(roles) {
return errors.New("expecting map of roles")
}
return deinterfacer.Each(roles, func(_ int, roleHandle string, oo interface{}) error {
return imp.appendPermissionRule(roleHandle, accessStr, resource, oo)
})
}
// CastResourcesSet - resolves permission rules:
// <role> { <resource>: [<operation>, ...] }
func (imp *Importer) CastResourcesSet(accessStr string, roles interface{}) (err error) {
// if !IsIterable(roles) {
// return errors.New("expecting map of roles")
// }
return deinterfacer.Each(roles, func(_ int, roleHandle string, perResource interface{}) error {
if !deinterfacer.IsMap(perResource) {
return errors.New("expecting map of resources")
}
// Each resource
return deinterfacer.Each(perResource, func(_ int, resource string, oo interface{}) error {
// We want to make life of the person that's preparing the import data easy, so
// let's do a little guessing instead of him:
if strings.Contains(resource, ":") {
// This is not service-level resource, trim * and : from the end
resource = strings.TrimRight(resource, ":*")
resource = Resource(resource + string(resourceDelimiter)).AppendWildcard().String()
}
return imp.appendPermissionRule(roleHandle, accessStr, resource, oo)
})
})
}
func (imp *Importer) appendPermissionRule(roleHandle, accessStr, res string, oo interface{}) (err error) {
var access Access
if err = access.UnmarshalJSON([]byte(accessStr)); err != nil {
return
}
if imp.rules == nil {
imp.rules = map[string]RuleSet{}
}
if imp.rules[roleHandle] == nil {
imp.rules[roleHandle] = RuleSet{}
}
operations := deinterfacer.ToStrings(oo)
if operations == nil {
return errors.New("could not resolve permission rule operations")
}
sort.Strings(operations)
for _, op := range operations {
rule := &Rule{
Access: access,
Resource: Resource(res),
Operation: Operation(op),
}
if imp.whitelist != nil && !imp.whitelist.Check(rule) {
return errors.Errorf("invalid rule: operation %q on resource %q", op, res)
}
imp.rules[roleHandle] = append(imp.rules[roleHandle], rule)
}
return nil
}
// UpdateResources iterates over all rules and replaces resource (foo:bar => foo:42)
func (imp *Importer) UpdateResources(base, handle string, ID uint64) {
var (
from = Resource(base).append(handle)
to = Resource(base).AppendID(ID)
)
for _, rules := range imp.rules {
for _, rule := range rules {
if rule.Resource == from {
rule.Resource = to
}
}
}
}
func (imp *Importer) UpdateRoles(handle string, ID uint64) {
if imp.rules[handle] != nil {
for _, rule := range imp.rules[handle] {
rule.RoleID = ID
}
}
}
func (imp *Importer) Store(ctx context.Context, k ImportKeeper) (err error) {
for _, rr := range imp.rules {
// Make sure all rules have valid role
rr, _ = rr.Filter(func(rule *Rule) (b bool, e error) {
return rule.RoleID > 0, nil
})
if err = k.Grant(ctx, rr...); err != nil {
return
}
}
return
}

48
pkg/rbac/importer_test.go Normal file
View File

@@ -0,0 +1,48 @@
package rbac
import (
"testing"
)
func TestPermissionRulesImport_CastResourcesSet(t *testing.T) {
t.Skip()
// tests := []struct {
// name string
// resource string
// yaml string
// rules map[string]RuleSet
// }{
// {name: "empty", yaml: ``},
// {name: "empty map", yaml: `{}`},
// {name: "empty slice", yaml: `[]`},
// {
// name: "one role, one resource, two ops",
// yaml: `admins: { resource: [ read, write ] }`,
// rules: map[string]RuleSet{
// "admins": {
// AllowRule(0, "resource", "read"),
// AllowRule(0, "resource", "write"),
// },
// },
// },
// {
// name: "op as string",
// yaml: `admins: { resource: read }`,
// rules: map[string]RuleSet{
// "admins": {
// AllowRule(0, "resource", "read"),
// },
// },
// },
// }
// for _, tt := range tests {
// t.Run(tt.name, func(t *testing.T) {
// imp := &Importer{}
//
// aux, err := importer.ParseYAML([]byte(tt.yaml))
// require.NoError(t, err)
// require.NoError(t, imp.CastResourcesSet("allow", aux))
// require.Equal(t, tt.rules, imp.rules)
// })
// }
}

172
pkg/rbac/permissions.go Normal file
View File

@@ -0,0 +1,172 @@
package rbac
// General permission stuff, types, constants
type (
Operation string
Access int
// CheckAccessFunc function.
CheckAccessFunc func() Access
Whitelist struct {
// Index is used for fast lookups
index map[Resource]map[Operation]bool
// we need this to maintain a stable order of res/ops
rules RuleSet
}
whitelistFlatten struct {
Resource `json:"resource"`
Operation `json:"operation"`
}
)
const (
// EveryoneRoleID -- everyone
EveryoneRoleID uint64 = 1
// AdminsRoleID - admins
AdminsRoleID uint64 = 2
// OwnersDynamicRoleID for Owners role is dynamically assigned
// when current user is owner of the resource
OwnersDynamicRoleID uint64 = 10000
// CreatorsDynamicRoleID for Creators role is dynamically assigned
// when current user created the resource
CreatorsDynamicRoleID uint64 = 10010
// UpdatersDynamicRoleID for Updaters role is dynamically assigned
// when current user updated the resource
UpdatersDynamicRoleID uint64 = 10011
// DeletersDynamicRoleID for Deleters role is dynamically assigned
// when current user deleted the resource
DeletersDynamicRoleID uint64 = 10012
// MembersDynamicRoleID for Members role is dynamically assigned
// when current user member of the resource
// Can be used by resources that have members
MembersDynamicRoleID uint64 = 10020
// AssigneesDynamicRoleID for Assignees role is dynamically assigned
// when current user member of the resource
// Can be used by resources that have assignees
AssigneesDynamicRoleID uint64 = 10021
)
func (op Operation) String() string {
return string(op)
}
func (a Access) String() string {
switch a {
case Allow:
return "allow"
case Deny:
return "deny"
default:
return "inherit"
}
}
// Bool convers boolean true to Allow and false to Deny
func BoolToCheckFunc(isTrue bool) CheckAccessFunc {
return func() Access {
if isTrue {
return Allow
}
return Deny
}
}
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
}
func (wl *Whitelist) Set(r Resource, oo ...Operation) {
if wl.index == nil {
wl.index = map[Resource]map[Operation]bool{}
}
wl.index[r] = map[Operation]bool{}
for _, o := range oo {
wl.index[r][o] = true
wl.rules = append(wl.rules, &Rule{Resource: r, Operation: o})
}
}
func (wl Whitelist) Check(rule *Rule) bool {
if rule == nil {
return false
}
res := rule.Resource.TrimID()
if _, ok := wl.index[res]; !ok {
return false
}
return wl.index[res][rule.Operation]
}
// Flatten casts list of operations for each resource from map to slice and creates more output friendly format
func (wl Whitelist) Flatten() []whitelistFlatten {
var (
wlf = []whitelistFlatten{}
)
for _, r := range wl.rules {
wlf = append(wlf, whitelistFlatten{r.Resource, r.Operation})
}
return wlf
}
// DynamicRoles is a utility function that compares
// given u with each odd element in cc
// and returns even element on a match
//
// In practice, pass userID as first argument and
// set of userID-roleID pairs. Function returns
// all roles that are paired with the same user
func DynamicRoles(u uint64, cc ...uint64) (rr []uint64) {
var l = len(cc)
if l%2 == 1 {
panic("expecting even number of id/dynamic-role pairs")
}
rr = make([]uint64, 0, l/2)
for i := 0; i < l; i += 2 {
if cc[i] == u {
rr = append(rr, cc[i+1])
}
}
return
}

View File

@@ -0,0 +1,53 @@
package rbac
import (
"reflect"
"testing"
)
func TestDynamicRoles(t *testing.T) {
tests := []struct {
name string
u uint64
cc []uint64
exp []uint64
}{
{
"empty",
42,
nil,
[]uint64{},
},
{
"only one",
42,
[]uint64{42, 2},
[]uint64{2},
},
{
"none",
42,
[]uint64{1, 2},
[]uint64{},
},
{
"few",
42,
[]uint64{42, 2, 43, 3},
[]uint64{2},
},
{
"all",
42,
[]uint64{42, 1, 42, 2},
[]uint64{1, 2},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if gotRr := DynamicRoles(tt.u, tt.cc...); !reflect.DeepEqual(gotRr, tt.exp) {
t.Errorf("DynamicRoles() = %v, want %v", gotRr, tt.exp)
}
})
}
}

83
pkg/rbac/resource.go Normal file
View File

@@ -0,0 +1,83 @@
package rbac
import (
"strconv"
"strings"
)
type (
Resource string
)
const (
resourceDelimiter = ':'
resourceWildcard = '*'
)
func (r Resource) append(suffix string) Resource {
if !r.IsAppendable() {
panic("can not append to non appendable resource '" + r.String() + "'")
}
return Resource(r.String() + suffix)
}
// Resource to satisfty interfaces and ease development
func (r Resource) RBACResource() Resource {
return r
}
// DynamicRoles satisfies Resourcable interface when Resource is
// used directly
func (r Resource) DynamicRoles(i uint64) []uint64 {
return nil
}
func (r Resource) AppendID(ID uint64) Resource {
return r.append(strconv.FormatUint(ID, 10))
}
func (r Resource) AppendWildcard() Resource {
return r.TrimID().append(string(resourceWildcard))
}
// Trims off wildcard/id from resource
func (r Resource) TrimID() Resource {
s := r.String()
p := strings.LastIndexByte(s, resourceDelimiter)
if p > 0 {
return Resource(s[0 : p+1])
}
return r
}
// IsAppendable checks if Resource has trailing resource delimiter
func (r Resource) IsAppendable() bool {
return strings.IndexByte(r.String(), resourceDelimiter) > -1
}
// IsValid does basic resource validation
func (r Resource) IsValid() bool {
return len(r) > 0 && r[len(r)-1] != resourceDelimiter
}
// IsServiceLevel checks for resource delimiters - service level resources do not have it
func (r Resource) GetService() Resource {
s := r.String()
p := strings.IndexByte(s, resourceDelimiter)
if p > 0 {
return Resource(s[0:p])
}
return r
}
// HasWildcard checks if resource has wildcard char at the end
func (r Resource) HasWildcard() bool {
return len(r) > 0 && r[len(r)-1] == resourceWildcard
}
func (r Resource) String() string {
return string(r)
}

63
pkg/rbac/resource_test.go Normal file
View File

@@ -0,0 +1,63 @@
package rbac
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestResource(t *testing.T) {
var (
req = require.New(t)
sCases = []struct {
r Resource
s string
}{
{
Resource("a:b:c"),
"a:b:c"},
{
Resource("a:b:c").RBACResource(),
"a:b:c"},
{
Resource("a:b:").AppendID(1),
"a:b:1"},
{
Resource("a:b:").AppendWildcard(),
"a:b:*"},
{
Resource("a:b:1").TrimID(),
"a:b:"},
{
Resource("a:b:1").GetService(),
"a"},
}
)
for _, sc := range sCases {
req.Equal(sc.s, sc.r.String())
}
var r string
r = "a:"
req.True(Resource(r).IsAppendable(), "Expecting resource %q to be appendable", r)
r = "a:1"
req.True(Resource(r).IsAppendable(), "Expecting resource %q to be appendable", r)
r = "a:*"
req.True(Resource(r).IsAppendable(), "Expecting resource %q to be appendable", r)
r = "a"
req.True(Resource(r).IsValid(), "Expecting resource %q to be valid", r)
r = "a:"
req.False(Resource(r).IsValid(), "Expecting resource %q not to be valid", r)
r = "a:1"
req.True(Resource(r).IsValid(), "Expecting resource %q to be valid", r)
r = "a:*"
req.True(Resource(r).IsValid(), "Expecting resource %q to be valid", r)
r = "a:1"
req.False(Resource(r).HasWildcard(), "Expecting resource %q to not have wildcard", r)
r = "a:*"
req.True(Resource(r).HasWildcard(), "Expecting resource %q to have wildcard", r)
}

57
pkg/rbac/rule.go Normal file
View File

@@ -0,0 +1,57 @@
package rbac
import (
"fmt"
)
type (
Rule struct {
RoleID uint64 `json:"roleID,string"`
Resource Resource `json:"resource"`
Operation Operation `json:"operation"`
Access Access `json:"access,string"`
// Do we need to flush it to storage?
dirty bool
}
)
const (
// Allow - Operation over a resource is allowed
Allow Access = 1
// Deny - Operation over a resource is denied
Deny = 0
// Inherit - Operation over a resource is not defined, inherit
Inherit = -1
)
func (r Rule) String() string {
return fmt.Sprintf("%s %d to %s on %s", r.Access, r.RoleID, r.Operation, r.Resource)
}
func (r Rule) Equals(cmp *Rule) bool {
if cmp == nil {
return false
}
return r.RoleID == cmp.RoleID &&
r.Resource == cmp.Resource &&
r.Operation == cmp.Operation
}
// AllowRule helper func to create allow rule
func AllowRule(id uint64, r Resource, o Operation) *Rule {
return &Rule{id, r, o, Allow, false}
}
// DenyRule helper func to create deny rule
func DenyRule(id uint64, r Resource, o Operation) *Rule {
return &Rule{id, r, o, Deny, false}
}
// InheritRule helper func to create inherit rule
func InheritRule(id uint64, r Resource, o Operation) *Rule {
return &Rule{id, r, o, Inherit, false}
}

View File

@@ -0,0 +1,86 @@
package rbac
// Check verifies if role has access to perform an operation on a resource
//
// Overall flow:
// - invalid resource, no access
// - can this combination of roles perform an operation on this specific resource
// - can this combination of roles perform an operation on any resource of the type (wildcard)
// - can anyone/everyone perform an operation on this specific resource
// - can anyone/everyone perform an operation on any resource of the type (wildcard)
func (set RuleSet) Check(res Resource, op Operation, roles ...uint64) (v Access) {
if !res.IsValid() {
return Deny
}
if len(roles) > 0 {
if v = set.checkResource(res, op, roles...); v != Inherit {
return
}
}
if v = set.checkResource(res, op, EveryoneRoleID); v != Inherit {
return
}
return
}
// Check ability to perform an operation on a specific and wildcard resource
func (set RuleSet) checkResource(res Resource, op Operation, roles ...uint64) (v Access) {
if v = set.check(res, op, roles...); v != Inherit {
return
}
if res.IsAppendable() {
// Is this a specific resource and can we turn it into a wild-carded resource?
if v = set.check(res.AppendWildcard(), op, roles...); v != Inherit {
return
}
}
return
}
// Check verifies if any of given roles has permission to perform an operation over a resource
//
// Will return Inherit when:
// - no roles are given
// - more than 1 role is given and one of the given roles is Everyone
//
// Will return Deny when:
// - there is one rule with Deny value
//
// Will return Allow when:
// - there is at least one rule with Allow value (and no Deny rules)
func (set RuleSet) check(res Resource, op Operation, roles ...uint64) (v Access) {
v = Inherit
for i := range set {
// Ignore resources & operations that do not match
if set[i].Resource != res || set[i].Operation != op {
continue
}
// Check for every role
for _, roleID := range roles {
// Skip rules that do not match
if set[i].RoleID != roleID || set[i].Access == Inherit {
continue
}
v = set[i].Access // set to Allow
// Return on first Deny
if v == Deny {
return
}
}
}
// If none of the rules matched, return Inherit (see 1st line)
// if at least one of the rules allowed this op over a resource,
// return Allow.
return v
}

View File

@@ -0,0 +1,187 @@
package rbac
import (
"testing"
"github.com/stretchr/testify/require"
)
const (
role1 uint64 = 10001
role2 uint64 = 10002
resService1 = Resource("service1")
resService2 = Resource("service2")
resThingWc = Resource("some:answer:*")
resThing13 = Resource("some:answer:13")
resThing42 = Resource("some:answer:42")
opAccess = "access"
opRead = "read"
opWrite = "write"
)
func TestRuleSet_check(t *testing.T) {
var (
req = require.New(t)
rr = RuleSet{
AllowRule(role1, resThing42, opRead),
DenyRule(role1, resThing13, opWrite),
AllowRule(role2, resThing13, opWrite),
}
sCases = []struct {
roles []uint64
res Resource
op Operation
expected Access
}{
{[]uint64{role1}, resThing42, opRead, Allow},
{[]uint64{role1}, resThing42, opWrite, Inherit},
{[]uint64{role1}, resThing13, opWrite, Deny},
{[]uint64{role2}, resThing13, opWrite, Allow},
{[]uint64{role1, role2}, resThing13, opWrite, Deny},
{[]uint64{role1, role2}, resThing42, opRead, Allow},
}
)
for c, sc := range sCases {
v := rr.check(sc.res, sc.op, sc.roles...)
req.Equalf(sc.expected, v, "Check test #%d failed, expected %s, got %s", c, sc.expected, v)
}
}
// Test resource inheritance
func TestRuleSet_checkResource(t *testing.T) {
const (
role1 uint64 = 10001
resService1 = Resource("service1")
resService2 = Resource("service2")
resThingWc = Resource("some:answer:*")
resThing13 = Resource("some:answer:13")
resThing42 = Resource("some:answer:42")
opAccess = "access"
)
var (
r = require.New(t)
sCases = []struct {
rr RuleSet
roles []uint64
res Resource
op Operation
expected Access
}{
{
RuleSet{
AllowRule(role1, resService1, opAccess),
},
[]uint64{role1},
resService1,
opAccess,
Allow,
},
{
RuleSet{
AllowRule(role1, resThingWc, opAccess),
},
[]uint64{role1},
resThing42,
opAccess,
Allow,
},
{ // deny wc and explictly allow 42
RuleSet{
DenyRule(role1, resThingWc, opAccess),
AllowRule(role1, resThing42, opAccess),
},
[]uint64{role1},
resThing42,
opAccess,
Allow,
},
{ // deny wc and explictly allow 42
RuleSet{
DenyRule(role1, resThingWc, opAccess),
AllowRule(role1, resThing42, opAccess),
},
[]uint64{role1},
resThing13,
opAccess,
Deny,
},
{ // deny wc and and check if wc is denied
RuleSet{
DenyRule(role1, resThingWc, opAccess),
AllowRule(role1, resThing42, opAccess),
},
[]uint64{role1},
resThingWc,
opAccess,
Deny,
},
{ // allow wc and and check if wc is allowed
RuleSet{
AllowRule(role1, resThingWc, opAccess),
DenyRule(role1, resThing42, opAccess),
},
[]uint64{role1},
resThingWc,
opAccess,
Allow,
},
}
)
for c, sc := range sCases {
v := sc.rr.checkResource(sc.res, sc.op, sc.roles...)
r.Equalf(sc.expected, v, "Check test #%d failed, expected %s, got %s", c, sc.expected, v)
}
}
// Test role inheritance
func TestRuleSet_Check(t *testing.T) {
var (
rr = RuleSet{
// 1st level
AllowRule(role1, resService1, opAccess),
DenyRule(role2, resService1, opAccess),
// 2nd level
DenyRule(EveryoneRoleID, resService2, opAccess),
AllowRule(EveryoneRoleID, resThing13, opAccess),
AllowRule(role1, resService2, opAccess),
// 3rd level
DenyRule(EveryoneRoleID, resThingWc, opAccess),
AllowRule(role1, resThing42, opAccess),
}
r = require.New(t)
sCases = []struct {
roles []uint64
res Resource
op Operation
expected Access
}{
{[]uint64{role1}, resService1, opAccess, Allow},
{[]uint64{role2}, resService1, opAccess, Deny},
{[]uint64{role1}, resService2, opAccess, Allow},
{[]uint64{role2}, resService2, opAccess, Deny},
{[]uint64{role1}, resThing42, opAccess, Allow},
{[]uint64{role2}, resThing42, opAccess, Deny},
{[]uint64{}, resThing42, opAccess, Deny},
{[]uint64{}, resThing13, opAccess, Allow},
}
)
for c, sc := range sCases {
v := rr.Check(sc.res, sc.op, sc.roles...)
r.Equalf(sc.expected, v, "Check test #%d failed, expected %s, got %s", c, sc.expected, v)
}
}

130
pkg/rbac/ruleset_utils.go Normal file
View File

@@ -0,0 +1,130 @@
package rbac
import "github.com/cortezaproject/corteza-server/pkg/slice"
// merge applies new rules (changes) to existing set and mark all changes as dirty
func (set RuleSet) merge(rules ...*Rule) (out RuleSet) {
var (
o int
olen = len(set)
)
if olen == 0 {
// Nothing exists yet, mark all as dirty
for r := range rules {
rules[r].dirty = true
}
return rules
} else {
out = set
newRules:
for _, rule := range rules {
// Never go beyond the last old rule (olen)
for o = 0; o < olen; o++ {
if out[o].Equals(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 old rules matched, append
var c = *rule
c.dirty = true
out = append(out, &c)
}
}
return
}
// dirty returns list of changed (dirty==true) and deleted (Access==Inherit) rules
func (set RuleSet) dirty() (inherited, rest RuleSet) {
inherited, rest = RuleSet{}, RuleSet{}
for _, r := range set {
var c = *r
if r.Access == Inherit {
inherited = append(inherited, &c)
} else if r.dirty {
rest = append(rest, &c)
}
}
return
}
// reset dirty flag
func (set RuleSet) clear() {
_ = set.Walk(func(rule *Rule) error {
rule.dirty = false
return nil
})
}
// Missing compares cmp with existing set
// and returns rules that exists in set but not in cmp
func (set RuleSet) Diff(cmp RuleSet) RuleSet {
diff := RuleSet{}
base:
for _, s := range set {
for _, c := range cmp {
if c.Equals(s) {
continue base
}
}
diff = append(diff, s)
}
return diff
}
// Roles returns list of unique id of all roles in the rule set
func (set RuleSet) Roles() []uint64 {
roles := make([]uint64, 0)
for _, r := range set {
if slice.HasUint64(roles, r.RoleID) {
continue
}
roles = append(roles, r.RoleID)
}
return roles
}
func (set RuleSet) ByResource(res Resource) RuleSet {
out, _ := set.Filter(func(r *Rule) (bool, error) {
return res == r.Resource, nil
})
return out
}
func (set RuleSet) AllAllows() RuleSet {
return set.ByAccess(Allow)
}
func (set RuleSet) AllDenies() RuleSet {
return set.ByAccess(Deny)
}
func (set RuleSet) ByAccess(a Access) RuleSet {
out, _ := set.Filter(func(r *Rule) (bool, error) {
return a == r.Access, nil
})
return out
}
func (set RuleSet) ByRole(roleID uint64) RuleSet {
out, _ := set.Filter(func(r *Rule) (bool, error) {
return roleID == r.RoleID, nil
})
return out
}

View File

@@ -0,0 +1,87 @@
package rbac
import (
"testing"
"github.com/stretchr/testify/require"
)
// Test role inheritance
func TestRuleSet_merge(t *testing.T) {
var (
req = require.New(t)
sCases = []struct {
old RuleSet
new RuleSet
del RuleSet
upd RuleSet
}{
{
RuleSet{AllowRule(role1, resService1, opAccess)},
RuleSet{AllowRule(role1, resService1, opAccess)},
RuleSet{},
RuleSet{},
},
{
RuleSet{AllowRule(role1, resService1, opAccess)},
RuleSet{DenyRule(role1, resService1, opAccess)},
RuleSet{},
RuleSet{DenyRule(role1, resService1, opAccess)},
},
{
RuleSet{AllowRule(role1, resService1, opAccess)},
RuleSet{InheritRule(role1, resService1, opAccess)},
RuleSet{InheritRule(role1, resService1, opAccess)},
RuleSet{},
},
{
RuleSet{AllowRule(role1, resService1, opAccess)},
RuleSet{AllowRule(role1, resService1, opAccess)},
RuleSet{},
RuleSet{},
},
{
RuleSet{
AllowRule(role1, resService1, opAccess),
DenyRule(role2, resService1, opAccess),
DenyRule(EveryoneRoleID, resService2, opAccess),
AllowRule(role1, resService2, opAccess),
AllowRule(role2, resThing42, opAccess),
},
RuleSet{
DenyRule(EveryoneRoleID, resThingWc, opAccess),
AllowRule(role1, resService2, opAccess),
AllowRule(role1, resThing42, opAccess),
InheritRule(role2, resThing42, opAccess),
},
RuleSet{
InheritRule(role2, resThing42, opAccess),
},
RuleSet{
// AllowRule(role1, resService1, opAccess),
// DenyRule(role2, resService1, opAccess),
// DenyRule(EveryoneRoleID, resService2, opAccess),
// AllowRule(role1, resService2, opAccess),
DenyRule(EveryoneRoleID, resThingWc, opAccess),
AllowRule(role1, resThing42, opAccess),
},
},
}
)
for _, sc := range sCases {
// Apply changed and get update candidates
mrg := sc.old.merge(sc.new...)
del, upd := mrg.dirty()
// Clear dirty flag so that we do not confuse DeepEqual
del.clear()
upd.clear()
req.Equal(len(sc.del), len(del))
req.Equal(len(sc.upd), len(upd))
req.Equal(sc.del, del)
req.Equal(sc.upd, upd)
}
}

227
pkg/rbac/service.go Normal file
View File

@@ -0,0 +1,227 @@
package rbac
import (
"context"
"github.com/cortezaproject/corteza-server/pkg/sentry"
"github.com/pkg/errors"
"go.uber.org/zap"
"sync"
"time"
)
type (
service struct {
l *sync.Mutex
logger *zap.Logger
// service will flush values on TRUE or just reload on FALSE
f chan bool
rules RuleSet
store rbacRulesStore
}
// RuleFilter is a dummy struct to satisfy store codegen
RuleFilter struct{}
Controller interface {
Can(roles []uint64, res Resource, op Operation, ff ...CheckAccessFunc) bool
Check(res Resource, op Operation, roles ...uint64) (v Access)
Grant(ctx context.Context, wl Whitelist, rules ...*Rule) (err error)
Watch(ctx context.Context)
FindRulesByRoleID(roleID uint64) (rr RuleSet)
Rules() (rr RuleSet)
Reload(ctx context.Context)
}
)
var (
// Global RBAC service
gRBAC Controller
)
const (
watchInterval = time.Hour
)
// Global returns global RBAC service
func Global() Controller {
return gRBAC
}
func SetGlobal(svc Controller) {
gRBAC = svc
}
func Initialize(logger *zap.Logger, s rbacRulesStore) error {
if gRBAC != nil {
// Prevent multiple initializations
return nil
}
SetGlobal(NewService(logger, s))
return nil
}
// 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.Mutex{},
f: make(chan bool),
logger: logger.Named("rbac"),
store: s,
}
return
}
// Can function performs permission check for roles in context
//
// First extracts roles from context, then
// use Check() to test against permission rules and
// iterate over all fallback functions
//
// System user is always allowed to do everything
//
// When not explicitly allowed through rules or fallbacks, function will return FALSE.
func (svc service) Can(roles []uint64, res Resource, op Operation, ff ...CheckAccessFunc) bool {
// Checking rules
var v = svc.Check(res.RBACResource(), op, roles...)
if v != Inherit {
return v == Allow
}
// Checking fallback functions
for _, f := range ff {
v = f()
if v != Inherit {
return v == Allow
}
}
return false
}
// Check verifies if role has access to perform an operation on a resource
//
// See RuleSet's Check() func for details
func (svc service) Check(res Resource, op Operation, roles ...uint64) (v Access) {
svc.l.Lock()
defer svc.l.Unlock()
return svc.rules.Check(res, op, roles...)
}
// Grant appends and/or overwrites internal rules slice
//
// All rules with Inherit are removed
func (svc *service) Grant(ctx context.Context, wl Whitelist, rules ...*Rule) (err error) {
svc.l.Lock()
defer svc.l.Unlock()
if err = svc.checkRules(wl, rules...); err != nil {
return err
}
svc.grant(rules...)
return svc.flush(ctx)
}
func (svc service) checkRules(wl Whitelist, rules ...*Rule) error {
for _, r := range rules {
if !wl.Check(r) {
return errors.Errorf("invalid rule: '%s' on '%s'", r.Operation, r.Resource)
}
}
return nil
}
func (svc *service) grant(rules ...*Rule) {
svc.rules = svc.rules.merge(rules...)
}
// Watches for changes
func (svc service) Watch(ctx context.Context) {
go func() {
defer sentry.Recover()
var ticker = time.NewTicker(watchInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
svc.Reload(ctx)
case <-svc.f:
svc.Reload(ctx)
}
}
}()
svc.logger.Debug("watcher initialized")
}
func (svc service) FindRulesByRoleID(roleID uint64) (rr RuleSet) {
svc.l.Lock()
defer svc.l.Unlock()
rr, _ = svc.rules.Filter(func(rule *Rule) (b bool, e error) {
return rule.RoleID == roleID, nil
})
return
}
func (svc service) Rules() (rr RuleSet) {
svc.l.Lock()
defer svc.l.Unlock()
return svc.rules
}
func (svc *service) Reload(ctx context.Context) {
svc.l.Lock()
defer svc.l.Unlock()
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.rules = rr
}
}
func (svc service) flush(ctx context.Context) (err error) {
d, u := svc.rules.dirty()
err = svc.store.DeleteRbacRule(ctx, d...)
if err != nil {
return
}
err = svc.store.UpsertRbacRule(ctx, u...)
if err != nil {
return
}
u.clear()
svc.rules = u
svc.logger.Debug("flushed rules",
zap.Int("updated", len(u)),
zap.Int("deleted", len(d)))
return
}

83
pkg/rbac/service_alt.go Normal file
View File

@@ -0,0 +1,83 @@
package rbac
import (
"context"
"fmt"
"go.uber.org/zap"
"strings"
"sync"
)
type (
ServiceAllowAll struct{}
ServiceDenyAll struct{}
TestService struct {
service
}
)
func (ServiceAllowAll) Can([]uint64, Resource, Operation, ...CheckAccessFunc) bool {
return true
}
func (ServiceAllowAll) Check(Resource, Operation, ...uint64) (v Access) {
return Allow
}
func (ServiceAllowAll) Grant(context.Context, Whitelist, ...*Rule) (err error) {
return nil
}
func (ServiceAllowAll) FindRulesByRoleID(roleID uint64) (rr RuleSet) {
return
}
func (ServiceDenyAll) Can([]uint64, Resource, Operation, ...CheckAccessFunc) bool {
return false
}
func (ServiceDenyAll) Check(Resource, Operation, ...uint64) (v Access) {
return Deny
}
func (ServiceDenyAll) Grant(context.Context, Whitelist, ...*Rule) (err error) {
return nil
}
func (ServiceDenyAll) FindRulesByRoleID(uint64) (rr RuleSet) {
return
}
func (svc *TestService) ClearGrants() {
_ = svc.store.TruncateRbacRules(context.Background())
svc.rules = RuleSet{}
}
func (svc *TestService) String() (out string) {
tpl := "%20v\t%-30s\t%-30s\t%v\n"
out = fmt.Sprintf(tpl, "role", "res", "op", "access")
out += strings.Repeat("-", 120) + "\n"
_ = svc.rules.Walk(func(r *Rule) error {
out += fmt.Sprintf(tpl, r.RoleID, r.Resource, r.Operation, r.Access)
return nil
})
out += strings.Repeat("-", 120) + "\n"
return
}
func NewTestService(logger *zap.Logger, s rbacRulesStore) (svc *TestService) {
svc = &TestService{
service: service{
l: &sync.Mutex{},
f: make(chan bool),
logger: logger.Named("rbac-test"),
store: s,
},
}
return
}

View File

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

82
pkg/rbac/type_set.gen.go Normal file
View File

@@ -0,0 +1,82 @@
package rbac
// This file is auto-generated.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
//
// Definitions file that controls how this file is generated:
// pkg/rbac/types.yaml
type (
// ResourceSet slice of Resource
//
// This type is auto-generated.
ResourceSet []*Resource
// RuleSet slice of Rule
//
// This type is auto-generated.
RuleSet []*Rule
)
// Walk iterates through every slice item and calls w(Resource) err
//
// This function is auto-generated.
func (set ResourceSet) Walk(w func(*Resource) error) (err error) {
for i := range set {
if err = w(set[i]); err != nil {
return
}
}
return
}
// Filter iterates through every slice item, calls f(Resource) (bool, err) and return filtered slice
//
// This function is auto-generated.
func (set ResourceSet) Filter(f func(*Resource) (bool, error)) (out ResourceSet, err error) {
var ok bool
out = ResourceSet{}
for i := range set {
if ok, err = f(set[i]); err != nil {
return
} else if ok {
out = append(out, set[i])
}
}
return
}
// Walk iterates through every slice item and calls w(Rule) err
//
// This function is auto-generated.
func (set RuleSet) Walk(w func(*Rule) error) (err error) {
for i := range set {
if err = w(set[i]); err != nil {
return
}
}
return
}
// Filter iterates through every slice item, calls f(Rule) (bool, err) and return filtered slice
//
// This function is auto-generated.
func (set RuleSet) Filter(f func(*Rule) (bool, error)) (out RuleSet, err error) {
var ok bool
out = RuleSet{}
for i := range set {
if ok, err = f(set[i]); err != nil {
return
} else if ok {
out = append(out, set[i])
}
}
return
}

View File

@@ -0,0 +1,127 @@
package rbac
// This file is auto-generated.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
//
// Definitions file that controls how this file is generated:
// pkg/rbac/types.yaml
import (
"fmt"
"github.com/stretchr/testify/require"
"testing"
)
func TestResourceSetWalk(t *testing.T) {
var (
value = make(ResourceSet, 3)
req = require.New(t)
)
// check walk with no errors
{
err := value.Walk(func(*Resource) error {
return nil
})
req.NoError(err)
}
// check walk with error
req.Error(value.Walk(func(*Resource) error { return fmt.Errorf("walk error") }))
}
func TestResourceSetFilter(t *testing.T) {
var (
value = make(ResourceSet, 3)
req = require.New(t)
)
// filter nothing
{
set, err := value.Filter(func(*Resource) (bool, error) {
return true, nil
})
req.NoError(err)
req.Equal(len(set), len(value))
}
// filter one item
{
found := false
set, err := value.Filter(func(*Resource) (bool, error) {
if !found {
found = true
return found, nil
}
return false, nil
})
req.NoError(err)
req.Len(set, 1)
}
// filter error
{
_, err := value.Filter(func(*Resource) (bool, error) {
return false, fmt.Errorf("filter error")
})
req.Error(err)
}
}
func TestRuleSetWalk(t *testing.T) {
var (
value = make(RuleSet, 3)
req = require.New(t)
)
// check walk with no errors
{
err := value.Walk(func(*Rule) error {
return nil
})
req.NoError(err)
}
// check walk with error
req.Error(value.Walk(func(*Rule) error { return fmt.Errorf("walk error") }))
}
func TestRuleSetFilter(t *testing.T) {
var (
value = make(RuleSet, 3)
req = require.New(t)
)
// filter nothing
{
set, err := value.Filter(func(*Rule) (bool, error) {
return true, nil
})
req.NoError(err)
req.Equal(len(set), len(value))
}
// filter one item
{
found := false
set, err := value.Filter(func(*Rule) (bool, error) {
if !found {
found = true
return found, nil
}
return false, nil
})
req.NoError(err)
req.Len(set, 1)
}
// filter error
{
_, err := value.Filter(func(*Rule) (bool, error) {
return false, fmt.Errorf("filter error")
})
req.Error(err)
}
}

6
pkg/rbac/types.yaml Normal file
View File

@@ -0,0 +1,6 @@
package: rbac
types:
Rule:
noIdField: true
Resource:
noIdField: true