From 087ba7c92da43d3a7646afc8accba00de2f3c2dc Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Wed, 8 May 2019 16:19:50 +0200 Subject: [PATCH] Permission rules refactored --- codegen.sh | 3 + internal/permissions/permissions.go | 37 +++++ internal/permissions/repository.go | 49 ++++++ internal/permissions/resource.gen.go | 41 +++++ internal/permissions/resource.go | 77 +++++++++ internal/permissions/resource_test.go | 63 ++++++++ internal/permissions/rule.gen.go | 41 +++++ internal/permissions/rule.go | 35 +++++ internal/permissions/ruleset_checks.go | 88 +++++++++++ internal/permissions/ruleset_checks_test.go | 164 ++++++++++++++++++++ internal/permissions/service.go | 63 ++++++++ 11 files changed, 661 insertions(+) create mode 100644 internal/permissions/permissions.go create mode 100644 internal/permissions/repository.go create mode 100644 internal/permissions/resource.gen.go create mode 100644 internal/permissions/resource.go create mode 100644 internal/permissions/resource_test.go create mode 100644 internal/permissions/rule.gen.go create mode 100644 internal/permissions/rule.go create mode 100644 internal/permissions/ruleset_checks.go create mode 100644 internal/permissions/ruleset_checks_test.go create mode 100644 internal/permissions/service.go diff --git a/codegen.sh b/codegen.sh index 59a1ffab3..2955c94f0 100755 --- a/codegen.sh +++ b/codegen.sh @@ -53,6 +53,9 @@ function types { ./build/gen-type-set --types Value --output internal/settings/types.gen.go --with-primary-key=false --package settings + ./build/gen-type-set --types Rule --output internal/permissions/rule.gen.go --with-primary-key=false --package permissions + ./build/gen-type-set --types Resource --output internal/permissions/resource.gen.go --with-primary-key=false --package permissions + green "OK" } diff --git a/internal/permissions/permissions.go b/internal/permissions/permissions.go new file mode 100644 index 000000000..2116d6a52 --- /dev/null +++ b/internal/permissions/permissions.go @@ -0,0 +1,37 @@ +package permissions + +// General permission stuff, types, constants + +type ( + Operation string + Access int +) + +const EveryoneRoleID = 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 +} diff --git a/internal/permissions/repository.go b/internal/permissions/repository.go new file mode 100644 index 000000000..fabbbca6a --- /dev/null +++ b/internal/permissions/repository.go @@ -0,0 +1,49 @@ +package permissions + +import ( + "context" + + "github.com/titpetric/factory" +) + +type ( + // repository servs as a db storage layer for permission rules + repository struct { + dbh *factory.DB + + // sql table reference + dbTable string + } +) + +func Repository(db *factory.DB, table string) *repository { + return &repository{ + dbTable: table, + dbh: db, + } +} + +func (r *repository) db() *factory.DB { + return r.dbh +} + +func (r repository) columns() []string { + return []string{ + "rel_role", + "resource", + "operation", + "value", + } +} + +func (r *repository) With(ctx context.Context) *repository { + return &repository{ + dbTable: r.dbTable, + dbh: r.db().With(ctx), + } +} + +func (r *repository) Load() (rr RuleSet, err error) { + // @todo load and return + return nil, nil +} diff --git a/internal/permissions/resource.gen.go b/internal/permissions/resource.gen.go new file mode 100644 index 000000000..05cb18175 --- /dev/null +++ b/internal/permissions/resource.gen.go @@ -0,0 +1,41 @@ +package permissions + +// Hello! This file is auto-generated. + +type ( + + // ResourceSet slice of Resource + // + // This type is auto-generated. + ResourceSet []*Resource +) + +// 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 +} diff --git a/internal/permissions/resource.go b/internal/permissions/resource.go new file mode 100644 index 000000000..a32b218c5 --- /dev/null +++ b/internal/permissions/resource.go @@ -0,0 +1,77 @@ +package permissions + +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) PermissionResource() Resource { + return r +} + +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) +} diff --git a/internal/permissions/resource_test.go b/internal/permissions/resource_test.go new file mode 100644 index 000000000..a639c8d62 --- /dev/null +++ b/internal/permissions/resource_test.go @@ -0,0 +1,63 @@ +package permissions + +import ( + "testing" + + "github.com/crusttech/crust/internal/test" +) + +func TestResource(t *testing.T) { + var ( + assert = test.Assert + + sCases = []struct { + r Resource + s string + }{ + { + Resource("a:b:c"), + "a:b:c"}, + { + Resource("a:b:c").PermissionResource(), + "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 { + assert(t, sc.r.String() == sc.s, "Resource check failed (%s != %s)", sc.r, sc.s) + } + + var r string + r = "a:" + assert(t, Resource(r).IsAppendable(), "Expecting resource %q to be appendable", r) + r = "a:1" + assert(t, Resource(r).IsAppendable(), "Expecting resource %q to be appendable", r) + r = "a:*" + assert(t, Resource(r).IsAppendable(), "Expecting resource %q to be appendable", r) + + r = "a" + assert(t, Resource(r).IsValid(), "Expecting resource %q to be valid", r) + r = "a:" + assert(t, !Resource(r).IsValid(), "Expecting resource %q not to be valid", r) + r = "a:1" + assert(t, Resource(r).IsValid(), "Expecting resource %q to be valid", r) + r = "a:*" + assert(t, Resource(r).IsValid(), "Expecting resource %q to be valid", r) + + r = "a:1" + assert(t, !Resource(r).HasWildcard(), "Expecting resource %q to not have wildcard", r) + r = "a:*" + assert(t, Resource(r).HasWildcard(), "Expecting resource %q to have wildcard", r) +} diff --git a/internal/permissions/rule.gen.go b/internal/permissions/rule.gen.go new file mode 100644 index 000000000..aaa3f2720 --- /dev/null +++ b/internal/permissions/rule.gen.go @@ -0,0 +1,41 @@ +package permissions + +// Hello! This file is auto-generated. + +type ( + + // RuleSet slice of Rule + // + // This type is auto-generated. + RuleSet []*Rule +) + +// 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 +} diff --git a/internal/permissions/rule.go b/internal/permissions/rule.go new file mode 100644 index 000000000..7aa8623ec --- /dev/null +++ b/internal/permissions/rule.go @@ -0,0 +1,35 @@ +package permissions + +import ( + "fmt" +) + +type ( + Rule struct { + RoleID uint64 `json:"roleID,string" db:"rel_role"` + Resource Resource `json:"resource" db:"resource"` + Operation Operation `json:"operation" db:"operation"` + Access Access `json:"value,string" db:"value"` + } +) + +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 { + return r.RoleID == cmp.RoleID && + r.Resource == cmp.Resource && + r.Operation == cmp.Operation +} diff --git a/internal/permissions/ruleset_checks.go b/internal/permissions/ruleset_checks.go new file mode 100644 index 000000000..712b83d02 --- /dev/null +++ b/internal/permissions/ruleset_checks.go @@ -0,0 +1,88 @@ +package permissions + +// Check verifies if role has access to perform an operation on a resource +// +// Overall flow: +// - no roles, no access +// - invalid resource, no access +// - can this specific role perform an operation on this specific resource +// - can this specific role perform an operation on any resource of the type (wildcard) +// - can anyone perform an operation on this specific resource +// - can anyone perform an operation on any resource of the type (wildcard) +func (set RuleSet) Check(res Resource, op Operation, roles ...uint64) (v Access) { + if len(roles) == 0 { + return Deny + } + + if !res.IsValid() { + return Deny + } + + 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 ito a wildcarded-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 + + // 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 +} diff --git a/internal/permissions/ruleset_checks_test.go b/internal/permissions/ruleset_checks_test.go new file mode 100644 index 000000000..05ba81084 --- /dev/null +++ b/internal/permissions/ruleset_checks_test.go @@ -0,0 +1,164 @@ +package permissions + +import ( + "testing" + + "github.com/crusttech/crust/internal/test" +) + +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 ( + assert = test.Assert + + rr = RuleSet{ + &Rule{role1, resThing42, opRead, Allow}, + &Rule{role1, resThing13, opWrite, Deny}, + &Rule{role2, resThing13, opWrite, Allow}, + } + + 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...) + assert(t, v == sc.expected, "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 ( + assert = test.Assert + + sCases = []struct { + rr RuleSet + roles []uint64 + res Resource + op Operation + expected Access + }{ + { + RuleSet{ + &Rule{role1, resService1, opAccess, Allow}, + }, + []uint64{role1}, + resService1, + opAccess, + Allow, + }, + { + RuleSet{ + &Rule{role1, resThingWc, opAccess, Allow}, + }, + []uint64{role1}, + resThing42, + opAccess, + Allow, + }, + { // deny wc and explictly allow 42 + RuleSet{ + &Rule{role1, resThingWc, opAccess, Deny}, + &Rule{role1, resThing42, opAccess, Allow}, + }, + []uint64{role1}, + resThing42, + opAccess, + Allow, + }, + { // deny wc and explictly allow 42 + RuleSet{ + &Rule{role1, resThingWc, opAccess, Deny}, + &Rule{role1, resThing42, opAccess, Allow}, + }, + []uint64{role1}, + resThing13, + opAccess, + Deny, + }, + } + ) + + for c, sc := range sCases { + v := sc.rr.checkResource(sc.res, sc.op, sc.roles...) + assert(t, v == sc.expected, "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 + &Rule{role1, resService1, opAccess, Allow}, + &Rule{role2, resService1, opAccess, Deny}, + // 2nd level + &Rule{EveryoneRoleID, resService2, opAccess, Deny}, + &Rule{role1, resService2, opAccess, Allow}, + // 3rd level + &Rule{EveryoneRoleID, resThingWc, opAccess, Deny}, + &Rule{role1, resThing42, opAccess, Allow}, + } + + assert = test.Assert + + 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}, + } + ) + + for c, sc := range sCases { + v := rr.Check(sc.res, sc.op, sc.roles...) + assert(t, v == sc.expected, "Check test #%d failed, expected %s, got %s", c, sc.expected, v) + } +} diff --git a/internal/permissions/service.go b/internal/permissions/service.go new file mode 100644 index 000000000..6a82f96b5 --- /dev/null +++ b/internal/permissions/service.go @@ -0,0 +1,63 @@ +package permissions + +import ( + "context" + "sync" +) + +type ( + service struct { + l sync.Locker + + rules RuleSet + repository *repository + } +) + +// Service initializes service{} struct +// +// service{} struct preloads, checks, grants and flushes privileges to and from repository +// It acts as a caching layer +func Service(repository *repository) *service { + return &service{ + repository: repository, + } +} + +func (svc *service) Preload(ctx context.Context) (err error) { + svc.l.Lock() + defer svc.l.Unlock() + + svc.rules, err = svc.repository.With(ctx).Load() + if err != nil { + return + } + + return nil +} + +// 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, rules ...*Rule) error { + svc.l.Lock() + defer svc.l.Unlock() + + // @todo update svc.rules + + return nil +} + +func (svc service) watcher() { + // @todo will listen to chan and load new stuff every time it gets a ping +}