Permission rules refactored
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
37
internal/permissions/permissions.go
Normal file
37
internal/permissions/permissions.go
Normal file
@@ -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
|
||||
}
|
||||
49
internal/permissions/repository.go
Normal file
49
internal/permissions/repository.go
Normal file
@@ -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
|
||||
}
|
||||
41
internal/permissions/resource.gen.go
Normal file
41
internal/permissions/resource.gen.go
Normal file
@@ -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
|
||||
}
|
||||
77
internal/permissions/resource.go
Normal file
77
internal/permissions/resource.go
Normal file
@@ -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)
|
||||
}
|
||||
63
internal/permissions/resource_test.go
Normal file
63
internal/permissions/resource_test.go
Normal file
@@ -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)
|
||||
}
|
||||
41
internal/permissions/rule.gen.go
Normal file
41
internal/permissions/rule.gen.go
Normal file
@@ -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
|
||||
}
|
||||
35
internal/permissions/rule.go
Normal file
35
internal/permissions/rule.go
Normal file
@@ -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
|
||||
}
|
||||
88
internal/permissions/ruleset_checks.go
Normal file
88
internal/permissions/ruleset_checks.go
Normal file
@@ -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
|
||||
}
|
||||
164
internal/permissions/ruleset_checks_test.go
Normal file
164
internal/permissions/ruleset_checks_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
63
internal/permissions/service.go
Normal file
63
internal/permissions/service.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user