From 7941e7ba4a4616fffb9b0096334ce518b3b0ca27 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Wed, 19 May 2021 20:59:11 +0200 Subject: [PATCH] Add general suport for new role types --- app/boot_levels.go | 1 + pkg/options/RBAC.gen.go | 41 +++++++++ pkg/options/RBAC.yaml | 37 ++++++++ pkg/options/options.go | 2 + pkg/rbac/roles.go | 23 ++++- pkg/rbac/roles_test.go | 26 +++--- pkg/rbac/ruleset_checks_test.go | 20 ++--- pkg/rbac/service.go | 31 +++++-- system/service/role.go | 69 ++++++++++++-- system/service/service.go | 155 +++++++++++++++++++++++++++++++- system/types/role.go | 33 +++++++ 11 files changed, 399 insertions(+), 39 deletions(-) create mode 100644 pkg/options/RBAC.gen.go create mode 100644 pkg/options/RBAC.yaml diff --git a/app/boot_levels.go b/app/boot_levels.go index ab6096713..c69da2b6f 100644 --- a/app/boot_levels.go +++ b/app/boot_levels.go @@ -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 { diff --git a/pkg/options/RBAC.gen.go b/pkg/options/RBAC.gen.go new file mode 100644 index 000000000..71ff2cda0 --- /dev/null +++ b/pkg/options/RBAC.gen.go @@ -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 +} diff --git a/pkg/options/RBAC.yaml b/pkg/options/RBAC.yaml new file mode 100644 index 000000000..68fada312 --- /dev/null +++ b/pkg/options/RBAC.yaml @@ -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. + diff --git a/pkg/options/options.go b/pkg/options/options.go index 82cebfe17..a1f232762 100644 --- a/pkg/options/options.go +++ b/pkg/options/options.go @@ -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(), } } diff --git a/pkg/rbac/roles.go b/pkg/rbac/roles.go index 829e4ac9f..cfc006b7c 100644 --- a/pkg/rbac/roles.go +++ b/pkg/rbac/roles.go @@ -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{}) diff --git a/pkg/rbac/roles_test.go b/pkg/rbac/roles_test.go index 01fc0c0c7..7554049e3 100644 --- a/pkg/rbac/roles_test.go +++ b/pkg/rbac/roles_test.go @@ -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}}, }, } ) diff --git a/pkg/rbac/ruleset_checks_test.go b/pkg/rbac/ruleset_checks_test.go index 13f3be661..571c92909 100644 --- a/pkg/rbac/ruleset_checks_test.go +++ b/pkg/rbac/ruleset_checks_test.go @@ -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}, ) ) diff --git a/pkg/rbac/service.go b/pkg/rbac/service.go index e9602dc8d..709036efd 100644 --- a/pkg/rbac/service.go +++ b/pkg/rbac/service.go @@ -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) diff --git a/system/service/role.go b/system/service/role.go index e6b29335b..1af22f8b9 100644 --- a/system/service/role.go +++ b/system/service/role.go @@ -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 } diff --git a/system/service/service.go b/system/service/service.go index 294baf332..a0963795a 100644 --- a/system/service/service.go +++ b/system/service/service.go @@ -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 }) diff --git a/system/types/role.go b/system/types/role.go index d362f8816..005632a98 100644 --- a/system/types/role.go +++ b/system/types/role.go @@ -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) +}