diff --git a/roles/roles.go b/roles/roles.go new file mode 100644 index 0000000000..e5c118c4f8 --- /dev/null +++ b/roles/roles.go @@ -0,0 +1,31 @@ +package roles + +import ( + "context" + "fmt" +) + +type contextKey string + +// ContextKey is the key used to specify the +// role via context +const ContextKey = contextKey("role") + +func validRole(ctx context.Context) error { + // prevents panic in case of nil context + if ctx == nil { + return fmt.Errorf("expect non nil context") + } + role, ok := ctx.Value(ContextKey).(string) + // should never happen + if !ok { + return fmt.Errorf("expected role key to be a string") + } + switch role { + // TODO(desa): make real roles + case "member", "viewer", "editor", "admin": + return nil + default: + return fmt.Errorf("expected role key to be set") + } +} diff --git a/roles/sources.go b/roles/sources.go new file mode 100644 index 0000000000..ae45046233 --- /dev/null +++ b/roles/sources.go @@ -0,0 +1,134 @@ +package roles + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +// ensure that SourcesStore implements chronograf.SourceStore +var _ chronograf.SourcesStore = &SourcesStore{} + +// SourcesStore facade on a SourceStore that filters sources +// by role. +type SourcesStore struct { + store chronograf.SourcesStore + role string +} + +// NewSourcesStore creates a new SourcesStore from an existing +// chronograf.SourceStore and an role string +func NewSourcesStore(s chronograf.SourcesStore, role string) *SourcesStore { + return &SourcesStore{ + store: s, + role: role, + } +} + +// All retrieves all sources from the underlying SourceStore and filters them +// by role. +func (s *SourcesStore) All(ctx context.Context) ([]chronograf.Source, error) { + err := validRole(ctx) + if err != nil { + return nil, err + } + + ds, err := s.store.All(ctx) + if err != nil { + return nil, err + } + + // This filters sources without allocating + // https://github.com/golang/go/wiki/SliceTricks#filtering-without-allocating + sources := ds[:0] + for _, d := range ds { + if hasAuthorizedRole(d.Role, s.role) { + sources = append(sources, d) + } + } + + return sources, nil +} + +// Add creates a new Source in the SourcesStore with source.Role set to be the +// role from the source store. +func (s *SourcesStore) Add(ctx context.Context, d chronograf.Source) (chronograf.Source, error) { + err := validRole(ctx) + if err != nil { + return chronograf.Source{}, err + } + + return s.store.Add(ctx, d) +} + +// Delete the source from SourcesStore +func (s *SourcesStore) Delete(ctx context.Context, d chronograf.Source) error { + err := validRole(ctx) + if err != nil { + return err + } + + d, err = s.store.Get(ctx, d.ID) + if err != nil { + return err + } + + return s.store.Delete(ctx, d) +} + +// Get returns a Source if the id exists and belongs to the role that is set. +func (s *SourcesStore) Get(ctx context.Context, id int) (chronograf.Source, error) { + err := validRole(ctx) + if err != nil { + return chronograf.Source{}, err + } + + d, err := s.store.Get(ctx, id) + if err != nil { + return chronograf.Source{}, err + } + + if !hasAuthorizedRole(d.Role, s.role) { + return chronograf.Source{}, chronograf.ErrSourceNotFound + } + + return d, nil +} + +// Update the source in SourcesStore. +func (s *SourcesStore) Update(ctx context.Context, d chronograf.Source) error { + err := validRole(ctx) + if err != nil { + return err + } + + _, err = s.store.Get(ctx, d.ID) + if err != nil { + return err + } + + return s.store.Update(ctx, d) +} + +func hasAuthorizedRole(sourceRole, providedRole string) bool { + // TODO(desa): make real roles + switch sourceRole { + case "viewer": + switch providedRole { + case "viewer", "editor", "admin": + return true + } + case "editor": + switch providedRole { + case "editor", "admin": + return true + } + case "admin": + switch providedRole { + case "admin": + return true + } + } + + return false +} diff --git a/roles/sources_test.go b/roles/sources_test.go new file mode 100644 index 0000000000..16371417f9 --- /dev/null +++ b/roles/sources_test.go @@ -0,0 +1,489 @@ +package roles + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/mocks" +) + +func TestSources_Get(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + } + type args struct { + role string + id int + } + type wants struct { + source chronograf.Source + err bool + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "Get viewer source as viewer", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, nil + }, + }, + }, + args: args{ + role: "viewer", + id: 1, + }, + wants: wants{ + source: chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, + }, + }, + { + name: "Get viewer source as editor", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, nil + }, + }, + }, + args: args{ + role: "editor", + id: 1, + }, + wants: wants{ + source: chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, + }, + }, + { + name: "Get viewer source as admin", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, nil + }, + }, + }, + args: args{ + role: "admin", + id: 1, + }, + wants: wants{ + source: chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, + }, + }, + { + name: "Get editor source as editor", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "editor", + }, nil + }, + }, + }, + args: args{ + role: "editor", + id: 1, + }, + wants: wants{ + source: chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "editor", + }, + }, + }, + { + name: "Get editor source as admin", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "editor", + }, nil + }, + }, + }, + args: args{ + role: "admin", + id: 1, + }, + wants: wants{ + source: chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "editor", + }, + }, + }, + { + name: "Get editor source as viewer - want error", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "editor", + }, nil + }, + }, + }, + args: args{ + role: "viewer", + id: 1, + }, + wants: wants{ + err: true, + }, + }, + { + name: "Get admin source as admin", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "admin", + }, nil + }, + }, + }, + args: args{ + role: "admin", + id: 1, + }, + wants: wants{ + source: chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "admin", + }, + }, + }, + { + name: "Get admin source as viewer - want error", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "admin", + }, nil + }, + }, + }, + args: args{ + role: "viewer", + id: 1, + }, + wants: wants{ + err: true, + }, + }, + { + name: "Get admin source as editor - want error", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "admin", + }, nil + }, + }, + }, + args: args{ + role: "editor", + id: 1, + }, + wants: wants{ + err: true, + }, + }, + { + name: "Get source bad context", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "admin", + }, nil + }, + }, + }, + args: args{ + role: "random role", + id: 1, + }, + wants: wants{ + err: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := NewSourcesStore(tt.fields.SourcesStore, tt.args.role) + + ctx := context.Background() + + if tt.args.role != "" { + ctx = context.WithValue(ctx, ContextKey, tt.args.role) + } + + source, err := store.Get(ctx, tt.args.id) + if (err != nil) != tt.wants.err { + t.Errorf("%q. Store.Sources().Get() error = %v, wantErr %v", tt.name, err, tt.wants.err) + return + } + if diff := cmp.Diff(source, tt.wants.source); diff != "" { + t.Errorf("%q. Store.Sources().Get():\n-got/+want\ndiff %s", tt.name, diff) + } + }) + } +} + +func TestSources_All(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + } + type args struct { + role string + } + type wants struct { + sources []chronograf.Source + err bool + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "Get viewer sources as viewer", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + AllF: func(ctx context.Context) ([]chronograf.Source, error) { + return []chronograf.Source{ + { + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, + { + ID: 2, + Name: "my sweet name", + Organization: "0", + Role: "editor", + }, + { + ID: 3, + Name: "my sweet name", + Organization: "0", + Role: "admin", + }, + }, nil + }, + }, + }, + args: args{ + role: "viewer", + }, + wants: wants{ + sources: []chronograf.Source{ + { + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, + }, + }, + }, + { + name: "Get editor sources as editor", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + AllF: func(ctx context.Context) ([]chronograf.Source, error) { + return []chronograf.Source{ + { + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, + { + ID: 2, + Name: "my sweet name", + Organization: "0", + Role: "editor", + }, + { + ID: 3, + Name: "my sweet name", + Organization: "0", + Role: "admin", + }, + }, nil + }, + }, + }, + args: args{ + role: "editor", + }, + wants: wants{ + sources: []chronograf.Source{ + { + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, + { + ID: 2, + Name: "my sweet name", + Organization: "0", + Role: "editor", + }, + }, + }, + }, + { + name: "Get admin sources as admin", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + AllF: func(ctx context.Context) ([]chronograf.Source, error) { + return []chronograf.Source{ + { + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, + { + ID: 2, + Name: "my sweet name", + Organization: "0", + Role: "editor", + }, + { + ID: 3, + Name: "my sweet name", + Organization: "0", + Role: "admin", + }, + }, nil + }, + }, + }, + args: args{ + role: "admin", + }, + wants: wants{ + sources: []chronograf.Source{ + { + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, + { + ID: 2, + Name: "my sweet name", + Organization: "0", + Role: "editor", + }, + { + ID: 3, + Name: "my sweet name", + Organization: "0", + Role: "admin", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := NewSourcesStore(tt.fields.SourcesStore, tt.args.role) + + ctx := context.Background() + + if tt.args.role != "" { + ctx = context.WithValue(ctx, ContextKey, tt.args.role) + } + + sources, err := store.All(ctx) + if (err != nil) != tt.wants.err { + t.Errorf("%q. Store.Sources().Get() error = %v, wantErr %v", tt.name, err, tt.wants.err) + return + } + if diff := cmp.Diff(sources, tt.wants.sources); diff != "" { + t.Errorf("%q. Store.Sources().Get():\n-got/+want\ndiff %s", tt.name, diff) + } + }) + } +} diff --git a/server/stores.go b/server/stores.go index caff3d0dc0..d9d7131a27 100644 --- a/server/stores.go +++ b/server/stores.go @@ -6,6 +6,7 @@ import ( "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/noop" "github.com/influxdata/chronograf/organizations" + "github.com/influxdata/chronograf/roles" ) // hasOrganizationContext retrieves organization specified on context @@ -26,6 +27,27 @@ func hasOrganizationContext(ctx context.Context) (string, bool) { return orgID, true } +// hasRoleContext retrieves organization specified on context +// under the organizations.ContextKey +func hasRoleContext(ctx context.Context) (string, bool) { + // prevents panic in case of nil context + if ctx == nil { + return "", false + } + role, ok := ctx.Value(roles.ContextKey).(string) + // should never happen + if !ok { + return "", false + } + switch role { + // TODO(desa): make real roles + case "member", "viewer", "editor", "admin": + return role, true + default: + return "", false + } +} + type superAdminKey string // SuperAdminKey is the context key for retrieving is the context @@ -75,7 +97,10 @@ type Store struct { // and a organization.SourcesStore otherwise. func (s *Store) Sources(ctx context.Context) chronograf.SourcesStore { if org, ok := hasOrganizationContext(ctx); ok { - return organizations.NewSourcesStore(s.SourcesStore, org) + store := organizations.NewSourcesStore(s.SourcesStore, org) + if role, ok := hasRoleContext(ctx); ok { + return roles.NewSourcesStore(store, role) + } } return &noop.SourcesStore{} diff --git a/server/stores_test.go b/server/stores_test.go new file mode 100644 index 0000000000..728c152808 --- /dev/null +++ b/server/stores_test.go @@ -0,0 +1,137 @@ +package server + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/mocks" + "github.com/influxdata/chronograf/organizations" + "github.com/influxdata/chronograf/roles" +) + +func TestStore_SourcesGet(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + } + type args struct { + superAdmin bool + organization string + role string + id int + } + type wants struct { + source chronograf.Source + err bool + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "Get user as super admin", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, nil + }, + }, + }, + args: args{ + superAdmin: true, + organization: "0", + role: "viewer", + }, + wants: wants{ + source: chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, + }, + }, + { + name: "Get user as super admin", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, nil + }, + }, + }, + args: args{ + superAdmin: true, + }, + wants: wants{ + err: true, + }, + }, + { + name: "Get user as super admin", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + Role: "viewer", + }, nil + }, + }, + }, + args: args{ + superAdmin: true, + }, + wants: wants{ + err: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := &Store{ + SourcesStore: tt.fields.SourcesStore, + } + + ctx := context.Background() + + if tt.args.superAdmin { + ctx = context.WithValue(ctx, SuperAdminKey, true) + } + + if tt.args.organization != "" { + ctx = context.WithValue(ctx, organizations.ContextKey, tt.args.organization) + } + + if tt.args.role != "" { + ctx = context.WithValue(ctx, roles.ContextKey, tt.args.role) + } + + source, err := store.Sources(ctx).Get(ctx, tt.args.id) + if (err != nil) != tt.wants.err { + t.Errorf("%q. Store.Sources().Get() error = %v, wantErr %v", tt.name, err, tt.wants.err) + return + } + if diff := cmp.Diff(source, tt.wants.source); diff != "" { + t.Errorf("%q. Store.Sources().Get():\n-got/+want\ndiff %s", tt.name, diff) + } + }) + } +}