Add general suport for new role types

This commit is contained in:
Denis Arh
2021-07-08 11:22:11 +02:00
parent b3da377c2d
commit 7941e7ba4a
11 changed files with 399 additions and 39 deletions
+1
View File
@@ -288,6 +288,7 @@ func (app *CortezaApp) InitServices(ctx context.Context) (err error) {
Storage: app.Opt.ObjStore,
Template: app.Opt.Template,
Auth: app.Opt.Auth,
RBAC: app.Opt.RBAC,
})
if err != nil {
+41
View File
@@ -0,0 +1,41 @@
package options
// 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/options/RBAC.yaml
type (
RBACOpt struct {
ServiceUser string `env:"RBAC_SERVICE_USER"`
BypassRoles string `env:"RBAC_BYPASS_ROLES"`
AuthenticatedRoles string `env:"RBAC_AUTHENTICATED_ROLES"`
AnonymousRoles string `env:"RBAC_ANONYMOUS_ROLES"`
}
)
// RBAC initializes and returns a RBACOpt with default values
func RBAC() (o *RBACOpt) {
o = &RBACOpt{
ServiceUser: "corteza",
BypassRoles: "superadmin",
AuthenticatedRoles: "authenticated",
AnonymousRoles: "anonymous",
}
fill(o)
// Function that allows access to custom logic inside the parent function.
// The custom logic in the other file should be like:
// func (o *RBAC) Defaults() {...}
func(o interface{}) {
if def, ok := o.(interface{ Defaults() }); ok {
def.Defaults()
}
}(o)
return
}
+37
View File
@@ -0,0 +1,37 @@
docs:
title: RBAC options
props:
# - name: enabled
# type: bool
# default: true
# description: When disabled all operations on all resources are allowed
- name: serviceUser
default: "corteza"
description: |-
Used at boot time, for provisioning and other service-level operations.
Service user is automatically member of all bypass roles.
- name: bypassRoles
default: "superadmin"
description: |-
Space delimited list of role handles.
These roles causes short-circuiting access control check and allowing all operations.
System will refuse to start if check-bypassing roles are also listed as authenticated or anonymous auto-assigned roles.
- name: authenticatedRoles
default: "authenticated"
description: |-
Space delimited list of role handles.
These roles are automatically assigned to authenticated user.
Memberships can not be managed for these roles.
System will refuse to start if roles listed here are also listed under anonymous roles
- name: anonymousRoles
default: "anonymous"
description: |-
Space delimited list of role handles.
These roles are automatically assigned to anonymous user.
Memberships can not be managed for these roles.
+2
View File
@@ -23,6 +23,7 @@ type (
Federation FederationOpt
SCIM SCIMOpt
Workflow WorkflowOpt
RBAC RBACOpt
}
)
@@ -49,5 +50,6 @@ func Init() *Options {
Federation: *Federation(),
SCIM: *SCIM(),
Workflow: *Workflow(),
RBAC: *RBAC(),
}
}
+20 -3
View File
@@ -8,7 +8,7 @@ type (
ctxRoleCheckFn func(map[string]interface{}) bool
// role information, adapted for the needs of RBAC package
role struct {
Role struct {
// all RBAC rules refer to role ID
id uint64
@@ -34,8 +34,25 @@ const (
BypassRole
)
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) *Role {
return &Role{
kind: ContextRole,
id: id,
handle: handle,
check: fn,
}
}
// partitions roles by kind
func partitionRoles(rr ...*role) partRoles {
func partitionRoles(rr ...*Role) partRoles {
out := make([]map[uint64]bool, len(roleKindsByPriority()))
for _, r := range rr {
if out[r.kind] == nil {
@@ -66,7 +83,7 @@ func roleKindsByPriority() []roleKind {
}
// compare list of session roles (ids) with preloaded roles and calculate the final list
func getContextRoles(sRoles []uint64, res Resource, preloadedRoles []*role) (out partRoles) {
func getContextRoles(sRoles []uint64, res Resource, preloadedRoles []*Role) (out partRoles) {
var (
mm = slice.ToUint64BoolMap(sRoles)
attr = make(map[string]interface{})
+13 -13
View File
@@ -9,11 +9,11 @@ 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},
&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},
)
)
@@ -44,33 +44,33 @@ func Test_getContextRoles(t *testing.T) {
name string
sessionRoles []uint64
res Resource
preloadRoles []*role
output []*role
preloadRoles []*Role
output []*Role
}{
{
"existing role",
[]uint64{1},
nil,
[]*role{{id: 1, kind: BypassRole}},
[]*role{{id: 1, kind: BypassRole}},
[]*Role{{id: 1, kind: BypassRole}},
[]*Role{{id: 1, kind: BypassRole}},
},
{
"missing role",
[]uint64{2},
nil,
[]*role{{id: 1, kind: BypassRole}},
[]*role{},
[]*Role{{id: 1, kind: BypassRole}},
[]*Role{},
},
{
"dynamic role",
[]uint64{1, 2},
nil,
[]*role{
[]*Role{
{id: 1, kind: BypassRole},
{id: 2, kind: ContextRole, check: dyCheck(true)},
{id: 3, kind: ContextRole, check: dyCheck(false)},
},
[]*role{{id: 1, kind: BypassRole}, {id: 2, kind: ContextRole}},
[]*Role{{id: 1, kind: BypassRole}, {id: 2, kind: ContextRole}},
},
}
)
+10 -10
View File
@@ -15,7 +15,7 @@ func Test_check(t *testing.T) {
exp Access
res string
op string
rr []*role
rr []*Role
set RuleSet
}{
{"inherit when no roles or rules",
@@ -25,7 +25,7 @@ func Test_check(t *testing.T) {
Allow,
"",
"",
[]*role{
[]*Role{
{id: 1, kind: BypassRole},
},
nil,
@@ -35,7 +35,7 @@ func Test_check(t *testing.T) {
Inherit,
"",
"",
[]*role{
[]*Role{
{id: 1, kind: CommonRole},
},
[]*Rule{
@@ -47,7 +47,7 @@ func Test_check(t *testing.T) {
Allow,
"",
"",
[]*role{
[]*Role{
{id: 1, kind: CommonRole},
},
@@ -72,12 +72,12 @@ func benchmarkCheck(b *testing.B, c int) {
rules = make(RuleSet, 0, c)
pr = partitionRoles(
&role{id: 1, kind: CommonRole},
&role{id: 2, kind: CommonRole},
&role{id: 3, kind: CommonRole},
&role{id: 4, kind: CommonRole},
&role{id: 5, kind: CommonRole},
&role{id: 6, kind: CommonRole},
&Role{id: 1, kind: CommonRole},
&Role{id: 2, kind: CommonRole},
&Role{id: 3, kind: CommonRole},
&Role{id: 4, kind: CommonRole},
&Role{id: 5, kind: CommonRole},
&Role{id: 6, kind: CommonRole},
)
)
+26 -5
View File
@@ -19,7 +19,7 @@ type (
rules RuleSet
indexed OptRuleSet
roles []*role
roles []*Role
store rbacRulesStore
}
@@ -27,7 +27,7 @@ type (
// RuleFilter is a dummy struct to satisfy store codegen
RuleFilter struct{}
ControllerV2 interface {
Controller interface {
Can(ses Session, op string, res Resource) bool
Check(ses Session, op string, res Resource) (v Access)
Grant(ctx context.Context, rules ...*Rule) (err error)
@@ -35,12 +35,19 @@ type (
FindRulesByRoleID(roleID uint64) (rr RuleSet)
Rules() (rr RuleSet)
Reload(ctx context.Context)
UpdateRoles(rr ...*Role)
}
RoleSettings struct {
Bypass []uint64
Authenticated []uint64
Anonymous []uint64
}
)
var (
// Global RBAC service
gRBAC ControllerV2
gRBAC Controller
)
const (
@@ -48,11 +55,11 @@ const (
)
// Global returns global RBAC service
func Global() ControllerV2 {
func Global() Controller {
return gRBAC
}
func SetGlobal(svc ControllerV2) {
func SetGlobal(svc Controller) {
gRBAC = svc
}
@@ -168,7 +175,10 @@ func (svc service) Rules() (rr RuleSet) {
func (svc *service) Reload(ctx context.Context) {
svc.l.Lock()
defer svc.l.Unlock()
svc.reloadRules(ctx)
}
func (svc *service) reloadRules(ctx context.Context) {
rr, _, err := svc.store.SearchRbacRules(ctx, RuleFilter{})
svc.logger.Debug(
"reloading rules",
@@ -182,6 +192,17 @@ func (svc *service) Reload(ctx context.Context) {
}
}
func (svc *service) UpdateRoles(rr ...*Role) {
svc.l.Lock()
defer svc.l.Unlock()
svc.logger.Debug(
"updating roles",
zap.Int("before", len(svc.rules)),
zap.Int("after", len(rr)),
)
svc.roles = rr
}
func (svc service) flush(ctx context.Context) (err error) {
d, u := flushable(svc.rules)
+63 -6
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"github.com/cortezaproject/corteza-server/pkg/slice"
"strconv"
"github.com/cortezaproject/corteza-server/pkg/actionlog"
@@ -9,7 +10,6 @@ import (
"github.com/cortezaproject/corteza-server/pkg/eventbus"
"github.com/cortezaproject/corteza-server/pkg/handle"
"github.com/cortezaproject/corteza-server/pkg/label"
"github.com/cortezaproject/corteza-server/pkg/rbac"
"github.com/cortezaproject/corteza-server/store"
"github.com/cortezaproject/corteza-server/system/service/event"
"github.com/cortezaproject/corteza-server/system/types"
@@ -25,6 +25,12 @@ type (
user UserService
store store.Storer
// list of all system roles
system map[string]bool
// list of all closed roles
closed map[string]bool
}
roleAccessController interface {
@@ -57,7 +63,7 @@ type (
}
)
func Role(ctx context.Context) *role {
func Role() *role {
return &role{
ac: DefaultAccessControl,
eventbus: eventbus.Service(),
@@ -66,9 +72,28 @@ func Role(ctx context.Context) *role {
user: DefaultUser,
store: DefaultStore,
system: make(map[string]bool),
closed: make(map[string]bool),
}
}
// SetImmutable sets list of handles for all system roles
//
// System roles can not be changed or deleted
func (svc role) SetSystem(hh ...string) {
svc.system = slice.ToStringBoolMap(hh)
delete(svc.system, "")
}
// SetClosed sets list of handles for all closed roles
//
// Closed roles can not have members
func (svc role) SetClosed(hh ...string) {
svc.closed = slice.ToStringBoolMap(hh)
delete(svc.closed, "")
}
func (svc role) Find(ctx context.Context, filter types.RoleFilter) (rr types.RoleSet, f types.RoleFilter, err error) {
var (
raProps = &roleActionProps{filter: &filter}
@@ -291,6 +316,10 @@ func (svc role) Update(ctx context.Context, upd *types.Role) (r *types.Role, err
return
}
if svc.system[r.Handle] {
return RoleErrNotAllowedToUpdate()
}
raProps.setRole(r)
if err = svc.eventbus.WaitFor(ctx, event.RoleBeforeUpdate(upd, r)); err != nil {
@@ -359,6 +388,10 @@ func (svc role) Delete(ctx context.Context, roleID uint64) (err error) {
return err
}
if svc.system[r.Handle] {
return RoleErrNotAllowedToDelete()
}
raProps.setRole(r)
if !svc.ac.CanDeleteRole(ctx, r) {
@@ -394,6 +427,10 @@ func (svc role) Undelete(ctx context.Context, roleID uint64) (err error) {
return err
}
if svc.system[r.Handle] {
return RoleErrNotAllowedToUndelete()
}
raProps.setRole(r)
if !svc.ac.CanDeleteRole(ctx, r) {
@@ -423,6 +460,10 @@ func (svc role) Archive(ctx context.Context, roleID uint64) (err error) {
return err
}
if svc.system[r.Handle] {
return RoleErrNotAllowedToArchive()
}
raProps.setRole(r)
if !svc.ac.CanUpdateRole(ctx, r) {
@@ -451,10 +492,14 @@ func (svc role) Unarchive(ctx context.Context, roleID uint64) (err error) {
return err
}
if svc.system[r.Handle] {
return RoleErrNotAllowedToUnarchive()
}
raProps.setRole(r)
if !svc.ac.CanDeleteRole(ctx, r) {
return RoleErrNotAllowedToDelete()
return RoleErrNotAllowedToUndelete()
}
r.ArchivedAt = nil
@@ -483,10 +528,14 @@ func (svc role) MemberList(ctx context.Context, roleID uint64) (mm types.RoleMem
)
err = func() error {
if roleID == rbac.EveryoneRoleID || roleID == 0 {
if roleID == 0 {
return RoleErrInvalidID()
}
if svc.closed[r.Handle] {
return RoleErrNotAllowedToManageMembers()
}
if r, err = svc.findByID(ctx, roleID); err != nil {
return err
}
@@ -515,10 +564,14 @@ func (svc role) MemberAdd(ctx context.Context, roleID, memberID uint64) (err err
)
err = func() (err error) {
if roleID == rbac.EveryoneRoleID || roleID == 0 || memberID == 0 {
if roleID == 0 || memberID == 0 {
return RoleErrInvalidID()
}
if svc.closed[r.Handle] {
return RoleErrNotAllowedToManageMembers()
}
if r, err = svc.findByID(ctx, roleID); err != nil {
return
}
@@ -562,10 +615,14 @@ func (svc role) MemberRemove(ctx context.Context, roleID, memberID uint64) (err
)
err = func() (err error) {
if roleID == rbac.EveryoneRoleID || roleID == 0 || memberID == 0 {
if roleID == 0 || memberID == 0 {
return RoleErrInvalidID()
}
if svc.closed[r.Handle] {
return RoleErrNotAllowedToManageMembers()
}
if r, err = svc.findByID(ctx, roleID); err != nil {
return
}
+153 -2
View File
@@ -3,6 +3,11 @@ package service
import (
"context"
"errors"
"fmt"
"github.com/cortezaproject/corteza-server/pkg/expr"
"github.com/cortezaproject/corteza-server/pkg/handle"
"github.com/cortezaproject/corteza-server/pkg/rbac"
"strings"
"time"
automationService "github.com/cortezaproject/corteza-server/automation/service"
@@ -32,6 +37,7 @@ type (
Storage options.ObjectStoreOpt
Template options.TemplateOpt
Auth options.AuthOpt
RBAC options.RBACOpt
}
eventDispatcher interface {
@@ -68,7 +74,7 @@ var (
DefaultAuth *auth
DefaultAuthClient *authClient
DefaultUser UserService
DefaultRole RoleService
DefaultRole *role
DefaultApplication *application
DefaultReminder ReminderService
DefaultAttachment AttachmentService
@@ -160,7 +166,7 @@ func Initialize(ctx context.Context, log *zap.Logger, s store.Storer, ws websock
DefaultAuth = Auth()
DefaultAuthClient = AuthClient(DefaultStore, DefaultAccessControl, DefaultActionlog, eventbus.Service())
DefaultUser = User(ctx)
DefaultRole = Role(ctx)
DefaultRole = Role()
DefaultApplication = Application(DefaultStore, DefaultAccessControl, DefaultActionlog, eventbus.Service())
DefaultReminder = Reminder(ctx, DefaultLogger.Named("reminder"), ws)
DefaultSink = Sink()
@@ -211,6 +217,151 @@ func Watchers(ctx context.Context) {
return
}
// Configures RBAC with roles
//
// Sets all closed & im
func initRoles(ctx context.Context, log *zap.Logger, opt options.RBACOpt) (err error) {
var (
// splits space separated string into map
s = func(l string) (map[string]bool, error) {
m := make(map[string]bool)
for _, r := range strings.Split(l, " ") {
if r = strings.TrimSpace(r); len(r) == 0 {
continue
}
if !handle.IsValid(r) {
return nil, fmt.Errorf("invalid handle '%s'", r)
}
m[r] = true
}
return m, nil
}
// joins map keys into string slice
j = func(mm ...map[string]bool) []string {
o := make([]string, 0)
for _, m := range mm {
for r := range m {
o = append(o, r)
}
}
return o
}
system, service, bypass, authenticated, anonymous map[string]bool
)
if bypass, err = s(opt.BypassRoles); err != nil {
return fmt.Errorf("failed to process list of bypass roles (RBAC_BYPASS_ROLES): %w", err)
}
if authenticated, err = s(opt.AuthenticatedRoles); err != nil {
return fmt.Errorf("failed to process list of authenticated roles (RBAC_AUTHENTICATED_ROLES): %w", err)
}
if anonymous, err = s(opt.AnonymousRoles); err != nil {
return fmt.Errorf("failed to process list of anonymous roles (RBAC_ANONYMOUS_ROLES): %w", err)
}
if len(service) != 1 {
return fmt.Errorf("role %s used for authenticated users can not be used as bypass role")
}
for r := range authenticated {
if bypass[r] {
return fmt.Errorf("role %s used for authenticated users must not be used as bypass role")
}
}
for r := range anonymous {
if bypass[r] {
return fmt.Errorf("role %s used for anonymous users must not be used as bypass role")
}
if authenticated[r] {
return fmt.Errorf("role %s used for anonymous users must not be used as bypass role")
}
}
DefaultRole.SetSystem(j(system)...)
DefaultRole.SetClosed(j(authenticated, anonymous)...)
// Hook to role create, update & delete events and
// re-apply all roles to RBAC
eventbus.Service().Register(
func(_ context.Context, ev eventbus.Event) (err error) {
var (
p = expr.NewParser()
f = types.RoleFilter{}
rr []*rbac.Role
)
f.Paging.Total = 0
roles, _, err := DefaultStore.SearchRoles(ctx, f)
for _, r := range roles {
log := log.With(
zap.Uint64("id", r.ID),
zap.String("handle", r.Handle),
zap.String("expr", r.Meta.ContextExpr),
)
switch {
case bypass[r.Handle]:
rr = append(rr, rbac.BypassRole.Make(r.ID, r.Handle))
case anonymous[r.Handle]:
rr = append(rr, rbac.AnonymousRole.Make(r.ID, r.Handle))
case authenticated[r.Handle]:
rr = append(rr, rbac.AuthenticatedRole.Make(r.ID, r.Handle))
case r.Meta != nil && len(r.Meta.ContextExpr) > 0:
log := log.With(zap.String("expr", r.Meta.ContextExpr))
eval, err := p.Parse(r.Meta.ContextExpr)
if err != nil {
log.Error("failed to parse role context expression", zap.Error(err))
continue
}
check := func(s map[string]interface{}) bool {
vars, err := expr.NewVars(s)
if err != nil {
log.Error("failed to convert check scope to expr.Vars", zap.Error(err))
return false
}
test, err := eval.Test(ctx, vars)
if err != nil {
log.Error("failed to evaluate role context expression", zap.Error(err))
return false
}
return test
}
rr = append(rr, rbac.MakeContextRole(r.ID, r.Handle, check))
default:
rr = append(rr, rbac.CommonRole.Make(r.ID, r.Handle))
}
}
log.Info("role changed " + ev.EventType())
rbac.Global().UpdateRoles(rr...)
return nil
},
eventbus.For("system:role"),
eventbus.On("afterUpdate", "afterCreate", "afterDelete"),
)
return nil
}
// isGeneric returns true if given error is generic
func isGeneric(err error) bool {
g, ok := err.(interface{ IsGeneric() bool })
+33
View File
@@ -1,6 +1,9 @@
package types
import (
"database/sql/driver"
"encoding/json"
"fmt"
"github.com/cortezaproject/corteza-server/pkg/filter"
"time"
)
@@ -10,6 +13,7 @@ type (
ID uint64 `json:"roleID,string"`
Name string `json:"name"`
Handle string `json:"handle"`
Meta *RoleMeta
Labels map[string]string `json:"labels,omitempty"`
@@ -19,6 +23,11 @@ type (
DeletedAt *time.Time `json:"deletedAt,omitempty"`
}
RoleMeta struct {
Description string
ContextExpr string
}
RoleFilter struct {
RoleID []uint64 `json:"roleID"`
MemberID uint64 `json:"memberID"`
@@ -71,3 +80,27 @@ func (set RoleSet) FindByHandle(handle string) *Role {
return nil
}
func (vv *RoleMeta) Scan(value interface{}) error {
//lint:ignore S1034 This typecast is intentional, we need to get []byte out of a []uint8
switch value.(type) {
case nil:
*vv = RoleMeta{}
case []uint8:
b := value.([]byte)
if err := json.Unmarshal(b, vv); err != nil {
return fmt.Errorf("cannot scan '%v' into RoleMeta: %w", string(b), err)
}
}
return nil
}
// Scan on RoleMeta gracefully handles conversion from NULL
func (vv *RoleMeta) Value() (driver.Value, error) {
if vv == nil {
return []byte("null"), nil
}
return json.Marshal(vv)
}