diff --git a/.bumpversion.cfg b/.bumpversion.cfg index b697f14b42..3fda06e846 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.3.10.0 +current_version = 1.4.0.0-beta1 files = README.md server/swagger.json parse = (?P\d+)\.(?P\d+)\.(?P\d+)\.(?P\d+) serialize = {major}.{minor}.{patch}.{release} diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e808117a..700fb75fce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,31 @@ -## v1.3.11.0 [unreleased] +## v1.4.0.0-beta2 [unreleased] +### Features +### UI Improvements +### Bug Fixes + +## v1.4.0.0-beta1 [2017-12-07] +### Features +1. [#2506](https://github.com/influxdata/chronograf/pull/2506): Add support for multiple organizations, multiple users with role-based access control, and private instances +1. [#2188](https://github.com/influxdata/chronograf/pull/2188): Add Kapacitor logs to the TICKscript editor +1. [#2385](https://github.com/influxdata/chronograf/pull/2385): Add time shift feature to DataExplorer and Dashboards +1. [#2426](https://github.com/influxdata/chronograf/pull/2426): Add auto group by time to Data Explorer +1. [#2479](https://github.com/influxdata/chronograf/pull/2479): Support authentication for Enterprise Meta Nodes +1. [#2456](https://github.com/influxdata/chronograf/pull/2456): Add boolean thresholds for kapacitor threshold alerts +1. [#2460](https://github.com/influxdata/chronograf/pull/2460): Update kapacitor alerts to cast to float before sending to influx +1. [#2400](https://github.com/influxdata/chronograf/pull/2400): Allow override of generic oauth2 keys for email + +### UI Improvements +1. [#2410](https://github.com/influxdata/chronograf/pull/2410): Introduce customizable Gauge visualization type for dashboard cells +1. [#2427](https://github.com/influxdata/chronograf/pull/2427): Improve performance of Hosts, Alert History, and TICKscript logging pages when there are many items to display +1. [#2384](https://github.com/influxdata/chronograf/pull/2384): Add filtering by name to Dashboard index page +1. [#2477](https://github.com/influxdata/chronograf/pull/2477): Improve performance of hoverline rendering + ### Bug Fixes 1. [#2449](https://github.com/influxdata/chronograf/pull/2449): Fix .jsdep step fails when LDFLAGS is exported 1. [#2157](https://github.com/influxdata/chronograf/pull/2157): Fix logscale producing console errors when only one point in graph -1. [#2157](https://github.com/influxdata/chronograf/pull/2157): Fix logscale producing console errors when only one point in graph 1. [#2158](https://github.com/influxdata/chronograf/pull/2158): Fix 'Cannot connect to source' false error flag on Dashboard page 1. [#2167](https://github.com/influxdata/chronograf/pull/2167): Add fractions of seconds to time field in csv export -1. [#1077](https://github.com/influxdata/chronograf/pull/2087): Fix Chronograf requiring Telegraf's CPU and system plugins to ensure that all Apps appear on the HOST LIST page. +1. [#2087](https://github.com/influxdata/chronograf/pull/2087): Fix Chronograf requiring Telegraf's CPU and system plugins to ensure that all Apps appear on the HOST LIST page. 1. [#2222](https://github.com/influxdata/chronograf/pull/2222): Fix template variables in dashboard query building. 1. [#2291](https://github.com/influxdata/chronograf/pull/2291): Fix several kapacitor alert creation panics. 1. [#2303](https://github.com/influxdata/chronograf/pull/2303): Add shadow-utils to RPM release packages @@ -14,7 +34,6 @@ 1. [#2327](https://github.com/influxdata/chronograf/pull/2327): Visualize CREATE/DELETE queries with Table view in Data Explorer 1. [#2329](https://github.com/influxdata/chronograf/pull/2329): Include tag values alongside measurement name in Data Explorer result tabs 1. [#2410](https://github.com/influxdata/chronograf/pull/2410): Redesign cell display options panel -1. [#2410](https://github.com/influxdata/chronograf/pull/2410): Introduce customizable Gauge visualization type for dashboard cells 1. [#2386](https://github.com/influxdata/chronograf/pull/2386): Fix queries that include regex, numbers and wildcard 1. [#2398](https://github.com/influxdata/chronograf/pull/2398): Fix apps on hosts page from parsing tags with null values 1. [#2408](https://github.com/influxdata/chronograf/pull/2408): Fix updated Dashboard names not updating dashboard list diff --git a/README.md b/README.md index 05cf900a6e..bf2ff7489e 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ option. ## Versions The most recent version of Chronograf is -[v1.3.10.0](https://www.influxdata.com/downloads/). +[v1.4.0.0-beta1](https://www.influxdata.com/downloads/). Spotted a bug or have a feature request? Please open [an issue](https://github.com/influxdata/chronograf/issues/new)! @@ -178,7 +178,7 @@ By default, chronograf runs on port `8888`. To get started right away with Docker, you can pull down our latest release: ```sh -docker pull chronograf:1.3.10.0 +docker pull chronograf:1.4.0.0 ``` ### From Source diff --git a/bolt/client.go b/bolt/client.go index 577541ab92..8ae39cfd09 100644 --- a/bolt/client.go +++ b/bolt/client.go @@ -2,6 +2,7 @@ package bolt import ( "context" + "path" "time" "github.com/boltdb/bolt" @@ -16,11 +17,12 @@ type Client struct { Now func() time.Time LayoutIDs chronograf.ID - SourcesStore *SourcesStore - ServersStore *ServersStore - LayoutStore *LayoutStore - UsersStore *UsersStore - DashboardsStore *DashboardsStore + SourcesStore *SourcesStore + ServersStore *ServersStore + LayoutsStore *LayoutsStore + DashboardsStore *DashboardsStore + UsersStore *UsersStore + OrganizationsStore *OrganizationsStore } // NewClient initializes all stores @@ -28,8 +30,7 @@ func NewClient() *Client { c := &Client{Now: time.Now} c.SourcesStore = &SourcesStore{client: c} c.ServersStore = &ServersStore{client: c} - c.UsersStore = &UsersStore{client: c} - c.LayoutStore = &LayoutStore{ + c.LayoutsStore = &LayoutsStore{ client: c, IDs: &uuid.V4{}, } @@ -37,6 +38,8 @@ func NewClient() *Client { client: c, IDs: &uuid.V4{}, } + c.UsersStore = &UsersStore{client: c} + c.OrganizationsStore = &OrganizationsStore{client: c} return c } @@ -50,6 +53,10 @@ func (c *Client) Open(ctx context.Context) error { c.db = db if err := c.db.Update(func(tx *bolt.Tx) error { + // Always create Organizations bucket. + if _, err := tx.CreateBucketIfNotExists(OrganizationsBucket); err != nil { + return err + } // Always create Sources bucket. if _, err := tx.CreateBucketIfNotExists(SourcesBucket); err != nil { return err @@ -59,11 +66,11 @@ func (c *Client) Open(ctx context.Context) error { return err } // Always create Layouts bucket. - if _, err := tx.CreateBucketIfNotExists(LayoutBucket); err != nil { + if _, err := tx.CreateBucketIfNotExists(LayoutsBucket); err != nil { return err } // Always create Dashboards bucket. - if _, err := tx.CreateBucketIfNotExists(DashboardBucket); err != nil { + if _, err := tx.CreateBucketIfNotExists(DashboardsBucket); err != nil { return err } // Always create Users bucket. @@ -76,7 +83,23 @@ func (c *Client) Open(ctx context.Context) error { } // Runtime migrations - return c.DashboardsStore.Migrate(ctx) + if err := c.OrganizationsStore.Migrate(ctx); err != nil { + return err + } + if err := c.SourcesStore.Migrate(ctx); err != nil { + return err + } + if err := c.ServersStore.Migrate(ctx); err != nil { + return err + } + if err := c.LayoutsStore.Migrate(ctx); err != nil { + return err + } + if err := c.DashboardsStore.Migrate(ctx); err != nil { + return err + } + + return nil } // Close the connection to the bolt database @@ -86,3 +109,7 @@ func (c *Client) Close() error { } return nil } + +func bucket(b []byte, org string) []byte { + return []byte(path.Join(string(b), org)) +} diff --git a/bolt/dashboards.go b/bolt/dashboards.go index 1a57d3da1c..f8fe0a6df8 100644 --- a/bolt/dashboards.go +++ b/bolt/dashboards.go @@ -2,6 +2,7 @@ package bolt import ( "context" + "fmt" "strconv" "github.com/boltdb/bolt" @@ -12,8 +13,8 @@ import ( // Ensure DashboardsStore implements chronograf.DashboardsStore. var _ chronograf.DashboardsStore = &DashboardsStore{} -// DashboardBucket is the bolt bucket dashboards are stored in -var DashboardBucket = []byte("Dashoard") +// DashboardsBucket is the bolt bucket dashboards are stored in +var DashboardsBucket = []byte("Dashoard") // DashboardsStore is the bolt implementation of storing dashboards type DashboardsStore struct { @@ -54,14 +55,34 @@ func (d *DashboardsStore) Migrate(ctx context.Context) error { if err != nil { return err } - return d.AddIDs(ctx, boards) + if err := d.AddIDs(ctx, boards); err != nil { + return nil + } + + defaultOrg, err := d.client.OrganizationsStore.DefaultOrganization(ctx) + if err != nil { + return err + } + + defaultOrgID := fmt.Sprintf("%d", defaultOrg.ID) + + for _, board := range boards { + if board.Organization == "" { + board.Organization = defaultOrgID + if err := d.Update(ctx, board); err != nil { + return nil + } + } + } + + return nil } // All returns all known dashboards func (d *DashboardsStore) All(ctx context.Context) ([]chronograf.Dashboard, error) { var srcs []chronograf.Dashboard if err := d.client.db.View(func(tx *bolt.Tx) error { - if err := tx.Bucket(DashboardBucket).ForEach(func(k, v []byte) error { + if err := tx.Bucket(DashboardsBucket).ForEach(func(k, v []byte) error { var src chronograf.Dashboard if err := internal.UnmarshalDashboard(v, &src); err != nil { return err @@ -82,7 +103,7 @@ func (d *DashboardsStore) All(ctx context.Context) ([]chronograf.Dashboard, erro // Add creates a new Dashboard in the DashboardsStore func (d *DashboardsStore) Add(ctx context.Context, src chronograf.Dashboard) (chronograf.Dashboard, error) { if err := d.client.db.Update(func(tx *bolt.Tx) error { - b := tx.Bucket(DashboardBucket) + b := tx.Bucket(DashboardsBucket) id, _ := b.NextSequence() src.ID = chronograf.DashboardID(id) @@ -113,7 +134,7 @@ func (d *DashboardsStore) Get(ctx context.Context, id chronograf.DashboardID) (c var src chronograf.Dashboard if err := d.client.db.View(func(tx *bolt.Tx) error { strID := strconv.Itoa(int(id)) - if v := tx.Bucket(DashboardBucket).Get([]byte(strID)); v == nil { + if v := tx.Bucket(DashboardsBucket).Get([]byte(strID)); v == nil { return chronograf.ErrDashboardNotFound } else if err := internal.UnmarshalDashboard(v, &src); err != nil { return err @@ -130,7 +151,7 @@ func (d *DashboardsStore) Get(ctx context.Context, id chronograf.DashboardID) (c func (d *DashboardsStore) Delete(ctx context.Context, dash chronograf.Dashboard) error { if err := d.client.db.Update(func(tx *bolt.Tx) error { strID := strconv.Itoa(int(dash.ID)) - if err := tx.Bucket(DashboardBucket).Delete([]byte(strID)); err != nil { + if err := tx.Bucket(DashboardsBucket).Delete([]byte(strID)); err != nil { return err } return nil @@ -145,7 +166,7 @@ func (d *DashboardsStore) Delete(ctx context.Context, dash chronograf.Dashboard) func (d *DashboardsStore) Update(ctx context.Context, dash chronograf.Dashboard) error { if err := d.client.db.Update(func(tx *bolt.Tx) error { // Get an existing dashboard with the same ID. - b := tx.Bucket(DashboardBucket) + b := tx.Bucket(DashboardsBucket) strID := strconv.Itoa(int(dash.ID)) if v := b.Get([]byte(strID)); v == nil { return chronograf.ErrDashboardNotFound diff --git a/bolt/internal/internal.go b/bolt/internal/internal.go index 3d584f6a38..c657ef6783 100644 --- a/bolt/internal/internal.go +++ b/bolt/internal/internal.go @@ -23,6 +23,8 @@ func MarshalSource(s chronograf.Source) ([]byte, error) { InsecureSkipVerify: s.InsecureSkipVerify, Default: s.Default, Telegraf: s.Telegraf, + Organization: s.Organization, + Role: s.Role, }) } @@ -44,19 +46,22 @@ func UnmarshalSource(data []byte, s *chronograf.Source) error { s.InsecureSkipVerify = pb.InsecureSkipVerify s.Default = pb.Default s.Telegraf = pb.Telegraf + s.Organization = pb.Organization + s.Role = pb.Role return nil } // MarshalServer encodes a server to binary protobuf format. func MarshalServer(s chronograf.Server) ([]byte, error) { return proto.Marshal(&Server{ - ID: int64(s.ID), - SrcID: int64(s.SrcID), - Name: s.Name, - Username: s.Username, - Password: s.Password, - URL: s.URL, - Active: s.Active, + ID: int64(s.ID), + SrcID: int64(s.SrcID), + Name: s.Name, + Username: s.Username, + Password: s.Password, + URL: s.URL, + Active: s.Active, + Organization: s.Organization, }) } @@ -74,6 +79,7 @@ func UnmarshalServer(data []byte, s *chronograf.Server) error { s.Password = pb.Password s.URL = pb.URL s.Active = pb.Active + s.Organization = pb.Organization return nil } @@ -280,10 +286,11 @@ func MarshalDashboard(d chronograf.Dashboard) ([]byte, error) { templates[i] = template } return proto.Marshal(&Dashboard{ - ID: int64(d.ID), - Cells: cells, - Templates: templates, - Name: d.Name, + ID: int64(d.ID), + Cells: cells, + Templates: templates, + Name: d.Name, + Organization: d.Organization, }) } @@ -418,6 +425,7 @@ func UnmarshalDashboard(data []byte, d *chronograf.Dashboard) error { d.Cells = cells d.Templates = templates d.Name = pb.Name + d.Organization = pb.Organization return nil } @@ -461,8 +469,20 @@ func UnmarshalAlertRule(data []byte, r *ScopedAlert) error { // MarshalUser encodes a user to binary protobuf format. // We are ignoring the password for now. func MarshalUser(u *chronograf.User) ([]byte, error) { + roles := make([]*Role, len(u.Roles)) + for i, role := range u.Roles { + roles[i] = &Role{ + Organization: role.Organization, + Name: role.Name, + } + } return MarshalUserPB(&User{ - Name: u.Name, + ID: u.ID, + Name: u.Name, + Provider: u.Provider, + Scheme: u.Scheme, + Roles: roles, + SuperAdmin: u.SuperAdmin, }) } @@ -479,7 +499,20 @@ func UnmarshalUser(data []byte, u *chronograf.User) error { if err := UnmarshalUserPB(data, &pb); err != nil { return err } + roles := make([]chronograf.Role, len(pb.Roles)) + for i, role := range pb.Roles { + roles[i] = chronograf.Role{ + Organization: role.Organization, + Name: role.Name, + } + } + u.ID = pb.ID u.Name = pb.Name + u.Provider = pb.Provider + u.Scheme = pb.Scheme + u.SuperAdmin = pb.SuperAdmin + u.Roles = roles + return nil } @@ -488,3 +521,73 @@ func UnmarshalUser(data []byte, u *chronograf.User) error { func UnmarshalUserPB(data []byte, u *User) error { return proto.Unmarshal(data, u) } + +// MarshalRole encodes a role to binary protobuf format. +func MarshalRole(r *chronograf.Role) ([]byte, error) { + return MarshalRolePB(&Role{ + Organization: r.Organization, + Name: r.Name, + }) +} + +// MarshalRolePB encodes a role to binary protobuf format. +func MarshalRolePB(r *Role) ([]byte, error) { + return proto.Marshal(r) +} + +// UnmarshalRole decodes a role from binary protobuf data. +func UnmarshalRole(data []byte, r *chronograf.Role) error { + var pb Role + if err := UnmarshalRolePB(data, &pb); err != nil { + return err + } + r.Organization = pb.Organization + r.Name = pb.Name + + return nil +} + +// UnmarshalRolePB decodes a role from binary protobuf data. +func UnmarshalRolePB(data []byte, r *Role) error { + if err := proto.Unmarshal(data, r); err != nil { + return err + } + return nil +} + +// MarshalOrganization encodes a organization to binary protobuf format. +func MarshalOrganization(o *chronograf.Organization) ([]byte, error) { + return MarshalOrganizationPB(&Organization{ + ID: o.ID, + Name: o.Name, + DefaultRole: o.DefaultRole, + Public: o.Public, + }) +} + +// MarshalOrganizationPB encodes a organization to binary protobuf format. +func MarshalOrganizationPB(o *Organization) ([]byte, error) { + return proto.Marshal(o) +} + +// UnmarshalOrganization decodes a organization from binary protobuf data. +func UnmarshalOrganization(data []byte, o *chronograf.Organization) error { + var pb Organization + if err := UnmarshalOrganizationPB(data, &pb); err != nil { + return err + } + o.ID = pb.ID + o.Name = pb.Name + o.DefaultRole = pb.DefaultRole + o.Public = pb.Public + + return nil +} + +// UnmarshalOrganizationPB decodes a organization from binary protobuf data. +func UnmarshalOrganizationPB(data []byte, o *Organization) error { + if err := proto.Unmarshal(data, o); err != nil { + return err + } + return nil +} diff --git a/bolt/internal/internal.pb.go b/bolt/internal/internal.pb.go index 1178ca241d..91eb31bf7f 100644 --- a/bolt/internal/internal.pb.go +++ b/bolt/internal/internal.pb.go @@ -25,6 +25,8 @@ It has these top-level messages: Range AlertRule User + Role + Organization */ package internal @@ -55,6 +57,8 @@ type Source struct { InsecureSkipVerify bool `protobuf:"varint,9,opt,name=InsecureSkipVerify,proto3" json:"InsecureSkipVerify,omitempty"` MetaURL string `protobuf:"bytes,10,opt,name=MetaURL,proto3" json:"MetaURL,omitempty"` SharedSecret string `protobuf:"bytes,11,opt,name=SharedSecret,proto3" json:"SharedSecret,omitempty"` + Organization string `protobuf:"bytes,12,opt,name=Organization,proto3" json:"Organization,omitempty"` + Role string `protobuf:"bytes,13,opt,name=Role,proto3" json:"Role,omitempty"` } func (m *Source) Reset() { *m = Source{} } @@ -63,10 +67,11 @@ func (*Source) ProtoMessage() {} func (*Source) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{0} } type Dashboard struct { - ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` - Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` - Cells []*DashboardCell `protobuf:"bytes,3,rep,name=cells" json:"cells,omitempty"` - Templates []*Template `protobuf:"bytes,4,rep,name=templates" json:"templates,omitempty"` + ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` + Cells []*DashboardCell `protobuf:"bytes,3,rep,name=cells" json:"cells,omitempty"` + Templates []*Template `protobuf:"bytes,4,rep,name=templates" json:"templates,omitempty"` + Organization string `protobuf:"bytes,5,opt,name=Organization,proto3" json:"Organization,omitempty"` } func (m *Dashboard) Reset() { *m = Dashboard{} } @@ -209,13 +214,14 @@ func (*TemplateQuery) ProtoMessage() {} func (*TemplateQuery) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{7} } type Server struct { - ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` - Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` - Username string `protobuf:"bytes,3,opt,name=Username,proto3" json:"Username,omitempty"` - Password string `protobuf:"bytes,4,opt,name=Password,proto3" json:"Password,omitempty"` - URL string `protobuf:"bytes,5,opt,name=URL,proto3" json:"URL,omitempty"` - SrcID int64 `protobuf:"varint,6,opt,name=SrcID,proto3" json:"SrcID,omitempty"` - Active bool `protobuf:"varint,7,opt,name=Active,proto3" json:"Active,omitempty"` + ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` + Username string `protobuf:"bytes,3,opt,name=Username,proto3" json:"Username,omitempty"` + Password string `protobuf:"bytes,4,opt,name=Password,proto3" json:"Password,omitempty"` + URL string `protobuf:"bytes,5,opt,name=URL,proto3" json:"URL,omitempty"` + SrcID int64 `protobuf:"varint,6,opt,name=SrcID,proto3" json:"SrcID,omitempty"` + Active bool `protobuf:"varint,7,opt,name=Active,proto3" json:"Active,omitempty"` + Organization string `protobuf:"bytes,8,opt,name=Organization,proto3" json:"Organization,omitempty"` } func (m *Server) Reset() { *m = Server{} } @@ -341,8 +347,12 @@ func (*AlertRule) ProtoMessage() {} func (*AlertRule) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{14} } type User struct { - ID uint64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` - Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` + ID uint64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` + Provider string `protobuf:"bytes,3,opt,name=Provider,proto3" json:"Provider,omitempty"` + Scheme string `protobuf:"bytes,4,opt,name=Scheme,proto3" json:"Scheme,omitempty"` + Roles []*Role `protobuf:"bytes,5,rep,name=Roles" json:"Roles,omitempty"` + SuperAdmin bool `protobuf:"varint,6,opt,name=SuperAdmin,proto3" json:"SuperAdmin,omitempty"` } func (m *User) Reset() { *m = User{} } @@ -350,6 +360,35 @@ func (m *User) String() string { return proto.CompactTextString(m) } func (*User) ProtoMessage() {} func (*User) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{15} } +func (m *User) GetRoles() []*Role { + if m != nil { + return m.Roles + } + return nil +} + +type Role struct { + Organization string `protobuf:"bytes,1,opt,name=Organization,proto3" json:"Organization,omitempty"` + Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` +} + +func (m *Role) Reset() { *m = Role{} } +func (m *Role) String() string { return proto.CompactTextString(m) } +func (*Role) ProtoMessage() {} +func (*Role) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{16} } + +type Organization struct { + ID uint64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` + DefaultRole string `protobuf:"bytes,3,opt,name=DefaultRole,proto3" json:"DefaultRole,omitempty"` + Public bool `protobuf:"varint,4,opt,name=Public,proto3" json:"Public,omitempty"` +} + +func (m *Organization) Reset() { *m = Organization{} } +func (m *Organization) String() string { return proto.CompactTextString(m) } +func (*Organization) ProtoMessage() {} +func (*Organization) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{17} } + func init() { proto.RegisterType((*Source)(nil), "internal.Source") proto.RegisterType((*Dashboard)(nil), "internal.Dashboard") @@ -367,81 +406,91 @@ func init() { proto.RegisterType((*Range)(nil), "internal.Range") proto.RegisterType((*AlertRule)(nil), "internal.AlertRule") proto.RegisterType((*User)(nil), "internal.User") + proto.RegisterType((*Role)(nil), "internal.Role") + proto.RegisterType((*Organization)(nil), "internal.Organization") } func init() { proto.RegisterFile("internal.proto", fileDescriptorInternal) } var fileDescriptorInternal = []byte{ - // 1134 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x02, 0xff, 0xbc, 0x56, 0xcf, 0x8e, 0xe3, 0xc4, - 0x13, 0x96, 0x63, 0x3b, 0x89, 0x2b, 0xbb, 0xf3, 0x5b, 0xf5, 0x6f, 0xc5, 0x9a, 0xe5, 0x12, 0x2c, - 0x10, 0xe1, 0xcf, 0x0e, 0x68, 0x57, 0x48, 0x88, 0x5b, 0x66, 0x82, 0x96, 0x61, 0x66, 0x97, 0x99, - 0xce, 0xcc, 0x70, 0x42, 0xab, 0x8e, 0x53, 0x49, 0xac, 0x75, 0x6c, 0xd3, 0xb6, 0x67, 0xe2, 0xb7, - 0xe0, 0x09, 0x90, 0x90, 0x38, 0x73, 0xe0, 0x05, 0xb8, 0x73, 0xe5, 0x61, 0xb8, 0xa2, 0xea, 0x6e, - 0x3b, 0xce, 0xec, 0x2c, 0xda, 0x03, 0xe2, 0xd6, 0x5f, 0x55, 0xa7, 0xaa, 0xba, 0xea, 0xab, 0x2f, - 0x86, 0xbd, 0x28, 0x29, 0x50, 0x26, 0x22, 0xde, 0xcf, 0x64, 0x5a, 0xa4, 0xac, 0x5f, 0xe3, 0xe0, - 0xd7, 0x0e, 0x74, 0xa7, 0x69, 0x29, 0x43, 0x64, 0x7b, 0xd0, 0x39, 0x9a, 0xf8, 0xd6, 0xd0, 0x1a, - 0xd9, 0xbc, 0x73, 0x34, 0x61, 0x0c, 0x9c, 0xe7, 0x62, 0x8d, 0x7e, 0x67, 0x68, 0x8d, 0x3c, 0xae, - 0xce, 0x64, 0x3b, 0xaf, 0x32, 0xf4, 0x6d, 0x6d, 0xa3, 0x33, 0x7b, 0x08, 0xfd, 0x8b, 0x9c, 0xa2, - 0xad, 0xd1, 0x77, 0x94, 0xbd, 0xc1, 0xe4, 0x3b, 0x15, 0x79, 0x7e, 0x9d, 0xca, 0xb9, 0xef, 0x6a, - 0x5f, 0x8d, 0xd9, 0x3d, 0xb0, 0x2f, 0xf8, 0x89, 0xdf, 0x55, 0x66, 0x3a, 0x32, 0x1f, 0x7a, 0x13, - 0x5c, 0x88, 0x32, 0x2e, 0xfc, 0xde, 0xd0, 0x1a, 0xf5, 0x79, 0x0d, 0x29, 0xce, 0x39, 0xc6, 0xb8, - 0x94, 0x62, 0xe1, 0xf7, 0x75, 0x9c, 0x1a, 0xb3, 0x7d, 0x60, 0x47, 0x49, 0x8e, 0x61, 0x29, 0x71, - 0xfa, 0x32, 0xca, 0x2e, 0x51, 0x46, 0x8b, 0xca, 0xf7, 0x54, 0x80, 0x5b, 0x3c, 0x94, 0xe5, 0x19, - 0x16, 0x82, 0x72, 0x83, 0x0a, 0x55, 0x43, 0x16, 0xc0, 0x9d, 0xe9, 0x4a, 0x48, 0x9c, 0x4f, 0x31, - 0x94, 0x58, 0xf8, 0x03, 0xe5, 0xde, 0xb1, 0x05, 0x3f, 0x5a, 0xe0, 0x4d, 0x44, 0xbe, 0x9a, 0xa5, - 0x42, 0xce, 0xdf, 0xa8, 0x67, 0x8f, 0xc0, 0x0d, 0x31, 0x8e, 0x73, 0xdf, 0x1e, 0xda, 0xa3, 0xc1, - 0xe3, 0x07, 0xfb, 0xcd, 0x30, 0x9a, 0x38, 0x87, 0x18, 0xc7, 0x5c, 0xdf, 0x62, 0x9f, 0x81, 0x57, - 0xe0, 0x3a, 0x8b, 0x45, 0x81, 0xb9, 0xef, 0xa8, 0x9f, 0xb0, 0xed, 0x4f, 0xce, 0x8d, 0x8b, 0x6f, - 0x2f, 0x05, 0x7f, 0x76, 0xe0, 0xee, 0x4e, 0x28, 0x76, 0x07, 0xac, 0x8d, 0xaa, 0xca, 0xe5, 0xd6, - 0x86, 0x50, 0xa5, 0x2a, 0x72, 0xb9, 0x55, 0x11, 0xba, 0x56, 0xf3, 0x73, 0xb9, 0x75, 0x4d, 0x68, - 0xa5, 0xa6, 0xe6, 0x72, 0x6b, 0xc5, 0x3e, 0x84, 0xde, 0x0f, 0x25, 0xca, 0x08, 0x73, 0xdf, 0x55, - 0x99, 0xff, 0xb7, 0xcd, 0x7c, 0x56, 0xa2, 0xac, 0x78, 0xed, 0xa7, 0x97, 0xaa, 0x89, 0xeb, 0xf1, - 0xa9, 0x33, 0xd9, 0x0a, 0x62, 0x47, 0x4f, 0xdb, 0xe8, 0x6c, 0x3a, 0xa4, 0x67, 0x46, 0x1d, 0xfa, - 0x1c, 0x1c, 0xb1, 0xc1, 0xdc, 0xf7, 0x54, 0xfc, 0x77, 0x5f, 0xd3, 0x8c, 0xfd, 0xf1, 0x06, 0xf3, - 0xaf, 0x92, 0x42, 0x56, 0x5c, 0x5d, 0x67, 0x1f, 0x40, 0x37, 0x4c, 0xe3, 0x54, 0xe6, 0x3e, 0xdc, - 0x2c, 0xec, 0x90, 0xec, 0xdc, 0xb8, 0x1f, 0x3e, 0x05, 0xaf, 0xf9, 0x2d, 0x51, 0xec, 0x25, 0x56, - 0xaa, 0x13, 0x1e, 0xa7, 0x23, 0x7b, 0x0f, 0xdc, 0x2b, 0x11, 0x97, 0x7a, 0x42, 0x83, 0xc7, 0x7b, - 0xdb, 0x30, 0xe3, 0x4d, 0x94, 0x73, 0xed, 0xfc, 0xb2, 0xf3, 0x85, 0x15, 0x2c, 0xc1, 0x55, 0x91, - 0x5b, 0x33, 0xf6, 0xea, 0x19, 0xab, 0x1d, 0xe8, 0xb4, 0x76, 0xe0, 0x1e, 0xd8, 0x5f, 0xe3, 0xc6, - 0xac, 0x05, 0x1d, 0x1b, 0x26, 0x38, 0x2d, 0x26, 0xdc, 0x07, 0xf7, 0x52, 0x25, 0xd7, 0xab, 0xa0, - 0x41, 0xf0, 0x9b, 0x05, 0x0e, 0x25, 0x27, 0xfa, 0xc5, 0xb8, 0x14, 0x61, 0x75, 0x90, 0x96, 0xc9, - 0x3c, 0xf7, 0xad, 0xa1, 0x3d, 0xb2, 0xf9, 0x8e, 0x8d, 0xbd, 0x05, 0xdd, 0x99, 0xf6, 0x76, 0x86, - 0xf6, 0xc8, 0xe3, 0x06, 0x51, 0xe8, 0x58, 0xcc, 0x30, 0x36, 0x25, 0x68, 0x40, 0xb7, 0x33, 0x89, - 0x8b, 0x68, 0x63, 0xca, 0x30, 0x88, 0xec, 0x79, 0xb9, 0x20, 0xbb, 0xae, 0xc4, 0x20, 0x2a, 0x7a, - 0x26, 0xf2, 0x66, 0xa8, 0x74, 0xa6, 0xc8, 0x79, 0x28, 0xe2, 0x7a, 0xaa, 0x1a, 0x04, 0xbf, 0x5b, - 0xb4, 0x91, 0x9a, 0x81, 0xaf, 0x74, 0xe8, 0x6d, 0xe8, 0x13, 0x3b, 0x5f, 0x5c, 0x09, 0x69, 0xba, - 0xd4, 0x23, 0x7c, 0x29, 0x24, 0xfb, 0x14, 0xba, 0xaa, 0xc5, 0xb7, 0x6c, 0x43, 0x1d, 0x4e, 0x75, - 0x85, 0x9b, 0x6b, 0x0d, 0xa7, 0x9c, 0x16, 0xa7, 0x9a, 0xc7, 0xba, 0xed, 0xc7, 0x3e, 0x02, 0x97, - 0xc8, 0x59, 0xa9, 0xea, 0x6f, 0x8d, 0xac, 0x29, 0xac, 0x6f, 0x05, 0x17, 0x70, 0x77, 0x27, 0x63, - 0x93, 0xc9, 0xda, 0xcd, 0xb4, 0xa5, 0x8b, 0x67, 0xe8, 0x41, 0x6a, 0x94, 0x63, 0x8c, 0x61, 0x81, - 0x73, 0xd5, 0xef, 0x3e, 0x6f, 0x70, 0xf0, 0xb3, 0xb5, 0x8d, 0xab, 0xf2, 0x91, 0xde, 0x84, 0xe9, - 0x7a, 0x2d, 0x92, 0xb9, 0x09, 0x5d, 0x43, 0xea, 0xdb, 0x7c, 0x66, 0x42, 0x77, 0xe6, 0x33, 0xc2, - 0x32, 0x33, 0x13, 0xec, 0xc8, 0x8c, 0x0d, 0x61, 0xb0, 0x46, 0x91, 0x97, 0x12, 0xd7, 0x98, 0x14, - 0xa6, 0x05, 0x6d, 0x13, 0x7b, 0x00, 0xbd, 0x42, 0x2c, 0x5f, 0x10, 0xc9, 0xcd, 0x24, 0x0b, 0xb1, - 0x3c, 0xc6, 0x8a, 0xbd, 0x03, 0xde, 0x22, 0xc2, 0x78, 0xae, 0x5c, 0x7a, 0x9c, 0x7d, 0x65, 0x38, - 0xc6, 0x2a, 0xf8, 0xc5, 0x82, 0xee, 0x14, 0xe5, 0x15, 0xca, 0x37, 0x12, 0xb0, 0xb6, 0xc0, 0xdb, - 0xff, 0x20, 0xf0, 0xce, 0xed, 0x02, 0xef, 0x6e, 0x05, 0xfe, 0x3e, 0xb8, 0x53, 0x19, 0x1e, 0x4d, - 0x54, 0x45, 0x36, 0xd7, 0x80, 0xd8, 0x38, 0x0e, 0x8b, 0xe8, 0x0a, 0x8d, 0xea, 0x1b, 0x14, 0xfc, - 0x64, 0x41, 0xf7, 0x44, 0x54, 0x69, 0x59, 0xbc, 0xc2, 0xb0, 0x21, 0x0c, 0xc6, 0x59, 0x16, 0x47, - 0xa1, 0x28, 0xa2, 0x34, 0x31, 0xd5, 0xb6, 0x4d, 0x74, 0xe3, 0x59, 0xab, 0x77, 0xba, 0xee, 0xb6, - 0x89, 0xa4, 0xe0, 0x50, 0xe9, 0xb2, 0x16, 0xd9, 0x96, 0x14, 0x68, 0x39, 0x56, 0x4e, 0x7a, 0xe0, - 0xb8, 0x2c, 0xd2, 0x45, 0x9c, 0x5e, 0xab, 0x97, 0xf4, 0x79, 0x83, 0x83, 0x3f, 0x3a, 0xe0, 0xfc, - 0x57, 0x7a, 0x7b, 0x07, 0xac, 0xc8, 0x0c, 0xd2, 0x8a, 0x1a, 0xf5, 0xed, 0xb5, 0xd4, 0xd7, 0x87, - 0x5e, 0x25, 0x45, 0xb2, 0xc4, 0xdc, 0xef, 0x2b, 0xe5, 0xa8, 0xa1, 0xf2, 0xa8, 0x1d, 0xd1, 0xb2, - 0xeb, 0xf1, 0x1a, 0x36, 0x9c, 0x87, 0x16, 0xe7, 0x3f, 0x31, 0x0a, 0x3d, 0x50, 0x15, 0xf9, 0xbb, - 0x6d, 0xb9, 0x29, 0xcc, 0xff, 0x9e, 0xde, 0xfe, 0x65, 0x81, 0xdb, 0x2c, 0xcc, 0xe1, 0xee, 0xc2, - 0x1c, 0x6e, 0x17, 0x66, 0x72, 0x50, 0x2f, 0xcc, 0xe4, 0x80, 0x30, 0x3f, 0xad, 0x17, 0x86, 0x9f, - 0xd2, 0xb0, 0x9e, 0xca, 0xb4, 0xcc, 0x0e, 0x2a, 0x3d, 0x55, 0x8f, 0x37, 0x98, 0x58, 0xf6, 0xdd, - 0x0a, 0xa5, 0x69, 0xb5, 0xc7, 0x0d, 0x22, 0x4e, 0x9e, 0x28, 0x31, 0xd1, 0xcd, 0xd5, 0x80, 0xbd, - 0x0f, 0x2e, 0xa7, 0xe6, 0xa9, 0x0e, 0xef, 0xcc, 0x45, 0x99, 0xb9, 0xf6, 0x52, 0x50, 0xfd, 0xf5, - 0x64, 0xfe, 0xe1, 0xea, 0x6f, 0xa9, 0x8f, 0xa1, 0x3b, 0x5d, 0x45, 0x8b, 0xa2, 0xfe, 0x9f, 0xfb, - 0x7f, 0x4b, 0x8c, 0xa2, 0x35, 0x2a, 0x1f, 0x37, 0x57, 0x82, 0x33, 0xf0, 0x1a, 0xe3, 0xb6, 0x1c, - 0xab, 0x5d, 0x0e, 0x03, 0xe7, 0x22, 0x89, 0x8a, 0x7a, 0x2d, 0xe9, 0x4c, 0x8f, 0x3d, 0x2b, 0x45, - 0x52, 0x44, 0x45, 0x55, 0xaf, 0x65, 0x8d, 0x83, 0x27, 0xa6, 0x7c, 0x0a, 0x77, 0x91, 0x65, 0x28, - 0xcd, 0x8a, 0x6b, 0xa0, 0x92, 0xa4, 0xd7, 0xa8, 0xd5, 0xd9, 0xe6, 0x1a, 0x04, 0xdf, 0x83, 0x37, - 0x8e, 0x51, 0x16, 0xbc, 0x8c, 0xf1, 0xb6, 0x7f, 0xbd, 0x6f, 0xa6, 0xdf, 0x3e, 0xaf, 0x2b, 0xa0, - 0xf3, 0x76, 0x9d, 0xed, 0x1b, 0xeb, 0x7c, 0x2c, 0x32, 0x71, 0x34, 0x51, 0x3c, 0xb7, 0xb9, 0x41, - 0xc1, 0x47, 0xe0, 0x90, 0x6c, 0xb4, 0x22, 0x3b, 0xaf, 0x93, 0x9c, 0x59, 0x57, 0x7d, 0xa7, 0x3e, - 0xf9, 0x3b, 0x00, 0x00, 0xff, 0xff, 0xe5, 0xc0, 0x79, 0x31, 0xb9, 0x0a, 0x00, 0x00, + // 1264 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x02, 0xff, 0xbc, 0x57, 0xdf, 0x8e, 0xdb, 0xc4, + 0x17, 0x96, 0xe3, 0x38, 0xb1, 0x4f, 0xb6, 0xfd, 0x55, 0xf3, 0xab, 0xa8, 0x29, 0x12, 0x0a, 0x16, + 0x88, 0x45, 0xd0, 0x05, 0xb5, 0x42, 0x42, 0x5c, 0x20, 0x65, 0x37, 0xa8, 0x2c, 0xfd, 0xb7, 0x9d, + 0x74, 0xcb, 0x15, 0xaa, 0x26, 0xce, 0x49, 0x62, 0xd5, 0xb1, 0xcd, 0xd8, 0xde, 0x8d, 0x79, 0x18, + 0x24, 0x24, 0x9e, 0x00, 0x71, 0xcf, 0x2d, 0xe2, 0x96, 0x77, 0xe0, 0x15, 0xb8, 0x45, 0x67, 0x66, + 0xec, 0x38, 0x9b, 0x50, 0xf5, 0x02, 0x71, 0x37, 0xdf, 0x39, 0x93, 0x33, 0x67, 0xce, 0xf9, 0xce, + 0x37, 0x0e, 0x5c, 0x8f, 0x92, 0x02, 0x65, 0x22, 0xe2, 0xa3, 0x4c, 0xa6, 0x45, 0xca, 0xdc, 0x1a, + 0x07, 0x7f, 0x76, 0xa0, 0x37, 0x49, 0x4b, 0x19, 0x22, 0xbb, 0x0e, 0x9d, 0xd3, 0xb1, 0x6f, 0x0d, + 0xad, 0x43, 0x9b, 0x77, 0x4e, 0xc7, 0x8c, 0x41, 0xf7, 0xb1, 0x58, 0xa1, 0xdf, 0x19, 0x5a, 0x87, + 0x1e, 0x57, 0x6b, 0xb2, 0x3d, 0xab, 0x32, 0xf4, 0x6d, 0x6d, 0xa3, 0x35, 0xbb, 0x0d, 0xee, 0x79, + 0x4e, 0xd1, 0x56, 0xe8, 0x77, 0x95, 0xbd, 0xc1, 0xe4, 0x3b, 0x13, 0x79, 0x7e, 0x99, 0xca, 0x99, + 0xef, 0x68, 0x5f, 0x8d, 0xd9, 0x0d, 0xb0, 0xcf, 0xf9, 0x43, 0xbf, 0xa7, 0xcc, 0xb4, 0x64, 0x3e, + 0xf4, 0xc7, 0x38, 0x17, 0x65, 0x5c, 0xf8, 0xfd, 0xa1, 0x75, 0xe8, 0xf2, 0x1a, 0x52, 0x9c, 0x67, + 0x18, 0xe3, 0x42, 0x8a, 0xb9, 0xef, 0xea, 0x38, 0x35, 0x66, 0x47, 0xc0, 0x4e, 0x93, 0x1c, 0xc3, + 0x52, 0xe2, 0xe4, 0x65, 0x94, 0x3d, 0x47, 0x19, 0xcd, 0x2b, 0xdf, 0x53, 0x01, 0xf6, 0x78, 0xe8, + 0x94, 0x47, 0x58, 0x08, 0x3a, 0x1b, 0x54, 0xa8, 0x1a, 0xb2, 0x00, 0x0e, 0x26, 0x4b, 0x21, 0x71, + 0x36, 0xc1, 0x50, 0x62, 0xe1, 0x0f, 0x94, 0x7b, 0xcb, 0x46, 0x7b, 0x9e, 0xc8, 0x85, 0x48, 0xa2, + 0xef, 0x45, 0x11, 0xa5, 0x89, 0x7f, 0xa0, 0xf7, 0xb4, 0x6d, 0x54, 0x25, 0x9e, 0xc6, 0xe8, 0x5f, + 0xd3, 0x55, 0xa2, 0x75, 0xf0, 0x8b, 0x05, 0xde, 0x58, 0xe4, 0xcb, 0x69, 0x2a, 0xe4, 0xec, 0xb5, + 0x6a, 0x7d, 0x07, 0x9c, 0x10, 0xe3, 0x38, 0xf7, 0xed, 0xa1, 0x7d, 0x38, 0xb8, 0x7b, 0xeb, 0xa8, + 0x69, 0x62, 0x13, 0xe7, 0x04, 0xe3, 0x98, 0xeb, 0x5d, 0xec, 0x13, 0xf0, 0x0a, 0x5c, 0x65, 0xb1, + 0x28, 0x30, 0xf7, 0xbb, 0xea, 0x27, 0x6c, 0xf3, 0x93, 0x67, 0xc6, 0xc5, 0x37, 0x9b, 0x76, 0xae, + 0xe2, 0xec, 0x5e, 0x25, 0xf8, 0xa3, 0x03, 0xd7, 0xb6, 0x8e, 0x63, 0x07, 0x60, 0xad, 0x55, 0xe6, + 0x0e, 0xb7, 0xd6, 0x84, 0x2a, 0x95, 0xb5, 0xc3, 0xad, 0x8a, 0xd0, 0xa5, 0xe2, 0x86, 0xc3, 0xad, + 0x4b, 0x42, 0x4b, 0xc5, 0x08, 0x87, 0x5b, 0x4b, 0xf6, 0x01, 0xf4, 0xbf, 0x2b, 0x51, 0x46, 0x98, + 0xfb, 0x8e, 0xca, 0xee, 0x7f, 0x9b, 0xec, 0x9e, 0x96, 0x28, 0x2b, 0x5e, 0xfb, 0xa9, 0x1a, 0x8a, + 0x4d, 0x9a, 0x1a, 0x6a, 0x4d, 0xb6, 0x82, 0x98, 0xd7, 0xd7, 0x36, 0x5a, 0x9b, 0x2a, 0x6a, 0x3e, + 0x50, 0x15, 0x3f, 0x85, 0xae, 0x58, 0x63, 0xee, 0x7b, 0x2a, 0xfe, 0x3b, 0xff, 0x50, 0xb0, 0xa3, + 0xd1, 0x1a, 0xf3, 0x2f, 0x93, 0x42, 0x56, 0x5c, 0x6d, 0x67, 0xef, 0x43, 0x2f, 0x4c, 0xe3, 0x54, + 0xe6, 0x3e, 0x5c, 0x4d, 0xec, 0x84, 0xec, 0xdc, 0xb8, 0x6f, 0xdf, 0x07, 0xaf, 0xf9, 0x2d, 0xd1, + 0xf7, 0x25, 0x56, 0xaa, 0x12, 0x1e, 0xa7, 0x25, 0x7b, 0x17, 0x9c, 0x0b, 0x11, 0x97, 0xba, 0x8b, + 0x83, 0xbb, 0xd7, 0x37, 0x61, 0x46, 0xeb, 0x28, 0xe7, 0xda, 0xf9, 0x79, 0xe7, 0x33, 0x2b, 0x58, + 0x80, 0xa3, 0x22, 0xb7, 0x78, 0xe0, 0xd5, 0x3c, 0x50, 0xf3, 0xd5, 0x69, 0xcd, 0xd7, 0x0d, 0xb0, + 0xbf, 0xc2, 0xb5, 0x19, 0x39, 0x5a, 0x36, 0x6c, 0xe9, 0xb6, 0xd8, 0x72, 0x13, 0x9c, 0xe7, 0xea, + 0x70, 0xdd, 0x45, 0x0d, 0x82, 0x9f, 0x2d, 0xe8, 0xd2, 0xe1, 0xd4, 0xeb, 0x18, 0x17, 0x22, 0xac, + 0x8e, 0xd3, 0x32, 0x99, 0xe5, 0xbe, 0x35, 0xb4, 0x0f, 0x6d, 0xbe, 0x65, 0x63, 0x6f, 0x40, 0x6f, + 0xaa, 0xbd, 0x9d, 0xa1, 0x7d, 0xe8, 0x71, 0x83, 0x28, 0x74, 0x2c, 0xa6, 0x18, 0x9b, 0x14, 0x34, + 0xa0, 0xdd, 0x99, 0xc4, 0x79, 0xb4, 0x36, 0x69, 0x18, 0x44, 0xf6, 0xbc, 0x9c, 0x93, 0x5d, 0x67, + 0x62, 0x10, 0x25, 0x3d, 0x15, 0x79, 0xd3, 0x54, 0x5a, 0x53, 0xe4, 0x3c, 0x14, 0x71, 0xdd, 0x55, + 0x0d, 0x82, 0x5f, 0x2d, 0x9a, 0x76, 0xcd, 0xd2, 0x9d, 0x0a, 0xbd, 0x09, 0x2e, 0x31, 0xf8, 0xc5, + 0x85, 0x90, 0xa6, 0x4a, 0x7d, 0xc2, 0xcf, 0x85, 0x64, 0x1f, 0x43, 0x4f, 0x95, 0x78, 0xcf, 0xc4, + 0xd4, 0xe1, 0x54, 0x55, 0xb8, 0xd9, 0xd6, 0x70, 0xaa, 0xdb, 0xe2, 0x54, 0x73, 0x59, 0xa7, 0x7d, + 0xd9, 0x3b, 0xe0, 0x10, 0x39, 0x2b, 0x95, 0xfd, 0xde, 0xc8, 0x9a, 0xc2, 0x7a, 0x57, 0x70, 0x0e, + 0xd7, 0xb6, 0x4e, 0x6c, 0x4e, 0xb2, 0xb6, 0x4f, 0xda, 0xd0, 0xc5, 0x33, 0xf4, 0x20, 0xa5, 0xcb, + 0x31, 0xc6, 0xb0, 0xc0, 0x99, 0xaa, 0xb7, 0xcb, 0x1b, 0x1c, 0xfc, 0x68, 0x6d, 0xe2, 0xaa, 0xf3, + 0x48, 0xcb, 0xc2, 0x74, 0xb5, 0x12, 0xc9, 0xcc, 0x84, 0xae, 0x21, 0xd5, 0x6d, 0x36, 0x35, 0xa1, + 0x3b, 0xb3, 0x29, 0x61, 0x99, 0x99, 0x0e, 0x76, 0x64, 0xc6, 0x86, 0x30, 0x58, 0xa1, 0xc8, 0x4b, + 0x89, 0x2b, 0x4c, 0x0a, 0x53, 0x82, 0xb6, 0x89, 0xdd, 0x82, 0x7e, 0x21, 0x16, 0x2f, 0x88, 0xe4, + 0xa6, 0x93, 0x85, 0x58, 0x3c, 0xc0, 0x8a, 0xbd, 0x05, 0xde, 0x3c, 0xc2, 0x78, 0xa6, 0x5c, 0xba, + 0x9d, 0xae, 0x32, 0x3c, 0xc0, 0x2a, 0xf8, 0xcd, 0x82, 0xde, 0x04, 0xe5, 0x05, 0xca, 0xd7, 0x12, + 0xb9, 0xf6, 0xe3, 0x61, 0xbf, 0xe2, 0xf1, 0xe8, 0xee, 0x7f, 0x3c, 0x9c, 0xcd, 0xe3, 0x71, 0x13, + 0x9c, 0x89, 0x0c, 0x4f, 0xc7, 0x2a, 0x23, 0x9b, 0x6b, 0x40, 0x6c, 0x1c, 0x85, 0x45, 0x74, 0x81, + 0xe6, 0x45, 0x31, 0x68, 0x47, 0xfb, 0xdc, 0x3d, 0xda, 0xf7, 0x83, 0x05, 0xbd, 0x87, 0xa2, 0x4a, + 0xcb, 0x62, 0x87, 0x85, 0x43, 0x18, 0x8c, 0xb2, 0x2c, 0x8e, 0x42, 0xfd, 0x6b, 0x7d, 0xa3, 0xb6, + 0x89, 0x76, 0x3c, 0x6a, 0xd5, 0x57, 0xdf, 0xad, 0x6d, 0x22, 0xb9, 0x38, 0x51, 0xfa, 0xae, 0xc5, + 0xba, 0x25, 0x17, 0x5a, 0xd6, 0x95, 0x93, 0x8a, 0x30, 0x2a, 0x8b, 0x74, 0x1e, 0xa7, 0x97, 0xea, + 0xb6, 0x2e, 0x6f, 0x70, 0xf0, 0x7b, 0x07, 0xba, 0xff, 0x95, 0x26, 0x1f, 0x80, 0x15, 0x99, 0x66, + 0x5b, 0x51, 0xa3, 0xd0, 0xfd, 0x96, 0x42, 0xfb, 0xd0, 0xaf, 0xa4, 0x48, 0x16, 0x98, 0xfb, 0xae, + 0x52, 0x97, 0x1a, 0x2a, 0x8f, 0x9a, 0x23, 0x2d, 0xcd, 0x1e, 0xaf, 0x61, 0x33, 0x17, 0xd0, 0x9a, + 0x8b, 0x8f, 0x8c, 0x8a, 0x0f, 0x54, 0x46, 0xfe, 0x76, 0x59, 0xae, 0x8a, 0xf7, 0xbf, 0xa7, 0xc9, + 0x7f, 0x59, 0xe0, 0x34, 0x43, 0x75, 0xb2, 0x3d, 0x54, 0x27, 0x9b, 0xa1, 0x1a, 0x1f, 0xd7, 0x43, + 0x35, 0x3e, 0x26, 0xcc, 0xcf, 0xea, 0xa1, 0xe2, 0x67, 0xd4, 0xac, 0xfb, 0x32, 0x2d, 0xb3, 0xe3, + 0x4a, 0x77, 0xd5, 0xe3, 0x0d, 0x26, 0x26, 0x7e, 0xb3, 0x44, 0x69, 0x4a, 0xed, 0x71, 0x83, 0x88, + 0xb7, 0x0f, 0x95, 0xe0, 0xe8, 0xe2, 0x6a, 0xc0, 0xde, 0x03, 0x87, 0x53, 0xf1, 0x54, 0x85, 0xb7, + 0xfa, 0xa2, 0xcc, 0x5c, 0x7b, 0x29, 0xa8, 0xfe, 0x7a, 0x33, 0x04, 0xae, 0xbf, 0xe5, 0x3e, 0x84, + 0xde, 0x64, 0x19, 0xcd, 0x8b, 0xfa, 0x2d, 0xfc, 0x7f, 0x4b, 0xb0, 0xa2, 0x15, 0x2a, 0x1f, 0x37, + 0x5b, 0x82, 0xa7, 0xe0, 0x35, 0xc6, 0x4d, 0x3a, 0x56, 0x3b, 0x1d, 0x06, 0xdd, 0xf3, 0x24, 0x2a, + 0xea, 0xd1, 0xa5, 0x35, 0x5d, 0xf6, 0x69, 0x29, 0x92, 0x22, 0x2a, 0xaa, 0x7a, 0x74, 0x6b, 0x1c, + 0xdc, 0x33, 0xe9, 0x53, 0xb8, 0xf3, 0x2c, 0x43, 0x69, 0x64, 0x40, 0x03, 0x75, 0x48, 0x7a, 0x89, + 0x5a, 0xc1, 0x6d, 0xae, 0x41, 0xf0, 0x2d, 0x78, 0xa3, 0x18, 0x65, 0xc1, 0xcb, 0x18, 0xf7, 0xbd, + 0x8c, 0x5f, 0x4f, 0x9e, 0x3c, 0xae, 0x33, 0xa0, 0xf5, 0x66, 0xe4, 0xed, 0x2b, 0x23, 0xff, 0x40, + 0x64, 0xe2, 0x74, 0xac, 0x78, 0x6e, 0x73, 0x83, 0x82, 0x9f, 0x2c, 0xe8, 0x92, 0xb6, 0xb4, 0x42, + 0x77, 0x5f, 0xa5, 0x4b, 0x67, 0x32, 0xbd, 0x88, 0x66, 0x28, 0xeb, 0xcb, 0xd5, 0x58, 0x15, 0x3d, + 0x5c, 0x62, 0xf3, 0x00, 0x1b, 0x44, 0x5c, 0xa3, 0x4f, 0xbd, 0x7a, 0x96, 0x5a, 0x5c, 0x23, 0x33, + 0xd7, 0x4e, 0xf6, 0x36, 0xc0, 0xa4, 0xcc, 0x50, 0x8e, 0x66, 0xab, 0x28, 0x51, 0x4d, 0x77, 0x79, + 0xcb, 0x12, 0x7c, 0xa1, 0x3f, 0x1e, 0x77, 0x14, 0xca, 0xda, 0xff, 0xa1, 0x79, 0x35, 0xf3, 0x20, + 0xde, 0xfe, 0xdd, 0x6b, 0xdd, 0x76, 0x08, 0x03, 0xf3, 0xa5, 0xad, 0xbe, 0x5b, 0x8d, 0x58, 0xb5, + 0x4c, 0x74, 0xe7, 0xb3, 0x72, 0x1a, 0x47, 0xa1, 0xba, 0xb3, 0xcb, 0x0d, 0x9a, 0xf6, 0xd4, 0x1f, + 0x8a, 0x7b, 0x7f, 0x07, 0x00, 0x00, 0xff, 0xff, 0xe0, 0xc4, 0x7a, 0x3e, 0x62, 0x0c, 0x00, 0x00, } diff --git a/bolt/internal/internal.proto b/bolt/internal/internal.proto index 2a0c3dfe93..2e540a64a4 100644 --- a/bolt/internal/internal.proto +++ b/bolt/internal/internal.proto @@ -13,6 +13,8 @@ message Source { bool InsecureSkipVerify = 9; // InsecureSkipVerify accepts any certificate from the influx server string MetaURL = 10; // MetaURL is the connection URL for the meta node. string SharedSecret = 11; // SharedSecret signs the optional InfluxDB JWT Authorization + string Organization = 12; // Organization is the organization ID that resource belongs to + string Role = 13; // Role is the name of the miniumum role that a user must possess to access the resource } message Dashboard { @@ -20,6 +22,7 @@ message Dashboard { string Name = 2; // Name is the user-defined name of the dashboard repeated DashboardCell cells = 3; // a representation of all visual data required for rendering the dashboard repeated Template templates = 4; // Templates replace template variables within InfluxQL + string Organization = 5; // Organization is the organization ID that resource belongs to } message DashboardCell { @@ -85,6 +88,7 @@ message Server { string URL = 5; // URL is the path to the server int64 SrcID = 6; // SrcID is the ID of the data source bool Active = 7; // is this the currently active server for the source + string Organization = 8; // Organization is the organization ID that resource belongs to } message Layout { @@ -140,8 +144,24 @@ message AlertRule { } message User { - uint64 ID = 1; // ID is the unique ID of this user - string Name = 2; // Name is the user's login name + uint64 ID = 1; // ID is the unique ID of this user + string Name = 2; // Name is the user's login name + string Provider = 3; // Provider is the provider that certifies and issues this user's authentication, e.g. GitHub + string Scheme = 4; // Scheme is the scheme used to perform this user's authentication, e.g. OAuth2 or LDAP + repeated Role Roles = 5; // Roles is set of roles a user has + bool SuperAdmin = 6; // SuperAdmin is bool that specifies whether a user is a super admin +} + +message Role { + string Organization = 1; // Organization is the ID of the organization that this user has a role in + string Name = 2; // Name is the name of the role of this user in the respective organization +} + +message Organization { + uint64 ID = 1; // ID is the unique ID of the organization + string Name = 2; // Name is the organization's name + string DefaultRole = 3; // DefaultRole is the name of the role that is the default for any users added to the organization + bool Public = 4; // Public specifies that users must be explicitly added to the organization } // The following is a vim modeline, it autoconfigures vim to have the diff --git a/bolt/layouts.go b/bolt/layouts.go index e443f80b16..0f11ecef22 100644 --- a/bolt/layouts.go +++ b/bolt/layouts.go @@ -8,23 +8,27 @@ import ( "github.com/influxdata/chronograf/bolt/internal" ) -// Ensure LayoutStore implements chronograf.LayoutStore. -var _ chronograf.LayoutStore = &LayoutStore{} +// Ensure LayoutsStore implements chronograf.LayoutsStore. +var _ chronograf.LayoutsStore = &LayoutsStore{} -// LayoutBucket is the bolt bucket layouts are stored in -var LayoutBucket = []byte("Layout") +// LayoutsBucket is the bolt bucket layouts are stored in +var LayoutsBucket = []byte("Layout") -// LayoutStore is the bolt implementation to store layouts -type LayoutStore struct { +// LayoutsStore is the bolt implementation to store layouts +type LayoutsStore struct { client *Client IDs chronograf.ID } +func (s *LayoutsStore) Migrate(ctx context.Context) error { + return nil +} + // All returns all known layouts -func (s *LayoutStore) All(ctx context.Context) ([]chronograf.Layout, error) { +func (s *LayoutsStore) All(ctx context.Context) ([]chronograf.Layout, error) { var srcs []chronograf.Layout if err := s.client.db.View(func(tx *bolt.Tx) error { - if err := tx.Bucket(LayoutBucket).ForEach(func(k, v []byte) error { + if err := tx.Bucket(LayoutsBucket).ForEach(func(k, v []byte) error { var src chronograf.Layout if err := internal.UnmarshalLayout(v, &src); err != nil { return err @@ -43,10 +47,10 @@ func (s *LayoutStore) All(ctx context.Context) ([]chronograf.Layout, error) { } -// Add creates a new Layout in the LayoutStore. -func (s *LayoutStore) Add(ctx context.Context, src chronograf.Layout) (chronograf.Layout, error) { +// Add creates a new Layout in the LayoutsStore. +func (s *LayoutsStore) Add(ctx context.Context, src chronograf.Layout) (chronograf.Layout, error) { if err := s.client.db.Update(func(tx *bolt.Tx) error { - b := tx.Bucket(LayoutBucket) + b := tx.Bucket(LayoutsBucket) id, err := s.IDs.Generate() if err != nil { return err @@ -66,14 +70,14 @@ func (s *LayoutStore) Add(ctx context.Context, src chronograf.Layout) (chronogra return src, nil } -// Delete removes the Layout from the LayoutStore -func (s *LayoutStore) Delete(ctx context.Context, src chronograf.Layout) error { +// Delete removes the Layout from the LayoutsStore +func (s *LayoutsStore) Delete(ctx context.Context, src chronograf.Layout) error { _, err := s.Get(ctx, src.ID) if err != nil { return err } if err := s.client.db.Update(func(tx *bolt.Tx) error { - if err := tx.Bucket(LayoutBucket).Delete([]byte(src.ID)); err != nil { + if err := tx.Bucket(LayoutsBucket).Delete([]byte(src.ID)); err != nil { return err } return nil @@ -85,10 +89,10 @@ func (s *LayoutStore) Delete(ctx context.Context, src chronograf.Layout) error { } // Get returns a Layout if the id exists. -func (s *LayoutStore) Get(ctx context.Context, id string) (chronograf.Layout, error) { +func (s *LayoutsStore) Get(ctx context.Context, id string) (chronograf.Layout, error) { var src chronograf.Layout if err := s.client.db.View(func(tx *bolt.Tx) error { - if v := tx.Bucket(LayoutBucket).Get([]byte(id)); v == nil { + if v := tx.Bucket(LayoutsBucket).Get([]byte(id)); v == nil { return chronograf.ErrLayoutNotFound } else if err := internal.UnmarshalLayout(v, &src); err != nil { return err @@ -102,10 +106,10 @@ func (s *LayoutStore) Get(ctx context.Context, id string) (chronograf.Layout, er } // Update a Layout -func (s *LayoutStore) Update(ctx context.Context, src chronograf.Layout) error { +func (s *LayoutsStore) Update(ctx context.Context, src chronograf.Layout) error { if err := s.client.db.Update(func(tx *bolt.Tx) error { // Get an existing layout with the same ID. - b := tx.Bucket(LayoutBucket) + b := tx.Bucket(LayoutsBucket) if v := b.Get([]byte(src.ID)); v == nil { return chronograf.ErrLayoutNotFound } diff --git a/bolt/organizations.go b/bolt/organizations.go new file mode 100644 index 0000000000..2a98769f38 --- /dev/null +++ b/bolt/organizations.go @@ -0,0 +1,278 @@ +package bolt + +import ( + "context" + "fmt" + + "github.com/boltdb/bolt" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/bolt/internal" + "github.com/influxdata/chronograf/organizations" +) + +// Ensure OrganizationsStore implements chronograf.OrganizationsStore. +var _ chronograf.OrganizationsStore = &OrganizationsStore{} + +// OrganizationsBucket is the bucket where organizations are stored. +var OrganizationsBucket = []byte("OrganizationsV1") + +const ( + // DefaultOrganizationID is the ID of the default organization. + DefaultOrganizationID uint64 = 0 + // DefaultOrganizationName is the Name of the default organization + DefaultOrganizationName string = "Default" + // DefaultOrganizationRole is the DefaultRole for the Default organization + DefaultOrganizationRole string = "member" + // DefaultOrganizationPublic is the Public setting for the Default organization. + DefaultOrganizationPublic bool = true +) + +// OrganizationsStore uses bolt to store and retrieve Organizations +type OrganizationsStore struct { + client *Client +} + +// Migrate sets the default organization at runtime +func (s *OrganizationsStore) Migrate(ctx context.Context) error { + return s.CreateDefault(ctx) +} + +// CreateDefault does a findOrCreate on the default organization +func (s *OrganizationsStore) CreateDefault(ctx context.Context) error { + o := chronograf.Organization{ + ID: DefaultOrganizationID, + Name: DefaultOrganizationName, + DefaultRole: DefaultOrganizationRole, + Public: DefaultOrganizationPublic, + } + return s.client.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(OrganizationsBucket) + v := b.Get(u64tob(o.ID)) + if v != nil { + return nil + } + if v, err := internal.MarshalOrganization(&o); err != nil { + return err + } else if err := b.Put(u64tob(o.ID), v); err != nil { + return err + } + + return nil + }) +} + +func (s *OrganizationsStore) nameIsUnique(ctx context.Context, name string) bool { + _, err := s.Get(ctx, chronograf.OrganizationQuery{Name: &name}) + switch err { + case chronograf.ErrOrganizationNotFound: + return true + default: + return false + } +} + +// DefaultOrganizationID returns the ID of the default organization +func (s *OrganizationsStore) DefaultOrganization(ctx context.Context) (*chronograf.Organization, error) { + var org chronograf.Organization + if err := s.client.db.View(func(tx *bolt.Tx) error { + v := tx.Bucket(OrganizationsBucket).Get(u64tob(DefaultOrganizationID)) + return internal.UnmarshalOrganization(v, &org) + }); err != nil { + return nil, err + } + + return &org, nil +} + +// Add creates a new Organization in the OrganizationsStore +func (s *OrganizationsStore) Add(ctx context.Context, o *chronograf.Organization) (*chronograf.Organization, error) { + if !s.nameIsUnique(ctx, o.Name) { + return nil, chronograf.ErrOrganizationAlreadyExists + } + if err := s.client.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(OrganizationsBucket) + seq, err := b.NextSequence() + if err != nil { + return err + } + o.ID = seq + if v, err := internal.MarshalOrganization(o); err != nil { + return err + } else if err := b.Put(u64tob(seq), v); err != nil { + return err + } + + return nil + }); err != nil { + return nil, err + } + + return o, nil +} + +// All returns all known organizations +func (s *OrganizationsStore) All(ctx context.Context) ([]chronograf.Organization, error) { + var orgs []chronograf.Organization + err := s.each(ctx, func(o *chronograf.Organization) { + orgs = append(orgs, *o) + }) + + if err != nil { + return nil, err + } + + return orgs, nil +} + +// Delete the organization from OrganizationsStore +func (s *OrganizationsStore) Delete(ctx context.Context, o *chronograf.Organization) error { + if o.ID == DefaultOrganizationID { + return chronograf.ErrCannotDeleteDefaultOrganization + } + _, err := s.get(ctx, o.ID) + if err != nil { + return err + } + if err := s.client.db.Update(func(tx *bolt.Tx) error { + return tx.Bucket(OrganizationsBucket).Delete(u64tob(o.ID)) + }); err != nil { + return err + } + + // Dependent Delete of all resources + + org := fmt.Sprintf("%d", o.ID) + // Each of the associated organization stores expects organization to be + // set on the context. + ctx = context.WithValue(ctx, organizations.ContextKey, org) + + sourcesStore := organizations.NewSourcesStore(s.client.SourcesStore, org) + sources, err := sourcesStore.All(ctx) + if err != nil { + return err + } + for _, source := range sources { + if err := sourcesStore.Delete(ctx, source); err != nil { + return err + } + } + + serversStore := organizations.NewServersStore(s.client.ServersStore, org) + servers, err := serversStore.All(ctx) + if err != nil { + return err + } + for _, server := range servers { + if err := serversStore.Delete(ctx, server); err != nil { + return err + } + } + + dashboardsStore := organizations.NewDashboardsStore(s.client.DashboardsStore, org) + dashboards, err := dashboardsStore.All(ctx) + if err != nil { + return err + } + for _, dashboard := range dashboards { + if err := dashboardsStore.Delete(ctx, dashboard); err != nil { + return err + } + } + + usersStore := organizations.NewUsersStore(s.client.UsersStore, org) + users, err := usersStore.All(ctx) + if err != nil { + return err + } + for _, user := range users { + if err := usersStore.Delete(ctx, &user); err != nil { + return err + } + } + + return nil +} + +func (s *OrganizationsStore) get(ctx context.Context, id uint64) (*chronograf.Organization, error) { + var o chronograf.Organization + err := s.client.db.View(func(tx *bolt.Tx) error { + v := tx.Bucket(OrganizationsBucket).Get(u64tob(id)) + if v == nil { + return chronograf.ErrOrganizationNotFound + } + return internal.UnmarshalOrganization(v, &o) + }) + + if err != nil { + return nil, err + } + + return &o, nil +} + +func (s *OrganizationsStore) each(ctx context.Context, fn func(*chronograf.Organization)) error { + return s.client.db.View(func(tx *bolt.Tx) error { + return tx.Bucket(OrganizationsBucket).ForEach(func(k, v []byte) error { + var org chronograf.Organization + if err := internal.UnmarshalOrganization(v, &org); err != nil { + return err + } + fn(&org) + return nil + }) + }) + return nil +} + +// Get returns a Organization if the id exists. +// If an ID is provided in the query, the lookup time for an organization will be O(1). +// If Name is provided, the lookup time will be O(n). +// Get expects that only one of ID or Name will be specified, but will prefer ID over Name if both are specified. +func (s *OrganizationsStore) Get(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID != nil { + return s.get(ctx, *q.ID) + } + + if q.Name != nil { + var org *chronograf.Organization + err := s.each(ctx, func(o *chronograf.Organization) { + if org != nil { + return + } + + if o.Name == *q.Name { + org = o + } + }) + + if err != nil { + return nil, err + } + + if org == nil { + return nil, chronograf.ErrOrganizationNotFound + } + + return org, nil + } + return nil, fmt.Errorf("must specify either ID, or Name in OrganizationQuery") +} + +// Update the organization in OrganizationsStore +func (s *OrganizationsStore) Update(ctx context.Context, o *chronograf.Organization) error { + org, err := s.get(ctx, o.ID) + if err != nil { + return err + } + if o.Name != org.Name && !s.nameIsUnique(ctx, o.Name) { + return chronograf.ErrOrganizationAlreadyExists + } + return s.client.db.Update(func(tx *bolt.Tx) error { + if v, err := internal.MarshalOrganization(o); err != nil { + return err + } else if err := tx.Bucket(OrganizationsBucket).Put(u64tob(o.ID), v); err != nil { + return err + } + return nil + }) +} diff --git a/bolt/organizations_test.go b/bolt/organizations_test.go new file mode 100644 index 0000000000..9ef54c3b23 --- /dev/null +++ b/bolt/organizations_test.go @@ -0,0 +1,678 @@ +package bolt_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/bolt" + "github.com/influxdata/chronograf/roles" +) + +var orgCmpOptions = cmp.Options{ + cmpopts.IgnoreFields(chronograf.Organization{}, "ID"), + cmpopts.EquateEmpty(), +} + +func TestOrganizationsStore_GetWithName(t *testing.T) { + type args struct { + ctx context.Context + org *chronograf.Organization + } + tests := []struct { + name string + args args + want *chronograf.Organization + wantErr bool + addFirst bool + }{ + { + name: "Organization not found", + args: args{ + ctx: context.Background(), + org: &chronograf.Organization{}, + }, + wantErr: true, + }, + { + name: "Get Organization", + args: args{ + ctx: context.Background(), + org: &chronograf.Organization{ + Name: "EE - Evil Empire", + }, + }, + want: &chronograf.Organization{ + Name: "EE - Evil Empire", + }, + addFirst: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + + if err := client.Open(context.TODO()); err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.OrganizationsStore + if tt.addFirst { + tt.args.org, err = s.Add(tt.args.ctx, tt.args.org) + if err != nil { + t.Fatal(err) + } + } + + got, err := s.Get(tt.args.ctx, chronograf.OrganizationQuery{Name: &tt.args.org.Name}) + if (err != nil) != tt.wantErr { + t.Errorf("%q. OrganizationsStore.Get() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + if tt.wantErr { + return + } + if diff := cmp.Diff(got, tt.want, orgCmpOptions...); diff != "" { + t.Errorf("%q. OrganizationsStore.Get():\n-got/+want\ndiff %s", tt.name, diff) + } + + }) + } +} + +func TestOrganizationsStore_GetWithID(t *testing.T) { + type args struct { + ctx context.Context + org *chronograf.Organization + } + tests := []struct { + name string + args args + want *chronograf.Organization + wantErr bool + addFirst bool + }{ + { + name: "Organization not found", + args: args{ + ctx: context.Background(), + org: &chronograf.Organization{ + ID: 1234, + }, + }, + wantErr: true, + }, + { + name: "Get Organization", + args: args{ + ctx: context.Background(), + org: &chronograf.Organization{ + Name: "EE - Evil Empire", + }, + }, + want: &chronograf.Organization{ + Name: "EE - Evil Empire", + }, + addFirst: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + + if err := client.Open(context.TODO()); err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.OrganizationsStore + if tt.addFirst { + tt.args.org, err = s.Add(tt.args.ctx, tt.args.org) + if err != nil { + t.Fatal(err) + } + } + + got, err := s.Get(tt.args.ctx, chronograf.OrganizationQuery{ID: &tt.args.org.ID}) + if (err != nil) != tt.wantErr { + t.Errorf("%q. OrganizationsStore.Get() error = %v, wantErr %v", tt.name, err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if diff := cmp.Diff(got, tt.want, orgCmpOptions...); diff != "" { + t.Errorf("%q. OrganizationsStore.Get():\n-got/+want\ndiff %s", tt.name, diff) + } + + }) + } +} + +func TestOrganizationsStore_All(t *testing.T) { + type args struct { + ctx context.Context + orgs []chronograf.Organization + } + tests := []struct { + name string + args args + want []chronograf.Organization + addFirst bool + }{ + { + name: "Get Organizations", + args: args{ + ctx: context.Background(), + orgs: []chronograf.Organization{ + { + Name: "EE - Evil Empire", + DefaultRole: roles.MemberRoleName, + Public: true, + }, + { + Name: "The Good Place", + DefaultRole: roles.EditorRoleName, + Public: true, + }, + }, + }, + want: []chronograf.Organization{ + { + Name: bolt.DefaultOrganizationName, + DefaultRole: bolt.DefaultOrganizationRole, + Public: bolt.DefaultOrganizationPublic, + }, + { + Name: "EE - Evil Empire", + DefaultRole: roles.MemberRoleName, + Public: true, + }, + { + Name: "The Good Place", + DefaultRole: roles.EditorRoleName, + Public: true, + }, + }, + addFirst: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + + if err := client.Open(context.TODO()); err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.OrganizationsStore + if tt.addFirst { + for _, org := range tt.args.orgs { + _, err = s.Add(tt.args.ctx, &org) + if err != nil { + t.Fatal(err) + } + } + } + + got, err := s.All(tt.args.ctx) + if err != nil { + t.Fatal(err) + return + } + if diff := cmp.Diff(got, tt.want, orgCmpOptions...); diff != "" { + t.Errorf("%q. OrganizationsStore.All():\n-got/+want\ndiff %s", tt.name, diff) + } + }) + } +} + +func TestOrganizationsStore_Update(t *testing.T) { + type fields struct { + orgs []chronograf.Organization + } + type args struct { + ctx context.Context + initial *chronograf.Organization + updates *chronograf.Organization + } + tests := []struct { + name string + fields fields + args args + addFirst bool + want *chronograf.Organization + wantErr bool + }{ + { + name: "No such organization", + fields: fields{}, + args: args{ + ctx: context.Background(), + initial: &chronograf.Organization{ + ID: 1234, + Name: "The Okay Place", + }, + updates: &chronograf.Organization{}, + }, + wantErr: true, + }, + { + name: "Update organization name", + fields: fields{}, + args: args{ + ctx: context.Background(), + initial: &chronograf.Organization{ + Name: "The Good Place", + }, + updates: &chronograf.Organization{ + Name: "The Bad Place", + }, + }, + want: &chronograf.Organization{ + Name: "The Bad Place", + }, + addFirst: true, + }, + { + name: "Update organization default role", + fields: fields{}, + args: args{ + ctx: context.Background(), + initial: &chronograf.Organization{ + Name: "The Good Place", + }, + updates: &chronograf.Organization{ + DefaultRole: roles.ViewerRoleName, + }, + }, + want: &chronograf.Organization{ + Name: "The Good Place", + DefaultRole: roles.ViewerRoleName, + }, + addFirst: true, + }, + { + name: "Update organization name and default role", + fields: fields{}, + args: args{ + ctx: context.Background(), + initial: &chronograf.Organization{ + Name: "The Good Place", + DefaultRole: roles.AdminRoleName, + }, + updates: &chronograf.Organization{ + Name: "The Bad Place", + DefaultRole: roles.ViewerRoleName, + }, + }, + want: &chronograf.Organization{ + Name: "The Bad Place", + DefaultRole: roles.ViewerRoleName, + }, + addFirst: true, + }, + { + name: "Update organization name, role, public", + fields: fields{}, + args: args{ + ctx: context.Background(), + initial: &chronograf.Organization{ + Name: "The Good Place", + DefaultRole: roles.ViewerRoleName, + Public: false, + }, + updates: &chronograf.Organization{ + Name: "The Bad Place", + Public: true, + DefaultRole: roles.AdminRoleName, + }, + }, + want: &chronograf.Organization{ + Name: "The Bad Place", + Public: true, + DefaultRole: roles.AdminRoleName, + }, + addFirst: true, + }, + { + name: "Update organization name and public", + fields: fields{}, + args: args{ + ctx: context.Background(), + initial: &chronograf.Organization{ + Name: "The Good Place", + DefaultRole: roles.EditorRoleName, + Public: false, + }, + updates: &chronograf.Organization{ + Name: "The Bad Place", + Public: true, + }, + }, + want: &chronograf.Organization{ + Name: "The Bad Place", + DefaultRole: roles.EditorRoleName, + Public: true, + }, + addFirst: true, + }, + { + name: "Update organization name - organization already exists", + fields: fields{ + orgs: []chronograf.Organization{ + { + Name: "The Bad Place", + }, + }, + }, + args: args{ + ctx: context.Background(), + initial: &chronograf.Organization{ + Name: "The Good Place", + }, + updates: &chronograf.Organization{ + Name: "The Bad Place", + }, + }, + wantErr: true, + addFirst: true, + }, + } + for _, tt := range tests { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + if err := client.Open(context.TODO()); err != nil { + t.Fatal(err) + } + defer client.Close() + s := client.OrganizationsStore + + for _, org := range tt.fields.orgs { + _, err = s.Add(tt.args.ctx, &org) + if err != nil { + t.Fatal(err) + } + } + + if tt.addFirst { + tt.args.initial, err = s.Add(tt.args.ctx, tt.args.initial) + } + + if tt.args.updates.Name != "" { + tt.args.initial.Name = tt.args.updates.Name + } + if tt.args.updates.DefaultRole != "" { + tt.args.initial.DefaultRole = tt.args.updates.DefaultRole + } + + if tt.args.updates.Public != tt.args.initial.Public { + tt.args.initial.Public = tt.args.updates.Public + } + + if err := s.Update(tt.args.ctx, tt.args.initial); (err != nil) != tt.wantErr { + t.Errorf("%q. OrganizationsStore.Update() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + + // for the empty test + if tt.want == nil { + continue + } + + got, err := s.Get(tt.args.ctx, chronograf.OrganizationQuery{Name: &tt.args.initial.Name}) + if err != nil { + t.Fatalf("failed to get organization: %v", err) + } + if diff := cmp.Diff(got, tt.want, orgCmpOptions...); diff != "" { + t.Errorf("%q. OrganizationsStore.Update():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} + +func TestOrganizationStore_Delete(t *testing.T) { + type args struct { + ctx context.Context + org *chronograf.Organization + } + tests := []struct { + name string + args args + addFirst bool + wantErr bool + }{ + { + name: "No such organization", + args: args{ + ctx: context.Background(), + org: &chronograf.Organization{ + ID: 10, + }, + }, + wantErr: true, + }, + { + name: "Delete new organization", + args: args{ + ctx: context.Background(), + org: &chronograf.Organization{ + Name: "The Deleted Place", + }, + }, + addFirst: true, + }, + } + for _, tt := range tests { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + if err := client.Open(context.TODO()); err != nil { + t.Fatal(err) + } + defer client.Close() + s := client.OrganizationsStore + + if tt.addFirst { + tt.args.org, _ = s.Add(tt.args.ctx, tt.args.org) + } + if err := s.Delete(tt.args.ctx, tt.args.org); (err != nil) != tt.wantErr { + t.Errorf("%q. OrganizationsStore.Delete() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + } +} + +func TestOrganizationStore_DeleteDefaultOrg(t *testing.T) { + type args struct { + ctx context.Context + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "Delete the default organization", + args: args{ + ctx: context.Background(), + }, + wantErr: true, + }, + } + for _, tt := range tests { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + if err := client.Open(context.TODO()); err != nil { + t.Fatal(err) + } + defer client.Close() + s := client.OrganizationsStore + + defaultOrg, err := s.DefaultOrganization(tt.args.ctx) + if err != nil { + t.Fatal(err) + } + if err := s.Delete(tt.args.ctx, defaultOrg); (err != nil) != tt.wantErr { + t.Errorf("%q. OrganizationsStore.Delete() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + } +} + +func TestOrganizationsStore_Add(t *testing.T) { + type fields struct { + orgs []chronograf.Organization + } + type args struct { + ctx context.Context + org *chronograf.Organization + } + tests := []struct { + name string + fields fields + args args + want *chronograf.Organization + wantErr bool + }{ + { + name: "Add organization - organization already exists", + fields: fields{ + orgs: []chronograf.Organization{ + { + Name: "The Good Place", + }, + }, + }, + args: args{ + ctx: context.Background(), + org: &chronograf.Organization{ + Name: "The Good Place", + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + if err := client.Open(context.TODO()); err != nil { + t.Fatal(err) + } + defer client.Close() + s := client.OrganizationsStore + + for _, org := range tt.fields.orgs { + _, err = s.Add(tt.args.ctx, &org) + if err != nil { + t.Fatal(err) + } + } + + _, err = s.Add(tt.args.ctx, tt.args.org) + + if (err != nil) != tt.wantErr { + t.Errorf("%q. OrganizationsStore.Update() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + + // for the empty test + if tt.want == nil { + continue + } + + got, err := s.Get(tt.args.ctx, chronograf.OrganizationQuery{Name: &tt.args.org.Name}) + if err != nil { + t.Fatalf("failed to get organization: %v", err) + } + if diff := cmp.Diff(got, tt.want, orgCmpOptions...); diff != "" { + t.Errorf("%q. OrganizationsStore.Update():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} + +func TestOrganizationsStore_DefaultOrganization(t *testing.T) { + type fields struct { + orgs []chronograf.Organization + } + type args struct { + ctx context.Context + } + tests := []struct { + name string + fields fields + args args + want *chronograf.Organization + wantErr bool + }{ + { + name: "Get Default Organization", + fields: fields{ + orgs: []chronograf.Organization{ + { + Name: "The Good Place", + }, + }, + }, + args: args{ + ctx: context.Background(), + }, + want: &chronograf.Organization{ + ID: bolt.DefaultOrganizationID, + Name: bolt.DefaultOrganizationName, + DefaultRole: bolt.DefaultOrganizationRole, + Public: bolt.DefaultOrganizationPublic, + }, + wantErr: false, + }, + } + for _, tt := range tests { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + if err := client.Open(context.TODO()); err != nil { + t.Fatal(err) + } + defer client.Close() + s := client.OrganizationsStore + + for _, org := range tt.fields.orgs { + _, err = s.Add(tt.args.ctx, &org) + if err != nil { + t.Fatal(err) + } + } + + got, err := s.DefaultOrganization(tt.args.ctx) + + if (err != nil) != tt.wantErr { + t.Errorf("%q. OrganizationsStore.Update() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + + if tt.want == nil { + continue + } + + if diff := cmp.Diff(got, tt.want, orgCmpOptions...); diff != "" { + t.Errorf("%q. OrganizationsStore.Update():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} diff --git a/bolt/servers.go b/bolt/servers.go index 3521c294fa..aef86045cc 100644 --- a/bolt/servers.go +++ b/bolt/servers.go @@ -2,6 +2,7 @@ package bolt import ( "context" + "fmt" "github.com/boltdb/bolt" "github.com/influxdata/chronograf" @@ -20,6 +21,31 @@ type ServersStore struct { client *Client } +func (s *ServersStore) Migrate(ctx context.Context) error { + servers, err := s.All(ctx) + if err != nil { + return err + } + + defaultOrg, err := s.client.OrganizationsStore.DefaultOrganization(ctx) + if err != nil { + return err + } + + defaultOrgID := fmt.Sprintf("%d", defaultOrg.ID) + + for _, server := range servers { + if server.Organization == "" { + server.Organization = defaultOrgID + if err := s.Update(ctx, server); err != nil { + return nil + } + } + } + + return nil +} + // All returns all known servers func (s *ServersStore) All(ctx context.Context) ([]chronograf.Server, error) { var srcs []chronograf.Server diff --git a/bolt/servers_test.go b/bolt/servers_test.go index bca2e1e745..e43176aa38 100644 --- a/bolt/servers_test.go +++ b/bolt/servers_test.go @@ -22,20 +22,22 @@ func TestServerStore(t *testing.T) { srcs := []chronograf.Server{ chronograf.Server{ - Name: "Of Truth", - SrcID: 10, - Username: "marty", - Password: "I❤️ jennifer parker", - URL: "toyota-hilux.lyon-estates.local", - Active: false, + Name: "Of Truth", + SrcID: 10, + Username: "marty", + Password: "I❤️ jennifer parker", + URL: "toyota-hilux.lyon-estates.local", + Active: false, + Organization: "133", }, chronograf.Server{ - Name: "HipToBeSquare", - SrcID: 12, - Username: "calvinklein", - Password: "chuck b3rry", - URL: "toyota-hilux.lyon-estates.local", - Active: false, + Name: "HipToBeSquare", + SrcID: 12, + Username: "calvinklein", + Password: "chuck b3rry", + URL: "toyota-hilux.lyon-estates.local", + Active: false, + Organization: "133", }, } @@ -56,6 +58,7 @@ func TestServerStore(t *testing.T) { // Update server. srcs[0].Username = "calvinklein" srcs[1].Name = "Enchantment Under the Sea Dance" + srcs[1].Organization = "1234" if err := s.Update(ctx, srcs[0]); err != nil { t.Fatal(err) } else if err := s.Update(ctx, srcs[1]); err != nil { @@ -72,6 +75,8 @@ func TestServerStore(t *testing.T) { t.Fatal(err) } else if src.Name != "Enchantment Under the Sea Dance" { t.Fatalf("server 1 update error: got %v, expected %v", src.Name, "Enchantment Under the Sea Dance") + } else if src.Organization != "1234" { + t.Fatalf("server 1 update error: got %v, expected %v", src.Organization, "1234") } // Attempt to make two active sources diff --git a/bolt/sources.go b/bolt/sources.go index a2809ff23d..9a9b52bb50 100644 --- a/bolt/sources.go +++ b/bolt/sources.go @@ -2,10 +2,12 @@ package bolt import ( "context" + "fmt" "github.com/boltdb/bolt" "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/bolt/internal" + "github.com/influxdata/chronograf/roles" ) // Ensure SourcesStore implements chronograf.SourcesStore. @@ -19,6 +21,34 @@ type SourcesStore struct { client *Client } +func (s *SourcesStore) Migrate(ctx context.Context) error { + sources, err := s.All(ctx) + if err != nil { + return err + } + + defaultOrg, err := s.client.OrganizationsStore.DefaultOrganization(ctx) + if err != nil { + return err + } + + defaultOrgID := fmt.Sprintf("%d", defaultOrg.ID) + + for _, source := range sources { + if source.Organization == "" { + source.Organization = defaultOrgID + } + if source.Role == "" { + source.Role = roles.ViewerRoleName + } + if err := s.Update(ctx, source); err != nil { + return nil + } + } + + return nil +} + // All returns all known sources func (s *SourcesStore) All(ctx context.Context) ([]chronograf.Source, error) { var srcs []chronograf.Source diff --git a/bolt/sources_test.go b/bolt/sources_test.go index f8680d4cf5..cde1af98cd 100644 --- a/bolt/sources_test.go +++ b/bolt/sources_test.go @@ -23,20 +23,22 @@ func TestSourceStore(t *testing.T) { srcs := []chronograf.Source{ chronograf.Source{ - Name: "Of Truth", - Type: "influx", - Username: "marty", - Password: "I❤️ jennifer parker", - URL: "toyota-hilux.lyon-estates.local", - Default: true, + Name: "Of Truth", + Type: "influx", + Username: "marty", + Password: "I❤️ jennifer parker", + URL: "toyota-hilux.lyon-estates.local", + Default: true, + Organization: "1337", }, chronograf.Source{ - Name: "HipToBeSquare", - Type: "influx", - Username: "calvinklein", - Password: "chuck b3rry", - URL: "toyota-hilux.lyon-estates.local", - Default: true, + Name: "HipToBeSquare", + Type: "influx", + Username: "calvinklein", + Password: "chuck b3rry", + URL: "toyota-hilux.lyon-estates.local", + Default: true, + Organization: "1337", }, chronograf.Source{ Name: "HipToBeSquare", @@ -46,6 +48,7 @@ func TestSourceStore(t *testing.T) { URL: "https://toyota-hilux.lyon-estates.local", InsecureSkipVerify: true, Default: false, + Organization: "1337", }, } @@ -95,12 +98,13 @@ func TestSourceStore(t *testing.T) { // Attempt to add a new default source srcs = append(srcs, chronograf.Source{ - Name: "Biff Tannen", - Type: "influx", - Username: "HELLO", - Password: "MCFLY", - URL: "anybody.in.there.local", - Default: true, + Name: "Biff Tannen", + Type: "influx", + Username: "HELLO", + Password: "MCFLY", + URL: "anybody.in.there.local", + Default: true, + Organization: "1892", }) srcs[3] = mustAddSource(t, s, srcs[3]) @@ -153,12 +157,13 @@ func TestSourceStore(t *testing.T) { // Try to add one source as a non-default and ensure that it becomes a // default src := mustAddSource(t, s, chronograf.Source{ - Name: "Biff Tannen", - Type: "influx", - Username: "HELLO", - Password: "MCFLY", - URL: "anybody.in.there.local", - Default: false, + Name: "Biff Tannen", + Type: "influx", + Username: "HELLO", + Password: "MCFLY", + URL: "anybody.in.there.local", + Default: false, + Organization: "1234", }) if actual, err := s.Get(ctx, src.ID); err != nil { diff --git a/bolt/users.go b/bolt/users.go index 6df80d32c1..2e52834b8d 100644 --- a/bolt/users.go +++ b/bolt/users.go @@ -2,6 +2,7 @@ package bolt import ( "context" + "fmt" "github.com/boltdb/bolt" "github.com/influxdata/chronograf" @@ -12,65 +13,129 @@ import ( var _ chronograf.UsersStore = &UsersStore{} // UsersBucket is used to store users local to chronograf -var UsersBucket = []byte("UsersV1") +var UsersBucket = []byte("UsersV2") // UsersStore uses bolt to store and retrieve users type UsersStore struct { client *Client } -// get searches the UsersStore for user with name and returns the bolt representation -func (s *UsersStore) get(ctx context.Context, name string) (*internal.User, error) { - found := false - var user internal.User +// get searches the UsersStore for user with id and returns the bolt representation +func (s *UsersStore) get(ctx context.Context, id uint64) (*chronograf.User, error) { + var u chronograf.User err := s.client.db.View(func(tx *bolt.Tx) error { - err := tx.Bucket(UsersBucket).ForEach(func(k, v []byte) error { - var u chronograf.User - if err := internal.UnmarshalUser(v, &u); err != nil { - return err - } else if u.Name != name { - return nil - } - found = true - if err := internal.UnmarshalUserPB(v, &user); err != nil { - return err - } - return nil - }) - if err != nil { - return err - } - if found == false { + v := tx.Bucket(UsersBucket).Get(u64tob(id)) + if v == nil { return chronograf.ErrUserNotFound } - return nil + return internal.UnmarshalUser(v, &u) }) + if err != nil { return nil, err } - return &user, nil + return &u, nil +} + +func (s *UsersStore) each(ctx context.Context, fn func(*chronograf.User)) error { + return s.client.db.View(func(tx *bolt.Tx) error { + return tx.Bucket(UsersBucket).ForEach(func(k, v []byte) error { + var user chronograf.User + if err := internal.UnmarshalUser(v, &user); err != nil { + return err + } + fn(&user) + return nil + }) + }) +} + +// Num returns the number of users in the UsersStore +func (s *UsersStore) Num(ctx context.Context) (int, error) { + count := 0 + + err := s.client.db.View(func(tx *bolt.Tx) error { + return tx.Bucket(UsersBucket).ForEach(func(k, v []byte) error { + count++ + return nil + }) + }) + + if err != nil { + return 0, err + } + + return count, nil } // Get searches the UsersStore for user with name -func (s *UsersStore) Get(ctx context.Context, name string) (*chronograf.User, error) { - u, err := s.get(ctx, name) +func (s *UsersStore) Get(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.ID != nil { + return s.get(ctx, *q.ID) + } + + if q.Name != nil && q.Provider != nil && q.Scheme != nil { + var user *chronograf.User + err := s.each(ctx, func(u *chronograf.User) { + if user != nil { + return + } + if u.Name == *q.Name && u.Provider == *q.Provider && u.Scheme == *q.Scheme { + user = u + } + }) + + if err != nil { + return nil, err + } + + if user == nil { + return nil, chronograf.ErrUserNotFound + } + + return user, nil + } + + return nil, fmt.Errorf("must specify either ID, or Name, Provider, and Scheme in UserQuery") +} + +func (s *UsersStore) userExists(ctx context.Context, u *chronograf.User) (bool, error) { + _, err := s.Get(ctx, chronograf.UserQuery{ + Name: &u.Name, + Provider: &u.Provider, + Scheme: &u.Scheme, + }) + if err == chronograf.ErrUserNotFound { + return false, nil + } + + if err != nil { + return false, err + } + + return true, nil +} + +// Add a new User to the UsersStore. +func (s *UsersStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + if u == nil { + return nil, fmt.Errorf("user provided is nil") + } + userExists, err := s.userExists(ctx, u) if err != nil { return nil, err } - return &chronograf.User{ - Name: u.Name, - }, nil -} - -// Add a new Users in the UsersStore. -func (s *UsersStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + if userExists { + return nil, chronograf.ErrUserAlreadyExists + } if err := s.client.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(UsersBucket) seq, err := b.NextSequence() if err != nil { return err } + u.ID = seq if v, err := internal.MarshalUser(u); err != nil { return err } else if err := b.Put(u64tob(seq), v); err != nil { @@ -84,60 +149,45 @@ func (s *UsersStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.U return u, nil } -// Delete the users from the UsersStore -func (s *UsersStore) Delete(ctx context.Context, user *chronograf.User) error { - u, err := s.get(ctx, user.Name) +// Delete a user from the UsersStore +func (s *UsersStore) Delete(ctx context.Context, usr *chronograf.User) error { + _, err := s.get(ctx, usr.ID) if err != nil { return err } - if err := s.client.db.Update(func(tx *bolt.Tx) error { - if err := tx.Bucket(UsersBucket).Delete(u64tob(u.ID)); err != nil { - return err - } - return nil - }); err != nil { - return err - } - - return nil + return s.client.db.Update(func(tx *bolt.Tx) error { + return tx.Bucket(UsersBucket).Delete(u64tob(usr.ID)) + }) } // Update a user func (s *UsersStore) Update(ctx context.Context, usr *chronograf.User) error { - u, err := s.get(ctx, usr.Name) + _, err := s.get(ctx, usr.ID) if err != nil { return err } - if err := s.client.db.Update(func(tx *bolt.Tx) error { - u.Name = usr.Name - if v, err := internal.MarshalUserPB(u); err != nil { + return s.client.db.Update(func(tx *bolt.Tx) error { + if v, err := internal.MarshalUser(usr); err != nil { return err - } else if err := tx.Bucket(UsersBucket).Put(u64tob(u.ID), v); err != nil { + } else if err := tx.Bucket(UsersBucket).Put(u64tob(usr.ID), v); err != nil { return err } return nil - }); err != nil { - return err - } - - return nil + }) } // All returns all users func (s *UsersStore) All(ctx context.Context) ([]chronograf.User, error) { var users []chronograf.User if err := s.client.db.View(func(tx *bolt.Tx) error { - if err := tx.Bucket(UsersBucket).ForEach(func(k, v []byte) error { + return tx.Bucket(UsersBucket).ForEach(func(k, v []byte) error { var user chronograf.User if err := internal.UnmarshalUser(v, &user); err != nil { return err } users = append(users, user) return nil - }); err != nil { - return err - } - return nil + }) }); err != nil { return nil, err } diff --git a/bolt/users_test.go b/bolt/users_test.go index bbe94d43b8..0386a8b39e 100644 --- a/bolt/users_test.go +++ b/bolt/users_test.go @@ -2,16 +2,189 @@ package bolt_test import ( "context" - "reflect" + "fmt" "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/influxdata/chronograf" ) -func TestUsersStore_Get(t *testing.T) { +// IgnoreFields is used because ID is created by BoltDB and cannot be predicted reliably +// EquateEmpty is used because we want nil slices, arrays, and maps to be equal to the empty map +var cmpOptions = cmp.Options{ + cmpopts.IgnoreFields(chronograf.User{}, "ID"), + cmpopts.EquateEmpty(), +} + +func TestUsersStore_GetWithID(t *testing.T) { type args struct { - ctx context.Context - name string + ctx context.Context + usr *chronograf.User + } + tests := []struct { + name string + args args + want *chronograf.User + wantErr bool + addFirst bool + }{ + { + name: "User not found", + args: args{ + ctx: context.Background(), + usr: &chronograf.User{ + ID: 1337, + }, + }, + wantErr: true, + }, + { + name: "Get user", + args: args{ + ctx: context.Background(), + usr: &chronograf.User{ + Name: "billietta", + Provider: "google", + Scheme: "oauth2", + }, + }, + want: &chronograf.User{ + Name: "billietta", + Provider: "google", + Scheme: "oauth2", + }, + addFirst: true, + }, + } + for _, tt := range tests { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + if err := client.Open(context.TODO()); err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.UsersStore + if tt.addFirst { + tt.args.usr, err = s.Add(tt.args.ctx, tt.args.usr) + if err != nil { + t.Fatal(err) + } + } + got, err := s.Get(tt.args.ctx, chronograf.UserQuery{ID: &tt.args.usr.ID}) + if (err != nil) != tt.wantErr { + t.Errorf("%q. UsersStore.Get() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if diff := cmp.Diff(got, tt.want, cmpOptions...); diff != "" { + t.Errorf("%q. UsersStore.Get():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} + +func TestUsersStore_GetWithNameProviderScheme(t *testing.T) { + type args struct { + ctx context.Context + name string + provider string + usr *chronograf.User + } + tests := []struct { + name string + args args + want *chronograf.User + wantErr bool + addFirst bool + }{ + { + name: "User not found", + args: args{ + ctx: context.Background(), + usr: &chronograf.User{ + Name: "billietta", + Provider: "google", + Scheme: "oauth2", + }, + }, + wantErr: true, + }, + { + name: "Get user", + args: args{ + ctx: context.Background(), + usr: &chronograf.User{ + Name: "billietta", + Provider: "google", + Scheme: "oauth2", + }, + }, + want: &chronograf.User{ + Name: "billietta", + Provider: "google", + Scheme: "oauth2", + }, + addFirst: true, + }, + } + for _, tt := range tests { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + if err := client.Open(context.TODO()); err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.UsersStore + if tt.addFirst { + tt.args.usr, err = s.Add(tt.args.ctx, tt.args.usr) + if err != nil { + t.Fatal(err) + } + } + + got, err := s.Get(tt.args.ctx, chronograf.UserQuery{ + Name: &tt.args.usr.Name, + Provider: &tt.args.usr.Provider, + Scheme: &tt.args.usr.Scheme, + }) + if (err != nil) != tt.wantErr { + t.Errorf("%q. UsersStore.Get() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if diff := cmp.Diff(got, tt.want, cmpOptions...); diff != "" { + t.Errorf("%q. UsersStore.Get():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} + +func TestUsersStore_GetInvalid(t *testing.T) { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + if err := client.Open(context.TODO()); err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.UsersStore + + _, err = s.Get(context.Background(), chronograf.UserQuery{}) + if err == nil { + t.Errorf("Invalid Get. UsersStore.Get() error = %v", err) + } +} + +func TestUsersStore_Add(t *testing.T) { + type args struct { + ctx context.Context + u *chronograf.User + addFirst bool } tests := []struct { name string @@ -20,10 +193,46 @@ func TestUsersStore_Get(t *testing.T) { wantErr bool }{ { - name: "User not found", + name: "Add new user", args: args{ - ctx: context.Background(), - name: "unknown", + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "editor", + }, + }, + }, + }, + want: &chronograf.User{ + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "editor", + }, + }, + }, + }, + { + name: "User already exists", + args: args{ + ctx: context.Background(), + addFirst: true, + u: &chronograf.User{ + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "editor", + }, + }, + }, }, wantErr: true, }, @@ -37,65 +246,26 @@ func TestUsersStore_Get(t *testing.T) { t.Fatal(err) } defer client.Close() - s := client.UsersStore - got, err := s.Get(tt.args.ctx, tt.args.name) - if (err != nil) != tt.wantErr { - t.Errorf("%q. UsersStore.Get() error = %v, wantErr %v", tt.name, err, tt.wantErr) - continue + if tt.args.addFirst { + _, _ = s.Add(tt.args.ctx, tt.args.u) } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("%q. UsersStore.Get() = %v, want %v", tt.name, got, tt.want) - } - } -} - -func TestUsersStore_Add(t *testing.T) { - type args struct { - ctx context.Context - u *chronograf.User - } - tests := []struct { - name string - args args - want *chronograf.User - wantErr bool - }{ - { - name: "Add new user", - args: args{ - ctx: context.Background(), - u: &chronograf.User{ - Name: "docbrown", - }, - }, - want: &chronograf.User{ - Name: "docbrown", - }, - }, - } - for _, tt := range tests { - client, err := NewTestClient() - if err != nil { - t.Fatal(err) - } - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } - defer client.Close() - s := client.UsersStore got, err := s.Add(tt.args.ctx, tt.args.u) if (err != nil) != tt.wantErr { t.Errorf("%q. UsersStore.Add() error = %v, wantErr %v", tt.name, err, tt.wantErr) continue } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("%q. UsersStore.Add() = %v, want %v", tt.name, got, tt.want) + + if tt.wantErr { + continue } - got, _ = s.Get(tt.args.ctx, got.Name) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("%q. UsersStore.Add() = %v, want %v", tt.name, got, tt.want) + got, err = s.Get(tt.args.ctx, chronograf.UserQuery{ID: &got.ID}) + if err != nil { + t.Fatalf("failed to get user: %v", err) + } + if diff := cmp.Diff(got, tt.want, cmpOptions...); diff != "" { + t.Errorf("%q. UsersStore.Add():\n-got/+want\ndiff %s", tt.name, diff) } } } @@ -116,7 +286,7 @@ func TestUsersStore_Delete(t *testing.T) { args: args{ ctx: context.Background(), user: &chronograf.User{ - Name: "noone", + ID: 10, }, }, wantErr: true, @@ -144,7 +314,9 @@ func TestUsersStore_Delete(t *testing.T) { s := client.UsersStore if tt.addFirst { - s.Add(tt.args.ctx, tt.args.user) + var err error + tt.args.user, err = s.Add(tt.args.ctx, tt.args.user) + fmt.Println(err) } if err := s.Delete(tt.args.ctx, tt.args.user); (err != nil) != tt.wantErr { t.Errorf("%q. UsersStore.Delete() error = %v, wantErr %v", tt.name, err, tt.wantErr) @@ -154,13 +326,18 @@ func TestUsersStore_Delete(t *testing.T) { func TestUsersStore_Update(t *testing.T) { type args struct { - ctx context.Context - usr *chronograf.User + ctx context.Context + usr *chronograf.User + roles []chronograf.Role + provider string + scheme string + name string } tests := []struct { name string args args addFirst bool + want *chronograf.User wantErr bool }{ { @@ -168,18 +345,60 @@ func TestUsersStore_Update(t *testing.T) { args: args{ ctx: context.Background(), usr: &chronograf.User{ - Name: "noone", + ID: 10, }, }, wantErr: true, }, { - name: "Update new user", + name: "Update user role", args: args{ ctx: context.Background(), usr: &chronograf.User{ - Name: "noone", + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "viewer", + }, + }, }, + roles: []chronograf.Role{ + { + Name: "editor", + }, + }, + }, + want: &chronograf.User{ + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "editor", + }, + }, + }, + addFirst: true, + }, + { + name: "Update user provider and scheme", + args: args{ + ctx: context.Background(), + usr: &chronograf.User{ + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + }, + provider: "google", + scheme: "oauth2", + name: "billietta", + }, + want: &chronograf.User{ + Name: "billietta", + Provider: "google", + Scheme: "oauth2", }, addFirst: true, }, @@ -196,12 +415,44 @@ func TestUsersStore_Update(t *testing.T) { s := client.UsersStore if tt.addFirst { - s.Add(tt.args.ctx, tt.args.usr) + tt.args.usr, err = s.Add(tt.args.ctx, tt.args.usr) + if err != nil { + t.Fatal(err) + } + } + + if tt.args.roles != nil { + tt.args.usr.Roles = tt.args.roles + } + + if tt.args.provider != "" { + tt.args.usr.Provider = tt.args.provider + } + + if tt.args.scheme != "" { + tt.args.usr.Scheme = tt.args.scheme + } + + if tt.args.name != "" { + tt.args.usr.Name = tt.args.name } if err := s.Update(tt.args.ctx, tt.args.usr); (err != nil) != tt.wantErr { t.Errorf("%q. UsersStore.Update() error = %v, wantErr %v", tt.name, err, tt.wantErr) } + + // for the empty test + if tt.want == nil { + continue + } + + got, err := s.Get(tt.args.ctx, chronograf.UserQuery{ID: &tt.args.usr.ID}) + if err != nil { + t.Fatalf("failed to get user: %v", err) + } + if diff := cmp.Diff(got, tt.want, cmpOptions...); diff != "" { + t.Errorf("%q. UsersStore.Update():\n-got/+want\ndiff %s", tt.name, diff) + } } } @@ -220,10 +471,24 @@ func TestUsersStore_All(t *testing.T) { name: "Update new user", want: []chronograf.User{ { - Name: "howdy", + Name: "howdy", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "viewer", + }, + }, }, { - Name: "doody", + Name: "doody", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "editor", + }, + }, }, }, addFirst: true, @@ -245,13 +510,79 @@ func TestUsersStore_All(t *testing.T) { s.Add(tt.ctx, &u) } } - got, err := s.All(tt.ctx) + gots, err := s.All(tt.ctx) if (err != nil) != tt.wantErr { t.Errorf("%q. UsersStore.All() error = %v, wantErr %v", tt.name, err, tt.wantErr) continue } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("%q. UsersStore.All() = %v, want %v", tt.name, got, tt.want) + for i, got := range gots { + if diff := cmp.Diff(got, tt.want[i], cmpOptions...); diff != "" { + t.Errorf("%q. UsersStore.All():\n-got/+want\ndiff %s", tt.name, diff) + } + } + } +} + +func TestUsersStore_Num(t *testing.T) { + tests := []struct { + name string + ctx context.Context + users []chronograf.User + want int + wantErr bool + }{ + { + name: "No users", + want: 0, + }, + { + name: "Update new user", + want: 2, + users: []chronograf.User{ + { + Name: "howdy", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "viewer", + }, + }, + }, + { + Name: "doody", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "editor", + }, + }, + }, + }, + }, + } + for _, tt := range tests { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + if err := client.Open(context.TODO()); err != nil { + t.Fatal(err) + } + defer client.Close() + s := client.UsersStore + + for _, u := range tt.users { + s.Add(tt.ctx, &u) + } + got, err := s.Num(tt.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("%q. UsersStore.Num() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if got != tt.want { + t.Errorf("%q. UsersStore.Num() = %d. want %d", tt.name, got, tt.want) } } } diff --git a/canned/apps.go b/canned/apps.go index 8ad626e8dc..f765d2bb98 100644 --- a/canned/apps.go +++ b/canned/apps.go @@ -14,7 +14,7 @@ import ( // AppExt is the the file extension searched for in the directory for layout files const AppExt = ".json" -// Apps are canned JSON layouts. Implements LayoutStore. +// Apps are canned JSON layouts. Implements LayoutsStore. type Apps struct { Dir string // Dir is the directory contained the pre-canned applications. Load func(string) (chronograf.Layout, error) // Load loads string name and return a Layout @@ -27,7 +27,7 @@ type Apps struct { } // NewApps constructs a layout store wrapping a file system directory -func NewApps(dir string, ids chronograf.ID, logger chronograf.Logger) chronograf.LayoutStore { +func NewApps(dir string, ids chronograf.ID, logger chronograf.Logger) chronograf.LayoutsStore { return &Apps{ Dir: dir, Load: loadFile, diff --git a/canned/bin.go b/canned/bin.go index 1b5fd4399f..63ab984525 100644 --- a/canned/bin.go +++ b/canned/bin.go @@ -10,13 +10,13 @@ import ( //go:generate go-bindata -o bin_gen.go -ignore README|apps|.sh|go -pkg canned . -// BinLayoutStore represents a layout store using data generated by go-bindata -type BinLayoutStore struct { +// BinLayoutsStore represents a layout store using data generated by go-bindata +type BinLayoutsStore struct { Logger chronograf.Logger } // All returns the set of all layouts -func (s *BinLayoutStore) All(ctx context.Context) ([]chronograf.Layout, error) { +func (s *BinLayoutsStore) All(ctx context.Context) ([]chronograf.Layout, error) { names := AssetNames() layouts := make([]chronograf.Layout, len(names)) for i, name := range names { @@ -43,18 +43,18 @@ func (s *BinLayoutStore) All(ctx context.Context) ([]chronograf.Layout, error) { return layouts, nil } -// Add is not support by BinLayoutStore -func (s *BinLayoutStore) Add(ctx context.Context, layout chronograf.Layout) (chronograf.Layout, error) { - return chronograf.Layout{}, fmt.Errorf("Add to BinLayoutStore not supported") +// Add is not support by BinLayoutsStore +func (s *BinLayoutsStore) Add(ctx context.Context, layout chronograf.Layout) (chronograf.Layout, error) { + return chronograf.Layout{}, fmt.Errorf("Add to BinLayoutsStore not supported") } -// Delete is not support by BinLayoutStore -func (s *BinLayoutStore) Delete(ctx context.Context, layout chronograf.Layout) error { - return fmt.Errorf("Delete to BinLayoutStore not supported") +// Delete is not support by BinLayoutsStore +func (s *BinLayoutsStore) Delete(ctx context.Context, layout chronograf.Layout) error { + return fmt.Errorf("Delete to BinLayoutsStore not supported") } // Get retrieves Layout if `ID` exists. -func (s *BinLayoutStore) Get(ctx context.Context, ID string) (chronograf.Layout, error) { +func (s *BinLayoutsStore) Get(ctx context.Context, ID string) (chronograf.Layout, error) { layouts, err := s.All(ctx) if err != nil { s.Logger. @@ -78,6 +78,6 @@ func (s *BinLayoutStore) Get(ctx context.Context, ID string) (chronograf.Layout, } // Update not supported -func (s *BinLayoutStore) Update(ctx context.Context, layout chronograf.Layout) error { - return fmt.Errorf("Update to BinLayoutStore not supported") +func (s *BinLayoutsStore) Update(ctx context.Context, layout chronograf.Layout) error { + return fmt.Errorf("Update to BinLayoutsStore not supported") } diff --git a/chronograf.go b/chronograf.go index 7b28653e45..77c7f6f896 100644 --- a/chronograf.go +++ b/chronograf.go @@ -9,19 +9,23 @@ import ( // General errors. const ( - ErrUpstreamTimeout = Error("request to backend timed out") - ErrSourceNotFound = Error("source not found") - ErrServerNotFound = Error("server not found") - ErrLayoutNotFound = Error("layout not found") - ErrDashboardNotFound = Error("dashboard not found") - ErrUserNotFound = Error("user not found") - ErrLayoutInvalid = Error("layout is invalid") - ErrAlertNotFound = Error("alert not found") - ErrAuthentication = Error("user not authenticated") - ErrUninitialized = Error("client uninitialized. Call Open() method") - ErrInvalidAxis = Error("Unexpected axis in cell. Valid axes are 'x', 'y', and 'y2'") - ErrInvalidColorType = Error("Invalid color type. Valid color types are 'min', 'max', 'threshold'") - ErrInvalidColor = Error("Invalid color. Accepted color format is #RRGGBB") + ErrUpstreamTimeout = Error("request to backend timed out") + ErrSourceNotFound = Error("source not found") + ErrServerNotFound = Error("server not found") + ErrLayoutNotFound = Error("layout not found") + ErrDashboardNotFound = Error("dashboard not found") + ErrUserNotFound = Error("user not found") + ErrLayoutInvalid = Error("layout is invalid") + ErrAlertNotFound = Error("alert not found") + ErrAuthentication = Error("user not authenticated") + ErrUninitialized = Error("client uninitialized. Call Open() method") + ErrInvalidAxis = Error("Unexpected axis in cell. Valid axes are 'x', 'y', and 'y2'") + ErrInvalidColorType = Error("Invalid color type. Valid color types are 'min', 'max', 'threshold'") + ErrInvalidColor = Error("Invalid color. Accepted color format is #RRGGBB") + ErrUserAlreadyExists = Error("user already exists") + ErrOrganizationNotFound = Error("organization not found") + ErrOrganizationAlreadyExists = Error("organization already exists") + ErrCannotDeleteDefaultOrganization = Error("cannot delete default organization") ) // Error is a domain error encountered while processing chronograf requests @@ -102,9 +106,10 @@ type TimeSeries interface { // Role is a restricted set of permissions assigned to a set of users. type Role struct { - Name string `json:"name"` - Permissions Permissions `json:"permissions,omitempty"` - Users []User `json:"users,omitempty"` + Name string `json:"name"` + Permissions Permissions `json:"permissions,omitempty"` + Users []User `json:"users,omitempty"` + Organization string `json:"organization,omitempty"` } // RolesStore is the Storage and retrieval of authentication information @@ -204,6 +209,8 @@ type Source struct { InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` // InsecureSkipVerify as true means any certificate presented by the source is accepted. Default bool `json:"default"` // Default specifies the default source for the application Telegraf string `json:"telegraf"` // Telegraf is the db telegraf is written to. By default it is "telegraf" + Organization string `json:"organization"` // Organization is the organization ID that resource belongs to + Role string `json:"role,omitempty"` // Not Currently Used. Role is the name of the minimum role that a user must possess to access the resource. } // SourcesStore stores connection information for a `TimeSeries` @@ -334,6 +341,7 @@ type Server struct { URL string // URL are the connections to the server InsecureSkipVerify bool // InsecureSkipVerify as true means any certificate presented by the server is accepted. Active bool // Is this the active server for the source? + Organization string // Organization is the organization ID that resource belongs to } // ServersStore stores connection information for a `Server` @@ -382,13 +390,34 @@ type Scope string // User represents an authenticated user. type User struct { + ID uint64 `json:"id,string,omitempty"` Name string `json:"name"` - Passwd string `json:"password"` + Passwd string `json:"password,omitempty"` Permissions Permissions `json:"permissions,omitempty"` Roles []Role `json:"roles,omitempty"` + Provider string `json:"provider,omitempty"` + Scheme string `json:"scheme,omitempty"` + SuperAdmin bool `json:"superAdmin,omitempty"` +} + +// UserQuery represents the attributes that a user may be retrieved by. +// It is predominantly used in the UsersStore.Get method. +// +// It is expected that only one of ID or Name, Provider, and Scheme will be +// specified, but all are provided UserStores should prefer ID. +type UserQuery struct { + ID *uint64 + Name *string + Provider *string + Scheme *string } // UsersStore is the Storage and retrieval of authentication information +// +// While not necessary for the app to function correctly, it is +// expected that Implementors of the UsersStore will take +// care to guarantee that the combinartion of a users Name, Provider, +// and Scheme are unique. type UsersStore interface { // All lists all users from the UsersStore All(context.Context) ([]User, error) @@ -397,9 +426,11 @@ type UsersStore interface { // Delete the User from the UsersStore Delete(context.Context, *User) error // Get retrieves a user if name exists. - Get(ctx context.Context, name string) (*User, error) + Get(ctx context.Context, q UserQuery) (*User, error) // Update the user's permissions or roles Update(context.Context, *User) error + // Num returns the number of users in the UsersStore + Num(context.Context) (int, error) } // Database represents a database in a time series source @@ -437,10 +468,11 @@ type DashboardID int // Dashboard represents all visual and query data for a dashboard type Dashboard struct { - ID DashboardID `json:"id"` - Cells []DashboardCell `json:"cells"` - Templates []Template `json:"templates"` - Name string `json:"name"` + ID DashboardID `json:"id"` + Cells []DashboardCell `json:"cells"` + Templates []Template `json:"templates"` + Name string `json:"name"` + Organization string `json:"organization"` // Organization is the organization ID that resource belongs to } // Axis represents the visible extents of a visualization @@ -513,11 +545,11 @@ type Layout struct { Cells []Cell `json:"cells"` } -// LayoutStore stores dashboards and associated Cells -type LayoutStore interface { +// LayoutsStore stores dashboards and associated Cells +type LayoutsStore interface { // All returns all dashboards in the store All(context.Context) ([]Layout, error) - // Add creates a new dashboard in the LayoutStore + // Add creates a new dashboard in the LayoutsStore Add(context.Context, Layout) (Layout, error) // Delete the dashboard from the store Delete(context.Context, Layout) error @@ -526,3 +558,49 @@ type LayoutStore interface { // Update the dashboard in the store. Update(context.Context, Layout) error } + +// Organization is a group of resources under a common name +type Organization struct { + ID uint64 `json:"id,string"` + Name string `json:"name"` + // DefaultRole is the name of the role that is the default for any users added to the organization + DefaultRole string `json:"defaultRole,omitempty"` + // Public specifies whether users must be explicitly added to the organization. + // It is currently only used by the default organization, but that may change in the future. + Public bool `json:"public"` +} + +// OrganizationQuery represents the attributes that a organization may be retrieved by. +// It is predominantly used in the OrganizationsStore.Get method. +// It is expected that only one of ID or Name will be specified, but will prefer ID over Name if both are specified. +type OrganizationQuery struct { + // If an ID is provided in the query, the lookup time for an organization will be O(1). + ID *uint64 + // If Name is provided, the lookup time will be O(n). + Name *string +} + +// OrganizationsStore is the storage and retrieval of Organizations +// +// While not necessary for the app to function correctly, it is +// expected that Implementors of the OrganizationsStore will take +// care to guarantee that the Organization.Name is unqiue. Allowing +// for duplicate names creates a confusing UX experience for the User. +type OrganizationsStore interface { + // Add creates a new Organization. + // The Created organization is returned back to the user with the + // ID field populated. + Add(context.Context, *Organization) (*Organization, error) + // All lists all Organizations in the OrganizationsStore + All(context.Context) ([]Organization, error) + // Delete removes an Organization from the OrganizationsStore + Delete(context.Context, *Organization) error + // Get retrieves an Organization from the OrganizationsStore + Get(context.Context, OrganizationQuery) (*Organization, error) + // Update updates an Organization in the OrganizationsStore + Update(context.Context, *Organization) error + // CreateDefault creates the default organization + CreateDefault(ctx context.Context) error + // DefaultOrganization returns the DefaultOrganization + DefaultOrganization(ctx context.Context) (*Organization, error) +} diff --git a/enterprise/users.go b/enterprise/users.go index aa1b9f23ed..03ad17a8de 100644 --- a/enterprise/users.go +++ b/enterprise/users.go @@ -2,6 +2,7 @@ package enterprise import ( "context" + "fmt" "github.com/influxdata/chronograf" ) @@ -28,7 +29,7 @@ func (c *UserStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.Us } } - return c.Get(ctx, u.Name) + return c.Get(ctx, chronograf.UserQuery{Name: &u.Name}) } // Delete the User from Influx Enterprise @@ -36,9 +37,22 @@ func (c *UserStore) Delete(ctx context.Context, u *chronograf.User) error { return c.Ctrl.DeleteUser(ctx, u.Name) } +// Number of users in Influx +func (c *UserStore) Num(ctx context.Context) (int, error) { + all, err := c.All(ctx) + if err != nil { + return 0, err + } + + return len(all), nil +} + // Get retrieves a user if name exists. -func (c *UserStore) Get(ctx context.Context, name string) (*chronograf.User, error) { - u, err := c.Ctrl.User(ctx, name) +func (c *UserStore) Get(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil { + return nil, fmt.Errorf("query must specify name") + } + u, err := c.Ctrl.User(ctx, *q.Name) if err != nil { return nil, err } @@ -48,7 +62,7 @@ func (c *UserStore) Get(ctx context.Context, name string) (*chronograf.User, err return nil, err } - role := ur[name] + role := ur[*q.Name] cr := role.ToChronograf() // For now we are removing all users from a role being returned. for i, r := range cr { diff --git a/enterprise/users_test.go b/enterprise/users_test.go index 9cc0cddc50..afd13fedb6 100644 --- a/enterprise/users_test.go +++ b/enterprise/users_test.go @@ -375,7 +375,7 @@ func TestClient_Get(t *testing.T) { Ctrl: tt.fields.Ctrl, Logger: tt.fields.Logger, } - got, err := c.Get(tt.args.ctx, tt.args.name) + got, err := c.Get(tt.args.ctx, chronograf.UserQuery{Name: &tt.args.name}) if (err != nil) != tt.wantErr { t.Errorf("%q. Client.Get() error = %v, wantErr %v", tt.name, err, tt.wantErr) continue @@ -534,6 +534,94 @@ func TestClient_Update(t *testing.T) { } } +func TestClient_Num(t *testing.T) { + type fields struct { + Ctrl *mockCtrl + Logger chronograf.Logger + } + type args struct { + ctx context.Context + } + tests := []struct { + name string + fields fields + args args + want []chronograf.User + wantErr bool + }{ + { + name: "Successful Get User", + fields: fields{ + Ctrl: &mockCtrl{ + users: func(ctx context.Context, name *string) (*enterprise.Users, error) { + return &enterprise.Users{ + Users: []enterprise.User{ + { + Name: "marty", + Password: "johnny be good", + Permissions: map[string][]string{ + "": { + "ViewChronograf", + "ReadData", + "WriteData", + }, + }, + }, + }, + }, nil + }, + userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) { + return map[string]enterprise.Roles{}, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + }, + want: []chronograf.User{ + { + Name: "marty", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"ViewChronograf", "ReadData", "WriteData"}, + }, + }, + Roles: []chronograf.Role{}, + }, + }, + }, + { + name: "Failure to get User", + fields: fields{ + Ctrl: &mockCtrl{ + users: func(ctx context.Context, name *string) (*enterprise.Users, error) { + return nil, fmt.Errorf("1.21 Gigawatts! Tom, how could I have been so careless?") + }, + }, + }, + args: args{ + ctx: context.Background(), + }, + wantErr: true, + }, + } + for _, tt := range tests { + c := &enterprise.UserStore{ + Ctrl: tt.fields.Ctrl, + Logger: tt.fields.Logger, + } + got, err := c.Num(tt.args.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("%q. Client.Num() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if got != len(tt.want) { + t.Errorf("%q. Client.Num() = %v, want %v", tt.name, got, len(tt.want)) + } + } +} + func TestClient_All(t *testing.T) { type fields struct { Ctrl *mockCtrl diff --git a/influx/users.go b/influx/users.go index a8e10bcfa1..601f7e1e4f 100644 --- a/influx/users.go +++ b/influx/users.go @@ -21,7 +21,7 @@ func (c *Client) Add(ctx context.Context, u *chronograf.User) (*chronograf.User, return nil, err } } - return c.Get(ctx, u.Name) + return c.Get(ctx, chronograf.UserQuery{Name: &u.Name}) } // Delete the User from InfluxDB @@ -54,14 +54,18 @@ func (c *Client) Delete(ctx context.Context, u *chronograf.User) error { } // Get retrieves a user if name exists. -func (c *Client) Get(ctx context.Context, name string) (*chronograf.User, error) { +func (c *Client) Get(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil { + return nil, fmt.Errorf("query must specify name") + } + users, err := c.showUsers(ctx) if err != nil { return nil, err } for _, user := range users { - if user.Name == name { + if user.Name == *q.Name { perms, err := c.userPermissions(ctx, user.Name) if err != nil { return nil, err @@ -82,7 +86,7 @@ func (c *Client) Update(ctx context.Context, u *chronograf.User) error { return c.updatePassword(ctx, u.Name, u.Passwd) } - user, err := c.Get(ctx, u.Name) + user, err := c.Get(ctx, chronograf.UserQuery{Name: &u.Name}) if err != nil { return err } @@ -122,6 +126,16 @@ func (c *Client) All(ctx context.Context) ([]chronograf.User, error) { return users, nil } +// Number of users in Influx +func (c *Client) Num(ctx context.Context) (int, error) { + all, err := c.All(ctx) + if err != nil { + return 0, err + } + + return len(all), nil +} + // showUsers runs SHOW USERS InfluxQL command and returns chronograf users. func (c *Client) showUsers(ctx context.Context) ([]chronograf.User, error) { res, err := c.Query(ctx, chronograf.Query{ diff --git a/influx/users_test.go b/influx/users_test.go index 9922d4e517..3f1eef673d 100644 --- a/influx/users_test.go +++ b/influx/users_test.go @@ -392,7 +392,7 @@ func TestClient_Get(t *testing.T) { Logger: log.New(log.DebugLevel), } defer ts.Close() - got, err := c.Get(tt.args.ctx, tt.args.name) + got, err := c.Get(tt.args.ctx, chronograf.UserQuery{Name: &tt.args.name}) if (err != nil) != tt.wantErr { t.Errorf("%q. Client.Get() error = %v, wantErr %v", tt.name, err, tt.wantErr) continue @@ -573,6 +573,102 @@ func TestClient_revokePermission(t *testing.T) { } } +func TestClient_Num(t *testing.T) { + type args struct { + ctx context.Context + } + tests := []struct { + name string + args args + statusUsers int + showUsers []byte + statusGrants int + showGrants []byte + want []chronograf.User + wantErr bool + }{ + { + name: "All Users", + statusUsers: http.StatusOK, + showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`), + statusGrants: http.StatusOK, + showGrants: []byte(`{"results":[{"series":[{"columns":["database","privilege"],"values":[["mydb","ALL PRIVILEGES"]]}]}]}`), + args: args{ + ctx: context.Background(), + }, + want: []chronograf.User{ + { + Name: "admin", + Permissions: chronograf.Permissions{ + chronograf.Permission{ + Scope: "all", + Allowed: []string{"ALL"}, + }, + chronograf.Permission{ + Scope: "database", + Name: "mydb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + }, + { + Name: "docbrown", + Permissions: chronograf.Permissions{ + chronograf.Permission{ + Scope: "all", + Allowed: []string{"ALL"}, + }, + chronograf.Permission{ + Scope: "database", + Name: "mydb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + }, + { + Name: "reader", + Permissions: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "mydb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if path := r.URL.Path; path != "/query" { + t.Error("Expected the path to contain `/query` but was", path) + } + query := r.URL.Query().Get("q") + if strings.Contains(query, "GRANTS") { + rw.WriteHeader(tt.statusGrants) + rw.Write(tt.showGrants) + } else if strings.Contains(query, "USERS") { + rw.WriteHeader(tt.statusUsers) + rw.Write(tt.showUsers) + } + })) + u, _ := url.Parse(ts.URL) + c := &Client{ + URL: u, + Logger: log.New(log.DebugLevel), + } + defer ts.Close() + got, err := c.Num(tt.args.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("%q. Client.Num() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if got != len(tt.want) { + t.Errorf("%q. Client.Num() = %v, want %v", tt.name, got, len(tt.want)) + } + } +} + func TestClient_All(t *testing.T) { type args struct { ctx context.Context diff --git a/integrations/server_test.go b/integrations/server_test.go new file mode 100644 index 0000000000..22e1bc149a --- /dev/null +++ b/integrations/server_test.go @@ -0,0 +1,296 @@ +package integrations + +// This was intentionally added under the integrations package and not the integrations test package +// so that changes in other parts of the code base that may have an effect on these test will not +// compile until they are fixed. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "testing" + "time" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/bolt" + "github.com/influxdata/chronograf/oauth2" + "github.com/influxdata/chronograf/server" +) + +func TestServer(t *testing.T) { + type fields struct { + Organizations []chronograf.Organization + Users []chronograf.User + Sources []chronograf.Source + Servers []chronograf.Server + Layouts []chronograf.Layout + Dashboards []chronograf.Dashboard + } + type args struct { + server *server.Server + method string + path string + payload interface{} // Expects this to be a json serializable struct + principal oauth2.Principal + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + subName string + fields fields + args args + wants wants + }{ + { + name: "GET /users", + subName: "User Not Found in the Default Organization", + fields: fields{ + Users: []chronograf.User{}, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/users", + principal: oauth2.Principal{ + Organization: "0", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 403, + body: `{"code":403,"message":"User is not authorized"}`, + }, + }, + { + name: "GET /users", + subName: "Single User in the Default Organization as SuperAdmin", + fields: fields{ + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "0", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/users", + principal: oauth2.Principal{ + Organization: "0", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` + { + "links": { + "self": "/chronograf/v1/users" + }, + "users": [ + { + "links": { + "self": "/chronograf/v1/users/1" + }, + "id": "1", + "name": "billibob", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + { + "name": "admin", + "organization": "0" + } + ] + } + ] + }`, + }, + }, + } + + for _, tt := range tests { + testName := fmt.Sprintf("%s: %s", tt.name, tt.subName) + t.Run(testName, func(t *testing.T) { + ctx := context.TODO() + // Create Test Server + host, port := hostAndPort() + tt.args.server.Host = host + tt.args.server.Port = port + + // This is so that we can use staticly generate jwts + tt.args.server.TokenSecret = "secret" + + boltFile := newBoltFile() + tt.args.server.BoltPath = boltFile + + // Prepopulate BoltDB Database for Server + boltdb := bolt.NewClient() + boltdb.Path = boltFile + _ = boltdb.Open(ctx) + + // Populate Organizations + for i, organization := range tt.fields.Organizations { + o, err := boltdb.OrganizationsStore.Add(ctx, &organization) + if err != nil { + t.Fatalf("failed to add organization: %v", err) + return + } + tt.fields.Organizations[i] = *o + } + + // Populate Users + for i, user := range tt.fields.Users { + u, err := boltdb.UsersStore.Add(ctx, &user) + if err != nil { + t.Fatalf("failed to add user: %v", err) + return + } + tt.fields.Users[i] = *u + } + + // Populate Sources + for i, source := range tt.fields.Sources { + s, err := boltdb.SourcesStore.Add(ctx, source) + if err != nil { + t.Fatalf("failed to add source: %v", err) + return + } + tt.fields.Sources[i] = s + } + + // Populate Servers + for i, server := range tt.fields.Servers { + s, err := boltdb.ServersStore.Add(ctx, server) + if err != nil { + t.Fatalf("failed to add server: %v", err) + return + } + tt.fields.Servers[i] = s + } + + // Populate Layouts + for i, layout := range tt.fields.Layouts { + l, err := boltdb.LayoutsStore.Add(ctx, layout) + if err != nil { + t.Fatalf("failed to add layout: %v", err) + return + } + tt.fields.Layouts[i] = l + } + + // Populate Dashboards + for i, dashboard := range tt.fields.Dashboards { + d, err := boltdb.DashboardsStore.Add(ctx, dashboard) + if err != nil { + t.Fatalf("failed to add dashboard: %v", err) + return + } + tt.fields.Dashboards[i] = d + } + + _ = boltdb.Close() + + go tt.args.server.Serve(ctx) + serverURL := fmt.Sprintf("http://%v:%v%v", host, port, tt.args.path) + + // Wait for the server to come online + timeout := time.Now().Add(100 * time.Millisecond) + for { + _, err := http.Get(serverURL + "/swagger.json") + if err == nil { + break + } + if time.Now().After(timeout) { + t.Fatalf("failed to start server") + return + } + } + + // Set the Expiry time on the principal + tt.args.principal.IssuedAt = time.Now() + tt.args.principal.ExpiresAt = time.Now().Add(10 * time.Second) + + // Construct HTTP Request + buf, _ := json.Marshal(tt.args.payload) + reqBody := ioutil.NopCloser(bytes.NewReader(buf)) + req, _ := http.NewRequest(tt.args.method, serverURL, reqBody) + token, _ := oauth2.NewJWT(tt.args.server.TokenSecret).Create(ctx, tt.args.principal) + req.AddCookie(&http.Cookie{ + Name: "session", + Value: string(token), + HttpOnly: true, + Path: "/", + }) + + // Make actual http request + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make httprequest: %v", err) + return + } + + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wants.statusCode { + t.Errorf( + "%s %s Status Code = %v, want %v", + tt.args.method, + tt.args.path, + resp.StatusCode, + tt.wants.statusCode, + ) + } + + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf( + "%s %s Content Type = %v, want %v", + tt.args.method, + tt.args.path, + content, + tt.wants.contentType, + ) + } + + if eq, err := jsonEqual(tt.wants.body, string(body)); err != nil || !eq { + t.Errorf( + "%s %s Body = %v, want %v", + tt.args.method, + tt.args.path, + string(body), + tt.wants.body, + ) + } + + tt.args.server.Listener.Close() + }) + } +} diff --git a/integrations/utils.go b/integrations/utils.go new file mode 100644 index 0000000000..2069c09595 --- /dev/null +++ b/integrations/utils.go @@ -0,0 +1,54 @@ +package integrations + +import ( + "encoding/json" + "io/ioutil" + "net/http/httptest" + "net/url" + "strconv" + "strings" + + "github.com/google/go-cmp/cmp" +) + +func hostAndPort() (string, int) { + s := httptest.NewServer(nil) + defer s.Close() + + u, err := url.Parse(s.URL) + if err != nil { + panic(err) + } + xs := strings.Split(u.Host, ":") + host := xs[0] + portStr := xs[1] + port, err := strconv.Atoi(portStr) + if err != nil { + panic(err) + } + return host, port + +} + +func newBoltFile() string { + f, err := ioutil.TempFile("", "chronograf-bolt-") + if err != nil { + panic(err) + } + f.Close() + + return f.Name() +} + +func jsonEqual(s1, s2 string) (eq bool, err error) { + var o1, o2 interface{} + + if err = json.Unmarshal([]byte(s1), &o1); err != nil { + return + } + if err = json.Unmarshal([]byte(s2), &o2); err != nil { + return + } + + return cmp.Equal(o1, o2), nil +} diff --git a/layouts/layouts.go b/layouts/layouts.go index 6ff575aafe..a9b21d8d8b 100644 --- a/layouts/layouts.go +++ b/layouts/layouts.go @@ -6,15 +6,15 @@ import ( "github.com/influxdata/chronograf" ) -// MultiLayoutStore is a Layoutstore that contains multiple LayoutStores +// MultiLayoutsStore is a LayoutsStore that contains multiple LayoutsStores // The All method will return the set of all Layouts. // Each method will be tried against the Stores slice serially. -type MultiLayoutStore struct { - Stores []chronograf.LayoutStore +type MultiLayoutsStore struct { + Stores []chronograf.LayoutsStore } // All returns the set of all layouts -func (s *MultiLayoutStore) All(ctx context.Context) ([]chronograf.Layout, error) { +func (s *MultiLayoutsStore) All(ctx context.Context) ([]chronograf.Layout, error) { all := []chronograf.Layout{} layoutSet := map[string]chronograf.Layout{} ok := false @@ -42,8 +42,8 @@ func (s *MultiLayoutStore) All(ctx context.Context) ([]chronograf.Layout, error) return all, nil } -// Add creates a new dashboard in the LayoutStore. Tries each store sequentially until success. -func (s *MultiLayoutStore) Add(ctx context.Context, layout chronograf.Layout) (chronograf.Layout, error) { +// Add creates a new dashboard in the LayoutsStore. Tries each store sequentially until success. +func (s *MultiLayoutsStore) Add(ctx context.Context, layout chronograf.Layout) (chronograf.Layout, error) { var err error for _, store := range s.Stores { var l chronograf.Layout @@ -57,7 +57,7 @@ func (s *MultiLayoutStore) Add(ctx context.Context, layout chronograf.Layout) (c // Delete the dashboard from the store. Searches through all stores to find Layout and // then deletes from that store. -func (s *MultiLayoutStore) Delete(ctx context.Context, layout chronograf.Layout) error { +func (s *MultiLayoutsStore) Delete(ctx context.Context, layout chronograf.Layout) error { var err error for _, store := range s.Stores { err = store.Delete(ctx, layout) @@ -69,7 +69,7 @@ func (s *MultiLayoutStore) Delete(ctx context.Context, layout chronograf.Layout) } // Get retrieves Layout if `ID` exists. Searches through each store sequentially until success. -func (s *MultiLayoutStore) Get(ctx context.Context, ID string) (chronograf.Layout, error) { +func (s *MultiLayoutsStore) Get(ctx context.Context, ID string) (chronograf.Layout, error) { var err error for _, store := range s.Stores { var l chronograf.Layout @@ -82,7 +82,7 @@ func (s *MultiLayoutStore) Get(ctx context.Context, ID string) (chronograf.Layou } // Update the dashboard in the store. Searches through each store sequentially until success. -func (s *MultiLayoutStore) Update(ctx context.Context, layout chronograf.Layout) error { +func (s *MultiLayoutsStore) Update(ctx context.Context, layout chronograf.Layout) error { var err error for _, store := range s.Stores { err = store.Update(ctx, layout) diff --git a/mocks/auth.go b/mocks/auth.go new file mode 100644 index 0000000000..3cadb520fd --- /dev/null +++ b/mocks/auth.go @@ -0,0 +1,51 @@ +package mocks + +import ( + "context" + "net/http" + + "github.com/influxdata/chronograf/oauth2" +) + +// Authenticator implements a OAuth2 authenticator +type Authenticator struct { + Principal oauth2.Principal + ValidateErr error + ExtendErr error + Serialized string +} + +// Validate returns Principal associated with authenticated and authorized +// entity if successful. +func (a *Authenticator) Validate(context.Context, *http.Request) (oauth2.Principal, error) { + return a.Principal, a.ValidateErr +} + +// Extend will extend the lifetime of a already validated Principal +func (a *Authenticator) Extend(ctx context.Context, w http.ResponseWriter, p oauth2.Principal) (oauth2.Principal, error) { + cookie := http.Cookie{} + + http.SetCookie(w, &cookie) + return a.Principal, a.ExtendErr +} + +// Authorize will grant privileges to a Principal +func (a *Authenticator) Authorize(ctx context.Context, w http.ResponseWriter, p oauth2.Principal) error { + cookie := http.Cookie{} + + http.SetCookie(w, &cookie) + return nil +} + +// Expire revokes privileges from a Principal +func (a *Authenticator) Expire(http.ResponseWriter) {} + +// ValidAuthorization returns the Principal +func (a *Authenticator) ValidAuthorization(ctx context.Context, serializedAuthorization string) (oauth2.Principal, error) { + return oauth2.Principal{}, nil +} + +// Serialize the serialized values stored on the Authenticator +func (a *Authenticator) Serialize(context.Context, oauth2.Principal) (string, error) { + return a.Serialized, nil +} diff --git a/mocks/layouts.go b/mocks/layouts.go index 13ca28de84..9a8eea319c 100644 --- a/mocks/layouts.go +++ b/mocks/layouts.go @@ -6,9 +6,9 @@ import ( "github.com/influxdata/chronograf" ) -var _ chronograf.LayoutStore = &LayoutStore{} +var _ chronograf.LayoutsStore = &LayoutsStore{} -type LayoutStore struct { +type LayoutsStore struct { AddF func(ctx context.Context, layout chronograf.Layout) (chronograf.Layout, error) AllF func(ctx context.Context) ([]chronograf.Layout, error) DeleteF func(ctx context.Context, layout chronograf.Layout) error @@ -16,22 +16,22 @@ type LayoutStore struct { UpdateF func(ctx context.Context, layout chronograf.Layout) error } -func (s *LayoutStore) Add(ctx context.Context, layout chronograf.Layout) (chronograf.Layout, error) { +func (s *LayoutsStore) Add(ctx context.Context, layout chronograf.Layout) (chronograf.Layout, error) { return s.AddF(ctx, layout) } -func (s *LayoutStore) All(ctx context.Context) ([]chronograf.Layout, error) { +func (s *LayoutsStore) All(ctx context.Context) ([]chronograf.Layout, error) { return s.AllF(ctx) } -func (s *LayoutStore) Delete(ctx context.Context, layout chronograf.Layout) error { +func (s *LayoutsStore) Delete(ctx context.Context, layout chronograf.Layout) error { return s.DeleteF(ctx, layout) } -func (s *LayoutStore) Get(ctx context.Context, id string) (chronograf.Layout, error) { +func (s *LayoutsStore) Get(ctx context.Context, id string) (chronograf.Layout, error) { return s.GetF(ctx, id) } -func (s *LayoutStore) Update(ctx context.Context, layout chronograf.Layout) error { +func (s *LayoutsStore) Update(ctx context.Context, layout chronograf.Layout) error { return s.UpdateF(ctx, layout) } diff --git a/mocks/organizations.go b/mocks/organizations.go new file mode 100644 index 0000000000..139185efb3 --- /dev/null +++ b/mocks/organizations.go @@ -0,0 +1,47 @@ +package mocks + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +var _ chronograf.OrganizationsStore = &OrganizationsStore{} + +type OrganizationsStore struct { + AllF func(context.Context) ([]chronograf.Organization, error) + AddF func(context.Context, *chronograf.Organization) (*chronograf.Organization, error) + DeleteF func(context.Context, *chronograf.Organization) error + GetF func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) + UpdateF func(context.Context, *chronograf.Organization) error + CreateDefaultF func(context.Context) error + DefaultOrganizationF func(context.Context) (*chronograf.Organization, error) +} + +func (s *OrganizationsStore) CreateDefault(ctx context.Context) error { + return s.CreateDefaultF(ctx) +} + +func (s *OrganizationsStore) DefaultOrganization(ctx context.Context) (*chronograf.Organization, error) { + return s.DefaultOrganizationF(ctx) +} + +func (s *OrganizationsStore) Add(ctx context.Context, o *chronograf.Organization) (*chronograf.Organization, error) { + return s.AddF(ctx, o) +} + +func (s *OrganizationsStore) All(ctx context.Context) ([]chronograf.Organization, error) { + return s.AllF(ctx) +} + +func (s *OrganizationsStore) Delete(ctx context.Context, o *chronograf.Organization) error { + return s.DeleteF(ctx, o) +} + +func (s *OrganizationsStore) Get(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return s.GetF(ctx, q) +} + +func (s *OrganizationsStore) Update(ctx context.Context, o *chronograf.Organization) error { + return s.UpdateF(ctx, o) +} diff --git a/mocks/store.go b/mocks/store.go new file mode 100644 index 0000000000..f207b87f1d --- /dev/null +++ b/mocks/store.go @@ -0,0 +1,41 @@ +package mocks + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +// Store is a server.DataStore +type Store struct { + SourcesStore chronograf.SourcesStore + ServersStore chronograf.ServersStore + LayoutsStore chronograf.LayoutsStore + UsersStore chronograf.UsersStore + DashboardsStore chronograf.DashboardsStore + OrganizationsStore chronograf.OrganizationsStore +} + +func (s *Store) Sources(ctx context.Context) chronograf.SourcesStore { + return s.SourcesStore +} + +func (s *Store) Servers(ctx context.Context) chronograf.ServersStore { + return s.ServersStore +} + +func (s *Store) Layouts(ctx context.Context) chronograf.LayoutsStore { + return s.LayoutsStore +} + +func (s *Store) Users(ctx context.Context) chronograf.UsersStore { + return s.UsersStore +} + +func (s *Store) Organizations(ctx context.Context) chronograf.OrganizationsStore { + return s.OrganizationsStore +} + +func (s *Store) Dashboards(ctx context.Context) chronograf.DashboardsStore { + return s.DashboardsStore +} diff --git a/mocks/users.go b/mocks/users.go index 78071307f8..581a7498ba 100644 --- a/mocks/users.go +++ b/mocks/users.go @@ -13,8 +13,9 @@ type UsersStore struct { AllF func(context.Context) ([]chronograf.User, error) AddF func(context.Context, *chronograf.User) (*chronograf.User, error) DeleteF func(context.Context, *chronograf.User) error - GetF func(ctx context.Context, name string) (*chronograf.User, error) + GetF func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) UpdateF func(context.Context, *chronograf.User) error + NumF func(context.Context) (int, error) } // All lists all users from the UsersStore @@ -22,6 +23,11 @@ func (s *UsersStore) All(ctx context.Context) ([]chronograf.User, error) { return s.AllF(ctx) } +// Num returns the number of users in the UsersStore +func (s *UsersStore) Num(ctx context.Context) (int, error) { + return s.NumF(ctx) +} + // Add a new User in the UsersStore func (s *UsersStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { return s.AddF(ctx, u) @@ -33,8 +39,8 @@ func (s *UsersStore) Delete(ctx context.Context, u *chronograf.User) error { } // Get retrieves a user if name exists. -func (s *UsersStore) Get(ctx context.Context, name string) (*chronograf.User, error) { - return s.GetF(ctx, name) +func (s *UsersStore) Get(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return s.GetF(ctx, q) } // Update the user's permissions or roles diff --git a/noop/dashboards.go b/noop/dashboards.go new file mode 100644 index 0000000000..2be150a43c --- /dev/null +++ b/noop/dashboards.go @@ -0,0 +1,33 @@ +package noop + +import ( + "context" + "fmt" + + "github.com/influxdata/chronograf" +) + +// ensure DashboardsStore implements chronograf.DashboardsStore +var _ chronograf.DashboardsStore = &DashboardsStore{} + +type DashboardsStore struct{} + +func (s *DashboardsStore) All(context.Context) ([]chronograf.Dashboard, error) { + return nil, fmt.Errorf("no dashboards found") +} + +func (s *DashboardsStore) Add(context.Context, chronograf.Dashboard) (chronograf.Dashboard, error) { + return chronograf.Dashboard{}, fmt.Errorf("failed to add dashboard") +} + +func (s *DashboardsStore) Delete(context.Context, chronograf.Dashboard) error { + return fmt.Errorf("failed to delete dashboard") +} + +func (s *DashboardsStore) Get(ctx context.Context, ID chronograf.DashboardID) (chronograf.Dashboard, error) { + return chronograf.Dashboard{}, chronograf.ErrDashboardNotFound +} + +func (s *DashboardsStore) Update(context.Context, chronograf.Dashboard) error { + return fmt.Errorf("failed to update dashboard") +} diff --git a/noop/layouts.go b/noop/layouts.go new file mode 100644 index 0000000000..aab8554e5d --- /dev/null +++ b/noop/layouts.go @@ -0,0 +1,33 @@ +package noop + +import ( + "context" + "fmt" + + "github.com/influxdata/chronograf" +) + +// ensure LayoutsStore implements chronograf.LayoutsStore +var _ chronograf.LayoutsStore = &LayoutsStore{} + +type LayoutsStore struct{} + +func (s *LayoutsStore) All(context.Context) ([]chronograf.Layout, error) { + return nil, fmt.Errorf("no layouts found") +} + +func (s *LayoutsStore) Add(context.Context, chronograf.Layout) (chronograf.Layout, error) { + return chronograf.Layout{}, fmt.Errorf("failed to add layout") +} + +func (s *LayoutsStore) Delete(context.Context, chronograf.Layout) error { + return fmt.Errorf("failed to delete layout") +} + +func (s *LayoutsStore) Get(ctx context.Context, ID string) (chronograf.Layout, error) { + return chronograf.Layout{}, chronograf.ErrLayoutNotFound +} + +func (s *LayoutsStore) Update(context.Context, chronograf.Layout) error { + return fmt.Errorf("failed to update layout") +} diff --git a/noop/organizations.go b/noop/organizations.go new file mode 100644 index 0000000000..75dfc15a13 --- /dev/null +++ b/noop/organizations.go @@ -0,0 +1,41 @@ +package noop + +import ( + "context" + "fmt" + + "github.com/influxdata/chronograf" +) + +// ensure OrganizationsStore implements chronograf.OrganizationsStore +var _ chronograf.OrganizationsStore = &OrganizationsStore{} + +type OrganizationsStore struct{} + +func (s *OrganizationsStore) CreateDefault(context.Context) error { + return fmt.Errorf("failed to add organization") +} + +func (s *OrganizationsStore) DefaultOrganization(context.Context) (*chronograf.Organization, error) { + return nil, fmt.Errorf("failed to retrieve default organization") +} + +func (s *OrganizationsStore) All(context.Context) ([]chronograf.Organization, error) { + return nil, fmt.Errorf("no organizations found") +} + +func (s *OrganizationsStore) Add(context.Context, *chronograf.Organization) (*chronograf.Organization, error) { + return nil, fmt.Errorf("failed to add organization") +} + +func (s *OrganizationsStore) Delete(context.Context, *chronograf.Organization) error { + return fmt.Errorf("failed to delete organization") +} + +func (s *OrganizationsStore) Get(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return nil, chronograf.ErrOrganizationNotFound +} + +func (s *OrganizationsStore) Update(context.Context, *chronograf.Organization) error { + return fmt.Errorf("failed to update organization") +} diff --git a/noop/servers.go b/noop/servers.go new file mode 100644 index 0000000000..d06c9705ac --- /dev/null +++ b/noop/servers.go @@ -0,0 +1,33 @@ +package noop + +import ( + "context" + "fmt" + + "github.com/influxdata/chronograf" +) + +// ensure ServersStore implements chronograf.ServersStore +var _ chronograf.ServersStore = &ServersStore{} + +type ServersStore struct{} + +func (s *ServersStore) All(context.Context) ([]chronograf.Server, error) { + return nil, fmt.Errorf("no servers found") +} + +func (s *ServersStore) Add(context.Context, chronograf.Server) (chronograf.Server, error) { + return chronograf.Server{}, fmt.Errorf("failed to add server") +} + +func (s *ServersStore) Delete(context.Context, chronograf.Server) error { + return fmt.Errorf("failed to delete server") +} + +func (s *ServersStore) Get(ctx context.Context, ID int) (chronograf.Server, error) { + return chronograf.Server{}, chronograf.ErrServerNotFound +} + +func (s *ServersStore) Update(context.Context, chronograf.Server) error { + return fmt.Errorf("failed to update server") +} diff --git a/noop/sources.go b/noop/sources.go new file mode 100644 index 0000000000..fb762b46ef --- /dev/null +++ b/noop/sources.go @@ -0,0 +1,33 @@ +package noop + +import ( + "context" + "fmt" + + "github.com/influxdata/chronograf" +) + +// ensure SourcesStore implements chronograf.SourcesStore +var _ chronograf.SourcesStore = &SourcesStore{} + +type SourcesStore struct{} + +func (s *SourcesStore) All(context.Context) ([]chronograf.Source, error) { + return nil, fmt.Errorf("no sources found") +} + +func (s *SourcesStore) Add(context.Context, chronograf.Source) (chronograf.Source, error) { + return chronograf.Source{}, fmt.Errorf("failed to add source") +} + +func (s *SourcesStore) Delete(context.Context, chronograf.Source) error { + return fmt.Errorf("failed to delete source") +} + +func (s *SourcesStore) Get(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{}, chronograf.ErrSourceNotFound +} + +func (s *SourcesStore) Update(context.Context, chronograf.Source) error { + return fmt.Errorf("failed to update source") +} diff --git a/noop/users.go b/noop/users.go new file mode 100644 index 0000000000..2d650c8d64 --- /dev/null +++ b/noop/users.go @@ -0,0 +1,37 @@ +package noop + +import ( + "context" + "fmt" + + "github.com/influxdata/chronograf" +) + +// ensure UsersStore implements chronograf.UsersStore +var _ chronograf.UsersStore = &UsersStore{} + +type UsersStore struct{} + +func (s *UsersStore) All(context.Context) ([]chronograf.User, error) { + return nil, fmt.Errorf("no users found") +} + +func (s *UsersStore) Add(context.Context, *chronograf.User) (*chronograf.User, error) { + return nil, fmt.Errorf("failed to add user") +} + +func (s *UsersStore) Delete(context.Context, *chronograf.User) error { + return fmt.Errorf("failed to delete user") +} + +func (s *UsersStore) Get(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return nil, chronograf.ErrUserNotFound +} + +func (s *UsersStore) Update(context.Context, *chronograf.User) error { + return fmt.Errorf("failed to update user") +} + +func (s *UsersStore) Num(context.Context) (int, error) { + return 0, fmt.Errorf("failed to get number of users") +} diff --git a/oauth2/jwt.go b/oauth2/jwt.go index b3911b422f..794f44a23c 100644 --- a/oauth2/jwt.go +++ b/oauth2/jwt.go @@ -31,6 +31,9 @@ var _ gojwt.Claims = &Claims{} // Claims extends jwt.StandardClaims' Valid to make sure claims has a subject. type Claims struct { gojwt.StandardClaims + // We were unable to find a standard claim at https://www.iana.org/assignments/jwt/jwt.xhtmldd + // that felt appropriate for Organization. As a result, we added a custom `org` field. + Organization string `json:"org,omitempty"` } // Valid adds an empty subject test to the StandardClaims checks. @@ -93,10 +96,11 @@ func (j *JWT) ValidClaims(jwtToken Token, lifespan time.Duration, alg gojwt.Keyf } return Principal{ - Subject: claims.Subject, - Issuer: claims.Issuer, - ExpiresAt: exp, - IssuedAt: iat, + Subject: claims.Subject, + Issuer: claims.Issuer, + Organization: claims.Organization, + ExpiresAt: exp, + IssuedAt: iat, }, nil } @@ -105,13 +109,14 @@ func (j *JWT) Create(ctx context.Context, user Principal) (Token, error) { // Create a new token object, specifying signing method and the claims // you would like it to contain. claims := &Claims{ - gojwt.StandardClaims{ + StandardClaims: gojwt.StandardClaims{ Subject: user.Subject, Issuer: user.Issuer, ExpiresAt: user.ExpiresAt.Unix(), IssuedAt: user.IssuedAt.Unix(), NotBefore: user.IssuedAt.Unix(), }, + Organization: user.Organization, } token := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims) // Sign and get the complete encoded token as a string using the secret diff --git a/oauth2/jwt_test.go b/oauth2/jwt_test.go index d339dcfa94..d8ba1f1eae 100644 --- a/oauth2/jwt_test.go +++ b/oauth2/jwt_test.go @@ -13,8 +13,9 @@ import ( func TestAuthenticate(t *testing.T) { history := time.Unix(-446774400, 0) var tests = []struct { - Desc string - Secret string + Desc string + Secret string + // JWT tokens were generated at https://jwt.io/ using their Debugger Token oauth2.Token Duration time.Duration Principal oauth2.Principal @@ -40,6 +41,18 @@ func TestAuthenticate(t *testing.T) { IssuedAt: history, }, }, + { + Desc: "Test valid jwt token with organization", + Secret: "secret", + Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsIm9yZyI6IjEzMzciLCJpYXQiOi00NDY3NzQ0MDAsImV4cCI6LTQ0Njc3NDM5OSwibmJmIjotNDQ2Nzc0NDAwfQ.b38MK5liimWsvvJr4a3GNYRDJOAN7WCrfZ0FfZftqjc", + Duration: time.Second, + Principal: oauth2.Principal{ + Subject: "/chronograf/v1/users/1", + Organization: "1337", + ExpiresAt: history.Add(time.Second), + IssuedAt: history, + }, + }, { Desc: "Test expired jwt token", Secret: "secret", diff --git a/oauth2/oauth2.go b/oauth2/oauth2.go index 58668485e2..ef0c44b51c 100644 --- a/oauth2/oauth2.go +++ b/oauth2/oauth2.go @@ -30,10 +30,11 @@ var ( // Principal is any entity that can be authenticated type Principal struct { - Subject string - Issuer string - ExpiresAt time.Time - IssuedAt time.Time + Subject string + Issuer string + Organization string + ExpiresAt time.Time + IssuedAt time.Time } /* Interfaces */ diff --git a/organizations/dashboards.go b/organizations/dashboards.go new file mode 100644 index 0000000000..63aea0ec4d --- /dev/null +++ b/organizations/dashboards.go @@ -0,0 +1,112 @@ +package organizations + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +// ensure that DashboardsStore implements chronograf.DashboardStore +var _ chronograf.DashboardsStore = &DashboardsStore{} + +// DashboardsStore facade on a DashboardStore that filters dashboards +// by organization. +type DashboardsStore struct { + store chronograf.DashboardsStore + organization string +} + +// NewDashboardsStore creates a new DashboardsStore from an existing +// chronograf.DashboardStore and an organization string +func NewDashboardsStore(s chronograf.DashboardsStore, org string) *DashboardsStore { + return &DashboardsStore{ + store: s, + organization: org, + } +} + +// All retrieves all dashboards from the underlying DashboardStore and filters them +// by organization. +func (s *DashboardsStore) All(ctx context.Context) ([]chronograf.Dashboard, error) { + err := validOrganization(ctx) + if err != nil { + return nil, err + } + + ds, err := s.store.All(ctx) + if err != nil { + return nil, err + } + + // This filters dashboards without allocating + // https://github.com/golang/go/wiki/SliceTricks#filtering-without-allocating + dashboards := ds[:0] + for _, d := range ds { + if d.Organization == s.organization { + dashboards = append(dashboards, d) + } + } + + return dashboards, nil +} + +// Add creates a new Dashboard in the DashboardsStore with dashboard.Organization set to be the +// organization from the dashboard store. +func (s *DashboardsStore) Add(ctx context.Context, d chronograf.Dashboard) (chronograf.Dashboard, error) { + err := validOrganization(ctx) + if err != nil { + return chronograf.Dashboard{}, err + } + + d.Organization = s.organization + return s.store.Add(ctx, d) +} + +// Delete the dashboard from DashboardsStore +func (s *DashboardsStore) Delete(ctx context.Context, d chronograf.Dashboard) error { + err := validOrganization(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 Dashboard if the id exists and belongs to the organization that is set. +func (s *DashboardsStore) Get(ctx context.Context, id chronograf.DashboardID) (chronograf.Dashboard, error) { + err := validOrganization(ctx) + if err != nil { + return chronograf.Dashboard{}, err + } + + d, err := s.store.Get(ctx, id) + if err != nil { + return chronograf.Dashboard{}, err + } + + if d.Organization != s.organization { + return chronograf.Dashboard{}, chronograf.ErrDashboardNotFound + } + + return d, nil +} + +// Update the dashboard in DashboardsStore. +func (s *DashboardsStore) Update(ctx context.Context, d chronograf.Dashboard) error { + err := validOrganization(ctx) + if err != nil { + return err + } + + _, err = s.store.Get(ctx, d.ID) + if err != nil { + return err + } + + return s.store.Update(ctx, d) +} diff --git a/organizations/dashboards_test.go b/organizations/dashboards_test.go new file mode 100644 index 0000000000..ba2dcaedb0 --- /dev/null +++ b/organizations/dashboards_test.go @@ -0,0 +1,339 @@ +package organizations_test + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/mocks" + "github.com/influxdata/chronograf/organizations" +) + +// IgnoreFields is used because ID cannot be predicted reliably +// EquateEmpty is used because we want nil slices, arrays, and maps to be equal to the empty map +var dashboardCmpOptions = cmp.Options{ + cmpopts.EquateEmpty(), + cmpopts.IgnoreFields(chronograf.Dashboard{}, "ID"), +} + +func TestDashboards_All(t *testing.T) { + type fields struct { + DashboardsStore chronograf.DashboardsStore + } + type args struct { + organization string + ctx context.Context + } + tests := []struct { + name string + args args + fields fields + want []chronograf.Dashboard + wantRaw []chronograf.Dashboard + wantErr bool + }{ + { + name: "No Dashboards", + fields: fields{ + DashboardsStore: &mocks.DashboardsStore{ + AllF: func(ctx context.Context) ([]chronograf.Dashboard, error) { + return nil, fmt.Errorf("No Dashboards") + }, + }, + }, + wantErr: true, + }, + { + name: "All Dashboards", + fields: fields{ + DashboardsStore: &mocks.DashboardsStore{ + AllF: func(ctx context.Context) ([]chronograf.Dashboard, error) { + return []chronograf.Dashboard{ + { + Name: "howdy", + Organization: "1337", + }, + { + Name: "doody", + Organization: "1338", + }, + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + }, + want: []chronograf.Dashboard{ + { + Name: "howdy", + Organization: "1337", + }, + }, + }, + } + for _, tt := range tests { + s := organizations.NewDashboardsStore(tt.fields.DashboardsStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + gots, err := s.All(tt.args.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("%q. DashboardsStore.All() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + for i, got := range gots { + if diff := cmp.Diff(got, tt.want[i], dashboardCmpOptions...); diff != "" { + t.Errorf("%q. DashboardsStore.All():\n-got/+want\ndiff %s", tt.name, diff) + } + } + } +} + +func TestDashboards_Add(t *testing.T) { + type fields struct { + DashboardsStore chronograf.DashboardsStore + } + type args struct { + organization string + ctx context.Context + dashboard chronograf.Dashboard + } + tests := []struct { + name string + args args + fields fields + want chronograf.Dashboard + wantErr bool + }{ + { + name: "Add Dashboard", + fields: fields{ + DashboardsStore: &mocks.DashboardsStore{ + AddF: func(ctx context.Context, s chronograf.Dashboard) (chronograf.Dashboard, error) { + return s, nil + }, + GetF: func(ctx context.Context, id chronograf.DashboardID) (chronograf.Dashboard, error) { + return chronograf.Dashboard{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + dashboard: chronograf.Dashboard{ + ID: 1229, + Name: "howdy", + }, + }, + want: chronograf.Dashboard{ + Name: "howdy", + Organization: "1337", + }, + }, + } + for _, tt := range tests { + s := organizations.NewDashboardsStore(tt.fields.DashboardsStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + d, err := s.Add(tt.args.ctx, tt.args.dashboard) + if (err != nil) != tt.wantErr { + t.Errorf("%q. DashboardsStore.Add() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + got, err := s.Get(tt.args.ctx, d.ID) + if diff := cmp.Diff(got, tt.want, dashboardCmpOptions...); diff != "" { + t.Errorf("%q. DashboardsStore.Add():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} + +func TestDashboards_Delete(t *testing.T) { + type fields struct { + DashboardsStore chronograf.DashboardsStore + } + type args struct { + organization string + ctx context.Context + dashboard chronograf.Dashboard + } + tests := []struct { + name string + fields fields + args args + want []chronograf.Dashboard + addFirst bool + wantErr bool + }{ + { + name: "Delete dashboard", + fields: fields{ + DashboardsStore: &mocks.DashboardsStore{ + DeleteF: func(ctx context.Context, s chronograf.Dashboard) error { + return nil + }, + GetF: func(ctx context.Context, id chronograf.DashboardID) (chronograf.Dashboard, error) { + return chronograf.Dashboard{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + dashboard: chronograf.Dashboard{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, + }, + addFirst: true, + }, + } + for _, tt := range tests { + s := organizations.NewDashboardsStore(tt.fields.DashboardsStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + err := s.Delete(tt.args.ctx, tt.args.dashboard) + if (err != nil) != tt.wantErr { + t.Errorf("%q. DashboardsStore.All() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + } +} + +func TestDashboards_Get(t *testing.T) { + type fields struct { + DashboardsStore chronograf.DashboardsStore + } + type args struct { + organization string + ctx context.Context + dashboard chronograf.Dashboard + } + tests := []struct { + name string + fields fields + args args + want chronograf.Dashboard + addFirst bool + wantErr bool + }{ + { + name: "Get Dashboard", + fields: fields{ + DashboardsStore: &mocks.DashboardsStore{ + GetF: func(ctx context.Context, id chronograf.DashboardID) (chronograf.Dashboard, error) { + return chronograf.Dashboard{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + dashboard: chronograf.Dashboard{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, + }, + want: chronograf.Dashboard{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, + }, + } + for _, tt := range tests { + s := organizations.NewDashboardsStore(tt.fields.DashboardsStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + got, err := s.Get(tt.args.ctx, tt.args.dashboard.ID) + if (err != nil) != tt.wantErr { + t.Errorf("%q. DashboardsStore.Get() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if diff := cmp.Diff(got, tt.want, dashboardCmpOptions...); diff != "" { + t.Errorf("%q. DashboardsStore.Get():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} + +func TestDashboards_Update(t *testing.T) { + type fields struct { + DashboardsStore chronograf.DashboardsStore + } + type args struct { + organization string + ctx context.Context + dashboard chronograf.Dashboard + name string + } + tests := []struct { + name string + fields fields + args args + want chronograf.Dashboard + addFirst bool + wantErr bool + }{ + { + name: "Update Dashboard Name", + fields: fields{ + DashboardsStore: &mocks.DashboardsStore{ + UpdateF: func(ctx context.Context, s chronograf.Dashboard) error { + return nil + }, + GetF: func(ctx context.Context, id chronograf.DashboardID) (chronograf.Dashboard, error) { + return chronograf.Dashboard{ + ID: 1229, + Name: "doody", + Organization: "1337", + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + dashboard: chronograf.Dashboard{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, + name: "doody", + }, + want: chronograf.Dashboard{ + Name: "doody", + Organization: "1337", + }, + addFirst: true, + }, + } + for _, tt := range tests { + if tt.args.name != "" { + tt.args.dashboard.Name = tt.args.name + } + s := organizations.NewDashboardsStore(tt.fields.DashboardsStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + err := s.Update(tt.args.ctx, tt.args.dashboard) + if (err != nil) != tt.wantErr { + t.Errorf("%q. DashboardsStore.Update() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + got, err := s.Get(tt.args.ctx, tt.args.dashboard.ID) + if diff := cmp.Diff(got, tt.want, dashboardCmpOptions...); diff != "" { + t.Errorf("%q. DashboardsStore.Update():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} diff --git a/organizations/organizations.go b/organizations/organizations.go new file mode 100644 index 0000000000..6f9136b30e --- /dev/null +++ b/organizations/organizations.go @@ -0,0 +1,158 @@ +package organizations + +import ( + "context" + "fmt" + + "github.com/influxdata/chronograf" +) + +type contextKey string + +// ContextKey is the key used to specify the +// organization via context +const ContextKey = contextKey("organization") + +func validOrganization(ctx context.Context) error { + // prevents panic in case of nil context + if ctx == nil { + return fmt.Errorf("expect non nil context") + } + orgID, ok := ctx.Value(ContextKey).(string) + // should never happen + if !ok { + return fmt.Errorf("expected organization key to be a string") + } + if orgID == "" { + return fmt.Errorf("expected organization key to be set") + } + return nil +} + +// ensure that OrganizationsStore implements chronograf.OrganizationStore +var _ chronograf.OrganizationsStore = &OrganizationsStore{} + +// OrganizationsStore facade on a OrganizationStore that filters organizations +// by organization. +type OrganizationsStore struct { + store chronograf.OrganizationsStore + organization string +} + +// NewOrganizationsStore creates a new OrganizationsStore from an existing +// chronograf.OrganizationStore and an organization string +func NewOrganizationsStore(s chronograf.OrganizationsStore, org string) *OrganizationsStore { + return &OrganizationsStore{ + store: s, + organization: org, + } +} + +// All retrieves all organizations from the underlying OrganizationStore and filters them +// by organization. +func (s *OrganizationsStore) All(ctx context.Context) ([]chronograf.Organization, error) { + err := validOrganization(ctx) + if err != nil { + return nil, err + } + + ds, err := s.store.All(ctx) + if err != nil { + return nil, err + } + + defaultOrg, err := s.store.DefaultOrganization(ctx) + if err != nil { + return nil, err + } + + defaultOrgID := fmt.Sprintf("%d", defaultOrg.ID) + + // This filters organizations without allocating + // https://github.com/golang/go/wiki/SliceTricks#filtering-without-allocating + organizations := ds[:0] + for _, d := range ds { + id := fmt.Sprintf("%d", d.ID) + switch id { + case s.organization, defaultOrgID: + organizations = append(organizations, d) + default: + continue + } + } + + return organizations, nil +} + +// Add creates a new Organization in the OrganizationsStore with organization.Organization set to be the +// organization from the organization store. +func (s *OrganizationsStore) Add(ctx context.Context, o *chronograf.Organization) (*chronograf.Organization, error) { + return nil, fmt.Errorf("cannot create organization") +} + +// Delete the organization from OrganizationsStore +func (s *OrganizationsStore) Delete(ctx context.Context, o *chronograf.Organization) error { + err := validOrganization(ctx) + if err != nil { + return err + } + + o, err = s.store.Get(ctx, chronograf.OrganizationQuery{ID: &o.ID}) + if err != nil { + return err + } + + return s.store.Delete(ctx, o) +} + +// Get returns a Organization if the id exists and belongs to the organization that is set. +func (s *OrganizationsStore) Get(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + err := validOrganization(ctx) + if err != nil { + return nil, err + } + + d, err := s.store.Get(ctx, q) + if err != nil { + return nil, err + } + + if fmt.Sprintf("%d", d.ID) != s.organization { + return nil, chronograf.ErrOrganizationNotFound + } + + return d, nil +} + +// Update the organization in OrganizationsStore. +func (s *OrganizationsStore) Update(ctx context.Context, o *chronograf.Organization) error { + err := validOrganization(ctx) + if err != nil { + return err + } + + _, err = s.store.Get(ctx, chronograf.OrganizationQuery{ID: &o.ID}) + if err != nil { + return err + } + + return s.store.Update(ctx, o) +} + +func (s *OrganizationsStore) CreateDefault(ctx context.Context) error { + err := validOrganization(ctx) + if err != nil { + return err + } + + return s.store.CreateDefault(ctx) +} + +func (s *OrganizationsStore) DefaultOrganization(ctx context.Context) (*chronograf.Organization, error) { + err := validOrganization(ctx) + if err != nil { + return nil, err + } + + return s.store.DefaultOrganization(ctx) +} diff --git a/organizations/organizations_test.go b/organizations/organizations_test.go new file mode 100644 index 0000000000..e95e9ce27d --- /dev/null +++ b/organizations/organizations_test.go @@ -0,0 +1,345 @@ +package organizations_test + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/mocks" + "github.com/influxdata/chronograf/organizations" +) + +// IgnoreFields is used because ID cannot be predicted reliably +// EquateEmpty is used because we want nil slices, arrays, and maps to be equal to the empty map +var organizationCmpOptions = cmp.Options{ + cmpopts.EquateEmpty(), + cmpopts.IgnoreFields(chronograf.Organization{}, "ID"), +} + +func TestOrganizations_All(t *testing.T) { + type fields struct { + OrganizationsStore chronograf.OrganizationsStore + } + type args struct { + organization string + ctx context.Context + } + tests := []struct { + name string + args args + fields fields + want []chronograf.Organization + wantRaw []chronograf.Organization + wantErr bool + }{ + { + name: "No Organizations", + fields: fields{ + OrganizationsStore: &mocks.OrganizationsStore{ + AllF: func(ctx context.Context) ([]chronograf.Organization, error) { + return nil, fmt.Errorf("No Organizations") + }, + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "Default", + }, nil + }, + }, + }, + wantErr: true, + }, + { + name: "All Organizations", + fields: fields{ + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "Default", + }, nil + }, + AllF: func(ctx context.Context) ([]chronograf.Organization, error) { + return []chronograf.Organization{ + { + Name: "howdy", + ID: 1337, + }, + { + Name: "doody", + ID: 1447, + }, + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + }, + want: []chronograf.Organization{ + { + Name: "howdy", + ID: 1337, + }, + { + Name: "Default", + ID: 0, + }, + }, + }, + } + for _, tt := range tests { + s := organizations.NewOrganizationsStore(tt.fields.OrganizationsStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + gots, err := s.All(tt.args.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("%q. OrganizationsStore.All() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + for i, got := range gots { + if diff := cmp.Diff(got, tt.want[i], organizationCmpOptions...); diff != "" { + t.Errorf("%q. OrganizationsStore.All():\n-got/+want\ndiff %s", tt.name, diff) + } + } + } +} + +func TestOrganizations_Add(t *testing.T) { + type fields struct { + OrganizationsStore chronograf.OrganizationsStore + } + type args struct { + organizationID string + ctx context.Context + organization *chronograf.Organization + } + tests := []struct { + name string + args args + fields fields + want *chronograf.Organization + wantErr bool + }{ + { + name: "Add Organization", + fields: fields{ + OrganizationsStore: &mocks.OrganizationsStore{ + AddF: func(ctx context.Context, s *chronograf.Organization) (*chronograf.Organization, error) { + return s, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 1229, + Name: "howdy", + }, nil + }, + }, + }, + args: args{ + organizationID: "1229", + ctx: context.Background(), + organization: &chronograf.Organization{ + Name: "howdy", + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + s := organizations.NewOrganizationsStore(tt.fields.OrganizationsStore, tt.args.organizationID) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organizationID) + d, err := s.Add(tt.args.ctx, tt.args.organization) + if (err != nil) != tt.wantErr { + t.Errorf("%q. OrganizationsStore.Add() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if tt.wantErr { + continue + } + got, err := s.Get(tt.args.ctx, chronograf.OrganizationQuery{ID: &d.ID}) + if diff := cmp.Diff(got, tt.want, organizationCmpOptions...); diff != "" { + t.Errorf("%q. OrganizationsStore.Add():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} + +func TestOrganizations_Delete(t *testing.T) { + type fields struct { + OrganizationsStore chronograf.OrganizationsStore + } + type args struct { + organizationID string + ctx context.Context + organization *chronograf.Organization + } + tests := []struct { + name string + fields fields + args args + want []chronograf.Organization + addFirst bool + wantErr bool + }{ + { + name: "Delete organization", + fields: fields{ + OrganizationsStore: &mocks.OrganizationsStore{ + DeleteF: func(ctx context.Context, s *chronograf.Organization) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 1229, + Name: "howdy", + }, nil + }, + }, + }, + args: args{ + organizationID: "1229", + ctx: context.Background(), + organization: &chronograf.Organization{ + ID: 1229, + Name: "howdy", + }, + }, + addFirst: true, + }, + } + for _, tt := range tests { + s := organizations.NewOrganizationsStore(tt.fields.OrganizationsStore, tt.args.organizationID) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organizationID) + err := s.Delete(tt.args.ctx, tt.args.organization) + if (err != nil) != tt.wantErr { + t.Errorf("%q. OrganizationsStore.All() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + } +} + +func TestOrganizations_Get(t *testing.T) { + type fields struct { + OrganizationsStore chronograf.OrganizationsStore + } + type args struct { + organizationID string + ctx context.Context + organization *chronograf.Organization + } + tests := []struct { + name string + fields fields + args args + want *chronograf.Organization + addFirst bool + wantErr bool + }{ + { + name: "Get Organization", + fields: fields{ + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 1337, + Name: "howdy", + }, nil + }, + }, + }, + args: args{ + organizationID: "1337", + ctx: context.Background(), + organization: &chronograf.Organization{ + ID: 1337, + Name: "howdy", + }, + }, + want: &chronograf.Organization{ + ID: 1337, + Name: "howdy", + }, + }, + } + for _, tt := range tests { + s := organizations.NewOrganizationsStore(tt.fields.OrganizationsStore, tt.args.organizationID) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organizationID) + got, err := s.Get(tt.args.ctx, chronograf.OrganizationQuery{ID: &tt.args.organization.ID}) + if (err != nil) != tt.wantErr { + t.Errorf("%q. OrganizationsStore.Get() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if diff := cmp.Diff(got, tt.want, organizationCmpOptions...); diff != "" { + t.Errorf("%q. OrganizationsStore.Get():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} + +func TestOrganizations_Update(t *testing.T) { + type fields struct { + OrganizationsStore chronograf.OrganizationsStore + } + type args struct { + organizationID string + ctx context.Context + organization *chronograf.Organization + name string + } + tests := []struct { + name string + fields fields + args args + want *chronograf.Organization + addFirst bool + wantErr bool + }{ + { + name: "Update Organization Name", + fields: fields{ + OrganizationsStore: &mocks.OrganizationsStore{ + UpdateF: func(ctx context.Context, s *chronograf.Organization) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 1229, + Name: "doody", + }, nil + }, + }, + }, + args: args{ + organizationID: "1229", + ctx: context.Background(), + organization: &chronograf.Organization{ + ID: 1229, + Name: "howdy", + }, + name: "doody", + }, + want: &chronograf.Organization{ + Name: "doody", + }, + addFirst: true, + }, + } + for _, tt := range tests { + if tt.args.name != "" { + tt.args.organization.Name = tt.args.name + } + s := organizations.NewOrganizationsStore(tt.fields.OrganizationsStore, tt.args.organizationID) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organizationID) + err := s.Update(tt.args.ctx, tt.args.organization) + if (err != nil) != tt.wantErr { + t.Errorf("%q. OrganizationsStore.Update() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + got, err := s.Get(tt.args.ctx, chronograf.OrganizationQuery{ID: &tt.args.organization.ID}) + if diff := cmp.Diff(got, tt.want, organizationCmpOptions...); diff != "" { + t.Errorf("%q. OrganizationsStore.Update():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} diff --git a/organizations/servers.go b/organizations/servers.go new file mode 100644 index 0000000000..18767ada2a --- /dev/null +++ b/organizations/servers.go @@ -0,0 +1,111 @@ +package organizations + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +// ensure that ServersStore implements chronograf.ServerStore +var _ chronograf.ServersStore = &ServersStore{} + +// ServersStore facade on a ServerStore that filters servers +// by organization. +type ServersStore struct { + store chronograf.ServersStore + organization string +} + +// NewServersStore creates a new ServersStore from an existing +// chronograf.ServerStore and an organization string +func NewServersStore(s chronograf.ServersStore, org string) *ServersStore { + return &ServersStore{ + store: s, + organization: org, + } +} + +// All retrieves all servers from the underlying ServerStore and filters them +// by organization. +func (s *ServersStore) All(ctx context.Context) ([]chronograf.Server, error) { + err := validOrganization(ctx) + if err != nil { + return nil, err + } + ds, err := s.store.All(ctx) + if err != nil { + return nil, err + } + + // This filters servers without allocating + // https://github.com/golang/go/wiki/SliceTricks#filtering-without-allocating + servers := ds[:0] + for _, d := range ds { + if d.Organization == s.organization { + servers = append(servers, d) + } + } + + return servers, nil +} + +// Add creates a new Server in the ServersStore with server.Organization set to be the +// organization from the server store. +func (s *ServersStore) Add(ctx context.Context, d chronograf.Server) (chronograf.Server, error) { + err := validOrganization(ctx) + if err != nil { + return chronograf.Server{}, err + } + + d.Organization = s.organization + return s.store.Add(ctx, d) +} + +// Delete the server from ServersStore +func (s *ServersStore) Delete(ctx context.Context, d chronograf.Server) error { + err := validOrganization(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 Server if the id exists and belongs to the organization that is set. +func (s *ServersStore) Get(ctx context.Context, id int) (chronograf.Server, error) { + err := validOrganization(ctx) + if err != nil { + return chronograf.Server{}, err + } + + d, err := s.store.Get(ctx, id) + if err != nil { + return chronograf.Server{}, err + } + + if d.Organization != s.organization { + return chronograf.Server{}, chronograf.ErrServerNotFound + } + + return d, nil +} + +// Update the server in ServersStore. +func (s *ServersStore) Update(ctx context.Context, d chronograf.Server) error { + err := validOrganization(ctx) + if err != nil { + return err + } + + _, err = s.store.Get(ctx, d.ID) + if err != nil { + return err + } + + return s.store.Update(ctx, d) +} diff --git a/organizations/servers_test.go b/organizations/servers_test.go new file mode 100644 index 0000000000..dbc4daae19 --- /dev/null +++ b/organizations/servers_test.go @@ -0,0 +1,340 @@ +package organizations_test + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/mocks" + "github.com/influxdata/chronograf/organizations" +) + +// IgnoreFields is used because ID cannot be predicted reliably +// EquateEmpty is used because we want nil slices, arrays, and maps to be equal to the empty map +var serverCmpOptions = cmp.Options{ + cmpopts.EquateEmpty(), + cmpopts.IgnoreFields(chronograf.Server{}, "ID"), + cmpopts.IgnoreFields(chronograf.Server{}, "Active"), +} + +func TestServers_All(t *testing.T) { + type fields struct { + ServersStore chronograf.ServersStore + } + type args struct { + organization string + ctx context.Context + } + tests := []struct { + name string + args args + fields fields + want []chronograf.Server + wantRaw []chronograf.Server + wantErr bool + }{ + { + name: "No Servers", + fields: fields{ + ServersStore: &mocks.ServersStore{ + AllF: func(ctx context.Context) ([]chronograf.Server, error) { + return nil, fmt.Errorf("No Servers") + }, + }, + }, + wantErr: true, + }, + { + name: "All Servers", + fields: fields{ + ServersStore: &mocks.ServersStore{ + AllF: func(ctx context.Context) ([]chronograf.Server, error) { + return []chronograf.Server{ + { + Name: "howdy", + Organization: "1337", + }, + { + Name: "doody", + Organization: "1338", + }, + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + }, + want: []chronograf.Server{ + { + Name: "howdy", + Organization: "1337", + }, + }, + }, + } + for _, tt := range tests { + s := organizations.NewServersStore(tt.fields.ServersStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + gots, err := s.All(tt.args.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("%q. ServersStore.All() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + for i, got := range gots { + if diff := cmp.Diff(got, tt.want[i], serverCmpOptions...); diff != "" { + t.Errorf("%q. ServersStore.All():\n-got/+want\ndiff %s", tt.name, diff) + } + } + } +} + +func TestServers_Add(t *testing.T) { + type fields struct { + ServersStore chronograf.ServersStore + } + type args struct { + organization string + ctx context.Context + server chronograf.Server + } + tests := []struct { + name string + args args + fields fields + want chronograf.Server + wantErr bool + }{ + { + name: "Add Server", + fields: fields{ + ServersStore: &mocks.ServersStore{ + AddF: func(ctx context.Context, s chronograf.Server) (chronograf.Server, error) { + return s, nil + }, + GetF: func(ctx context.Context, id int) (chronograf.Server, error) { + return chronograf.Server{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + server: chronograf.Server{ + ID: 1229, + Name: "howdy", + }, + }, + want: chronograf.Server{ + Name: "howdy", + Organization: "1337", + }, + }, + } + for _, tt := range tests { + s := organizations.NewServersStore(tt.fields.ServersStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + d, err := s.Add(tt.args.ctx, tt.args.server) + if (err != nil) != tt.wantErr { + t.Errorf("%q. ServersStore.Add() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + got, err := s.Get(tt.args.ctx, d.ID) + if diff := cmp.Diff(got, tt.want, serverCmpOptions...); diff != "" { + t.Errorf("%q. ServersStore.Add():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} + +func TestServers_Delete(t *testing.T) { + type fields struct { + ServersStore chronograf.ServersStore + } + type args struct { + organization string + ctx context.Context + server chronograf.Server + } + tests := []struct { + name string + fields fields + args args + want []chronograf.Server + addFirst bool + wantErr bool + }{ + { + name: "Delete server", + fields: fields{ + ServersStore: &mocks.ServersStore{ + DeleteF: func(ctx context.Context, s chronograf.Server) error { + return nil + }, + GetF: func(ctx context.Context, id int) (chronograf.Server, error) { + return chronograf.Server{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + server: chronograf.Server{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, + }, + addFirst: true, + }, + } + for _, tt := range tests { + s := organizations.NewServersStore(tt.fields.ServersStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + err := s.Delete(tt.args.ctx, tt.args.server) + if (err != nil) != tt.wantErr { + t.Errorf("%q. ServersStore.All() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + } +} + +func TestServers_Get(t *testing.T) { + type fields struct { + ServersStore chronograf.ServersStore + } + type args struct { + organization string + ctx context.Context + server chronograf.Server + } + tests := []struct { + name string + fields fields + args args + want chronograf.Server + addFirst bool + wantErr bool + }{ + { + name: "Get Server", + fields: fields{ + ServersStore: &mocks.ServersStore{ + GetF: func(ctx context.Context, id int) (chronograf.Server, error) { + return chronograf.Server{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + server: chronograf.Server{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, + }, + want: chronograf.Server{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, + }, + } + for _, tt := range tests { + s := organizations.NewServersStore(tt.fields.ServersStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + got, err := s.Get(tt.args.ctx, tt.args.server.ID) + if (err != nil) != tt.wantErr { + t.Errorf("%q. ServersStore.Get() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if diff := cmp.Diff(got, tt.want, serverCmpOptions...); diff != "" { + t.Errorf("%q. ServersStore.Get():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} + +func TestServers_Update(t *testing.T) { + type fields struct { + ServersStore chronograf.ServersStore + } + type args struct { + organization string + ctx context.Context + server chronograf.Server + name string + } + tests := []struct { + name string + fields fields + args args + want chronograf.Server + addFirst bool + wantErr bool + }{ + { + name: "Update Server Name", + fields: fields{ + ServersStore: &mocks.ServersStore{ + UpdateF: func(ctx context.Context, s chronograf.Server) error { + return nil + }, + GetF: func(ctx context.Context, id int) (chronograf.Server, error) { + return chronograf.Server{ + ID: 1229, + Name: "doody", + Organization: "1337", + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + server: chronograf.Server{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, + name: "doody", + }, + want: chronograf.Server{ + Name: "doody", + Organization: "1337", + }, + addFirst: true, + }, + } + for _, tt := range tests { + if tt.args.name != "" { + tt.args.server.Name = tt.args.name + } + s := organizations.NewServersStore(tt.fields.ServersStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + err := s.Update(tt.args.ctx, tt.args.server) + if (err != nil) != tt.wantErr { + t.Errorf("%q. ServersStore.Update() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + got, err := s.Get(tt.args.ctx, tt.args.server.ID) + if diff := cmp.Diff(got, tt.want, serverCmpOptions...); diff != "" { + t.Errorf("%q. ServersStore.Update():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} diff --git a/organizations/sources.go b/organizations/sources.go new file mode 100644 index 0000000000..ca12f23496 --- /dev/null +++ b/organizations/sources.go @@ -0,0 +1,112 @@ +package organizations + +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 organization. +type SourcesStore struct { + store chronograf.SourcesStore + organization string +} + +// NewSourcesStore creates a new SourcesStore from an existing +// chronograf.SourceStore and an organization string +func NewSourcesStore(s chronograf.SourcesStore, org string) *SourcesStore { + return &SourcesStore{ + store: s, + organization: org, + } +} + +// All retrieves all sources from the underlying SourceStore and filters them +// by organization. +func (s *SourcesStore) All(ctx context.Context) ([]chronograf.Source, error) { + err := validOrganization(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 d.Organization == s.organization { + sources = append(sources, d) + } + } + + return sources, nil +} + +// Add creates a new Source in the SourcesStore with source.Organization set to be the +// organization from the source store. +func (s *SourcesStore) Add(ctx context.Context, d chronograf.Source) (chronograf.Source, error) { + err := validOrganization(ctx) + if err != nil { + return chronograf.Source{}, err + } + + d.Organization = s.organization + return s.store.Add(ctx, d) +} + +// Delete the source from SourcesStore +func (s *SourcesStore) Delete(ctx context.Context, d chronograf.Source) error { + err := validOrganization(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 organization that is set. +func (s *SourcesStore) Get(ctx context.Context, id int) (chronograf.Source, error) { + err := validOrganization(ctx) + if err != nil { + return chronograf.Source{}, err + } + + d, err := s.store.Get(ctx, id) + if err != nil { + return chronograf.Source{}, err + } + + if d.Organization != s.organization { + 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 := validOrganization(ctx) + if err != nil { + return err + } + + _, err = s.store.Get(ctx, d.ID) + if err != nil { + return err + } + + return s.store.Update(ctx, d) +} diff --git a/organizations/sources_test.go b/organizations/sources_test.go new file mode 100644 index 0000000000..8a57e24fbe --- /dev/null +++ b/organizations/sources_test.go @@ -0,0 +1,340 @@ +package organizations_test + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/mocks" + "github.com/influxdata/chronograf/organizations" +) + +// IgnoreFields is used because ID cannot be predicted reliably +// EquateEmpty is used because we want nil slices, arrays, and maps to be equal to the empty map +var sourceCmpOptions = cmp.Options{ + cmpopts.EquateEmpty(), + cmpopts.IgnoreFields(chronograf.Source{}, "ID"), + cmpopts.IgnoreFields(chronograf.Source{}, "Default"), +} + +func TestSources_All(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + } + type args struct { + organization string + ctx context.Context + } + tests := []struct { + name string + args args + fields fields + want []chronograf.Source + wantRaw []chronograf.Source + wantErr bool + }{ + { + name: "No Sources", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + AllF: func(ctx context.Context) ([]chronograf.Source, error) { + return nil, fmt.Errorf("No Sources") + }, + }, + }, + wantErr: true, + }, + { + name: "All Sources", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + AllF: func(ctx context.Context) ([]chronograf.Source, error) { + return []chronograf.Source{ + { + Name: "howdy", + Organization: "1337", + }, + { + Name: "doody", + Organization: "1338", + }, + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + }, + want: []chronograf.Source{ + { + Name: "howdy", + Organization: "1337", + }, + }, + }, + } + for _, tt := range tests { + s := organizations.NewSourcesStore(tt.fields.SourcesStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + gots, err := s.All(tt.args.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("%q. SourcesStore.All() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + for i, got := range gots { + if diff := cmp.Diff(got, tt.want[i], sourceCmpOptions...); diff != "" { + t.Errorf("%q. SourcesStore.All():\n-got/+want\ndiff %s", tt.name, diff) + } + } + } +} + +func TestSources_Add(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + } + type args struct { + organization string + ctx context.Context + source chronograf.Source + } + tests := []struct { + name string + args args + fields fields + want chronograf.Source + wantErr bool + }{ + { + name: "Add Source", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + AddF: func(ctx context.Context, s chronograf.Source) (chronograf.Source, error) { + return s, nil + }, + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + source: chronograf.Source{ + ID: 1229, + Name: "howdy", + }, + }, + want: chronograf.Source{ + Name: "howdy", + Organization: "1337", + }, + }, + } + for _, tt := range tests { + s := organizations.NewSourcesStore(tt.fields.SourcesStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + d, err := s.Add(tt.args.ctx, tt.args.source) + if (err != nil) != tt.wantErr { + t.Errorf("%q. SourcesStore.Add() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + got, err := s.Get(tt.args.ctx, d.ID) + if diff := cmp.Diff(got, tt.want, sourceCmpOptions...); diff != "" { + t.Errorf("%q. SourcesStore.Add():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} + +func TestSources_Delete(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + } + type args struct { + organization string + ctx context.Context + source chronograf.Source + } + tests := []struct { + name string + fields fields + args args + want []chronograf.Source + addFirst bool + wantErr bool + }{ + { + name: "Delete source", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + DeleteF: func(ctx context.Context, s chronograf.Source) error { + return nil + }, + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + source: chronograf.Source{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, + }, + addFirst: true, + }, + } + for _, tt := range tests { + s := organizations.NewSourcesStore(tt.fields.SourcesStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + err := s.Delete(tt.args.ctx, tt.args.source) + if (err != nil) != tt.wantErr { + t.Errorf("%q. SourcesStore.All() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + } +} + +func TestSources_Get(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + } + type args struct { + organization string + ctx context.Context + source chronograf.Source + } + tests := []struct { + name string + fields fields + args args + want chronograf.Source + addFirst bool + wantErr bool + }{ + { + name: "Get Source", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + source: chronograf.Source{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, + }, + want: chronograf.Source{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, + }, + } + for _, tt := range tests { + s := organizations.NewSourcesStore(tt.fields.SourcesStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + got, err := s.Get(tt.args.ctx, tt.args.source.ID) + if (err != nil) != tt.wantErr { + t.Errorf("%q. SourcesStore.Get() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if diff := cmp.Diff(got, tt.want, sourceCmpOptions...); diff != "" { + t.Errorf("%q. SourcesStore.Get():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} + +func TestSources_Update(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + } + type args struct { + organization string + ctx context.Context + source chronograf.Source + name string + } + tests := []struct { + name string + fields fields + args args + want chronograf.Source + addFirst bool + wantErr bool + }{ + { + name: "Update Source Name", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + UpdateF: func(ctx context.Context, s chronograf.Source) error { + return nil + }, + GetF: func(ctx context.Context, id int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1229, + Name: "doody", + Organization: "1337", + }, nil + }, + }, + }, + args: args{ + organization: "1337", + ctx: context.Background(), + source: chronograf.Source{ + ID: 1229, + Name: "howdy", + Organization: "1337", + }, + name: "doody", + }, + want: chronograf.Source{ + Name: "doody", + Organization: "1337", + }, + addFirst: true, + }, + } + for _, tt := range tests { + if tt.args.name != "" { + tt.args.source.Name = tt.args.name + } + s := organizations.NewSourcesStore(tt.fields.SourcesStore, tt.args.organization) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.organization) + err := s.Update(tt.args.ctx, tt.args.source) + if (err != nil) != tt.wantErr { + t.Errorf("%q. SourcesStore.Update() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + got, err := s.Get(tt.args.ctx, tt.args.source.ID) + if diff := cmp.Diff(got, tt.want, sourceCmpOptions...); diff != "" { + t.Errorf("%q. SourcesStore.Update():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} diff --git a/organizations/users.go b/organizations/users.go new file mode 100644 index 0000000000..0db6e2cd62 --- /dev/null +++ b/organizations/users.go @@ -0,0 +1,270 @@ +package organizations + +import ( + "context" + "fmt" + + "github.com/influxdata/chronograf" +) + +// Ensure UsersStore implements chronograf.UsersStore. +var _ chronograf.UsersStore = &UsersStore{} + +// UsersStore facade on a UserStore that filters a users roles +// by organization. +// +// The high level idea here is to use the same underlying store for all users. +// In particular, this is done by having all the users Roles field be a set of +// all of the users roles in all organizations. Each CRUD method here takes care +// to ensure that the only roles that are modified are the roles for the organization +// that was provided on the UsersStore. +type UsersStore struct { + organization string + store chronograf.UsersStore +} + +// NewUsersStore creates a new UsersStore from an existing +// chronograf.UserStore and an organization string +func NewUsersStore(s chronograf.UsersStore, org string) *UsersStore { + return &UsersStore{ + store: s, + organization: org, + } +} + +// validOrganizationRoles ensures that each User Role has both an associated Organization and a Name +func validOrganizationRoles(orgID string, u *chronograf.User) error { + if u == nil || u.Roles == nil { + return nil + } + for _, r := range u.Roles { + if r.Organization == "" { + return fmt.Errorf("user role must have an Organization") + } + if r.Organization != orgID { + return fmt.Errorf("organizationID %s does not match %s", r.Organization, orgID) + } + if r.Name == "" { + return fmt.Errorf("user role must have a Name") + } + } + return nil +} + +// Get searches the UsersStore for using the query. +// The roles returned on the user are filtered to only contain roles that are for the organization +// specified on the organization store. +func (s *UsersStore) Get(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + err := validOrganization(ctx) + if err != nil { + return nil, err + } + + usr, err := s.store.Get(ctx, q) + if err != nil { + return nil, err + } + + // This filters a users roles so that the resulting struct only contains roles + // from the organization on the UsersStore. + roles := usr.Roles[:0] + for _, r := range usr.Roles { + if r.Organization == s.organization { + roles = append(roles, r) + } + } + + if len(roles) == 0 { + // This means that the user does not belong to the organization + // and therefore, is not found. + return nil, chronograf.ErrUserNotFound + } + + usr.Roles = roles + return usr, nil +} + +// Add creates a new User in the UsersStore. It validates that the user provided only +// has roles for the organization set on the UsersStore. +// If a user is not found in the underlying, it calls the underlying UsersStore Add method. +// If a user is found, it removes any existing roles a user has for an organization and appends +// the roles specified on the provided user and calls the uderlying UsersStore Update method. +func (s *UsersStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + err := validOrganization(ctx) + if err != nil { + return nil, err + } + + // Validates that the users roles are only for the current organization. + if err := validOrganizationRoles(s.organization, u); err != nil { + return nil, err + } + + // retrieve the user from the underlying store + usr, err := s.store.Get(ctx, chronograf.UserQuery{ + Name: &u.Name, + Provider: &u.Provider, + Scheme: &u.Scheme, + }) + + switch err { + case nil: + // If there is no error continue to the rest of the code + break + case chronograf.ErrUserNotFound: + // If user is not found in the backed store, attempt to add the user + return s.store.Add(ctx, u) + default: + // return the error + return nil, err + } + + // Filter the retrieved users roles so that the resulting struct only contains roles + // that are not from the organization on the UsersStore. + roles := usr.Roles[:0] + for _, r := range usr.Roles { + if r.Organization != s.organization { + roles = append(roles, r) + } + } + + // If the user already has a role in the organization then the user + // cannot be "created". + // This can be thought of as: + // (total # of roles a user has) - (# of roles not in the organization) = (# of roles in organization) + // if this value is greater than 1 the user cannot be "added". + numRolesInOrganization := len(usr.Roles) - len(roles) + if numRolesInOrganization > 0 { + return nil, chronograf.ErrUserAlreadyExists + } + + // Set the users roles to be the union of the roles set on the provided user + // and the user that was found in the underlying store + usr.Roles = append(roles, u.Roles...) + + // Update the user in the underlying store + if err := s.store.Update(ctx, usr); err != nil { + return nil, err + } + + // Return the provided user with ID set + u.ID = usr.ID + return u, nil +} + +// Delete a user from the UsersStore. This is done by stripping a user of +// any roles it has in the organization speicified on the UsersStore. +func (s *UsersStore) Delete(ctx context.Context, usr *chronograf.User) error { + err := validOrganization(ctx) + if err != nil { + return err + } + + // retrieve the user from the underlying store + u, err := s.store.Get(ctx, chronograf.UserQuery{ID: &usr.ID}) + if err != nil { + return err + } + + // Filter the retrieved users roles so that the resulting slice contains + // roles that are not scoped to the organization provided + roles := u.Roles[:0] + for _, r := range u.Roles { + if r.Organization != s.organization { + roles = append(roles, r) + } + } + u.Roles = roles + return s.store.Update(ctx, u) +} + +// Update a user in the UsersStore. +func (s *UsersStore) Update(ctx context.Context, usr *chronograf.User) error { + err := validOrganization(ctx) + if err != nil { + return err + } + + // Validates that the users roles are only for the current organization. + if err := validOrganizationRoles(s.organization, usr); err != nil { + return err + } + + // retrieve the user from the underlying store + u, err := s.store.Get(ctx, chronograf.UserQuery{ID: &usr.ID}) + if err != nil { + return err + } + + // Filter the retrieved users roles so that the resulting slice contains + // roles that are not scoped to the organization provided + roles := u.Roles[:0] + for _, r := range u.Roles { + if r.Organization != s.organization { + roles = append(roles, r) + } + } + + // Make a copy of the usr so that we dont modify the underlying add roles on to + // the user that was passed in + user := *usr + + // Set the users roles to be the union of the roles set on the provided user + // and the user that was found in the underlying store + user.Roles = append(roles, usr.Roles...) + + return s.store.Update(ctx, &user) +} + +// All returns all users where roles have been filters to be exclusively for +// the organization provided on the UsersStore. +func (s *UsersStore) All(ctx context.Context) ([]chronograf.User, error) { + err := validOrganization(ctx) + if err != nil { + return nil, err + } + + // retrieve all users from the underlying UsersStore + usrs, err := s.store.All(ctx) + if err != nil { + return nil, err + } + + // Filter users to only contain users that have at least one role + // in the provided organization. + us := usrs[:0] + for _, usr := range usrs { + roles := usr.Roles[:0] + // This filters a users roles so that the resulting struct only contains roles + // from the organization on the UsersStore. + for _, r := range usr.Roles { + if r.Organization == s.organization { + roles = append(roles, r) + } + } + if len(roles) != 0 { + // Only add users if they have a role in the associated organization + usr.Roles = roles + us = append(us, usr) + } + } + + return us, nil +} + +// Num returns the number of users in the UsersStore +// This is unperformant, but should rarely be used. +func (s *UsersStore) Num(ctx context.Context) (int, error) { + err := validOrganization(ctx) + if err != nil { + return 0, err + } + + // retrieve all users from the underlying UsersStore + usrs, err := s.All(ctx) + if err != nil { + return 0, err + } + + return len(usrs), nil +} diff --git a/organizations/users_test.go b/organizations/users_test.go new file mode 100644 index 0000000000..0baa40a08f --- /dev/null +++ b/organizations/users_test.go @@ -0,0 +1,1049 @@ +package organizations_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/mocks" + "github.com/influxdata/chronograf/organizations" +) + +// IgnoreFields is used because ID cannot be predicted reliably +// EquateEmpty is used because we want nil slices, arrays, and maps to be equal to the empty map +var userCmpOptions = cmp.Options{ + cmpopts.IgnoreFields(chronograf.User{}, "ID"), + cmpopts.EquateEmpty(), +} + +func TestUsersStore_Get(t *testing.T) { + type fields struct { + UsersStore chronograf.UsersStore + } + type args struct { + ctx context.Context + usr *chronograf.User + userID uint64 + orgID string + } + tests := []struct { + name string + fields fields + args args + want *chronograf.User + wantErr bool + }{ + { + name: "Get user with no role in organization", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1234, + Name: "billietta", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "The HillBilliettas", + }, + }, + }, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + userID: 1234, + orgID: "1336", + }, + wantErr: true, + }, + { + name: "Get user no organization set", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1234, + Name: "billietta", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "The HillBilliettas", + }, + }, + }, nil + }, + }, + }, + args: args{ + userID: 1234, + ctx: context.Background(), + }, + wantErr: true, + }, + { + name: "Get user scoped to an organization", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1234, + Name: "billietta", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "The HillBilliettas", + }, + { + Organization: "1336", + Name: "The BillHilliettos", + }, + }, + }, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + userID: 1234, + orgID: "1336", + }, + want: &chronograf.User{ + Name: "billietta", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1336", + Name: "The BillHilliettos", + }, + }, + }, + }, + } + for _, tt := range tests { + s := organizations.NewUsersStore(tt.fields.UsersStore, tt.args.orgID) + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.orgID) + got, err := s.Get(tt.args.ctx, chronograf.UserQuery{ID: &tt.args.userID}) + if (err != nil) != tt.wantErr { + t.Errorf("%q. UsersStore.Get() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if diff := cmp.Diff(got, tt.want, userCmpOptions...); diff != "" { + t.Errorf("%q. UsersStore.Get():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} + +func TestUsersStore_Add(t *testing.T) { + type fields struct { + UsersStore chronograf.UsersStore + } + type args struct { + ctx context.Context + u *chronograf.User + orgID string + uInitial *chronograf.User + } + tests := []struct { + name string + fields fields + args args + want *chronograf.User + wantErr bool + }{ + { + name: "Add new user - no org", + fields: fields{ + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + ID: 1234, + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1336", + Name: "editor", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "Add new user", + fields: fields{ + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return nil, chronograf.ErrUserNotFound + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + ID: 1234, + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1336", + Name: "editor", + }, + }, + }, + orgID: "1336", + }, + want: &chronograf.User{ + ID: 1234, + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1336", + Name: "editor", + }, + }, + }, + }, + { + name: "Add non-new user without Role", + fields: fields{ + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1234, + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{}, + }, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + ID: 1234, + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{}, + }, + orgID: "1336", + }, + want: &chronograf.User{ + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{}, + }, + }, + { + name: "Add non-new user with Role", + fields: fields{ + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1234, + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1337", + Name: "editor", + }, + }, + }, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + ID: 1234, + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1336", + Name: "admin", + }, + }, + }, + orgID: "1336", + }, + want: &chronograf.User{ + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1337", + Name: "editor", + }, + { + Organization: "1336", + Name: "admin", + }, + }, + }, + }, + { + name: "Add user that already exists", + fields: fields{ + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1234, + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1337", + Name: "editor", + }, + }, + }, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + ID: 1234, + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1337", + Name: "admin", + }, + }, + }, + orgID: "1337", + }, + wantErr: true, + }, + { + name: "Has invalid Role: missing Organization", + fields: fields{ + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return nil, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + orgID: "1338", + u: &chronograf.User{ + Name: "henrietta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "editor", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "Has invalid Role: missing Name", + fields: fields{ + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return nil, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + orgID: "1337", + u: &chronograf.User{ + Name: "henrietta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1337", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "Has invalid Organization", + fields: fields{ + UsersStore: &mocks.UsersStore{}, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "henrietta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + chronograf.Role{}, + }, + }, + orgID: "1337", + }, + wantErr: true, + }, + { + name: "Organization does not match orgID", + fields: fields{ + UsersStore: &mocks.UsersStore{}, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "henrietta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "editor", + }, + }, + }, + orgID: "1337", + }, + wantErr: true, + }, + { + name: "Role Name not specified", + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "henrietta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1337", + }, + }, + }, + orgID: "1337", + }, + wantErr: true, + }, + } + for _, tt := range tests { + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.orgID) + s := organizations.NewUsersStore(tt.fields.UsersStore, tt.args.orgID) + + got, err := s.Add(tt.args.ctx, tt.args.u) + if (err != nil) != tt.wantErr { + t.Errorf("%q. UsersStore.Add() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if got == nil && tt.want == nil { + continue + } + } +} + +func TestUsersStore_Delete(t *testing.T) { + type fields struct { + UsersStore chronograf.UsersStore + } + type args struct { + ctx context.Context + user *chronograf.User + orgID string + } + tests := []struct { + name string + fields fields + args args + wantErr bool + wantRaw *chronograf.User + }{ + { + name: "No such user", + fields: fields{ + UsersStore: &mocks.UsersStore{ + //AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + // return u, nil + //}, + //UpdateF: func(ctx context.Context, u *chronograf.User) error { + // return nil + //}, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return nil, chronograf.ErrUserNotFound + }, + }, + }, + args: args{ + ctx: context.Background(), + user: &chronograf.User{ + ID: 10, + }, + orgID: "1336", + }, + wantErr: true, + }, + { + name: "Derlete user", + fields: fields{ + UsersStore: &mocks.UsersStore{ + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1234, + Name: "noone", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "The BillHilliettas", + }, + { + Organization: "1336", + Name: "The HillBilliettas", + }, + }, + }, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + user: &chronograf.User{ + ID: 1234, + Name: "noone", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "The BillHilliettas", + }, + { + Organization: "1336", + Name: "The HillBilliettas", + }, + }, + }, + orgID: "1336", + }, + }, + } + for _, tt := range tests { + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.orgID) + s := organizations.NewUsersStore(tt.fields.UsersStore, tt.args.orgID) + if err := s.Delete(tt.args.ctx, tt.args.user); (err != nil) != tt.wantErr { + t.Errorf("%q. UsersStore.Delete() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + } +} + +func TestUsersStore_Update(t *testing.T) { + type fields struct { + UsersStore chronograf.UsersStore + } + type args struct { + ctx context.Context + usr *chronograf.User + roles []chronograf.Role + superAdmin bool + orgID string + } + tests := []struct { + name string + fields fields + args args + want *chronograf.User + wantRaw *chronograf.User + wantErr bool + }{ + { + name: "No such user", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return nil, chronograf.ErrUserNotFound + }, + }, + }, + args: args{ + ctx: context.Background(), + usr: &chronograf.User{ + ID: 10, + }, + orgID: "1338", + }, + wantErr: true, + }, + { + name: "Update user role", + fields: fields{ + UsersStore: &mocks.UsersStore{ + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return &chronograf.User{ + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1337", + Name: "viewer", + }, + { + Organization: "1338", + Name: "editor", + }, + }, + }, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + usr: &chronograf.User{ + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{}, + }, + roles: []chronograf.Role{ + { + Organization: "1338", + Name: "editor", + }, + }, + orgID: "1338", + }, + want: &chronograf.User{ + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "editor", + }, + }, + }, + }, + { + name: "Update user super admin", + fields: fields{ + UsersStore: &mocks.UsersStore{ + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return &chronograf.User{ + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + Roles: []chronograf.Role{ + { + Organization: "1337", + Name: "viewer", + }, + { + Organization: "1338", + Name: "editor", + }, + }, + }, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + usr: &chronograf.User{ + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{}, + }, + superAdmin: true, + orgID: "1338", + }, + want: &chronograf.User{ + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + }, + }, + } + for _, tt := range tests { + tt.args.ctx = context.WithValue(tt.args.ctx, organizations.ContextKey, tt.args.orgID) + s := organizations.NewUsersStore(tt.fields.UsersStore, tt.args.orgID) + + if tt.args.roles != nil { + tt.args.usr.Roles = tt.args.roles + } + + if tt.args.superAdmin { + tt.args.usr.SuperAdmin = tt.args.superAdmin + } + + if err := s.Update(tt.args.ctx, tt.args.usr); (err != nil) != tt.wantErr { + t.Errorf("%q. UsersStore.Update() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + + // for the empty test + if tt.want == nil { + continue + } + + if diff := cmp.Diff(tt.args.usr, tt.want, userCmpOptions...); diff != "" { + t.Errorf("%q. UsersStore.Update():\n-got/+want\ndiff %s", tt.name, diff) + } + + } +} + +func TestUsersStore_All(t *testing.T) { + type fields struct { + UsersStore chronograf.UsersStore + } + tests := []struct { + name string + fields fields + ctx context.Context + want []chronograf.User + wantRaw []chronograf.User + orgID string + wantErr bool + }{ + { + name: "No users", + fields: fields{ + UsersStore: &mocks.UsersStore{ + AllF: func(ctx context.Context) ([]chronograf.User, error) { + return []chronograf.User{ + { + Name: "howdy", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "viewer", + }, + { + Organization: "1336", + Name: "viewer", + }, + }, + }, + { + Name: "doody2", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1337", + Name: "editor", + }, + }, + }, + { + Name: "doody", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "editor", + }, + }, + }, + }, nil + }, + }, + }, + ctx: context.Background(), + orgID: "2330", + }, + { + name: "get all users", + orgID: "1338", + fields: fields{ + UsersStore: &mocks.UsersStore{ + AllF: func(ctx context.Context) ([]chronograf.User, error) { + return []chronograf.User{ + { + Name: "howdy", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "viewer", + }, + { + Organization: "1336", + Name: "viewer", + }, + }, + }, + { + Name: "doody2", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1337", + Name: "editor", + }, + }, + }, + { + Name: "doody", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "editor", + }, + }, + }, + }, nil + }, + }, + }, + ctx: context.Background(), + want: []chronograf.User{ + { + Name: "howdy", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "viewer", + }, + }, + }, + { + Name: "doody", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "editor", + }, + }, + }, + }, + }, + } + for _, tt := range tests { + tt.ctx = context.WithValue(tt.ctx, organizations.ContextKey, tt.orgID) + for _, u := range tt.wantRaw { + tt.fields.UsersStore.Add(tt.ctx, &u) + } + s := organizations.NewUsersStore(tt.fields.UsersStore, tt.orgID) + gots, err := s.All(tt.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("%q. UsersStore.All() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if diff := cmp.Diff(gots, tt.want, userCmpOptions...); diff != "" { + t.Errorf("%q. UsersStore.All():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} + +func TestUsersStore_Num(t *testing.T) { + type fields struct { + UsersStore chronograf.UsersStore + } + tests := []struct { + name string + fields fields + ctx context.Context + orgID string + want int + wantErr bool + }{ + { + name: "No users", + fields: fields{ + UsersStore: &mocks.UsersStore{ + AllF: func(ctx context.Context) ([]chronograf.User, error) { + return []chronograf.User{ + { + Name: "howdy", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "viewer", + }, + { + Organization: "1336", + Name: "viewer", + }, + }, + }, + { + Name: "doody2", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1337", + Name: "editor", + }, + }, + }, + { + Name: "doody", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "editor", + }, + }, + }, + }, nil + }, + }, + }, + ctx: context.Background(), + orgID: "2330", + }, + { + name: "get all users", + orgID: "1338", + fields: fields{ + UsersStore: &mocks.UsersStore{ + AllF: func(ctx context.Context) ([]chronograf.User, error) { + return []chronograf.User{ + { + Name: "howdy", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "viewer", + }, + { + Organization: "1336", + Name: "viewer", + }, + }, + }, + { + Name: "doody2", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1337", + Name: "editor", + }, + }, + }, + { + Name: "doody", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Organization: "1338", + Name: "editor", + }, + }, + }, + }, nil + }, + }, + }, + ctx: context.Background(), + want: 2, + }, + } + for _, tt := range tests { + tt.ctx = context.WithValue(tt.ctx, organizations.ContextKey, tt.orgID) + s := organizations.NewUsersStore(tt.fields.UsersStore, tt.orgID) + got, err := s.Num(tt.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("%q. UsersStore.Num() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if got != tt.want { + t.Errorf("%q. UsersStore.Num() = %d. want %d", tt.name, got, tt.want) + } + } +} diff --git a/roles/roles.go b/roles/roles.go new file mode 100644 index 0000000000..53b9b9632e --- /dev/null +++ b/roles/roles.go @@ -0,0 +1,63 @@ +package roles + +import ( + "context" + "fmt" + + "github.com/influxdata/chronograf" +) + +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 { + case MemberRoleName, ViewerRoleName, EditorRoleName, AdminRoleName: + return nil + default: + return fmt.Errorf("expected role key to be set") + } +} + +// Chronograf User Roles +const ( + MemberRoleName = "member" + ViewerRoleName = "viewer" + EditorRoleName = "editor" + AdminRoleName = "admin" + SuperAdminStatus = "superadmin" +) + +var ( + // MemberRole is the role for a user who can only perform No operations. + MemberRole = chronograf.Role{ + Name: MemberRoleName, + } + + // ViewerRole is the role for a user who can only perform READ operations on Dashboards, Rules, Sources, and Servers, + ViewerRole = chronograf.Role{ + Name: ViewerRoleName, + } + + // EditorRole is the role for a user who can perform READ and WRITE operations on Dashboards, Rules, Sources, and Servers. + EditorRole = chronograf.Role{ + Name: EditorRoleName, + } + + // AdminRole is the role for a user who can perform READ and WRITE operations on Dashboards, Rules, Sources, Servers, and Users + AdminRole = chronograf.Role{ + Name: AdminRoleName, + } +) diff --git a/roles/sources.go b/roles/sources.go new file mode 100644 index 0000000000..06b453bbd4 --- /dev/null +++ b/roles/sources.go @@ -0,0 +1,143 @@ +package roles + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +// NOTE: +// This code is currently unused. however, it has been left in place because we aniticipate +// that it may be used in the future. It was originally developed as a misunderstanding of +// https://github.com/influxdata/chronograf/issues/1915 + +// ensure that SourcesStore implements chronograf.SourceStore +var _ chronograf.SourcesStore = &SourcesStore{} + +// SourcesStore facade on a SourceStore that filters sources +// by minimum role required to access the source. +// +// The role is passed around on the context and set when the +// SourcesStore is instantiated. +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) +} + +// hasAuthorizedRole checks that the role provided has at least +// the minimum role required. +func hasAuthorizedRole(sourceRole, providedRole string) bool { + switch sourceRole { + case ViewerRoleName: + switch providedRole { + case ViewerRoleName, EditorRoleName, AdminRoleName: + return true + } + case EditorRoleName: + switch providedRole { + case EditorRoleName, AdminRoleName: + return true + } + case AdminRoleName: + switch providedRole { + case AdminRoleName: + 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/auth.go b/server/auth.go index eb58681094..6ce6b6430f 100644 --- a/server/auth.go +++ b/server/auth.go @@ -2,10 +2,13 @@ package server import ( "context" + "fmt" "net/http" "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/oauth2" + "github.com/influxdata/chronograf/organizations" + "github.com/influxdata/chronograf/roles" ) // AuthorizedToken extracts the token and validates; if valid the next handler @@ -15,7 +18,7 @@ import ( func AuthorizedToken(auth oauth2.Authenticator, logger chronograf.Logger, next http.Handler) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log := logger. - WithField("component", "auth"). + WithField("component", "token_auth"). WithField("remote_addr", r.RemoteAddr). WithField("method", r.Method). WithField("url", r.URL) @@ -45,3 +48,171 @@ func AuthorizedToken(auth oauth2.Authenticator, logger chronograf.Logger, next h return }) } + +// AuthorizedUser extracts the user name and provider from context. If the +// user and provider can be found on the context, we look up the user by their +// name and provider. If the user is found, we verify that the user has at at +// least the role supplied. +func AuthorizedUser( + store DataStore, + useAuth bool, + role string, + logger chronograf.Logger, + next http.HandlerFunc, +) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !useAuth { + ctx := r.Context() + // If there is no auth, then give the user raw access to the DataStore + r = r.WithContext(serverContext(ctx)) + next(w, r) + return + } + + log := logger. + WithField("component", "role_auth"). + WithField("remote_addr", r.RemoteAddr). + WithField("method", r.Method). + WithField("url", r.URL) + + ctx := r.Context() + serverCtx := serverContext(ctx) + + p, err := getValidPrincipal(ctx) + if err != nil { + log.Error("Failed to retrieve principal from context") + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + } + scheme, err := getScheme(ctx) + if err != nil { + log.Error("Failed to retrieve scheme from context") + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + } + + // This is as if the user was logged into the default organization + if p.Organization == "" { + defaultOrg, err := store.Organizations(serverCtx).DefaultOrganization(serverCtx) + if err != nil { + log.Error(fmt.Sprintf("Failed to retrieve the default organization: %v", err)) + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + } + p.Organization = fmt.Sprintf("%d", defaultOrg.ID) + } + + // validate that the organization exists + orgID, err := parseOrganizationID(p.Organization) + if err != nil { + log.Error("Failed to validate organization on context") + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + } + _, err = store.Organizations(serverCtx).Get(serverCtx, chronograf.OrganizationQuery{ID: &orgID}) + if err != nil { + log.Error(fmt.Sprintf("Failed to retrieve organization %d from organizations store", orgID)) + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + } + ctx = context.WithValue(ctx, organizations.ContextKey, p.Organization) + // TODO: seems silly to look up a user twice + u, err := store.Users(serverCtx).Get(serverCtx, chronograf.UserQuery{ + Name: &p.Subject, + Provider: &p.Issuer, + Scheme: &scheme, + }) + + if err != nil { + log.Error("Failed to retrieve user") + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + } + // In particular this is used by sever/users.go so that we know when and when not to + // allow users to make someone a super admin + ctx = context.WithValue(ctx, UserContextKey, u) + + if u.SuperAdmin { + // To access resources (servers, sources, databases, layouts) within a DataStore, + // an organization and a role are required even if you are a super admin or are + // not using auth. Every user's current organization is set on context to filter + // the resources accessed within a DataStore, including for super admin or when + // not using auth. In this way, a DataStore can treat all requests the same, + // including those from a super admin and when not using auth. + // + // As for roles, in the case of super admin or when not using auth, the user's + // role on context (though not on their JWT or user) is set to be admin. In order + // to access all resources belonging to their current organization. + ctx = context.WithValue(ctx, roles.ContextKey, roles.AdminRoleName) + r = r.WithContext(ctx) + next(w, r) + return + } + + u, err = store.Users(ctx).Get(ctx, chronograf.UserQuery{ + Name: &p.Subject, + Provider: &p.Issuer, + Scheme: &scheme, + }) + if err != nil { + log.Error("Failed to retrieve user") + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + } + + if hasAuthorizedRole(u, role) { + if len(u.Roles) != 1 { + msg := `User %d has too many role in organization. User: %#v.Please report this log at https://github.com/influxdata/chronograf/issues/new"` + log.Error(fmt.Sprint(msg, u.ID, u)) + unknownErrorWithMessage(w, fmt.Errorf("please have administrator check logs and report error"), logger) + return + } + // use the first role, since there should only ever be one + // for any particular organization and hasAuthorizedRole + // should ensure that at least one role for the org exists + ctx = context.WithValue(ctx, roles.ContextKey, u.Roles[0].Name) + r = r.WithContext(ctx) + next(w, r) + return + } + + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + }) +} + +func hasAuthorizedRole(u *chronograf.User, role string) bool { + if u == nil { + return false + } + + switch role { + case roles.ViewerRoleName: + for _, r := range u.Roles { + switch r.Name { + case roles.ViewerRoleName, roles.EditorRoleName, roles.AdminRoleName: + return true + } + } + case roles.EditorRoleName: + for _, r := range u.Roles { + switch r.Name { + case roles.EditorRoleName, roles.AdminRoleName: + return true + } + } + case roles.AdminRoleName: + for _, r := range u.Roles { + switch r.Name { + case roles.AdminRoleName: + return true + } + } + case roles.SuperAdminStatus: + // SuperAdmins should have been authorized before this. + // This is only meant to restrict access for non-superadmins. + return false + } + + return false +} diff --git a/server/auth_test.go b/server/auth_test.go index a0d482de36..87a5768349 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -1,51 +1,20 @@ -package server_test +package server import ( "context" "errors" + "fmt" "net/http" "net/http/httptest" "testing" + "github.com/influxdata/chronograf" clog "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/mocks" "github.com/influxdata/chronograf/oauth2" - "github.com/influxdata/chronograf/server" + "github.com/influxdata/chronograf/roles" ) -type MockAuthenticator struct { - Principal oauth2.Principal - ValidateErr error - ExtendErr error - Serialized string -} - -func (m *MockAuthenticator) Validate(context.Context, *http.Request) (oauth2.Principal, error) { - return m.Principal, m.ValidateErr -} - -func (m *MockAuthenticator) Extend(ctx context.Context, w http.ResponseWriter, p oauth2.Principal) (oauth2.Principal, error) { - cookie := http.Cookie{} - - http.SetCookie(w, &cookie) - return m.Principal, m.ExtendErr -} - -func (m *MockAuthenticator) Authorize(ctx context.Context, w http.ResponseWriter, p oauth2.Principal) error { - cookie := http.Cookie{} - - http.SetCookie(w, &cookie) - return nil -} - -func (m *MockAuthenticator) Expire(http.ResponseWriter) {} - -func (m *MockAuthenticator) ValidAuthorization(ctx context.Context, serializedAuthorization string) (oauth2.Principal, error) { - return oauth2.Principal{}, nil -} -func (m *MockAuthenticator) Serialize(context.Context, oauth2.Principal) (string, error) { - return m.Serialized, nil -} - func TestAuthorizedToken(t *testing.T) { var tests = []struct { Desc string @@ -79,13 +48,13 @@ func TestAuthorizedToken(t *testing.T) { req, _ := http.NewRequest("GET", "", nil) w := httptest.NewRecorder() - a := &MockAuthenticator{ + a := &mocks.Authenticator{ Principal: test.Principal, ValidateErr: test.ValidateErr, } logger := clog.New(clog.DebugLevel) - handler := server.AuthorizedToken(a, logger, next) + handler := AuthorizedToken(a, logger, next) handler.ServeHTTP(w, req) if w.Code != test.Code { t.Errorf("Status code expected: %d actual %d", test.Code, w.Code) @@ -94,3 +63,1546 @@ func TestAuthorizedToken(t *testing.T) { } } } + +func TestAuthorizedUser(t *testing.T) { + type fields struct { + UsersStore chronograf.UsersStore + OrganizationsStore chronograf.OrganizationsStore + Logger chronograf.Logger + } + type args struct { + principal *oauth2.Principal + scheme string + useAuth bool + role string + } + tests := []struct { + name string + fields fields + args args + hasOrganizationContext bool + hasSuperAdminContext bool + hasRoleContext bool + hasServerContext bool + authorized bool + }{ + { + name: "Not using auth", + fields: fields{ + UsersStore: &mocks.UsersStore{}, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + useAuth: false, + }, + hasOrganizationContext: false, + hasSuperAdminContext: false, + hasRoleContext: false, + hasServerContext: true, + authorized: true, + }, + { + name: "User with viewer role is viewer authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.ViewerRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "viewer", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: false, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "User with editor role is viewer authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "viewer", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: false, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "User with admin role is viewer authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "viewer", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: false, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "User with viewer role is editor unauthorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.ViewerRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "editor", + useAuth: true, + }, + authorized: false, + }, + { + name: "User with editor role is editor authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "editor", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: false, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "User with admin role is editor authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "editor", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: false, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "User with viewer role is admin unauthorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.ViewerRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "admin", + useAuth: true, + }, + authorized: false, + }, + { + name: "User with editor role is admin unauthorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "admin", + useAuth: true, + }, + authorized: false, + }, + { + name: "User with admin role is admin authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "admin", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: false, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "User with no role is viewer unauthorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{}, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "view", + useAuth: true, + }, + authorized: false, + }, + { + name: "User with no role is editor unauthorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{}, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "editor", + useAuth: true, + }, + authorized: false, + }, + { + name: "User with no role is admin unauthorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{}, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "admin", + useAuth: true, + }, + authorized: false, + }, + { + name: "User with unknown role is viewer unauthorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "sweet_role", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "viewer", + useAuth: true, + }, + authorized: false, + }, + { + name: "User with unknown role is editor unauthorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "sweet_role", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "editor", + useAuth: true, + }, + authorized: false, + }, + { + name: "User with unknown role is admin unauthorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "sweet_role", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "admin", + useAuth: true, + }, + authorized: false, + }, + { + name: "User with viewer role is SuperAdmin unauthorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.ViewerRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "superadmin", + useAuth: true, + }, + authorized: false, + }, + { + name: "User with editor role is SuperAdmin unauthorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "superadmin", + useAuth: true, + }, + authorized: false, + }, + { + name: "User with admin role is SuperAdmin unauthorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "superadmin", + useAuth: true, + }, + authorized: false, + }, + { + name: "SuperAdmin is Viewer authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: roles.MemberRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "viewer", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: true, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "SuperAdmin is Editor authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: roles.MemberRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "editor", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: true, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "SuperAdmin is Admin authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: roles.MemberRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "admin", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: true, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "SuperAdmin is SuperAdmin authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: roles.MemberRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "superadmin", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: true, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "Invalid principal – principal is nil", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: nil, + scheme: "oauth2", + role: "admin", + useAuth: true, + }, + authorized: false, + }, + { + name: "Invalid principal - missing organization", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + }, + scheme: "oauth2", + role: "admin", + useAuth: true, + }, + authorized: false, + }, + { + name: "Invalid principal - organization id not uint64", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1ee7", + }, + scheme: "oauth2", + role: "admin", + useAuth: true, + }, + authorized: false, + }, + { + name: "Failed to retrieve organization", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + switch *q.ID { + case 1338: + return &chronograf.Organization{ + ID: 1338, + Name: "The ShillBillThrilliettas", + }, nil + default: + return nil, chronograf.ErrOrganizationNotFound + } + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "admin", + useAuth: true, + }, + authorized: false, + }, + { + name: "Failed to retrieve user", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + switch *q.Name { + case "billysteve": + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1337", + }, + }, + }, nil + default: + return nil, chronograf.ErrUserNotFound + } + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billietta", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "admin", + useAuth: true, + }, + authorized: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var authorized bool + var hasServerCtx bool + var hasSuperAdminCtx bool + var hasOrganizationCtx bool + var hasRoleCtx bool + next := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + hasServerCtx = hasServerContext(ctx) + hasSuperAdminCtx = hasSuperAdminContext(ctx) + _, hasOrganizationCtx = hasOrganizationContext(ctx) + _, hasRoleCtx = hasRoleContext(ctx) + authorized = true + } + fn := AuthorizedUser( + &Store{ + UsersStore: tt.fields.UsersStore, + OrganizationsStore: tt.fields.OrganizationsStore, + }, + tt.args.useAuth, + tt.args.role, + tt.fields.Logger, + next, + ) + + w := httptest.NewRecorder() + r := httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ) + if tt.args.principal == nil { + r = r.WithContext(context.WithValue(r.Context(), oauth2.PrincipalKey, nil)) + } else { + r = r.WithContext(context.WithValue(r.Context(), oauth2.PrincipalKey, *tt.args.principal)) + } + fn(w, r) + + if authorized != tt.authorized { + t.Errorf("%q. AuthorizedUser() = %v, expected %v", tt.name, authorized, tt.authorized) + } + + if !authorized && w.Code != http.StatusForbidden { + t.Errorf("%q. AuthorizedUser() Status Code = %v, expected %v", tt.name, w.Code, http.StatusForbidden) + } + + if hasServerCtx != tt.hasServerContext { + t.Errorf("%q. AuthorizedUser().Context().Server = %v, expected %v", tt.name, hasServerCtx, tt.hasServerContext) + } + + if hasSuperAdminCtx != tt.hasSuperAdminContext { + t.Errorf("%q. AuthorizedUser().Context().SuperAdmin = %v, expected %v", tt.name, hasSuperAdminCtx, tt.hasSuperAdminContext) + } + + if hasOrganizationCtx != tt.hasOrganizationContext { + t.Errorf("%q. AuthorizedUser.Context().Organization = %v, expected %v", tt.name, hasOrganizationCtx, tt.hasOrganizationContext) + } + + if hasRoleCtx != tt.hasRoleContext { + t.Errorf("%q. AuthorizedUser().Context().Role = %v, expected %v", tt.name, hasRoleCtx, tt.hasRoleContext) + } + + }) + } +} diff --git a/server/builders.go b/server/builders.go index 57a43eb841..144225fb92 100644 --- a/server/builders.go +++ b/server/builders.go @@ -9,30 +9,30 @@ import ( // LayoutBuilder is responsible for building Layouts type LayoutBuilder interface { - Build(chronograf.LayoutStore) (*layouts.MultiLayoutStore, error) + Build(chronograf.LayoutsStore) (*layouts.MultiLayoutsStore, error) } -// MultiLayoutBuilder implements LayoutBuilder and will return a MultiLayoutStore +// MultiLayoutBuilder implements LayoutBuilder and will return a MultiLayoutsStore type MultiLayoutBuilder struct { Logger chronograf.Logger UUID chronograf.ID CannedPath string } -// Build will construct a MultiLayoutStore of canned and db-backed personalized +// Build will construct a MultiLayoutsStore of canned and db-backed personalized // layouts -func (builder *MultiLayoutBuilder) Build(db chronograf.LayoutStore) (*layouts.MultiLayoutStore, error) { +func (builder *MultiLayoutBuilder) Build(db chronograf.LayoutsStore) (*layouts.MultiLayoutsStore, error) { // These apps are those handled from a directory apps := canned.NewApps(builder.CannedPath, builder.UUID, builder.Logger) // These apps are statically compiled into chronograf - binApps := &canned.BinLayoutStore{ + binApps := &canned.BinLayoutsStore{ Logger: builder.Logger, } // Acts as a front-end to both the bolt layouts, filesystem layouts and binary statically compiled layouts. // The idea here is that these stores form a hierarchy in which each is tried sequentially until // the operation has success. So, the database is preferred over filesystem over binary data. - layouts := &layouts.MultiLayoutStore{ - Stores: []chronograf.LayoutStore{ + layouts := &layouts.MultiLayoutsStore{ + Stores: []chronograf.LayoutsStore{ db, apps, binApps, diff --git a/server/builders_test.go b/server/builders_test.go index 19fd0f9a27..eb8a907995 100644 --- a/server/builders_test.go +++ b/server/builders_test.go @@ -10,7 +10,7 @@ func TestLayoutBuilder(t *testing.T) { var l server.LayoutBuilder = &server.MultiLayoutBuilder{} layout, err := l.Build(nil) if err != nil { - t.Fatalf("MultiLayoutBuilder can't build a MultiLayoutStore: %v", err) + t.Fatalf("MultiLayoutBuilder can't build a MultiLayoutsStore: %v", err) } if layout == nil { diff --git a/server/cells.go b/server/cells.go index 499100b1e6..e1d0c08fa4 100644 --- a/server/cells.go +++ b/server/cells.go @@ -174,7 +174,7 @@ func (s *Service) DashboardCells(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - e, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + e, err := s.Store.Dashboards(ctx).Get(ctx, chronograf.DashboardID(id)) if err != nil { notFound(w, id, s.Logger) return @@ -194,7 +194,7 @@ func (s *Service) NewDashboardCell(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + dash, err := s.Store.Dashboards(ctx).Get(ctx, chronograf.DashboardID(id)) if err != nil { notFound(w, id, s.Logger) return @@ -220,7 +220,7 @@ func (s *Service) NewDashboardCell(w http.ResponseWriter, r *http.Request) { cell.ID = cid dash.Cells = append(dash.Cells, cell) - if err := s.DashboardsStore.Update(ctx, dash); err != nil { + if err := s.Store.Dashboards(ctx).Update(ctx, dash); err != nil { msg := fmt.Sprintf("Error adding cell %s to dashboard %d: %v", cid, id, err) Error(w, http.StatusInternalServerError, msg, s.Logger) return @@ -244,7 +244,7 @@ func (s *Service) DashboardCellID(w http.ResponseWriter, r *http.Request) { return } - dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + dash, err := s.Store.Dashboards(ctx).Get(ctx, chronograf.DashboardID(id)) if err != nil { notFound(w, id, s.Logger) return @@ -270,7 +270,7 @@ func (s *Service) RemoveDashboardCell(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + dash, err := s.Store.Dashboards(ctx).Get(ctx, chronograf.DashboardID(id)) if err != nil { notFound(w, id, s.Logger) return @@ -290,7 +290,7 @@ func (s *Service) RemoveDashboardCell(w http.ResponseWriter, r *http.Request) { } dash.Cells = append(dash.Cells[:cellid], dash.Cells[cellid+1:]...) - if err := s.DashboardsStore.Update(ctx, dash); err != nil { + if err := s.Store.Dashboards(ctx).Update(ctx, dash); err != nil { msg := fmt.Sprintf("Error removing cell %s from dashboard %d: %v", cid, id, err) Error(w, http.StatusInternalServerError, msg, s.Logger) return @@ -307,7 +307,7 @@ func (s *Service) ReplaceDashboardCell(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + dash, err := s.Store.Dashboards(ctx).Get(ctx, chronograf.DashboardID(id)) if err != nil { notFound(w, id, s.Logger) return @@ -339,7 +339,7 @@ func (s *Service) ReplaceDashboardCell(w http.ResponseWriter, r *http.Request) { cell.ID = cid dash.Cells[cellid] = cell - if err := s.DashboardsStore.Update(ctx, dash); err != nil { + if err := s.Store.Dashboards(ctx).Update(ctx, dash); err != nil { msg := fmt.Sprintf("Error updating cell %s in dashboard %d: %v", cid, id, err) Error(w, http.StatusInternalServerError, msg, s.Logger) return diff --git a/server/cells_test.go b/server/cells_test.go index e146f0ceec..e90014af19 100644 --- a/server/cells_test.go +++ b/server/cells_test.go @@ -227,14 +227,16 @@ func Test_Service_DashboardCells(t *testing.T) { // setup mock DashboardCells store and logger tlog := &mocks.TestLogger{} svc := &server.Service{ - DashboardsStore: &mocks.DashboardsStore{ - GetF: func(ctx context.Context, id chronograf.DashboardID) (chronograf.Dashboard, error) { - return chronograf.Dashboard{ - ID: chronograf.DashboardID(1), - Cells: test.mockResponse, - Templates: []chronograf.Template{}, - Name: "empty dashboard", - }, nil + Store: &mocks.Store{ + DashboardsStore: &mocks.DashboardsStore{ + GetF: func(ctx context.Context, id chronograf.DashboardID) (chronograf.Dashboard, error) { + return chronograf.Dashboard{ + ID: chronograf.DashboardID(1), + Cells: test.mockResponse, + Templates: []chronograf.Template{}, + Name: "empty dashboard", + }, nil + }, }, }, Logger: tlog, diff --git a/server/context.go b/server/context.go new file mode 100644 index 0000000000..b3f4e05679 --- /dev/null +++ b/server/context.go @@ -0,0 +1,30 @@ +package server + +import ( + "context" +) + +type serverContextKey string + +// ServerContextKey is the key used to specify that the +// server is making the requet via context +const ServerContextKey = serverContextKey("server") + +// hasServerContext speficies if the context contains +// the ServerContextKey and that the value stored there is true +func hasServerContext(ctx context.Context) bool { + // prevents panic in case of nil context + if ctx == nil { + return false + } + sa, ok := ctx.Value(ServerContextKey).(bool) + // should never happen + if !ok { + return false + } + return sa +} + +func serverContext(ctx context.Context) context.Context { + return context.WithValue(ctx, ServerContextKey, true) +} diff --git a/server/dashboards.go b/server/dashboards.go index 62e6aac1c5..a3995ff92f 100644 --- a/server/dashboards.go +++ b/server/dashboards.go @@ -15,11 +15,12 @@ type dashboardLinks struct { } type dashboardResponse struct { - ID chronograf.DashboardID `json:"id"` - Cells []dashboardCellResponse `json:"cells"` - Templates []templateResponse `json:"templates"` - Name string `json:"name"` - Links dashboardLinks `json:"links"` + ID chronograf.DashboardID `json:"id"` + Cells []dashboardCellResponse `json:"cells"` + Templates []templateResponse `json:"templates"` + Name string `json:"name"` + Organization string `json:"organization"` + Links dashboardLinks `json:"links"` } type getDashboardsResponse struct { @@ -33,10 +34,11 @@ func newDashboardResponse(d chronograf.Dashboard) *dashboardResponse { templates := newTemplateResponses(dd.ID, dd.Templates) return &dashboardResponse{ - ID: dd.ID, - Name: dd.Name, - Cells: cells, - Templates: templates, + ID: dd.ID, + Name: dd.Name, + Cells: cells, + Templates: templates, + Organization: d.Organization, Links: dashboardLinks{ Self: fmt.Sprintf("%s/%d", base, dd.ID), Cells: fmt.Sprintf("%s/%d/cells", base, dd.ID), @@ -48,7 +50,7 @@ func newDashboardResponse(d chronograf.Dashboard) *dashboardResponse { // Dashboards returns all dashboards within the store func (s *Service) Dashboards(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - dashboards, err := s.DashboardsStore.All(ctx) + dashboards, err := s.Store.Dashboards(ctx).All(ctx) if err != nil { Error(w, http.StatusInternalServerError, "Error loading dashboards", s.Logger) return @@ -73,7 +75,7 @@ func (s *Service) DashboardID(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - e, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + e, err := s.Store.Dashboards(ctx).Get(ctx, chronograf.DashboardID(id)) if err != nil { notFound(w, id, s.Logger) return @@ -86,25 +88,32 @@ func (s *Service) DashboardID(w http.ResponseWriter, r *http.Request) { // NewDashboard creates and returns a new dashboard object func (s *Service) NewDashboard(w http.ResponseWriter, r *http.Request) { var dashboard chronograf.Dashboard + var err error if err := json.NewDecoder(r.Body).Decode(&dashboard); err != nil { invalidJSON(w, s.Logger) return } - if err := ValidDashboardRequest(&dashboard); err != nil { + ctx := r.Context() + defaultOrg, err := s.Store.Organizations(ctx).DefaultOrganization(ctx) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + + if err := ValidDashboardRequest(&dashboard, fmt.Sprintf("%d", defaultOrg.ID)); err != nil { invalidData(w, err, s.Logger) return } - var err error - if dashboard, err = s.DashboardsStore.Add(r.Context(), dashboard); err != nil { + if dashboard, err = s.Store.Dashboards(ctx).Add(r.Context(), dashboard); err != nil { msg := fmt.Errorf("Error storing dashboard %v: %v", dashboard, err) unknownErrorWithMessage(w, msg, s.Logger) return } res := newDashboardResponse(dashboard) - w.Header().Add("Location", res.Links.Self) + location(w, res.Links.Self) encodeJSON(w, http.StatusCreated, res, s.Logger) } @@ -117,13 +126,13 @@ func (s *Service) RemoveDashboard(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - e, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + e, err := s.Store.Dashboards(ctx).Get(ctx, chronograf.DashboardID(id)) if err != nil { notFound(w, id, s.Logger) return } - if err := s.DashboardsStore.Delete(ctx, e); err != nil { + if err := s.Store.Dashboards(ctx).Delete(ctx, e); err != nil { unknownErrorWithMessage(w, err, s.Logger) return } @@ -140,7 +149,7 @@ func (s *Service) ReplaceDashboard(w http.ResponseWriter, r *http.Request) { } id := chronograf.DashboardID(idParam) - _, err = s.DashboardsStore.Get(ctx, id) + _, err = s.Store.Dashboards(ctx).Get(ctx, id) if err != nil { Error(w, http.StatusNotFound, fmt.Sprintf("ID %d not found", id), s.Logger) return @@ -153,12 +162,18 @@ func (s *Service) ReplaceDashboard(w http.ResponseWriter, r *http.Request) { } req.ID = id - if err := ValidDashboardRequest(&req); err != nil { + defaultOrg, err := s.Store.Organizations(ctx).DefaultOrganization(ctx) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + + if err := ValidDashboardRequest(&req, fmt.Sprintf("%d", defaultOrg.ID)); err != nil { invalidData(w, err, s.Logger) return } - if err := s.DashboardsStore.Update(ctx, req); err != nil { + if err := s.Store.Dashboards(ctx).Update(ctx, req); err != nil { msg := fmt.Sprintf("Error updating dashboard ID %d: %v", id, err) Error(w, http.StatusInternalServerError, msg, s.Logger) return @@ -179,7 +194,7 @@ func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) { } id := chronograf.DashboardID(idParam) - orig, err := s.DashboardsStore.Get(ctx, id) + orig, err := s.Store.Dashboards(ctx).Get(ctx, id) if err != nil { Error(w, http.StatusNotFound, fmt.Sprintf("ID %d not found", id), s.Logger) return @@ -195,7 +210,12 @@ func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) { if req.Name != "" { orig.Name = req.Name } else if len(req.Cells) > 0 { - if err := ValidDashboardRequest(&req); err != nil { + defaultOrg, err := s.Store.Organizations(ctx).DefaultOrganization(ctx) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + if err := ValidDashboardRequest(&req, fmt.Sprintf("%d", defaultOrg.ID)); err != nil { invalidData(w, err, s.Logger) return } @@ -205,7 +225,7 @@ func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) { return } - if err := s.DashboardsStore.Update(ctx, orig); err != nil { + if err := s.Store.Dashboards(ctx).Update(ctx, orig); err != nil { msg := fmt.Sprintf("Error updating dashboard ID %d: %v", id, err) Error(w, http.StatusInternalServerError, msg, s.Logger) return @@ -216,7 +236,10 @@ func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) { } // ValidDashboardRequest verifies that the dashboard cells have a query -func ValidDashboardRequest(d *chronograf.Dashboard) error { +func ValidDashboardRequest(d *chronograf.Dashboard, defaultOrgID string) error { + if d.Organization == "" { + d.Organization = defaultOrgID + } for i, c := range d.Cells { if err := ValidDashboardCellRequest(&c); err != nil { return err @@ -238,6 +261,7 @@ func DashboardDefaults(d chronograf.Dashboard) (newDash chronograf.Dashboard) { newDash.ID = d.ID newDash.Templates = d.Templates newDash.Name = d.Name + newDash.Organization = d.Organization newDash.Cells = make([]chronograf.DashboardCell, len(d.Cells)) for i, c := range d.Cells { diff --git a/server/dashboards_test.go b/server/dashboards_test.go index a8afc3e012..e19e1e86f7 100644 --- a/server/dashboards_test.go +++ b/server/dashboards_test.go @@ -144,6 +144,7 @@ func TestValidDashboardRequest(t *testing.T) { { name: "Updates all cell widths/heights", d: chronograf.Dashboard{ + Organization: "1337", Cells: []chronograf.DashboardCell{ { W: 0, @@ -166,6 +167,7 @@ func TestValidDashboardRequest(t *testing.T) { }, }, want: chronograf.Dashboard{ + Organization: "1337", Cells: []chronograf.DashboardCell{ { W: 4, @@ -190,13 +192,14 @@ func TestValidDashboardRequest(t *testing.T) { }, } for _, tt := range tests { - err := ValidDashboardRequest(&tt.d) + // TODO(desa): this Okay? + err := ValidDashboardRequest(&tt.d, "0") if (err != nil) != tt.wantErr { t.Errorf("%q. ValidDashboardRequest() error = %v, wantErr %v", tt.name, err, tt.wantErr) continue } - if !reflect.DeepEqual(tt.d, tt.want) { - t.Errorf("%q. ValidDashboardRequest() = %v, want %v", tt.name, tt.d, tt.want) + if diff := cmp.Diff(tt.d, tt.want); diff != "" { + t.Errorf("%q. ValidDashboardRequest(). got/want diff:\n%s", tt.name, diff) } } } @@ -210,6 +213,7 @@ func Test_newDashboardResponse(t *testing.T) { { name: "creates a dashboard response", d: chronograf.Dashboard{ + Organization: "0", Cells: []chronograf.DashboardCell{ { ID: "a", @@ -252,7 +256,8 @@ func Test_newDashboardResponse(t *testing.T) { }, }, want: &dashboardResponse{ - Templates: []templateResponse{}, + Organization: "0", + Templates: []templateResponse{}, Cells: []dashboardCellResponse{ dashboardCellResponse{ Links: dashboardCellLinks{ diff --git a/server/databases.go b/server/databases.go index 805e2d3f90..d64db14e06 100644 --- a/server/databases.go +++ b/server/databases.go @@ -76,7 +76,7 @@ func (h *Service) GetDatabases(w http.ResponseWriter, r *http.Request) { return } - src, err := h.SourcesStore.Get(ctx, srcID) + src, err := h.Store.Sources(ctx).Get(ctx, srcID) if err != nil { notFound(w, srcID, h.Logger) return @@ -122,7 +122,7 @@ func (h *Service) NewDatabase(w http.ResponseWriter, r *http.Request) { return } - src, err := h.SourcesStore.Get(ctx, srcID) + src, err := h.Store.Sources(ctx).Get(ctx, srcID) if err != nil { notFound(w, srcID, h.Logger) return @@ -172,7 +172,7 @@ func (h *Service) DropDatabase(w http.ResponseWriter, r *http.Request) { return } - src, err := h.SourcesStore.Get(ctx, srcID) + src, err := h.Store.Sources(ctx).Get(ctx, srcID) if err != nil { notFound(w, srcID, h.Logger) return @@ -207,7 +207,7 @@ func (h *Service) RetentionPolicies(w http.ResponseWriter, r *http.Request) { return } - src, err := h.SourcesStore.Get(ctx, srcID) + src, err := h.Store.Sources(ctx).Get(ctx, srcID) if err != nil { notFound(w, srcID, h.Logger) return @@ -261,7 +261,7 @@ func (h *Service) NewRetentionPolicy(w http.ResponseWriter, r *http.Request) { return } - src, err := h.SourcesStore.Get(ctx, srcID) + src, err := h.Store.Sources(ctx).Get(ctx, srcID) if err != nil { notFound(w, srcID, h.Logger) return @@ -311,7 +311,7 @@ func (h *Service) UpdateRetentionPolicy(w http.ResponseWriter, r *http.Request) return } - src, err := h.SourcesStore.Get(ctx, srcID) + src, err := h.Store.Sources(ctx).Get(ctx, srcID) if err != nil { notFound(w, srcID, h.Logger) return @@ -355,25 +355,25 @@ func (h *Service) UpdateRetentionPolicy(w http.ResponseWriter, r *http.Request) } // DropRetentionPolicy removes a retention policy from a database -func (h *Service) DropRetentionPolicy(w http.ResponseWriter, r *http.Request) { +func (s *Service) DropRetentionPolicy(w http.ResponseWriter, r *http.Request) { ctx := r.Context() srcID, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } - src, err := h.SourcesStore.Get(ctx, srcID) + src, err := s.Store.Sources(ctx).Get(ctx, srcID) if err != nil { - notFound(w, srcID, h.Logger) + notFound(w, srcID, s.Logger) return } - db := h.Databases + db := s.Databases if err = db.Connect(ctx, &src); err != nil { msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err) - Error(w, http.StatusBadRequest, msg, h.Logger) + Error(w, http.StatusBadRequest, msg, s.Logger) return } @@ -381,7 +381,7 @@ func (h *Service) DropRetentionPolicy(w http.ResponseWriter, r *http.Request) { rpID := httprouter.GetParamFromContext(ctx, "rpid") dropErr := db.DropRP(ctx, dbID, rpID) if dropErr != nil { - Error(w, http.StatusBadRequest, dropErr.Error(), h.Logger) + Error(w, http.StatusBadRequest, dropErr.Error(), s.Logger) return } diff --git a/server/databases_test.go b/server/databases_test.go index c2ca257bdd..c4789a3ee8 100644 --- a/server/databases_test.go +++ b/server/databases_test.go @@ -11,7 +11,7 @@ func TestService_GetDatabases(t *testing.T) { type fields struct { SourcesStore chronograf.SourcesStore ServersStore chronograf.ServersStore - LayoutStore chronograf.LayoutStore + LayoutsStore chronograf.LayoutsStore UsersStore chronograf.UsersStore DashboardsStore chronograf.DashboardsStore TimeSeriesClient TimeSeriesClient @@ -33,11 +33,13 @@ func TestService_GetDatabases(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := &Service{ - SourcesStore: tt.fields.SourcesStore, - ServersStore: tt.fields.ServersStore, - LayoutStore: tt.fields.LayoutStore, - UsersStore: tt.fields.UsersStore, - DashboardsStore: tt.fields.DashboardsStore, + Store: &Store{ + SourcesStore: tt.fields.SourcesStore, + ServersStore: tt.fields.ServersStore, + LayoutsStore: tt.fields.LayoutsStore, + UsersStore: tt.fields.UsersStore, + DashboardsStore: tt.fields.DashboardsStore, + }, TimeSeriesClient: tt.fields.TimeSeriesClient, Logger: tt.fields.Logger, UseAuth: tt.fields.UseAuth, @@ -52,7 +54,7 @@ func TestService_NewDatabase(t *testing.T) { type fields struct { SourcesStore chronograf.SourcesStore ServersStore chronograf.ServersStore - LayoutStore chronograf.LayoutStore + LayoutsStore chronograf.LayoutsStore UsersStore chronograf.UsersStore DashboardsStore chronograf.DashboardsStore TimeSeriesClient TimeSeriesClient @@ -74,11 +76,13 @@ func TestService_NewDatabase(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := &Service{ - SourcesStore: tt.fields.SourcesStore, - ServersStore: tt.fields.ServersStore, - LayoutStore: tt.fields.LayoutStore, - UsersStore: tt.fields.UsersStore, - DashboardsStore: tt.fields.DashboardsStore, + Store: &Store{ + SourcesStore: tt.fields.SourcesStore, + ServersStore: tt.fields.ServersStore, + LayoutsStore: tt.fields.LayoutsStore, + UsersStore: tt.fields.UsersStore, + DashboardsStore: tt.fields.DashboardsStore, + }, TimeSeriesClient: tt.fields.TimeSeriesClient, Logger: tt.fields.Logger, UseAuth: tt.fields.UseAuth, @@ -93,7 +97,7 @@ func TestService_DropDatabase(t *testing.T) { type fields struct { SourcesStore chronograf.SourcesStore ServersStore chronograf.ServersStore - LayoutStore chronograf.LayoutStore + LayoutsStore chronograf.LayoutsStore UsersStore chronograf.UsersStore DashboardsStore chronograf.DashboardsStore TimeSeriesClient TimeSeriesClient @@ -115,11 +119,13 @@ func TestService_DropDatabase(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := &Service{ - SourcesStore: tt.fields.SourcesStore, - ServersStore: tt.fields.ServersStore, - LayoutStore: tt.fields.LayoutStore, - UsersStore: tt.fields.UsersStore, - DashboardsStore: tt.fields.DashboardsStore, + Store: &Store{ + SourcesStore: tt.fields.SourcesStore, + ServersStore: tt.fields.ServersStore, + LayoutsStore: tt.fields.LayoutsStore, + UsersStore: tt.fields.UsersStore, + DashboardsStore: tt.fields.DashboardsStore, + }, TimeSeriesClient: tt.fields.TimeSeriesClient, Logger: tt.fields.Logger, UseAuth: tt.fields.UseAuth, @@ -134,7 +140,7 @@ func TestService_RetentionPolicies(t *testing.T) { type fields struct { SourcesStore chronograf.SourcesStore ServersStore chronograf.ServersStore - LayoutStore chronograf.LayoutStore + LayoutsStore chronograf.LayoutsStore UsersStore chronograf.UsersStore DashboardsStore chronograf.DashboardsStore TimeSeriesClient TimeSeriesClient @@ -156,11 +162,13 @@ func TestService_RetentionPolicies(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := &Service{ - SourcesStore: tt.fields.SourcesStore, - ServersStore: tt.fields.ServersStore, - LayoutStore: tt.fields.LayoutStore, - UsersStore: tt.fields.UsersStore, - DashboardsStore: tt.fields.DashboardsStore, + Store: &Store{ + SourcesStore: tt.fields.SourcesStore, + ServersStore: tt.fields.ServersStore, + LayoutsStore: tt.fields.LayoutsStore, + UsersStore: tt.fields.UsersStore, + DashboardsStore: tt.fields.DashboardsStore, + }, TimeSeriesClient: tt.fields.TimeSeriesClient, Logger: tt.fields.Logger, UseAuth: tt.fields.UseAuth, @@ -175,7 +183,7 @@ func TestService_NewRetentionPolicy(t *testing.T) { type fields struct { SourcesStore chronograf.SourcesStore ServersStore chronograf.ServersStore - LayoutStore chronograf.LayoutStore + LayoutsStore chronograf.LayoutsStore UsersStore chronograf.UsersStore DashboardsStore chronograf.DashboardsStore TimeSeriesClient TimeSeriesClient @@ -197,11 +205,13 @@ func TestService_NewRetentionPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := &Service{ - SourcesStore: tt.fields.SourcesStore, - ServersStore: tt.fields.ServersStore, - LayoutStore: tt.fields.LayoutStore, - UsersStore: tt.fields.UsersStore, - DashboardsStore: tt.fields.DashboardsStore, + Store: &Store{ + SourcesStore: tt.fields.SourcesStore, + ServersStore: tt.fields.ServersStore, + LayoutsStore: tt.fields.LayoutsStore, + UsersStore: tt.fields.UsersStore, + DashboardsStore: tt.fields.DashboardsStore, + }, TimeSeriesClient: tt.fields.TimeSeriesClient, Logger: tt.fields.Logger, UseAuth: tt.fields.UseAuth, @@ -216,7 +226,7 @@ func TestService_UpdateRetentionPolicy(t *testing.T) { type fields struct { SourcesStore chronograf.SourcesStore ServersStore chronograf.ServersStore - LayoutStore chronograf.LayoutStore + LayoutsStore chronograf.LayoutsStore UsersStore chronograf.UsersStore DashboardsStore chronograf.DashboardsStore TimeSeriesClient TimeSeriesClient @@ -238,11 +248,13 @@ func TestService_UpdateRetentionPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := &Service{ - SourcesStore: tt.fields.SourcesStore, - ServersStore: tt.fields.ServersStore, - LayoutStore: tt.fields.LayoutStore, - UsersStore: tt.fields.UsersStore, - DashboardsStore: tt.fields.DashboardsStore, + Store: &Store{ + SourcesStore: tt.fields.SourcesStore, + ServersStore: tt.fields.ServersStore, + LayoutsStore: tt.fields.LayoutsStore, + UsersStore: tt.fields.UsersStore, + DashboardsStore: tt.fields.DashboardsStore, + }, TimeSeriesClient: tt.fields.TimeSeriesClient, Logger: tt.fields.Logger, UseAuth: tt.fields.UseAuth, @@ -257,7 +269,7 @@ func TestService_DropRetentionPolicy(t *testing.T) { type fields struct { SourcesStore chronograf.SourcesStore ServersStore chronograf.ServersStore - LayoutStore chronograf.LayoutStore + LayoutsStore chronograf.LayoutsStore UsersStore chronograf.UsersStore DashboardsStore chronograf.DashboardsStore TimeSeriesClient TimeSeriesClient @@ -279,11 +291,13 @@ func TestService_DropRetentionPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := &Service{ - SourcesStore: tt.fields.SourcesStore, - ServersStore: tt.fields.ServersStore, - LayoutStore: tt.fields.LayoutStore, - UsersStore: tt.fields.UsersStore, - DashboardsStore: tt.fields.DashboardsStore, + Store: &Store{ + SourcesStore: tt.fields.SourcesStore, + ServersStore: tt.fields.ServersStore, + LayoutsStore: tt.fields.LayoutsStore, + UsersStore: tt.fields.UsersStore, + DashboardsStore: tt.fields.DashboardsStore, + }, TimeSeriesClient: tt.fields.TimeSeriesClient, Logger: tt.fields.Logger, UseAuth: tt.fields.UseAuth, diff --git a/server/helpers.go b/server/helpers.go new file mode 100644 index 0000000000..8952734982 --- /dev/null +++ b/server/helpers.go @@ -0,0 +1,7 @@ +package server + +import "net/http" + +func location(w http.ResponseWriter, self string) { + w.Header().Add("Location", self) +} diff --git a/server/influx.go b/server/influx.go index 1d2dd84831..0f1a027676 100644 --- a/server/influx.go +++ b/server/influx.go @@ -24,40 +24,40 @@ type postInfluxResponse struct { } // Influx proxies requests to influxdb. -func (h *Service) Influx(w http.ResponseWriter, r *http.Request) { +func (s *Service) Influx(w http.ResponseWriter, r *http.Request) { id, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } var req chronograf.Query if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - invalidJSON(w, h.Logger) + invalidJSON(w, s.Logger) return } if err = ValidInfluxRequest(req); err != nil { - invalidData(w, err, h.Logger) + invalidData(w, err, s.Logger) return } ctx := r.Context() - src, err := h.SourcesStore.Get(ctx, id) + src, err := s.Store.Sources(ctx).Get(ctx, id) if err != nil { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } - ts, err := h.TimeSeries(src) + ts, err := s.TimeSeries(src) if err != nil { msg := fmt.Sprintf("Unable to connect to source %d: %v", id, err) - Error(w, http.StatusBadRequest, msg, h.Logger) + Error(w, http.StatusBadRequest, msg, s.Logger) return } if err = ts.Connect(ctx, &src); err != nil { msg := fmt.Sprintf("Unable to connect to source %d: %v", id, err) - Error(w, http.StatusBadRequest, msg, h.Logger) + Error(w, http.StatusBadRequest, msg, s.Logger) return } @@ -65,38 +65,38 @@ func (h *Service) Influx(w http.ResponseWriter, r *http.Request) { if err != nil { if err == chronograf.ErrUpstreamTimeout { msg := "Timeout waiting for Influx response" - Error(w, http.StatusRequestTimeout, msg, h.Logger) + Error(w, http.StatusRequestTimeout, msg, s.Logger) return } // TODO: Here I want to return the error code from influx. - Error(w, http.StatusBadRequest, err.Error(), h.Logger) + Error(w, http.StatusBadRequest, err.Error(), s.Logger) return } res := postInfluxResponse{ Results: response, } - encodeJSON(w, http.StatusOK, res, h.Logger) + encodeJSON(w, http.StatusOK, res, s.Logger) } -func (h *Service) Write(w http.ResponseWriter, r *http.Request) { +func (s *Service) Write(w http.ResponseWriter, r *http.Request) { id, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } ctx := r.Context() - src, err := h.SourcesStore.Get(ctx, id) + src, err := s.Store.Sources(ctx).Get(ctx, id) if err != nil { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } u, err := url.Parse(src.URL) if err != nil { msg := fmt.Sprintf("Error parsing source url: %v", err) - Error(w, http.StatusUnprocessableEntity, msg, h.Logger) + Error(w, http.StatusUnprocessableEntity, msg, s.Logger) return } u.Path = "/write" diff --git a/server/kapacitors.go b/server/kapacitors.go index 937770ca7a..3f439a4a13 100644 --- a/server/kapacitors.go +++ b/server/kapacitors.go @@ -18,13 +18,18 @@ type postKapacitorRequest struct { Password string `json:"password,omitempty"` InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` // InsecureSkipVerify as true means any certificate presented by the kapacitor is accepted. Active bool `json:"active"` + Organization string `json:"organization"` // Organization is the organization ID that resource belongs to } -func (p *postKapacitorRequest) Valid() error { +func (p *postKapacitorRequest) Valid(defaultOrgID string) error { if p.Name == nil || p.URL == nil { return fmt.Errorf("name and url required") } + if p.Organization == "" { + p.Organization = defaultOrgID + } + url, err := url.ParseRequestURI(*p.URL) if err != nil { return fmt.Errorf("invalid source URI: %v", err) @@ -56,27 +61,34 @@ type kapacitor struct { } // NewKapacitor adds valid kapacitor store store. -func (h *Service) NewKapacitor(w http.ResponseWriter, r *http.Request) { +func (s *Service) NewKapacitor(w http.ResponseWriter, r *http.Request) { srcID, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } ctx := r.Context() - _, err = h.SourcesStore.Get(ctx, srcID) + _, err = s.Store.Sources(ctx).Get(ctx, srcID) if err != nil { - notFound(w, srcID, h.Logger) + notFound(w, srcID, s.Logger) return } var req postKapacitorRequest if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - invalidJSON(w, h.Logger) + invalidJSON(w, s.Logger) return } - if err := req.Valid(); err != nil { - invalidData(w, err, h.Logger) + + defaultOrg, err := s.Store.Organizations(ctx).DefaultOrganization(ctx) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + + if err := req.Valid(fmt.Sprintf("%d", defaultOrg.ID)); err != nil { + invalidData(w, err, s.Logger) return } @@ -88,17 +100,18 @@ func (h *Service) NewKapacitor(w http.ResponseWriter, r *http.Request) { InsecureSkipVerify: req.InsecureSkipVerify, URL: *req.URL, Active: req.Active, + Organization: req.Organization, } - if srv, err = h.ServersStore.Add(ctx, srv); err != nil { + if srv, err = s.Store.Servers(ctx).Add(ctx, srv); err != nil { msg := fmt.Errorf("Error storing kapacitor %v: %v", req, err) - unknownErrorWithMessage(w, msg, h.Logger) + unknownErrorWithMessage(w, msg, s.Logger) return } res := newKapacitor(srv) - w.Header().Add("Location", res.Links.Self) - encodeJSON(w, http.StatusCreated, res, h.Logger) + location(w, res.Links.Self) + encodeJSON(w, http.StatusCreated, res, s.Logger) } func newKapacitor(srv chronograf.Server) kapacitor { @@ -125,17 +138,17 @@ type kapacitors struct { } // Kapacitors retrieves all kapacitors from store. -func (h *Service) Kapacitors(w http.ResponseWriter, r *http.Request) { +func (s *Service) Kapacitors(w http.ResponseWriter, r *http.Request) { srcID, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } ctx := r.Context() - mrSrvs, err := h.ServersStore.All(ctx) + mrSrvs, err := s.Store.Servers(ctx).All(ctx) if err != nil { - Error(w, http.StatusInternalServerError, "Error loading kapacitors", h.Logger) + Error(w, http.StatusInternalServerError, "Error loading kapacitors", s.Logger) return } @@ -150,57 +163,57 @@ func (h *Service) Kapacitors(w http.ResponseWriter, r *http.Request) { Kapacitors: srvs, } - encodeJSON(w, http.StatusOK, res, h.Logger) + encodeJSON(w, http.StatusOK, res, s.Logger) } // KapacitorsID retrieves a kapacitor with ID from store. -func (h *Service) KapacitorsID(w http.ResponseWriter, r *http.Request) { +func (s *Service) KapacitorsID(w http.ResponseWriter, r *http.Request) { id, err := paramID("kid", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } srcID, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } ctx := r.Context() - srv, err := h.ServersStore.Get(ctx, id) + srv, err := s.Store.Servers(ctx).Get(ctx, id) if err != nil || srv.SrcID != srcID { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } res := newKapacitor(srv) - encodeJSON(w, http.StatusOK, res, h.Logger) + encodeJSON(w, http.StatusOK, res, s.Logger) } // RemoveKapacitor deletes kapacitor from store. -func (h *Service) RemoveKapacitor(w http.ResponseWriter, r *http.Request) { +func (s *Service) RemoveKapacitor(w http.ResponseWriter, r *http.Request) { id, err := paramID("kid", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } srcID, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } ctx := r.Context() - srv, err := h.ServersStore.Get(ctx, id) + srv, err := s.Store.Servers(ctx).Get(ctx, id) if err != nil || srv.SrcID != srcID { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } - if err = h.ServersStore.Delete(ctx, srv); err != nil { - unknownErrorWithMessage(w, err, h.Logger) + if err = s.Store.Servers(ctx).Delete(ctx, srv); err != nil { + unknownErrorWithMessage(w, err, s.Logger) return } @@ -230,34 +243,34 @@ func (p *patchKapacitorRequest) Valid() error { } // UpdateKapacitor incrementally updates a kapacitor definition in the store -func (h *Service) UpdateKapacitor(w http.ResponseWriter, r *http.Request) { +func (s *Service) UpdateKapacitor(w http.ResponseWriter, r *http.Request) { id, err := paramID("kid", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } srcID, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } ctx := r.Context() - srv, err := h.ServersStore.Get(ctx, id) + srv, err := s.Store.Servers(ctx).Get(ctx, id) if err != nil || srv.SrcID != srcID { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } var req patchKapacitorRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - invalidJSON(w, h.Logger) + invalidJSON(w, s.Logger) return } if err := req.Valid(); err != nil { - invalidData(w, err, h.Logger) + invalidData(w, err, s.Logger) return } @@ -280,34 +293,34 @@ func (h *Service) UpdateKapacitor(w http.ResponseWriter, r *http.Request) { srv.Active = *req.Active } - if err := h.ServersStore.Update(ctx, srv); err != nil { + if err := s.Store.Servers(ctx).Update(ctx, srv); err != nil { msg := fmt.Sprintf("Error updating kapacitor ID %d", id) - Error(w, http.StatusInternalServerError, msg, h.Logger) + Error(w, http.StatusInternalServerError, msg, s.Logger) return } res := newKapacitor(srv) - encodeJSON(w, http.StatusOK, res, h.Logger) + encodeJSON(w, http.StatusOK, res, s.Logger) } // KapacitorRulesPost proxies POST to kapacitor -func (h *Service) KapacitorRulesPost(w http.ResponseWriter, r *http.Request) { +func (s *Service) KapacitorRulesPost(w http.ResponseWriter, r *http.Request) { id, err := paramID("kid", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } srcID, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } ctx := r.Context() - srv, err := h.ServersStore.Get(ctx, id) + srv, err := s.Store.Servers(ctx).Get(ctx, id) if err != nil || srv.SrcID != srcID { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } @@ -315,7 +328,7 @@ func (h *Service) KapacitorRulesPost(w http.ResponseWriter, r *http.Request) { var req chronograf.AlertRule if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - invalidJSON(w, h.Logger) + invalidJSON(w, s.Logger) return } // TODO: validate this data @@ -328,12 +341,12 @@ func (h *Service) KapacitorRulesPost(w http.ResponseWriter, r *http.Request) { task, err := c.Create(ctx, req) if err != nil { - Error(w, http.StatusInternalServerError, err.Error(), h.Logger) + Error(w, http.StatusInternalServerError, err.Error(), s.Logger) return } res := newAlertResponse(task, srv.SrcID, srv.ID) - w.Header().Add("Location", res.Links.Self) - encodeJSON(w, http.StatusCreated, res, h.Logger) + location(w, res.Links.Self) + encodeJSON(w, http.StatusCreated, res, s.Logger) } type alertLinks struct { @@ -420,23 +433,23 @@ func ValidRuleRequest(rule chronograf.AlertRule) error { } // KapacitorRulesPut proxies PATCH to kapacitor -func (h *Service) KapacitorRulesPut(w http.ResponseWriter, r *http.Request) { +func (s *Service) KapacitorRulesPut(w http.ResponseWriter, r *http.Request) { id, err := paramID("kid", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } srcID, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } ctx := r.Context() - srv, err := h.ServersStore.Get(ctx, id) + srv, err := s.Store.Servers(ctx).Get(ctx, id) if err != nil || srv.SrcID != srcID { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } @@ -444,7 +457,7 @@ func (h *Service) KapacitorRulesPut(w http.ResponseWriter, r *http.Request) { c := kapa.NewClient(srv.URL, srv.Username, srv.Password, srv.InsecureSkipVerify) var req chronograf.AlertRule if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - invalidJSON(w, h.Logger) + invalidJSON(w, s.Logger) return } // TODO: validate this data @@ -458,10 +471,10 @@ func (h *Service) KapacitorRulesPut(w http.ResponseWriter, r *http.Request) { // Check if the rule exists and is scoped correctly if _, err = c.Get(ctx, tid); err != nil { if err == chronograf.ErrAlertNotFound { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } - Error(w, http.StatusInternalServerError, err.Error(), h.Logger) + Error(w, http.StatusInternalServerError, err.Error(), s.Logger) return } @@ -469,11 +482,11 @@ func (h *Service) KapacitorRulesPut(w http.ResponseWriter, r *http.Request) { req.ID = tid task, err := c.Update(ctx, c.Href(tid), req) if err != nil { - Error(w, http.StatusInternalServerError, err.Error(), h.Logger) + Error(w, http.StatusInternalServerError, err.Error(), s.Logger) return } res := newAlertResponse(task, srv.SrcID, srv.ID) - encodeJSON(w, http.StatusOK, res, h.Logger) + encodeJSON(w, http.StatusOK, res, s.Logger) } // KapacitorStatus is the current state of a running task @@ -490,23 +503,23 @@ func (k *KapacitorStatus) Valid() error { } // KapacitorRulesStatus proxies PATCH to kapacitor to enable/disable tasks -func (h *Service) KapacitorRulesStatus(w http.ResponseWriter, r *http.Request) { +func (s *Service) KapacitorRulesStatus(w http.ResponseWriter, r *http.Request) { id, err := paramID("kid", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } srcID, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } ctx := r.Context() - srv, err := h.ServersStore.Get(ctx, id) + srv, err := s.Store.Servers(ctx).Get(ctx, id) if err != nil || srv.SrcID != srcID { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } @@ -515,11 +528,11 @@ func (h *Service) KapacitorRulesStatus(w http.ResponseWriter, r *http.Request) { var req KapacitorStatus if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - invalidJSON(w, h.Logger) + invalidJSON(w, s.Logger) return } if err := req.Valid(); err != nil { - invalidData(w, err, h.Logger) + invalidData(w, err, s.Logger) return } @@ -527,10 +540,10 @@ func (h *Service) KapacitorRulesStatus(w http.ResponseWriter, r *http.Request) { _, err = c.Get(ctx, tid) if err != nil { if err == chronograf.ErrAlertNotFound { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } - Error(w, http.StatusInternalServerError, err.Error(), h.Logger) + Error(w, http.StatusInternalServerError, err.Error(), s.Logger) return } @@ -542,39 +555,39 @@ func (h *Service) KapacitorRulesStatus(w http.ResponseWriter, r *http.Request) { } if err != nil { - Error(w, http.StatusInternalServerError, err.Error(), h.Logger) + Error(w, http.StatusInternalServerError, err.Error(), s.Logger) return } res := newAlertResponse(task, srv.SrcID, srv.ID) - encodeJSON(w, http.StatusOK, res, h.Logger) + encodeJSON(w, http.StatusOK, res, s.Logger) } // KapacitorRulesGet retrieves all rules -func (h *Service) KapacitorRulesGet(w http.ResponseWriter, r *http.Request) { +func (s *Service) KapacitorRulesGet(w http.ResponseWriter, r *http.Request) { id, err := paramID("kid", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } srcID, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } ctx := r.Context() - srv, err := h.ServersStore.Get(ctx, id) + srv, err := s.Store.Servers(ctx).Get(ctx, id) if err != nil || srv.SrcID != srcID { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } c := kapa.NewClient(srv.URL, srv.Username, srv.Password, srv.InsecureSkipVerify) tasks, err := c.All(ctx) if err != nil { - Error(w, http.StatusInternalServerError, err.Error(), h.Logger) + Error(w, http.StatusInternalServerError, err.Error(), s.Logger) return } @@ -585,7 +598,7 @@ func (h *Service) KapacitorRulesGet(w http.ResponseWriter, r *http.Request) { ar := newAlertResponse(task, srv.SrcID, srv.ID) res.Rules = append(res.Rules, ar) } - encodeJSON(w, http.StatusOK, res, h.Logger) + encodeJSON(w, http.StatusOK, res, s.Logger) } type allAlertsResponse struct { @@ -593,23 +606,23 @@ type allAlertsResponse struct { } // KapacitorRulesID retrieves specific task -func (h *Service) KapacitorRulesID(w http.ResponseWriter, r *http.Request) { +func (s *Service) KapacitorRulesID(w http.ResponseWriter, r *http.Request) { id, err := paramID("kid", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } srcID, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } ctx := r.Context() - srv, err := h.ServersStore.Get(ctx, id) + srv, err := s.Store.Servers(ctx).Get(ctx, id) if err != nil || srv.SrcID != srcID { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } tid := httprouter.GetParamFromContext(ctx, "tid") @@ -620,35 +633,35 @@ func (h *Service) KapacitorRulesID(w http.ResponseWriter, r *http.Request) { task, err := c.Get(ctx, tid) if err != nil { if err == chronograf.ErrAlertNotFound { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } - Error(w, http.StatusInternalServerError, err.Error(), h.Logger) + Error(w, http.StatusInternalServerError, err.Error(), s.Logger) return } res := newAlertResponse(task, srv.SrcID, srv.ID) - encodeJSON(w, http.StatusOK, res, h.Logger) + encodeJSON(w, http.StatusOK, res, s.Logger) } // KapacitorRulesDelete proxies DELETE to kapacitor -func (h *Service) KapacitorRulesDelete(w http.ResponseWriter, r *http.Request) { +func (s *Service) KapacitorRulesDelete(w http.ResponseWriter, r *http.Request) { id, err := paramID("kid", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } srcID, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } ctx := r.Context() - srv, err := h.ServersStore.Get(ctx, id) + srv, err := s.Store.Servers(ctx).Get(ctx, id) if err != nil || srv.SrcID != srcID { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } @@ -658,14 +671,14 @@ func (h *Service) KapacitorRulesDelete(w http.ResponseWriter, r *http.Request) { // Check if the rule is linked to this server and kapacitor if _, err := c.Get(ctx, tid); err != nil { if err == chronograf.ErrAlertNotFound { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } - Error(w, http.StatusInternalServerError, err.Error(), h.Logger) + Error(w, http.StatusInternalServerError, err.Error(), s.Logger) return } if err := c.Delete(ctx, c.Href(tid)); err != nil { - Error(w, http.StatusInternalServerError, err.Error(), h.Logger) + Error(w, http.StatusInternalServerError, err.Error(), s.Logger) return } diff --git a/server/kapacitors_test.go b/server/kapacitors_test.go index 81bdbee681..0979beca97 100644 --- a/server/kapacitors_test.go +++ b/server/kapacitors_test.go @@ -186,20 +186,22 @@ func Test_KapacitorRulesGet(t *testing.T) { // setup mock service and test logger testLogger := mocks.TestLogger{} svc := &server.Service{ - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: ID, - InsecureSkipVerify: true, - }, nil + Store: &mocks.Store{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: ID, + InsecureSkipVerify: true, + }, nil + }, }, - }, - ServersStore: &mocks.ServersStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Server, error) { - return chronograf.Server{ - SrcID: ID, - URL: kapaSrv.URL, - }, nil + ServersStore: &mocks.ServersStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Server, error) { + return chronograf.Server{ + SrcID: ID, + URL: kapaSrv.URL, + }, nil + }, }, }, Logger: &testLogger, diff --git a/server/layout.go b/server/layout.go index 8cc53068e3..d3870a1363 100644 --- a/server/layout.go +++ b/server/layout.go @@ -1,7 +1,6 @@ package server import ( - "encoding/json" "fmt" "net/http" @@ -49,37 +48,12 @@ func newLayoutResponse(layout chronograf.Layout) layoutResponse { } } -// NewLayout adds a valid layout to store. -func (h *Service) NewLayout(w http.ResponseWriter, r *http.Request) { - var layout chronograf.Layout - if err := json.NewDecoder(r.Body).Decode(&layout); err != nil { - invalidJSON(w, h.Logger) - return - } - - if err := ValidLayoutRequest(layout); err != nil { - invalidData(w, err, h.Logger) - return - } - - var err error - if layout, err = h.LayoutStore.Add(r.Context(), layout); err != nil { - msg := fmt.Errorf("Error storing layout %v: %v", layout, err) - unknownErrorWithMessage(w, msg, h.Logger) - return - } - - res := newLayoutResponse(layout) - w.Header().Add("Location", res.Link.Href) - encodeJSON(w, http.StatusCreated, res, h.Logger) -} - type getLayoutsResponse struct { Layouts []layoutResponse `json:"layouts"` } // Layouts retrieves all layouts from store -func (h *Service) Layouts(w http.ResponseWriter, r *http.Request) { +func (s *Service) Layouts(w http.ResponseWriter, r *http.Request) { // Construct a filter sieve for both applications and measurements filtered := map[string]bool{} for _, a := range r.URL.Query()["app"] { @@ -91,9 +65,9 @@ func (h *Service) Layouts(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - layouts, err := h.LayoutStore.All(ctx) + layouts, err := s.Store.Layouts(ctx).All(ctx) if err != nil { - Error(w, http.StatusInternalServerError, "Error loading layouts", h.Logger) + Error(w, http.StatusInternalServerError, "Error loading layouts", s.Logger) return } @@ -110,94 +84,32 @@ func (h *Service) Layouts(w http.ResponseWriter, r *http.Request) { res := getLayoutsResponse{ Layouts: []layoutResponse{}, } + + seen := make(map[string]bool) for _, layout := range layouts { + // remove duplicates + if seen[layout.Measurement+layout.ID] { + continue + } + // filter for data that belongs to provided application or measurement if filter(&layout) { res.Layouts = append(res.Layouts, newLayoutResponse(layout)) } } - encodeJSON(w, http.StatusOK, res, h.Logger) + encodeJSON(w, http.StatusOK, res, s.Logger) } // LayoutsID retrieves layout with ID from store -func (h *Service) LayoutsID(w http.ResponseWriter, r *http.Request) { +func (s *Service) LayoutsID(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id := httprouter.GetParamFromContext(ctx, "id") - layout, err := h.LayoutStore.Get(ctx, id) + layout, err := s.Store.Layouts(ctx).Get(ctx, id) if err != nil { - Error(w, http.StatusNotFound, fmt.Sprintf("ID %s not found", id), h.Logger) + Error(w, http.StatusNotFound, fmt.Sprintf("ID %s not found", id), s.Logger) return } res := newLayoutResponse(layout) - encodeJSON(w, http.StatusOK, res, h.Logger) -} - -// RemoveLayout deletes layout from store. -func (h *Service) RemoveLayout(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id := httprouter.GetParamFromContext(ctx, "id") - - layout := chronograf.Layout{ - ID: id, - } - - if err := h.LayoutStore.Delete(ctx, layout); err != nil { - unknownErrorWithMessage(w, err, h.Logger) - return - } - - w.WriteHeader(http.StatusNoContent) -} - -// UpdateLayout replaces the layout of ID with new valid layout. -func (h *Service) UpdateLayout(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id := httprouter.GetParamFromContext(ctx, "id") - - _, err := h.LayoutStore.Get(ctx, id) - if err != nil { - Error(w, http.StatusNotFound, fmt.Sprintf("ID %s not found", id), h.Logger) - return - } - - var req chronograf.Layout - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - invalidJSON(w, h.Logger) - return - } - req.ID = id - - if err := ValidLayoutRequest(req); err != nil { - invalidData(w, err, h.Logger) - return - } - - if err := h.LayoutStore.Update(ctx, req); err != nil { - msg := fmt.Sprintf("Error updating layout ID %s: %v", id, err) - Error(w, http.StatusInternalServerError, msg, h.Logger) - return - } - - res := newLayoutResponse(req) - encodeJSON(w, http.StatusOK, res, h.Logger) -} - -// ValidLayoutRequest checks if the layout has valid application, measurement and cells. -func ValidLayoutRequest(l chronograf.Layout) error { - if l.Application == "" || l.Measurement == "" || len(l.Cells) == 0 { - return fmt.Errorf("app, measurement, and cells required") - } - - for _, c := range l.Cells { - if c.W == 0 || c.H == 0 { - return fmt.Errorf("w, and h required") - } - for _, q := range c.Queries { - if q.Command == "" { - return fmt.Errorf("query required") - } - } - } - return nil + encodeJSON(w, http.StatusOK, res, s.Logger) } diff --git a/server/layout_test.go b/server/layout_test.go index 16380da308..6a9479f159 100644 --- a/server/layout_test.go +++ b/server/layout_test.go @@ -126,7 +126,7 @@ func Test_Layouts(t *testing.T) { // setup mock chronograf.Service and mock logger lg := &mocks.TestLogger{} svc := server.Service{ - LayoutStore: &mocks.LayoutStore{ + Store: &mocks.Store{LayoutsStore: &mocks.LayoutsStore{ AllF: func(ctx context.Context) ([]chronograf.Layout, error) { if len(test.allLayouts) == 0 { return []chronograf.Layout{ @@ -137,6 +137,7 @@ func Test_Layouts(t *testing.T) { } }, }, + }, Logger: lg, } diff --git a/server/mappings.go b/server/mappings.go deleted file mode 100644 index e26b630130..0000000000 --- a/server/mappings.go +++ /dev/null @@ -1,38 +0,0 @@ -package server - -import "net/http" - -type getMappingsResponse struct { - Mappings []mapping `json:"mappings"` -} - -type mapping struct { - Measurement string `json:"measurement"` // The measurement where data for this mapping is found - Name string `json:"name"` // The application name which will be assigned to the corresponding measurement -} - -// GetMappings returns the known mappings of measurements to applications -func (h *Service) GetMappings(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - layouts, err := h.LayoutStore.All(ctx) - if err != nil { - Error(w, http.StatusInternalServerError, "Error loading layouts", h.Logger) - return - } - - mp := getMappingsResponse{ - Mappings: []mapping{}, - } - - seen := make(map[string]bool) - - for _, layout := range layouts { - if seen[layout.Measurement+layout.ID] { - continue - } - mp.Mappings = append(mp.Mappings, mapping{layout.Measurement, layout.Application}) - seen[layout.Measurement+layout.ID] = true - } - - encodeJSON(w, http.StatusOK, mp, h.Logger) -} diff --git a/server/me.go b/server/me.go index ba44090f9c..1521afabde 100644 --- a/server/me.go +++ b/server/me.go @@ -1,13 +1,16 @@ package server import ( + "encoding/json" "fmt" "net/http" + "sort" "golang.org/x/net/context" "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/oauth2" + "github.com/influxdata/chronograf/organizations" ) type meLinks struct { @@ -16,7 +19,9 @@ type meLinks struct { type meResponse struct { *chronograf.User - Links meLinks `json:"links"` + Links meLinks `json:"links"` + Organizations []chronograf.Organization `json:"organizations,omitempty"` + CurrentOrganization *chronograf.Organization `json:"currentOrganization,omitempty"` } // If new user response is nil, return an empty meResponse because it @@ -25,7 +30,7 @@ func newMeResponse(usr *chronograf.User) meResponse { base := "/chronograf/v1/users" name := "me" if usr != nil { - name = PathEscape(usr.Name) + name = PathEscape(fmt.Sprintf("%d", usr.ID)) } return meResponse{ @@ -36,15 +41,11 @@ func newMeResponse(usr *chronograf.User) meResponse { } } -func getEmail(ctx context.Context) (string, error) { - principal, err := getPrincipal(ctx) - if err != nil { - return "", err - } - if principal.Subject == "" { - return "", fmt.Errorf("Token not found") - } - return principal.Subject, nil +// TODO: This Scheme value is hard-coded temporarily since we only currently +// support OAuth2. This hard-coding should be removed whenever we add +// support for other authentication schemes. +func getScheme(ctx context.Context) (string, error) { + return "oauth2", nil } func getPrincipal(ctx context.Context) (oauth2.Principal, error) { @@ -56,41 +57,305 @@ func getPrincipal(ctx context.Context) (oauth2.Principal, error) { return principal, nil } -// Me does a findOrCreate based on the email in the context -func (h *Service) Me(w http.ResponseWriter, r *http.Request) { +func getValidPrincipal(ctx context.Context) (oauth2.Principal, error) { + p, err := getPrincipal(ctx) + if err != nil { + return p, err + } + if p.Subject == "" { + return oauth2.Principal{}, fmt.Errorf("Token not found") + } + if p.Issuer == "" { + return oauth2.Principal{}, fmt.Errorf("Token not found") + } + return p, nil +} + +type meRequest struct { + // Organization is the OrganizationID + Organization string `json:"organization"` +} + +// UpdateMe changes the user's current organization on the JWT and responds +// with the same semantics as Me +func (s *Service) UpdateMe(auth oauth2.Authenticator) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + serverCtx := serverContext(ctx) + principal, err := auth.Validate(ctx, r) + if err != nil { + s.Logger.Error(fmt.Sprintf("Invalid principal: %v", err)) + Error(w, http.StatusForbidden, "invalid principal", s.Logger) + return + } + var req meRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, s.Logger) + return + } + + // validate that the organization exists + orgID, err := parseOrganizationID(req.Organization) + if err != nil { + Error(w, http.StatusInternalServerError, err.Error(), s.Logger) + return + } + _, err = s.Store.Organizations(serverCtx).Get(serverCtx, chronograf.OrganizationQuery{ID: &orgID}) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + // validate that user belongs to organization + ctx = context.WithValue(ctx, organizations.ContextKey, req.Organization) + + p, err := getValidPrincipal(ctx) + if err != nil { + invalidData(w, err, s.Logger) + return + } + if p.Organization == "" { + defaultOrg, err := s.Store.Organizations(serverCtx).DefaultOrganization(serverCtx) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + p.Organization = fmt.Sprintf("%d", defaultOrg.ID) + } + scheme, err := getScheme(ctx) + if err != nil { + invalidData(w, err, s.Logger) + return + } + _, err = s.Store.Users(ctx).Get(ctx, chronograf.UserQuery{ + Name: &p.Subject, + Provider: &p.Issuer, + Scheme: &scheme, + }) + if err == chronograf.ErrUserNotFound { + // Since a user is not a part of this organization, we should tell them that they are Forbidden (403) from accessing this resource + Error(w, http.StatusForbidden, err.Error(), s.Logger) + return + } + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + // TODO: change to principal.CurrentOrganization + principal.Organization = req.Organization + + if err := auth.Authorize(ctx, w, principal); err != nil { + Error(w, http.StatusInternalServerError, err.Error(), s.Logger) + return + } + + ctx = context.WithValue(ctx, oauth2.PrincipalKey, principal) + + s.Me(w, r.WithContext(ctx)) + } +} + +// Me does a findOrCreate based on the username in the context +func (s *Service) Me(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - if !h.UseAuth { + if !s.UseAuth { // If there's no authentication, return an empty user res := newMeResponse(nil) - encodeJSON(w, http.StatusOK, res, h.Logger) + encodeJSON(w, http.StatusOK, res, s.Logger) return } - email, err := getEmail(ctx) + p, err := getValidPrincipal(ctx) if err != nil { - invalidData(w, err, h.Logger) + invalidData(w, err, s.Logger) + return + } + scheme, err := getScheme(ctx) + if err != nil { + invalidData(w, err, s.Logger) return } - usr, err := h.UsersStore.Get(ctx, email) - if err == nil { + ctx = context.WithValue(ctx, organizations.ContextKey, p.Organization) + serverCtx := serverContext(ctx) + + if p.Organization == "" { + defaultOrg, err := s.Store.Organizations(serverCtx).DefaultOrganization(serverCtx) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + p.Organization = fmt.Sprintf("%d", defaultOrg.ID) + } + + usr, err := s.Store.Users(serverCtx).Get(serverCtx, chronograf.UserQuery{ + Name: &p.Subject, + Provider: &p.Issuer, + Scheme: &scheme, + }) + if err != nil && err != chronograf.ErrUserNotFound { + unknownErrorWithMessage(w, err, s.Logger) + return + } + + defaultOrg, err := s.Store.Organizations(serverCtx).DefaultOrganization(serverCtx) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + + if usr != nil { + // If the default org is private and the user has no roles, they should not have access + if !defaultOrg.Public && len(usr.Roles) == 0 { + Error(w, http.StatusForbidden, "This organization is private. To gain access, you must be explicitly added by an administrator.", s.Logger) + return + } + orgID, err := parseOrganizationID(p.Organization) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + currentOrg, err := s.Store.Organizations(serverCtx).Get(serverCtx, chronograf.OrganizationQuery{ID: &orgID}) + if err == chronograf.ErrOrganizationNotFound { + // The intent is to force a the user to go through another auth flow + Error(w, http.StatusForbidden, "user's current organization was not found", s.Logger) + return + } + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + + defaultOrgID := fmt.Sprintf("%d", defaultOrg.ID) + // If a user was added via the API, they might not yet be a member of the default organization + // Here we check to verify that they are a user in the default organization + if !hasRoleInDefaultOrganization(usr, defaultOrgID) { + usr.Roles = append(usr.Roles, chronograf.Role{ + Organization: defaultOrgID, + Name: defaultOrg.DefaultRole, + }) + if err := s.Store.Users(serverCtx).Update(serverCtx, usr); err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + } + orgs, err := s.usersOrganizations(serverCtx, usr) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } res := newMeResponse(usr) - encodeJSON(w, http.StatusOK, res, h.Logger) + res.Organizations = orgs + res.CurrentOrganization = currentOrg + encodeJSON(w, http.StatusOK, res, s.Logger) + return + } + + // If users must be explicitly added to the default organization, respond with 403 + // forbidden + if !defaultOrg.Public { + Error(w, http.StatusForbidden, "This organization is private. To gain access, you must be explicitly added by an administrator.", s.Logger) return } // Because we didnt find a user, making a new one user := &chronograf.User{ - Name: email, + Name: p.Subject, + Provider: p.Issuer, + // TODO: This Scheme value is hard-coded temporarily since we only currently + // support OAuth2. This hard-coding should be removed whenever we add + // support for other authentication schemes. + Scheme: scheme, + Roles: []chronograf.Role{ + { + Name: defaultOrg.DefaultRole, + // This is the ID of the default organization + Organization: fmt.Sprintf("%d", defaultOrg.ID), + }, + }, + // TODO(desa): this needs a better name + SuperAdmin: s.newUsersAreSuperAdmin(), } - newUser, err := h.UsersStore.Add(ctx, user) + newUser, err := s.Store.Users(serverCtx).Add(serverCtx, user) if err != nil { msg := fmt.Errorf("error storing user %s: %v", user.Name, err) - unknownErrorWithMessage(w, msg, h.Logger) + unknownErrorWithMessage(w, msg, s.Logger) return } + orgs, err := s.usersOrganizations(serverCtx, newUser) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + orgID, err := parseOrganizationID(p.Organization) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + currentOrg, err := s.Store.Organizations(serverCtx).Get(serverCtx, chronograf.OrganizationQuery{ID: &orgID}) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } res := newMeResponse(newUser) - encodeJSON(w, http.StatusOK, res, h.Logger) + res.Organizations = orgs + res.CurrentOrganization = currentOrg + encodeJSON(w, http.StatusOK, res, s.Logger) +} + +func (s *Service) firstUser() bool { + serverCtx := serverContext(context.Background()) + numUsers, err := s.Store.Users(serverCtx).Num(serverCtx) + if err != nil { + return false + } + + return numUsers == 0 +} +func (s *Service) newUsersAreSuperAdmin() bool { + if s.firstUser() { + return true + } + return !s.SuperAdminFirstUserOnly +} + +func (s *Service) usersOrganizations(ctx context.Context, u *chronograf.User) ([]chronograf.Organization, error) { + if u == nil { + // TODO(desa): better error + return nil, fmt.Errorf("user was nil") + } + + orgIDs := map[string]bool{} + for _, role := range u.Roles { + orgIDs[role.Organization] = true + } + + orgs := []chronograf.Organization{} + for orgID, _ := range orgIDs { + id, err := parseOrganizationID(orgID) + org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &id}) + if err != nil { + return nil, err + } + orgs = append(orgs, *org) + } + + sort.Slice(orgs, func(i, j int) bool { + return orgs[i].ID < orgs[j].ID + }) + + return orgs, nil +} + +func hasRoleInDefaultOrganization(u *chronograf.User, orgID string) bool { + for _, role := range u.Roles { + if role.Organization == orgID { + return true + } + } + + return false } diff --git a/server/me_test.go b/server/me_test.go index 147bf8f3ae..f92dc8a622 100644 --- a/server/me_test.go +++ b/server/me_test.go @@ -1,7 +1,9 @@ package server import ( + "bytes" "context" + "encoding/json" "fmt" "io/ioutil" "net/http" @@ -12,15 +14,18 @@ import ( "github.com/influxdata/chronograf/log" "github.com/influxdata/chronograf/mocks" "github.com/influxdata/chronograf/oauth2" + "github.com/influxdata/chronograf/roles" ) type MockUsers struct{} func TestService_Me(t *testing.T) { type fields struct { - UsersStore chronograf.UsersStore - Logger chronograf.Logger - UseAuth bool + UsersStore chronograf.UsersStore + OrganizationsStore chronograf.OrganizationsStore + Logger chronograf.Logger + UseAuth bool + SuperAdminFirstUserOnly bool } type args struct { w *httptest.ResponseRecorder @@ -35,6 +40,72 @@ func TestService_Me(t *testing.T) { wantContentType string wantBody string }{ + { + name: "Existing user - not member of any organization", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: true, + SuperAdminFirstUserOnly: true, + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "Default", + DefaultRole: roles.ViewerRoleName, + Public: false, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case 0: + return &chronograf.Organization{ + ID: 0, + Name: "Default", + DefaultRole: roles.ViewerRoleName, + Public: false, + }, nil + case 1: + return &chronograf.Organization{ + ID: 1, + Name: "The Bad Place", + Public: false, + }, nil + } + return nil, nil + }, + }, + UsersStore: &mocks.UsersStore{ + NumF: func(ctx context.Context) (int, error) { + // This function gets to verify that there is at least one first user + return 1, nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + Name: "me", + Provider: "github", + Scheme: "oauth2", + }, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + }, + }, + principal: oauth2.Principal{ + Subject: "me", + Issuer: "github", + }, + wantStatus: http.StatusForbidden, + wantContentType: "application/json", + wantBody: `{"code":403,"message":"This organization is private. To gain access, you must be explicitly added by an administrator."}`, + }, { name: "Existing user", args: args{ @@ -43,46 +114,286 @@ func TestService_Me(t *testing.T) { }, fields: fields{ UseAuth: true, - UsersStore: &mocks.UsersStore{ - GetF: func(ctx context.Context, name string) (*chronograf.User, error) { - return &chronograf.User{ - Name: "me", - Passwd: "hunter2", + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "Default", + DefaultRole: roles.ViewerRoleName, + Public: true, }, nil }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case 0: + return &chronograf.Organization{ + ID: 0, + Name: "Default", + DefaultRole: roles.ViewerRoleName, + Public: true, + }, nil + case 1: + return &chronograf.Organization{ + ID: 1, + Name: "The Bad Place", + Public: true, + }, nil + } + return nil, nil + }, + }, + UsersStore: &mocks.UsersStore{ + NumF: func(ctx context.Context) (int, error) { + // This function gets to verify that there is at least one first user + return 1, nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + Name: "me", + Provider: "github", + Scheme: "oauth2", + }, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, }, }, principal: oauth2.Principal{ Subject: "me", + Issuer: "github", }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"me","password":"hunter2","links":{"self":"/chronograf/v1/users/me"}} + wantBody: `{"name":"me","roles":[{"name":"viewer","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"Default","public":true,"defaultRole":"viewer"}],"currentOrganization":{"id":"0","defaultRole":"viewer","name":"Default","public":true}} `, }, { - name: "New user", + name: "Existing user - organization doesn't exist", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest("GET", "http://example.com/foo", nil), }, fields: fields{ UseAuth: true, + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "Default", + DefaultRole: roles.ViewerRoleName, + Public: true, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case 0: + return &chronograf.Organization{ + ID: 0, + Name: "Default", + DefaultRole: roles.ViewerRoleName, + Public: true, + }, nil + } + return nil, chronograf.ErrOrganizationNotFound + }, + }, UsersStore: &mocks.UsersStore{ - GetF: func(ctx context.Context, name string) (*chronograf.User, error) { - return nil, fmt.Errorf("Unknown User") + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + Name: "me", + Provider: "github", + Scheme: "oauth2", + }, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + }, + }, + principal: oauth2.Principal{ + Subject: "me", + Issuer: "github", + Organization: "1", + }, + wantStatus: http.StatusForbidden, + wantContentType: "application/json", + wantBody: `{"code":403,"message":"user's current organization was not found"}`, + }, + { + name: "new user - default org is public", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: true, + SuperAdminFirstUserOnly: false, + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + Public: true, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + Public: true, + }, nil + }, + }, + UsersStore: &mocks.UsersStore{ + NumF: func(ctx context.Context) (int, error) { + // This function gets to verify that there is at least one first user + return 1, nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return nil, chronograf.ErrUserNotFound }, AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { return u, nil }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, }, }, principal: oauth2.Principal{ Subject: "secret", + Issuer: "auth0", }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"secret","password":"","links":{"self":"/chronograf/v1/users/secret"}} + wantBody: `{"name":"secret","superAdmin":true,"roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","defaultRole":"viewer","public":true}],"currentOrganization":{"id":"0","name":"The Gnarly Default","defaultRole":"viewer","public":true}} +`, + }, + { + name: "New user - New users not super admin, not first user", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: true, + SuperAdminFirstUserOnly: true, + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + Public: true, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + Public: true, + }, nil + }, + }, + UsersStore: &mocks.UsersStore{ + NumF: func(ctx context.Context) (int, error) { + // This function gets to verify that there is at least one first user + return 1, nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return nil, chronograf.ErrUserNotFound + }, + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + }, + }, + principal: oauth2.Principal{ + Subject: "secret", + Issuer: "auth0", + }, + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"name":"secret","roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}} +`, + }, + { + name: "New user - New users not super admin, first user", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: true, + SuperAdminFirstUserOnly: true, + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + Public: true, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + Public: true, + }, nil + }, + }, + UsersStore: &mocks.UsersStore{ + NumF: func(ctx context.Context) (int, error) { + // This function gets to verify that there is at least one first user + return 0, nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return nil, chronograf.ErrUserNotFound + }, + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + }, + }, + principal: oauth2.Principal{ + Subject: "secret", + Issuer: "auth0", + }, + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"name":"secret","superAdmin":true,"roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}} `, }, { @@ -92,19 +403,43 @@ func TestService_Me(t *testing.T) { r: httptest.NewRequest("GET", "http://example.com/foo", nil), }, fields: fields{ - UseAuth: true, + UseAuth: true, + SuperAdminFirstUserOnly: true, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Public: true, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "The Bad Place", + Public: true, + }, nil + }, + }, UsersStore: &mocks.UsersStore{ - GetF: func(ctx context.Context, name string) (*chronograf.User, error) { - return nil, fmt.Errorf("Unknown User") + NumF: func(ctx context.Context) (int, error) { + // This function gets to verify that there is at least one first user + return 1, nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return nil, chronograf.ErrUserNotFound }, AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { return nil, fmt.Errorf("Why Heavy?") }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, }, Logger: log.New(log.DebugLevel), }, principal: oauth2.Principal{ Subject: "secret", + Issuer: "heroku", }, wantStatus: http.StatusInternalServerError, wantContentType: "application/json", @@ -117,8 +452,9 @@ func TestService_Me(t *testing.T) { r: httptest.NewRequest("GET", "http://example.com/foo", nil), }, fields: fields{ - UseAuth: false, - Logger: log.New(log.DebugLevel), + UseAuth: false, + SuperAdminFirstUserOnly: true, + Logger: log.New(log.DebugLevel), }, wantStatus: http.StatusOK, wantContentType: "application/json", @@ -132,24 +468,76 @@ func TestService_Me(t *testing.T) { r: httptest.NewRequest("GET", "http://example.com/foo", nil), }, fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), + UseAuth: true, + SuperAdminFirstUserOnly: true, + Logger: log.New(log.DebugLevel), }, wantStatus: http.StatusUnprocessableEntity, principal: oauth2.Principal{ Subject: "", + Issuer: "", }, }, + { + name: "new user - default org is private", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "The Bad Place", + DefaultRole: roles.MemberRoleName, + Public: false, + }, nil + }, + }, + UsersStore: &mocks.UsersStore{ + NumF: func(ctx context.Context) (int, error) { + // This function gets to verify that there is at least one first user + return 1, nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return nil, chronograf.ErrUserNotFound + }, + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + }, + }, + principal: oauth2.Principal{ + Subject: "secret", + Issuer: "auth0", + }, + wantStatus: http.StatusForbidden, + wantContentType: "application/json", + wantBody: `{"code":403,"message":"This organization is private. To gain access, you must be explicitly added by an administrator."}`, + }, } for _, tt := range tests { tt.args.r = tt.args.r.WithContext(context.WithValue(context.Background(), oauth2.PrincipalKey, tt.principal)) - h := &Service{ - UsersStore: tt.fields.UsersStore, - Logger: tt.fields.Logger, - UseAuth: tt.fields.UseAuth, + s := &Service{ + Store: &mocks.Store{ + UsersStore: tt.fields.UsersStore, + OrganizationsStore: tt.fields.OrganizationsStore, + }, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + SuperAdminFirstUserOnly: tt.fields.SuperAdminFirstUserOnly, } - h.Me(tt.args.w, tt.args.r) + s.Me(tt.args.w, tt.args.r) resp := tt.args.w.Result() content := resp.Header.Get("Content-Type") @@ -161,8 +549,331 @@ func TestService_Me(t *testing.T) { if tt.wantContentType != "" && content != tt.wantContentType { t.Errorf("%q. Me() = %v, want %v", tt.name, content, tt.wantContentType) } - if tt.wantBody != "" && string(body) != tt.wantBody { + if tt.wantBody == "" { + continue + } + if eq, err := jsonEqual(tt.wantBody, string(body)); err != nil || !eq { t.Errorf("%q. Me() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) } } } + +func TestService_UpdateMe(t *testing.T) { + type fields struct { + UsersStore chronograf.UsersStore + OrganizationsStore chronograf.OrganizationsStore + Logger chronograf.Logger + UseAuth bool + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + meRequest *meRequest + auth mocks.Authenticator + } + tests := []struct { + name string + fields fields + args args + principal oauth2.Principal + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Set the current User's organization", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + meRequest: &meRequest{ + Organization: "1337", + }, + auth: mocks.Authenticator{}, + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + Name: "me", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1337", + }, + }, + }, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "Default", + DefaultRole: roles.AdminRoleName, + Public: true, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + switch *q.ID { + case 0: + return &chronograf.Organization{ + ID: 0, + Name: "Default", + DefaultRole: roles.AdminRoleName, + Public: true, + }, nil + case 1337: + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + Public: true, + }, nil + } + return nil, nil + }, + }, + }, + principal: oauth2.Principal{ + Subject: "me", + Issuer: "github", + }, + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"},{"name":"admin","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"Default","public":true,"defaultRole":"admin"},{"id":"1337","name":"The ShillBillThrilliettas","public":true}],"currentOrganization":{"id":"1337","name":"The ShillBillThrilliettas","public":true}}`, + }, + { + name: "Change the current User's organization", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + meRequest: &meRequest{ + Organization: "1337", + }, + auth: mocks.Authenticator{}, + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + Name: "me", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1337", + }, + }, + }, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "Default", + DefaultRole: roles.EditorRoleName, + Public: true, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + switch *q.ID { + case 1337: + return &chronograf.Organization{ + ID: 1337, + Name: "The ThrillShilliettos", + Public: false, + }, nil + case 0: + return &chronograf.Organization{ + ID: 0, + Name: "Default", + DefaultRole: roles.EditorRoleName, + Public: true, + }, nil + } + return nil, nil + }, + }, + }, + principal: oauth2.Principal{ + Subject: "me", + Issuer: "github", + Organization: "1338", + }, + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"},{"name":"editor","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"Default","public":true,"defaultRole":"editor"},{"id":"1337","name":"The ThrillShilliettos","public":false}],"currentOrganization":{"id":"1337","name":"The ThrillShilliettos","public":false}}`, + }, + { + name: "Unable to find requested user in valid organization", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + meRequest: &meRequest{ + Organization: "1337", + }, + auth: mocks.Authenticator{}, + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + Name: "me", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1338", + }, + }, + }, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: 1337, + Name: "The ShillBillThrilliettas", + Public: true, + }, nil + }, + }, + }, + principal: oauth2.Principal{ + Subject: "me", + Issuer: "github", + Organization: "1338", + }, + wantStatus: http.StatusForbidden, + wantContentType: "application/json", + wantBody: `{"code":403,"message":"user not found"}`, + }, + { + name: "Unable to find requested organization", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + meRequest: &meRequest{ + Organization: "1337", + }, + auth: mocks.Authenticator{}, + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + Name: "me", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1337", + }, + }, + }, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return nil, chronograf.ErrOrganizationNotFound + }, + }, + }, + principal: oauth2.Principal{ + Subject: "me", + Issuer: "github", + Organization: "1338", + }, + wantStatus: http.StatusBadRequest, + wantContentType: "application/json", + wantBody: `{"code":400,"message":"organization not found"}`, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(context.WithValue(context.Background(), oauth2.PrincipalKey, tt.principal)) + s := &Service{ + Store: &Store{ + UsersStore: tt.fields.UsersStore, + OrganizationsStore: tt.fields.OrganizationsStore, + }, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + + buf, _ := json.Marshal(tt.args.meRequest) + tt.args.r.Body = ioutil.NopCloser(bytes.NewReader(buf)) + tt.args.auth.Principal = tt.principal + + s.UpdateMe(&tt.args.auth)(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. UpdateMe() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. UpdateMe() = %v, want %v", tt.name, content, tt.wantContentType) + } + if eq, err := jsonEqual(tt.wantBody, string(body)); err != nil || !eq { + t.Errorf("%q. UpdateMe() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} diff --git a/server/mux.go b/server/mux.go index dabf153e46..2848fc5935 100644 --- a/server/mux.go +++ b/server/mux.go @@ -12,6 +12,7 @@ import ( "github.com/bouk/httprouter" "github.com/influxdata/chronograf" // When julienschmidt/httprouter v2 w/ context is out, switch "github.com/influxdata/chronograf/oauth2" + "github.com/influxdata/chronograf/roles" ) const ( @@ -67,120 +68,174 @@ func NewMux(opts MuxOpts, service Service) http.Handler { hr.NotFound = http.StripPrefix(opts.Basepath, hr.NotFound) } + EnsureViewer := func(next http.HandlerFunc) http.HandlerFunc { + return AuthorizedUser( + service.Store, + opts.UseAuth, + roles.ViewerRoleName, + opts.Logger, + next, + ) + } + EnsureEditor := func(next http.HandlerFunc) http.HandlerFunc { + return AuthorizedUser( + service.Store, + opts.UseAuth, + roles.EditorRoleName, + opts.Logger, + next, + ) + } + EnsureAdmin := func(next http.HandlerFunc) http.HandlerFunc { + return AuthorizedUser( + service.Store, + opts.UseAuth, + roles.AdminRoleName, + opts.Logger, + next, + ) + } + EnsureSuperAdmin := func(next http.HandlerFunc) http.HandlerFunc { + return AuthorizedUser( + service.Store, + opts.UseAuth, + roles.SuperAdminStatus, + opts.Logger, + next, + ) + } + /* Documentation */ router.GET("/swagger.json", Spec()) router.GET("/docs", Redoc("/swagger.json")) /* API */ - // Sources - router.GET("/chronograf/v1/sources", service.Sources) - router.POST("/chronograf/v1/sources", service.NewSource) + // Organizations + router.GET("/chronograf/v1/organizations", EnsureAdmin(service.Organizations)) + router.POST("/chronograf/v1/organizations", EnsureSuperAdmin(service.NewOrganization)) - router.GET("/chronograf/v1/sources/:id", service.SourcesID) - router.PATCH("/chronograf/v1/sources/:id", service.UpdateSource) - router.DELETE("/chronograf/v1/sources/:id", service.RemoveSource) + router.GET("/chronograf/v1/organizations/:id", EnsureAdmin(service.OrganizationID)) + router.PATCH("/chronograf/v1/organizations/:id", EnsureSuperAdmin(service.UpdateOrganization)) + router.DELETE("/chronograf/v1/organizations/:id", EnsureSuperAdmin(service.RemoveOrganization)) + + // Sources + router.GET("/chronograf/v1/sources", EnsureViewer(service.Sources)) + router.POST("/chronograf/v1/sources", EnsureEditor(service.NewSource)) + + router.GET("/chronograf/v1/sources/:id", EnsureViewer(service.SourcesID)) + router.PATCH("/chronograf/v1/sources/:id", EnsureEditor(service.UpdateSource)) + router.DELETE("/chronograf/v1/sources/:id", EnsureEditor(service.RemoveSource)) // Source Proxy to Influx; Has gzip compression around the handler - influx := gziphandler.GzipHandler(http.HandlerFunc(service.Influx)) + influx := gziphandler.GzipHandler(http.HandlerFunc(EnsureViewer(service.Influx))) router.Handler("POST", "/chronograf/v1/sources/:id/proxy", influx) // Write proxies line protocol write requests to InfluxDB - router.POST("/chronograf/v1/sources/:id/write", service.Write) + router.POST("/chronograf/v1/sources/:id/write", EnsureViewer(service.Write)) - // Queries is used to analyze a specific queries - router.POST("/chronograf/v1/sources/:id/queries", service.Queries) + // Queries is used to analyze a specific queries and does not create any + // resources. It's a POST because Queries are POSTed to InfluxDB, but this + // only modifies InfluxDB resources with certain metaqueries, e.g. DROP DATABASE. + // + // Admins should ensure that the InfluxDB source as the proper permissions + // intended for Chronograf Users with the Viewer Role type. + router.POST("/chronograf/v1/sources/:id/queries", EnsureViewer(service.Queries)) // All possible permissions for users in this source - router.GET("/chronograf/v1/sources/:id/permissions", service.Permissions) + router.GET("/chronograf/v1/sources/:id/permissions", EnsureViewer(service.Permissions)) // Users associated with the data source - router.GET("/chronograf/v1/sources/:id/users", service.SourceUsers) - router.POST("/chronograf/v1/sources/:id/users", service.NewSourceUser) + router.GET("/chronograf/v1/sources/:id/users", EnsureAdmin(service.SourceUsers)) + router.POST("/chronograf/v1/sources/:id/users", EnsureAdmin(service.NewSourceUser)) - router.GET("/chronograf/v1/sources/:id/users/:uid", service.SourceUserID) - router.DELETE("/chronograf/v1/sources/:id/users/:uid", service.RemoveSourceUser) - router.PATCH("/chronograf/v1/sources/:id/users/:uid", service.UpdateSourceUser) + router.GET("/chronograf/v1/sources/:id/users/:uid", EnsureAdmin(service.SourceUserID)) + router.DELETE("/chronograf/v1/sources/:id/users/:uid", EnsureAdmin(service.RemoveSourceUser)) + router.PATCH("/chronograf/v1/sources/:id/users/:uid", EnsureAdmin(service.UpdateSourceUser)) // Roles associated with the data source - router.GET("/chronograf/v1/sources/:id/roles", service.Roles) - router.POST("/chronograf/v1/sources/:id/roles", service.NewRole) + router.GET("/chronograf/v1/sources/:id/roles", EnsureViewer(service.SourceRoles)) + router.POST("/chronograf/v1/sources/:id/roles", EnsureEditor(service.NewSourceRole)) - router.GET("/chronograf/v1/sources/:id/roles/:rid", service.RoleID) - router.DELETE("/chronograf/v1/sources/:id/roles/:rid", service.RemoveRole) - router.PATCH("/chronograf/v1/sources/:id/roles/:rid", service.UpdateRole) + router.GET("/chronograf/v1/sources/:id/roles/:rid", EnsureViewer(service.SourceRoleID)) + router.DELETE("/chronograf/v1/sources/:id/roles/:rid", EnsureEditor(service.RemoveSourceRole)) + router.PATCH("/chronograf/v1/sources/:id/roles/:rid", EnsureEditor(service.UpdateSourceRole)) // Kapacitor - router.GET("/chronograf/v1/sources/:id/kapacitors", service.Kapacitors) - router.POST("/chronograf/v1/sources/:id/kapacitors", service.NewKapacitor) + router.GET("/chronograf/v1/sources/:id/kapacitors", EnsureViewer(service.Kapacitors)) + router.POST("/chronograf/v1/sources/:id/kapacitors", EnsureEditor(service.NewKapacitor)) - router.GET("/chronograf/v1/sources/:id/kapacitors/:kid", service.KapacitorsID) - router.PATCH("/chronograf/v1/sources/:id/kapacitors/:kid", service.UpdateKapacitor) - router.DELETE("/chronograf/v1/sources/:id/kapacitors/:kid", service.RemoveKapacitor) + router.GET("/chronograf/v1/sources/:id/kapacitors/:kid", EnsureViewer(service.KapacitorsID)) + router.PATCH("/chronograf/v1/sources/:id/kapacitors/:kid", EnsureEditor(service.UpdateKapacitor)) + router.DELETE("/chronograf/v1/sources/:id/kapacitors/:kid", EnsureEditor(service.RemoveKapacitor)) // Kapacitor rules - router.GET("/chronograf/v1/sources/:id/kapacitors/:kid/rules", service.KapacitorRulesGet) - router.POST("/chronograf/v1/sources/:id/kapacitors/:kid/rules", service.KapacitorRulesPost) + router.GET("/chronograf/v1/sources/:id/kapacitors/:kid/rules", EnsureViewer(service.KapacitorRulesGet)) + router.POST("/chronograf/v1/sources/:id/kapacitors/:kid/rules", EnsureEditor(service.KapacitorRulesPost)) - router.GET("/chronograf/v1/sources/:id/kapacitors/:kid/rules/:tid", service.KapacitorRulesID) - router.PUT("/chronograf/v1/sources/:id/kapacitors/:kid/rules/:tid", service.KapacitorRulesPut) - router.PATCH("/chronograf/v1/sources/:id/kapacitors/:kid/rules/:tid", service.KapacitorRulesStatus) - router.DELETE("/chronograf/v1/sources/:id/kapacitors/:kid/rules/:tid", service.KapacitorRulesDelete) + router.GET("/chronograf/v1/sources/:id/kapacitors/:kid/rules/:tid", EnsureViewer(service.KapacitorRulesID)) + router.PUT("/chronograf/v1/sources/:id/kapacitors/:kid/rules/:tid", EnsureEditor(service.KapacitorRulesPut)) + router.PATCH("/chronograf/v1/sources/:id/kapacitors/:kid/rules/:tid", EnsureEditor(service.KapacitorRulesStatus)) + router.DELETE("/chronograf/v1/sources/:id/kapacitors/:kid/rules/:tid", EnsureEditor(service.KapacitorRulesDelete)) // Kapacitor Proxy - router.GET("/chronograf/v1/sources/:id/kapacitors/:kid/proxy", service.KapacitorProxyGet) - router.POST("/chronograf/v1/sources/:id/kapacitors/:kid/proxy", service.KapacitorProxyPost) - router.PATCH("/chronograf/v1/sources/:id/kapacitors/:kid/proxy", service.KapacitorProxyPatch) - router.DELETE("/chronograf/v1/sources/:id/kapacitors/:kid/proxy", service.KapacitorProxyDelete) - - // Mappings - router.GET("/chronograf/v1/mappings", service.GetMappings) + router.GET("/chronograf/v1/sources/:id/kapacitors/:kid/proxy", EnsureViewer(service.KapacitorProxyGet)) + router.POST("/chronograf/v1/sources/:id/kapacitors/:kid/proxy", EnsureEditor(service.KapacitorProxyPost)) + router.PATCH("/chronograf/v1/sources/:id/kapacitors/:kid/proxy", EnsureEditor(service.KapacitorProxyPatch)) + router.DELETE("/chronograf/v1/sources/:id/kapacitors/:kid/proxy", EnsureEditor(service.KapacitorProxyDelete)) // Layouts - router.GET("/chronograf/v1/layouts", service.Layouts) - router.POST("/chronograf/v1/layouts", service.NewLayout) + router.GET("/chronograf/v1/layouts", EnsureViewer(service.Layouts)) + router.GET("/chronograf/v1/layouts/:id", EnsureViewer(service.LayoutsID)) - router.GET("/chronograf/v1/layouts/:id", service.LayoutsID) - router.PUT("/chronograf/v1/layouts/:id", service.UpdateLayout) - router.DELETE("/chronograf/v1/layouts/:id", service.RemoveLayout) - - // Users + // Users associated with Chronograf router.GET("/chronograf/v1/me", service.Me) + // Set current chronograf organization the user is logged into + router.PUT("/chronograf/v1/me", service.UpdateMe(opts.Auth)) + + // TODO(desa): what to do about admin's being able to set superadmin + router.GET("/chronograf/v1/users", EnsureAdmin(service.Users)) + router.POST("/chronograf/v1/users", EnsureAdmin(service.NewUser)) + + router.GET("/chronograf/v1/users/:id", EnsureAdmin(service.UserID)) + router.DELETE("/chronograf/v1/users/:id", EnsureAdmin(service.RemoveUser)) + router.PATCH("/chronograf/v1/users/:id", EnsureAdmin(service.UpdateUser)) + // Dashboards - router.GET("/chronograf/v1/dashboards", service.Dashboards) - router.POST("/chronograf/v1/dashboards", service.NewDashboard) + router.GET("/chronograf/v1/dashboards", EnsureViewer(service.Dashboards)) + router.POST("/chronograf/v1/dashboards", EnsureEditor(service.NewDashboard)) - router.GET("/chronograf/v1/dashboards/:id", service.DashboardID) - router.DELETE("/chronograf/v1/dashboards/:id", service.RemoveDashboard) - router.PUT("/chronograf/v1/dashboards/:id", service.ReplaceDashboard) - router.PATCH("/chronograf/v1/dashboards/:id", service.UpdateDashboard) + router.GET("/chronograf/v1/dashboards/:id", EnsureViewer(service.DashboardID)) + router.DELETE("/chronograf/v1/dashboards/:id", EnsureEditor(service.RemoveDashboard)) + router.PUT("/chronograf/v1/dashboards/:id", EnsureEditor(service.ReplaceDashboard)) + router.PATCH("/chronograf/v1/dashboards/:id", EnsureEditor(service.UpdateDashboard)) // Dashboard Cells - router.GET("/chronograf/v1/dashboards/:id/cells", service.DashboardCells) - router.POST("/chronograf/v1/dashboards/:id/cells", service.NewDashboardCell) + router.GET("/chronograf/v1/dashboards/:id/cells", EnsureViewer(service.DashboardCells)) + router.POST("/chronograf/v1/dashboards/:id/cells", EnsureEditor(service.NewDashboardCell)) - router.GET("/chronograf/v1/dashboards/:id/cells/:cid", service.DashboardCellID) - router.DELETE("/chronograf/v1/dashboards/:id/cells/:cid", service.RemoveDashboardCell) - router.PUT("/chronograf/v1/dashboards/:id/cells/:cid", service.ReplaceDashboardCell) + router.GET("/chronograf/v1/dashboards/:id/cells/:cid", EnsureViewer(service.DashboardCellID)) + router.DELETE("/chronograf/v1/dashboards/:id/cells/:cid", EnsureEditor(service.RemoveDashboardCell)) + router.PUT("/chronograf/v1/dashboards/:id/cells/:cid", EnsureEditor(service.ReplaceDashboardCell)) // Dashboard Templates - router.GET("/chronograf/v1/dashboards/:id/templates", service.Templates) - router.POST("/chronograf/v1/dashboards/:id/templates", service.NewTemplate) + router.GET("/chronograf/v1/dashboards/:id/templates", EnsureViewer(service.Templates)) + router.POST("/chronograf/v1/dashboards/:id/templates", EnsureEditor(service.NewTemplate)) - router.GET("/chronograf/v1/dashboards/:id/templates/:tid", service.TemplateID) - router.DELETE("/chronograf/v1/dashboards/:id/templates/:tid", service.RemoveTemplate) - router.PUT("/chronograf/v1/dashboards/:id/templates/:tid", service.ReplaceTemplate) + router.GET("/chronograf/v1/dashboards/:id/templates/:tid", EnsureViewer(service.TemplateID)) + router.DELETE("/chronograf/v1/dashboards/:id/templates/:tid", EnsureEditor(service.RemoveTemplate)) + router.PUT("/chronograf/v1/dashboards/:id/templates/:tid", EnsureEditor(service.ReplaceTemplate)) // Databases - router.GET("/chronograf/v1/sources/:id/dbs", service.GetDatabases) - router.POST("/chronograf/v1/sources/:id/dbs", service.NewDatabase) + router.GET("/chronograf/v1/sources/:id/dbs", EnsureViewer(service.GetDatabases)) + router.POST("/chronograf/v1/sources/:id/dbs", EnsureEditor(service.NewDatabase)) - router.DELETE("/chronograf/v1/sources/:id/dbs/:dbid", service.DropDatabase) + router.DELETE("/chronograf/v1/sources/:id/dbs/:dbid", EnsureEditor(service.DropDatabase)) // Retention Policies - router.GET("/chronograf/v1/sources/:id/dbs/:dbid/rps", service.RetentionPolicies) - router.POST("/chronograf/v1/sources/:id/dbs/:dbid/rps", service.NewRetentionPolicy) + router.GET("/chronograf/v1/sources/:id/dbs/:dbid/rps", EnsureViewer(service.RetentionPolicies)) + router.POST("/chronograf/v1/sources/:id/dbs/:dbid/rps", EnsureEditor(service.NewRetentionPolicy)) - router.PUT("/chronograf/v1/sources/:id/dbs/:dbid/rps/:rpid", service.UpdateRetentionPolicy) - router.DELETE("/chronograf/v1/sources/:id/dbs/:dbid/rps/:rpid", service.DropRetentionPolicy) + router.PUT("/chronograf/v1/sources/:id/dbs/:dbid/rps/:rpid", EnsureEditor(service.UpdateRetentionPolicy)) + router.DELETE("/chronograf/v1/sources/:id/dbs/:dbid/rps/:rpid", EnsureEditor(service.DropRetentionPolicy)) allRoutes := &AllRoutes{ Logger: opts.Logger, diff --git a/server/organizations.go b/server/organizations.go new file mode 100644 index 0000000000..ed8df2cb84 --- /dev/null +++ b/server/organizations.go @@ -0,0 +1,262 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/organizations" + "github.com/influxdata/chronograf/roles" +) + +func parseOrganizationID(id string) (uint64, error) { + return strconv.ParseUint(id, 10, 64) +} + +type organizationRequest struct { + Name string `json:"name"` + DefaultRole string `json:"defaultRole"` + Public *bool `json:"public"` +} + +func (r *organizationRequest) ValidCreate() error { + if r.Name == "" { + return fmt.Errorf("Name required on Chronograf Organization request body") + } + + return r.ValidDefaultRole() +} + +func (r *organizationRequest) ValidUpdate() error { + if r.Name == "" && r.DefaultRole == "" && r.Public == nil { + return fmt.Errorf("No fields to update") + } + + if r.DefaultRole != "" { + return r.ValidDefaultRole() + } + + return nil +} + +func (r *organizationRequest) ValidDefaultRole() error { + if r.DefaultRole == "" { + r.DefaultRole = roles.MemberRoleName + } + + switch r.DefaultRole { + case roles.MemberRoleName, roles.ViewerRoleName, roles.EditorRoleName, roles.AdminRoleName: + return nil + default: + return fmt.Errorf("default role must be member, viewer, editor, or admin") + } +} + +type organizationResponse struct { + Links selfLinks `json:"links"` + chronograf.Organization +} + +func newOrganizationResponse(o *chronograf.Organization) *organizationResponse { + if o == nil { + o = &chronograf.Organization{} + } + return &organizationResponse{ + Organization: *o, + Links: selfLinks{ + Self: fmt.Sprintf("/chronograf/v1/organizations/%d", o.ID), + }, + } +} + +type organizationsResponse struct { + Links selfLinks `json:"links"` + Organizations []*organizationResponse `json:"organizations"` +} + +func newOrganizationsResponse(orgs []chronograf.Organization) *organizationsResponse { + orgsResp := make([]*organizationResponse, len(orgs)) + for i, org := range orgs { + orgsResp[i] = newOrganizationResponse(&org) + } + return &organizationsResponse{ + Organizations: orgsResp, + Links: selfLinks{ + Self: "/chronograf/v1/organizations", + }, + } +} + +// Organizations retrieves all organizations from store +func (s *Service) Organizations(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + orgs, err := s.Store.Organizations(ctx).All(ctx) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + res := newOrganizationsResponse(orgs) + encodeJSON(w, http.StatusOK, res, s.Logger) +} + +// NewOrganization adds a new organization to store +func (s *Service) NewOrganization(w http.ResponseWriter, r *http.Request) { + var req organizationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, s.Logger) + return + } + + if err := req.ValidCreate(); err != nil { + invalidData(w, err, s.Logger) + return + } + + ctx := r.Context() + org := &chronograf.Organization{ + Name: req.Name, + DefaultRole: req.DefaultRole, + } + + if req.Public != nil { + org.Public = *req.Public + } + + res, err := s.Store.Organizations(ctx).Add(ctx, org) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + // Now that the organization was created, add the user + // making the request to the organization + user, ok := hasUserContext(ctx) + if !ok { + // Best attempt at cleanup the organization if there were any errors + _ = s.Store.Organizations(ctx).Delete(ctx, res) + Error(w, http.StatusInternalServerError, "failed to retrieve user from context", s.Logger) + return + } + + orgID := fmt.Sprintf("%d", res.ID) + user.Roles = []chronograf.Role{ + { + Organization: orgID, + Name: roles.AdminRoleName, + }, + } + + orgCtx := context.WithValue(ctx, organizations.ContextKey, orgID) + _, err = s.Store.Users(orgCtx).Add(orgCtx, user) + if err != nil { + // Best attempt at cleanup the organization if there were any errors adding user to org + _ = s.Store.Organizations(ctx).Delete(ctx, res) + s.Logger.Error("failed to add user to organization", err.Error()) + Error(w, http.StatusInternalServerError, "failed to add user to organization", s.Logger) + return + } + + co := newOrganizationResponse(res) + location(w, co.Links.Self) + encodeJSON(w, http.StatusCreated, co, s.Logger) +} + +// OrganizationID retrieves a organization with ID from store +func (s *Service) OrganizationID(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + idStr := httprouter.GetParamFromContext(ctx, "id") + id, err := parseOrganizationID(idStr) + if err != nil { + Error(w, http.StatusBadRequest, fmt.Sprintf("invalid organization id: %s", err.Error()), s.Logger) + return + } + + org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &id}) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + res := newOrganizationResponse(org) + encodeJSON(w, http.StatusOK, res, s.Logger) +} + +// UpdateOrganization updates an organization in the organizations store +func (s *Service) UpdateOrganization(w http.ResponseWriter, r *http.Request) { + var req organizationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, s.Logger) + return + } + + if err := req.ValidUpdate(); err != nil { + invalidData(w, err, s.Logger) + return + } + + ctx := r.Context() + idStr := httprouter.GetParamFromContext(ctx, "id") + id, err := parseOrganizationID(idStr) + if err != nil { + Error(w, http.StatusBadRequest, fmt.Sprintf("invalid organization id: %s", err.Error()), s.Logger) + return + } + + org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &id}) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + if req.Name != "" { + org.Name = req.Name + } + + if req.DefaultRole != "" { + org.DefaultRole = req.DefaultRole + } + + if req.Public != nil { + org.Public = *req.Public + } + + err = s.Store.Organizations(ctx).Update(ctx, org) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + res := newOrganizationResponse(org) + location(w, res.Links.Self) + encodeJSON(w, http.StatusOK, res, s.Logger) + +} + +// RemoveOrganization removes an organization in the organizations store +func (s *Service) RemoveOrganization(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + idStr := httprouter.GetParamFromContext(ctx, "id") + id, err := parseOrganizationID(idStr) + if err != nil { + Error(w, http.StatusBadRequest, fmt.Sprintf("invalid organization id: %s", err.Error()), s.Logger) + return + } + + org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &id}) + if err != nil { + Error(w, http.StatusNotFound, err.Error(), s.Logger) + return + } + if err := s.Store.Organizations(ctx).Delete(ctx, org); err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/organizations_test.go b/server/organizations_test.go new file mode 100644 index 0000000000..8d4870149d --- /dev/null +++ b/server/organizations_test.go @@ -0,0 +1,705 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/mocks" + "github.com/influxdata/chronograf/roles" +) + +func TestService_OrganizationID(t *testing.T) { + type fields struct { + OrganizationsStore chronograf.OrganizationsStore + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + id string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Get Single Organization", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case 1337: + return &chronograf.Organization{ + ID: 1337, + Name: "The Good Place", + Public: false, + }, nil + default: + return nil, fmt.Errorf("Organization with ID %s not found", *q.ID) + } + }, + }, + }, + id: "1337", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place","public":false}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + OrganizationsStore: tt.fields.OrganizationsStore, + }, + Logger: tt.fields.Logger, + } + + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.id, + }, + })) + + s.OrganizationID(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. OrganizationID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. OrganizationID() = %v, want %v", tt.name, content, tt.wantContentType) + } + if eq, _ := jsonEqual(string(body), tt.wantBody); tt.wantBody != "" && !eq { + t.Errorf("%q. OrganizationID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + }) + } +} + +func TestService_Organizations(t *testing.T) { + type fields struct { + OrganizationsStore chronograf.OrganizationsStore + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Get Single Organization", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + AllF: func(ctx context.Context) ([]chronograf.Organization, error) { + return []chronograf.Organization{ + chronograf.Organization{ + ID: 1337, + Name: "The Good Place", + Public: false, + }, + chronograf.Organization{ + ID: 100, + Name: "The Bad Place", + Public: false, + }, + }, nil + }, + }, + }, + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/organizations"},"organizations":[{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place","public":false},{"links":{"self":"/chronograf/v1/organizations/100"},"id":"100","name":"The Bad Place","public":false}]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + OrganizationsStore: tt.fields.OrganizationsStore, + }, + Logger: tt.fields.Logger, + } + + s.Organizations(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. Organizations() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. Organizations() = %v, want %v", tt.name, content, tt.wantContentType) + } + if eq, _ := jsonEqual(string(body), tt.wantBody); tt.wantBody != "" && !eq { + t.Errorf("%q. Organizations() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + }) + } +} + +func TestService_UpdateOrganization(t *testing.T) { + type fields struct { + OrganizationsStore chronograf.OrganizationsStore + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + org *organizationRequest + public bool + setPtr bool + } + tests := []struct { + name string + fields fields + args args + id string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Update Organization name", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + org: &organizationRequest{ + Name: "The Bad Place", + }, + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + UpdateF: func(ctx context.Context, o *chronograf.Organization) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 1337, + Name: "The Good Place", + DefaultRole: roles.ViewerRoleName, + Public: false, + }, nil + }, + }, + }, + id: "1337", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"id":"1337","name":"The Bad Place","defaultRole":"viewer","links":{"self":"/chronograf/v1/organizations/1337"},"public":false}`, + }, + { + name: "Update Organization public", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + org: &organizationRequest{}, + public: false, + setPtr: true, + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + UpdateF: func(ctx context.Context, o *chronograf.Organization) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 0, + Name: "The Good Place", + DefaultRole: roles.ViewerRoleName, + Public: true, + }, nil + }, + }, + }, + id: "0", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"id":"0","name":"The Good Place","defaultRole":"viewer","public":false,"links":{"self":"/chronograf/v1/organizations/0"}}`, + }, + { + name: "Update Organization - nothing to update", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + org: &organizationRequest{}, + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + UpdateF: func(ctx context.Context, o *chronograf.Organization) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 1337, + Name: "The Good Place", + DefaultRole: roles.ViewerRoleName, + Public: true, + }, nil + }, + }, + }, + id: "1337", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"No fields to update"}`, + }, + { + name: "Update Organization default role", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + org: &organizationRequest{ + DefaultRole: roles.ViewerRoleName, + }, + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + UpdateF: func(ctx context.Context, o *chronograf.Organization) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 1337, + Name: "The Good Place", + DefaultRole: roles.MemberRoleName, + Public: false, + }, nil + }, + }, + }, + id: "1337", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place","defaultRole":"viewer","public":false}`, + }, + { + name: "Update Organization - invalid update", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + org: &organizationRequest{}, + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + UpdateF: func(ctx context.Context, o *chronograf.Organization) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return nil, nil + }, + }, + }, + id: "1337", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"No fields to update"}`, + }, + { + name: "Update Organization - invalid role", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + org: &organizationRequest{ + DefaultRole: "sillyrole", + }, + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + UpdateF: func(ctx context.Context, o *chronograf.Organization) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return nil, nil + }, + }, + }, + id: "1337", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"default role must be member, viewer, editor, or admin"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + OrganizationsStore: tt.fields.OrganizationsStore, + }, + Logger: tt.fields.Logger, + } + + tt.args.r = tt.args.r.WithContext(httprouter.WithParams(context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.id, + }, + })) + + if tt.args.setPtr { + tt.args.org.Public = &tt.args.public + } + + buf, _ := json.Marshal(tt.args.org) + tt.args.r.Body = ioutil.NopCloser(bytes.NewReader(buf)) + s.UpdateOrganization(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. NewOrganization() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. NewOrganization() = %v, want %v", tt.name, content, tt.wantContentType) + } + if eq, _ := jsonEqual(string(body), tt.wantBody); tt.wantBody != "" && !eq { + t.Errorf("%q. NewOrganization() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + }) + } +} + +func TestService_RemoveOrganization(t *testing.T) { + type fields struct { + OrganizationsStore chronograf.OrganizationsStore + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + id string + wantStatus int + }{ + { + name: "Update Organization name", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DeleteF: func(ctx context.Context, o *chronograf.Organization) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case 1337: + return &chronograf.Organization{ + ID: 1337, + Name: "The Good Place", + }, nil + default: + return nil, fmt.Errorf("Organization with ID %s not found", *q.ID) + } + }, + }, + }, + id: "1337", + wantStatus: http.StatusNoContent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + OrganizationsStore: tt.fields.OrganizationsStore, + }, + Logger: tt.fields.Logger, + } + + tt.args.r = tt.args.r.WithContext(httprouter.WithParams(context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.id, + }, + })) + s.RemoveOrganization(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. NewOrganization() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + }) + } +} + +func TestService_NewOrganization(t *testing.T) { + type fields struct { + OrganizationsStore chronograf.OrganizationsStore + UsersStore chronograf.UsersStore + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + org *organizationRequest + user *chronograf.User + } + tests := []struct { + name string + fields fields + args args + id string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Create Organization", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + user: &chronograf.User{ + ID: 1, + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + }, + org: &organizationRequest{ + Name: "The Good Place", + }, + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1, + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + AddF: func(ctx context.Context, o *chronograf.Organization) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 1337, + Name: "The Good Place", + Public: false, + }, nil + }, + }, + }, + wantStatus: http.StatusCreated, + wantContentType: "application/json", + wantBody: `{"id":"1337","public":false,"name":"The Good Place","links":{"self":"/chronograf/v1/organizations/1337"}}`, + }, + { + name: "Create Organization - no user on context", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + org: &organizationRequest{ + Name: "The Good Place", + }, + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1, + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + AddF: func(ctx context.Context, o *chronograf.Organization) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 1337, + Name: "The Good Place", + }, nil + }, + DeleteF: func(ctx context.Context, o *chronograf.Organization) error { + return nil + }, + }, + }, + wantStatus: http.StatusInternalServerError, + wantContentType: "application/json", + wantBody: `{"code":500,"message":"failed to retrieve user from context"}`, + }, + { + name: "Create Organization - failed to add user to organization", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + org: &organizationRequest{ + Name: "The Good Place", + }, + user: &chronograf.User{ + ID: 1, + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + }, + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return nil, fmt.Errorf("failed to add user to org") + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + AddF: func(ctx context.Context, o *chronograf.Organization) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 1337, + Name: "The Good Place", + }, nil + }, + DeleteF: func(ctx context.Context, o *chronograf.Organization) error { + return nil + }, + }, + }, + wantStatus: http.StatusInternalServerError, + wantContentType: "application/json", + wantBody: `{"code":500,"message":"failed to add user to organization"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + OrganizationsStore: tt.fields.OrganizationsStore, + UsersStore: tt.fields.UsersStore, + }, + Logger: tt.fields.Logger, + } + + ctx := tt.args.r.Context() + ctx = context.WithValue(ctx, UserContextKey, tt.args.user) + tt.args.r = tt.args.r.WithContext(ctx) + + buf, _ := json.Marshal(tt.args.org) + tt.args.r.Body = ioutil.NopCloser(bytes.NewReader(buf)) + s.NewOrganization(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. NewOrganization() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. NewOrganization() = %v, want %v", tt.name, content, tt.wantContentType) + } + if eq, _ := jsonEqual(string(body), tt.wantBody); tt.wantBody != "" && !eq { + t.Errorf("%q. NewOrganization() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + }) + } +} diff --git a/server/permissions.go b/server/permissions.go index cd353d8b24..744306e45d 100644 --- a/server/permissions.go +++ b/server/permissions.go @@ -8,36 +8,36 @@ import ( ) // Permissions returns all possible permissions for this source. -func (h *Service) Permissions(w http.ResponseWriter, r *http.Request) { +func (s *Service) Permissions(w http.ResponseWriter, r *http.Request) { ctx := r.Context() srcID, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } - src, err := h.SourcesStore.Get(ctx, srcID) + src, err := s.Store.Sources(ctx).Get(ctx, srcID) if err != nil { - notFound(w, srcID, h.Logger) + notFound(w, srcID, s.Logger) return } - ts, err := h.TimeSeries(src) + ts, err := s.TimeSeries(src) if err != nil { msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err) - Error(w, http.StatusBadRequest, msg, h.Logger) + Error(w, http.StatusBadRequest, msg, s.Logger) return } if err = ts.Connect(ctx, &src); err != nil { msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err) - Error(w, http.StatusBadRequest, msg, h.Logger) + Error(w, http.StatusBadRequest, msg, s.Logger) return } perms := ts.Permissions(ctx) if err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) + Error(w, http.StatusBadRequest, err.Error(), s.Logger) return } httpAPISrcs := "/chronograf/v1/sources" @@ -51,7 +51,7 @@ func (h *Service) Permissions(w http.ResponseWriter, r *http.Request) { "source": fmt.Sprintf("%s/%d", httpAPISrcs, srcID), }, } - encodeJSON(w, http.StatusOK, res, h.Logger) + encodeJSON(w, http.StatusOK, res, s.Logger) } func validPermissions(perms *chronograf.Permissions) error { diff --git a/server/permissions_test.go b/server/permissions_test.go index 092a89c45b..7a49b4e18c 100644 --- a/server/permissions_test.go +++ b/server/permissions_test.go @@ -89,7 +89,9 @@ func TestService_Permissions(t *testing.T) { }, })) h := &Service{ - SourcesStore: tt.fields.SourcesStore, + Store: &mocks.Store{ + SourcesStore: tt.fields.SourcesStore, + }, TimeSeriesClient: tt.fields.TimeSeries, Logger: tt.fields.Logger, UseAuth: tt.fields.UseAuth, diff --git a/server/proxy.go b/server/proxy.go index 3ba5713c59..da2bddc21c 100644 --- a/server/proxy.go +++ b/server/proxy.go @@ -10,29 +10,29 @@ import ( ) // KapacitorProxy proxies requests to kapacitor using the path query parameter. -func (h *Service) KapacitorProxy(w http.ResponseWriter, r *http.Request) { +func (s *Service) KapacitorProxy(w http.ResponseWriter, r *http.Request) { srcID, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } id, err := paramID("kid", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } path := r.URL.Query().Get("path") if path == "" { - Error(w, http.StatusUnprocessableEntity, "path query parameter required", h.Logger) + Error(w, http.StatusUnprocessableEntity, "path query parameter required", s.Logger) return } ctx := r.Context() - srv, err := h.ServersStore.Get(ctx, id) + srv, err := s.Store.Servers(ctx).Get(ctx, id) if err != nil || srv.SrcID != srcID { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } @@ -42,7 +42,7 @@ func (h *Service) KapacitorProxy(w http.ResponseWriter, r *http.Request) { u, err := url.Parse(uri) if err != nil { msg := fmt.Sprintf("Error parsing kapacitor url: %v", err) - Error(w, http.StatusUnprocessableEntity, msg, h.Logger) + Error(w, http.StatusUnprocessableEntity, msg, s.Logger) return } @@ -68,23 +68,23 @@ func (h *Service) KapacitorProxy(w http.ResponseWriter, r *http.Request) { } // KapacitorProxyPost proxies POST to kapacitor -func (h *Service) KapacitorProxyPost(w http.ResponseWriter, r *http.Request) { - h.KapacitorProxy(w, r) +func (s *Service) KapacitorProxyPost(w http.ResponseWriter, r *http.Request) { + s.KapacitorProxy(w, r) } // KapacitorProxyPatch proxies PATCH to kapacitor -func (h *Service) KapacitorProxyPatch(w http.ResponseWriter, r *http.Request) { - h.KapacitorProxy(w, r) +func (s *Service) KapacitorProxyPatch(w http.ResponseWriter, r *http.Request) { + s.KapacitorProxy(w, r) } // KapacitorProxyGet proxies GET to kapacitor -func (h *Service) KapacitorProxyGet(w http.ResponseWriter, r *http.Request) { - h.KapacitorProxy(w, r) +func (s *Service) KapacitorProxyGet(w http.ResponseWriter, r *http.Request) { + s.KapacitorProxy(w, r) } // KapacitorProxyDelete proxies DELETE to kapacitor -func (h *Service) KapacitorProxyDelete(w http.ResponseWriter, r *http.Request) { - h.KapacitorProxy(w, r) +func (s *Service) KapacitorProxyDelete(w http.ResponseWriter, r *http.Request) { + s.KapacitorProxy(w, r) } func singleJoiningSlash(a, b string) string { diff --git a/server/queries.go b/server/queries.go index 38e937b342..fbd20635bd 100644 --- a/server/queries.go +++ b/server/queries.go @@ -51,7 +51,7 @@ func (s *Service) Queries(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - src, err := s.SourcesStore.Get(ctx, srcID) + src, err := s.Store.Sources(ctx).Get(ctx, srcID) if err != nil { notFound(w, srcID, s.Logger) return diff --git a/server/queries_test.go b/server/queries_test.go index 14e0c794a8..d161db6773 100644 --- a/server/queries_test.go +++ b/server/queries_test.go @@ -181,8 +181,10 @@ func TestService_Queries(t *testing.T) { }, })) s := &Service{ - SourcesStore: tt.SourcesStore, - Logger: &mocks.TestLogger{}, + Store: &mocks.Store{ + SourcesStore: tt.SourcesStore, + }, + Logger: &mocks.TestLogger{}, } s.Queries(tt.w, tt.r) got := tt.w.Body.String() diff --git a/server/roles.go b/server/roles.go deleted file mode 100644 index d738cbc42f..0000000000 --- a/server/roles.go +++ /dev/null @@ -1,224 +0,0 @@ -package server - -import ( - "encoding/json" - "fmt" - "net/http" - - "github.com/bouk/httprouter" - "github.com/influxdata/chronograf" -) - -// NewRole adds role to source -func (h *Service) NewRole(w http.ResponseWriter, r *http.Request) { - var req sourceRoleRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - invalidJSON(w, h.Logger) - return - } - - if err := req.ValidCreate(); err != nil { - invalidData(w, err, h.Logger) - return - } - - ctx := r.Context() - srcID, ts, err := h.sourcesSeries(ctx, w, r) - if err != nil { - return - } - - roles, ok := h.hasRoles(ctx, ts) - if !ok { - Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger) - return - } - - if _, err := roles.Get(ctx, req.Name); err == nil { - Error(w, http.StatusBadRequest, fmt.Sprintf("Source %d already has role %s", srcID, req.Name), h.Logger) - return - } - - res, err := roles.Add(ctx, &req.Role) - if err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) - return - } - - rr := newRoleResponse(srcID, res) - w.Header().Add("Location", rr.Links.Self) - encodeJSON(w, http.StatusCreated, rr, h.Logger) -} - -// UpdateRole changes the permissions or users of a role -func (h *Service) UpdateRole(w http.ResponseWriter, r *http.Request) { - var req sourceRoleRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - invalidJSON(w, h.Logger) - return - } - if err := req.ValidUpdate(); err != nil { - invalidData(w, err, h.Logger) - return - } - - ctx := r.Context() - srcID, ts, err := h.sourcesSeries(ctx, w, r) - if err != nil { - return - } - - roles, ok := h.hasRoles(ctx, ts) - if !ok { - Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger) - return - } - - rid := httprouter.GetParamFromContext(ctx, "rid") - req.Name = rid - - if err := roles.Update(ctx, &req.Role); err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) - return - } - - role, err := roles.Get(ctx, req.Name) - if err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) - return - } - rr := newRoleResponse(srcID, role) - w.Header().Add("Location", rr.Links.Self) - encodeJSON(w, http.StatusOK, rr, h.Logger) -} - -// RoleID retrieves a role with ID from store. -func (h *Service) RoleID(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - srcID, ts, err := h.sourcesSeries(ctx, w, r) - if err != nil { - return - } - - roles, ok := h.hasRoles(ctx, ts) - if !ok { - Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger) - return - } - - rid := httprouter.GetParamFromContext(ctx, "rid") - role, err := roles.Get(ctx, rid) - if err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) - return - } - rr := newRoleResponse(srcID, role) - encodeJSON(w, http.StatusOK, rr, h.Logger) -} - -// Roles retrieves all roles from the store -func (h *Service) Roles(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - srcID, ts, err := h.sourcesSeries(ctx, w, r) - if err != nil { - return - } - - store, ok := h.hasRoles(ctx, ts) - if !ok { - Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger) - return - } - - roles, err := store.All(ctx) - if err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) - return - } - - rr := make([]roleResponse, len(roles)) - for i, role := range roles { - rr[i] = newRoleResponse(srcID, &role) - } - - res := struct { - Roles []roleResponse `json:"roles"` - }{rr} - encodeJSON(w, http.StatusOK, res, h.Logger) -} - -// RemoveRole removes role from data source. -func (h *Service) RemoveRole(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - srcID, ts, err := h.sourcesSeries(ctx, w, r) - if err != nil { - return - } - - roles, ok := h.hasRoles(ctx, ts) - if !ok { - Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger) - return - } - - rid := httprouter.GetParamFromContext(ctx, "rid") - if err := roles.Delete(ctx, &chronograf.Role{Name: rid}); err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// sourceRoleRequest is the format used for both creating and updating roles -type sourceRoleRequest struct { - chronograf.Role -} - -func (r *sourceRoleRequest) ValidCreate() error { - if r.Name == "" || len(r.Name) > 254 { - return fmt.Errorf("Name is required for a role") - } - for _, user := range r.Users { - if user.Name == "" { - return fmt.Errorf("Username required") - } - } - return validPermissions(&r.Permissions) -} - -func (r *sourceRoleRequest) ValidUpdate() error { - if len(r.Name) > 254 { - return fmt.Errorf("Username too long; must be less than 254 characters") - } - for _, user := range r.Users { - if user.Name == "" { - return fmt.Errorf("Username required") - } - } - return validPermissions(&r.Permissions) -} - -type roleResponse struct { - Users []*userResponse `json:"users"` - Name string `json:"name"` - Permissions chronograf.Permissions `json:"permissions"` - Links selfLinks `json:"links"` -} - -func newRoleResponse(srcID int, res *chronograf.Role) roleResponse { - su := make([]*userResponse, len(res.Users)) - for i := range res.Users { - name := res.Users[i].Name - su[i] = newUserResponse(srcID, name) - } - - if res.Permissions == nil { - res.Permissions = make(chronograf.Permissions, 0) - } - return roleResponse{ - Name: res.Name, - Permissions: res.Permissions, - Users: su, - Links: newSelfLinks(srcID, "roles", res.Name), - } -} diff --git a/server/roles_test.go b/server/roles_test.go deleted file mode 100644 index 7f6da27c27..0000000000 --- a/server/roles_test.go +++ /dev/null @@ -1,697 +0,0 @@ -package server - -import ( - "bytes" - "context" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - - "github.com/bouk/httprouter" - "github.com/influxdata/chronograf" - "github.com/influxdata/chronograf/log" - "github.com/influxdata/chronograf/mocks" -) - -func TestService_NewSourceRole(t *testing.T) { - type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries TimeSeriesClient - Logger chronograf.Logger - } - type args struct { - w *httptest.ResponseRecorder - r *http.Request - } - tests := []struct { - name string - fields fields - args args - ID string - wantStatus int - wantContentType string - wantBody string - }{ - { - name: "Bad JSON", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1/roles", - ioutil.NopCloser( - bytes.NewReader([]byte(`{BAD}`)))), - }, - fields: fields{ - Logger: log.New(log.DebugLevel), - }, - wantStatus: http.StatusBadRequest, - wantContentType: "application/json", - wantBody: `{"code":400,"message":"Unparsable JSON"}`, - }, - { - name: "Invalid request", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1/roles", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": ""}`)))), - }, - fields: fields{ - Logger: log.New(log.DebugLevel), - }, - ID: "1", - wantStatus: http.StatusUnprocessableEntity, - wantContentType: "application/json", - wantBody: `{"code":422,"message":"Name is required for a role"}`, - }, - { - name: "Invalid source ID", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1/roles", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": "newrole"}`)))), - }, - fields: fields{ - Logger: log.New(log.DebugLevel), - }, - ID: "BADROLE", - wantStatus: http.StatusUnprocessableEntity, - wantContentType: "application/json", - wantBody: `{"code":422,"message":"Error converting ID BADROLE"}`, - }, - { - name: "Source doesn't support roles", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1/roles", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": "role"}`)))), - }, - fields: fields{ - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", - }, nil - }, - }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return nil, fmt.Errorf("roles not supported") - }, - }, - }, - ID: "1", - wantStatus: http.StatusNotFound, - wantContentType: "application/json", - wantBody: `{"code":404,"message":"Source 1 does not have role capability"}`, - }, - { - name: "Unable to add role to server", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1/roles", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": "role"}`)))), - }, - fields: fields{ - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", - }, nil - }, - }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return &mocks.RolesStore{ - AddF: func(ctx context.Context, u *chronograf.Role) (*chronograf.Role, error) { - return nil, fmt.Errorf("server had and issue") - }, - GetF: func(ctx context.Context, name string) (*chronograf.Role, error) { - return nil, fmt.Errorf("No such role") - }, - }, nil - }, - }, - }, - ID: "1", - wantStatus: http.StatusBadRequest, - wantContentType: "application/json", - wantBody: `{"code":400,"message":"server had and issue"}`, - }, - { - name: "New role for data source", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1/roles", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": "biffsgang","users": [{"name": "match"},{"name": "skinhead"},{"name": "3-d"}]}`)))), - }, - fields: fields{ - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", - }, nil - }, - }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return &mocks.RolesStore{ - AddF: func(ctx context.Context, u *chronograf.Role) (*chronograf.Role, error) { - return u, nil - }, - GetF: func(ctx context.Context, name string) (*chronograf.Role, error) { - return nil, fmt.Errorf("no such role") - }, - }, nil - }, - }, - }, - ID: "1", - wantStatus: http.StatusCreated, - wantContentType: "application/json", - wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}} -`, - }, - } - for _, tt := range tests { - h := &Service{ - SourcesStore: tt.fields.SourcesStore, - TimeSeriesClient: tt.fields.TimeSeries, - Logger: tt.fields.Logger, - } - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, - }, - })) - - h.NewRole(tt.args.w, tt.args.r) - - resp := tt.args.w.Result() - content := resp.Header.Get("Content-Type") - body, _ := ioutil.ReadAll(resp.Body) - - if resp.StatusCode != tt.wantStatus { - t.Errorf("%q. NewRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - if tt.wantContentType != "" && content != tt.wantContentType { - t.Errorf("%q. NewRole() = %v, want %v", tt.name, content, tt.wantContentType) - } - if tt.wantBody != "" && string(body) != tt.wantBody { - t.Errorf("%q. NewRole() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) - } - } -} - -func TestService_UpdateRole(t *testing.T) { - type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries TimeSeriesClient - Logger chronograf.Logger - } - type args struct { - w *httptest.ResponseRecorder - r *http.Request - } - tests := []struct { - name string - fields fields - args args - ID string - RoleID string - wantStatus int - wantContentType string - wantBody string - }{ - { - name: "Update role for data source", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1/roles", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": "biffsgang","users": [{"name": "match"},{"name": "skinhead"},{"name": "3-d"}]}`)))), - }, - fields: fields{ - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", - }, nil - }, - }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return &mocks.RolesStore{ - UpdateF: func(ctx context.Context, u *chronograf.Role) error { - return nil - }, - GetF: func(ctx context.Context, name string) (*chronograf.Role, error) { - return &chronograf.Role{ - Name: "biffsgang", - Users: []chronograf.User{ - { - Name: "match", - }, - { - Name: "skinhead", - }, - { - Name: "3-d", - }, - }, - }, nil - }, - }, nil - }, - }, - }, - ID: "1", - RoleID: "biffsgang", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}} -`, - }, - } - for _, tt := range tests { - h := &Service{ - SourcesStore: tt.fields.SourcesStore, - TimeSeriesClient: tt.fields.TimeSeries, - Logger: tt.fields.Logger, - } - - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, - }, - { - Key: "rid", - Value: tt.RoleID, - }, - })) - - h.UpdateRole(tt.args.w, tt.args.r) - - resp := tt.args.w.Result() - content := resp.Header.Get("Content-Type") - body, _ := ioutil.ReadAll(resp.Body) - - if resp.StatusCode != tt.wantStatus { - t.Errorf("%q. NewRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - if tt.wantContentType != "" && content != tt.wantContentType { - t.Errorf("%q. NewRole() = %v, want %v", tt.name, content, tt.wantContentType) - } - if tt.wantBody != "" && string(body) != tt.wantBody { - t.Errorf("%q. NewRole() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) - } - } -} - -func TestService_RoleID(t *testing.T) { - type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries TimeSeriesClient - Logger chronograf.Logger - } - type args struct { - w *httptest.ResponseRecorder - r *http.Request - } - tests := []struct { - name string - fields fields - args args - ID string - RoleID string - wantStatus int - wantContentType string - wantBody string - }{ - { - name: "Get role for data source", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "GET", - "http://server.local/chronograf/v1/sources/1/roles/biffsgang", - nil), - }, - fields: fields{ - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", - }, nil - }, - }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return &mocks.RolesStore{ - GetF: func(ctx context.Context, name string) (*chronograf.Role, error) { - return &chronograf.Role{ - Name: "biffsgang", - Permissions: chronograf.Permissions{ - { - Name: "grays_sports_almanac", - Scope: "DBScope", - Allowed: chronograf.Allowances{ - "ReadData", - }, - }, - }, - Users: []chronograf.User{ - { - Name: "match", - }, - { - Name: "skinhead", - }, - { - Name: "3-d", - }, - }, - }, nil - }, - }, nil - }, - }, - }, - ID: "1", - RoleID: "biffsgang", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[{"scope":"DBScope","name":"grays_sports_almanac","allowed":["ReadData"]}],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}} -`, - }, - } - for _, tt := range tests { - h := &Service{ - SourcesStore: tt.fields.SourcesStore, - TimeSeriesClient: tt.fields.TimeSeries, - Logger: tt.fields.Logger, - } - - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, - }, - { - Key: "rid", - Value: tt.RoleID, - }, - })) - - h.RoleID(tt.args.w, tt.args.r) - - resp := tt.args.w.Result() - content := resp.Header.Get("Content-Type") - body, _ := ioutil.ReadAll(resp.Body) - - if resp.StatusCode != tt.wantStatus { - t.Errorf("%q. RoleID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - if tt.wantContentType != "" && content != tt.wantContentType { - t.Errorf("%q. RoleID() = %v, want %v", tt.name, content, tt.wantContentType) - } - if tt.wantBody != "" && string(body) != tt.wantBody { - t.Errorf("%q. RoleID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) - } - } -} - -func TestService_RemoveRole(t *testing.T) { - type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries TimeSeriesClient - Logger chronograf.Logger - } - type args struct { - w *httptest.ResponseRecorder - r *http.Request - } - tests := []struct { - name string - fields fields - args args - ID string - RoleID string - wantStatus int - }{ - { - name: "remove role for data source", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "GET", - "http://server.local/chronograf/v1/sources/1/roles/biffsgang", - nil), - }, - fields: fields{ - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", - }, nil - }, - }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return &mocks.RolesStore{ - DeleteF: func(context.Context, *chronograf.Role) error { - return nil - }, - }, nil - }, - }, - }, - ID: "1", - RoleID: "biffsgang", - wantStatus: http.StatusNoContent, - }, - } - for _, tt := range tests { - h := &Service{ - SourcesStore: tt.fields.SourcesStore, - TimeSeriesClient: tt.fields.TimeSeries, - Logger: tt.fields.Logger, - } - - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, - }, - { - Key: "rid", - Value: tt.RoleID, - }, - })) - - h.RemoveRole(tt.args.w, tt.args.r) - - resp := tt.args.w.Result() - if resp.StatusCode != tt.wantStatus { - t.Errorf("%q. RemoveRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - } -} - -func TestService_Roles(t *testing.T) { - type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries TimeSeriesClient - Logger chronograf.Logger - } - type args struct { - w *httptest.ResponseRecorder - r *http.Request - } - tests := []struct { - name string - fields fields - args args - ID string - RoleID string - wantStatus int - wantContentType string - wantBody string - }{ - { - name: "Get roles for data source", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "GET", - "http://server.local/chronograf/v1/sources/1/roles", - nil), - }, - fields: fields{ - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - }, nil - }, - }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return &mocks.RolesStore{ - AllF: func(ctx context.Context) ([]chronograf.Role, error) { - return []chronograf.Role{ - chronograf.Role{ - Name: "biffsgang", - Permissions: chronograf.Permissions{ - { - Name: "grays_sports_almanac", - Scope: "DBScope", - Allowed: chronograf.Allowances{ - "ReadData", - }, - }, - }, - Users: []chronograf.User{ - { - Name: "match", - }, - { - Name: "skinhead", - }, - { - Name: "3-d", - }, - }, - }, - }, nil - }, - }, nil - }, - }, - }, - ID: "1", - RoleID: "biffsgang", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"roles":[{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[{"scope":"DBScope","name":"grays_sports_almanac","allowed":["ReadData"]}],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}]} -`, - }, - } - for _, tt := range tests { - h := &Service{ - SourcesStore: tt.fields.SourcesStore, - TimeSeriesClient: tt.fields.TimeSeries, - Logger: tt.fields.Logger, - } - - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, - }, - { - Key: "rid", - Value: tt.RoleID, - }, - })) - - h.Roles(tt.args.w, tt.args.r) - - resp := tt.args.w.Result() - content := resp.Header.Get("Content-Type") - body, _ := ioutil.ReadAll(resp.Body) - - if resp.StatusCode != tt.wantStatus { - t.Errorf("%q. RoleID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - if tt.wantContentType != "" && content != tt.wantContentType { - t.Errorf("%q. RoleID() = %v, want %v", tt.name, content, tt.wantContentType) - } - if tt.wantBody != "" && string(body) != tt.wantBody { - t.Errorf("%q. RoleID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) - } - } -} diff --git a/server/routes.go b/server/routes.go index abf9821dab..109601d6ec 100644 --- a/server/routes.go +++ b/server/routes.go @@ -30,6 +30,8 @@ func (r *AuthRoutes) Lookup(provider string) (AuthRoute, bool) { type getRoutesResponse struct { Layouts string `json:"layouts"` // Location of the layouts endpoint + Users string `json:"users"` // Location of the users endpoint + Organizations string `json:"organizations"` // Location of the organizations endpoint Mappings string `json:"mappings"` // Location of the application mappings endpoint Sources string `json:"sources"` // Location of the sources endpoint Me string `json:"me"` // Location of the me endpoint @@ -59,12 +61,14 @@ func (a *AllRoutes) ServeHTTP(w http.ResponseWriter, r *http.Request) { } routes := getRoutesResponse{ - Sources: "/chronograf/v1/sources", - Layouts: "/chronograf/v1/layouts", - Me: "/chronograf/v1/me", - Mappings: "/chronograf/v1/mappings", - Dashboards: "/chronograf/v1/dashboards", - Auth: make([]AuthRoute, len(a.AuthRoutes)), // We want to return at least an empty array, rather than null + Sources: "/chronograf/v1/sources", + Layouts: "/chronograf/v1/layouts", + Users: "/chronograf/v1/users", + Organizations: "/chronograf/v1/organizations", + Me: "/chronograf/v1/me", + Mappings: "/chronograf/v1/mappings", + Dashboards: "/chronograf/v1/dashboards", + Auth: make([]AuthRoute, len(a.AuthRoutes)), // We want to return at least an empty array, rather than null ExternalLinks: getExternalLinksResponse{ StatusFeed: &a.StatusFeed, CustomLinks: customLinks, diff --git a/server/routes_test.go b/server/routes_test.go index ccec29a1f8..f8aff689ff 100644 --- a/server/routes_test.go +++ b/server/routes_test.go @@ -29,7 +29,7 @@ func TestAllRoutes(t *testing.T) { if err := json.Unmarshal(body, &routes); err != nil { t.Error("TestAllRoutes not able to unmarshal JSON response") } - want := `{"layouts":"/chronograf/v1/layouts","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[],"external":{"statusFeed":""}} + want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[],"external":{"statusFeed":""}} ` if want != string(body) { t.Errorf("TestAllRoutes\nwanted\n*%s*\ngot\n*%s*", want, string(body)) @@ -67,7 +67,7 @@ func TestAllRoutesWithAuth(t *testing.T) { if err := json.Unmarshal(body, &routes); err != nil { t.Error("TestAllRoutesWithAuth not able to unmarshal JSON response") } - want := `{"layouts":"/chronograf/v1/layouts","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[{"name":"github","label":"GitHub","login":"/oauth/github/login","logout":"/oauth/github/logout","callback":"/oauth/github/callback"}],"logout":"/oauth/logout","external":{"statusFeed":""}} + want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[{"name":"github","label":"GitHub","login":"/oauth/github/login","logout":"/oauth/github/logout","callback":"/oauth/github/callback"}],"logout":"/oauth/logout","external":{"statusFeed":""}} ` if want != string(body) { t.Errorf("TestAllRoutesWithAuth\nwanted\n*%s*\ngot\n*%s*", want, string(body)) @@ -100,7 +100,7 @@ func TestAllRoutesWithExternalLinks(t *testing.T) { if err := json.Unmarshal(body, &routes); err != nil { t.Error("TestAllRoutesWithExternalLinks not able to unmarshal JSON response") } - want := `{"layouts":"/chronograf/v1/layouts","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[],"external":{"statusFeed":"http://pineapple.life/feed.json","custom":[{"name":"cubeapple","url":"https://cube.apple"}]}} + want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[],"external":{"statusFeed":"http://pineapple.life/feed.json","custom":[{"name":"cubeapple","url":"https://cube.apple"}]}} ` if want != string(body) { t.Errorf("TestAllRoutesWithExternalLinks\nwanted\n*%s*\ngot\n*%s*", want, string(body)) diff --git a/server/server.go b/server/server.go index fc3354cb9c..7687a4f872 100644 --- a/server/server.go +++ b/server/server.go @@ -52,11 +52,12 @@ type Server struct { NewSources string `long:"new-sources" description:"Config for adding a new InfluxDB source and Kapacitor server, in JSON as an array of objects, and surrounded by single quotes. E.g. --new-sources='[{\"influxdb\":{\"name\":\"Influx 1\",\"username\":\"user1\",\"password\":\"pass1\",\"url\":\"http://localhost:8086\",\"metaUrl\":\"http://metaurl.com\",\"type\":\"influx-enterprise\",\"insecureSkipVerify\":false,\"default\":true,\"telegraf\":\"telegraf\",\"sharedSecret\":\"cubeapples\"},\"kapacitor\":{\"name\":\"Kapa 1\",\"url\":\"http://localhost:9092\",\"active\":true}}]'" env:"NEW_SOURCES" hidden:"true"` - Develop bool `short:"d" long:"develop" description:"Run server in develop mode."` - BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (e.g. './chronograf-v1.db')" env:"BOLT_PATH" default:"chronograf-v1.db"` - CannedPath string `short:"c" long:"canned-path" description:"Path to directory of pre-canned application layouts (/usr/share/chronograf/canned)" env:"CANNED_PATH" default:"canned"` - TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"` - AuthDuration time.Duration `long:"auth-duration" default:"720h" description:"Total duration of cookie life for authentication (in hours). 0 means authentication expires on browser close." env:"AUTH_DURATION"` + Develop bool `short:"d" long:"develop" description:"Run server in develop mode."` + BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (e.g. './chronograf-v1.db')" env:"BOLT_PATH" default:"chronograf-v1.db"` + CannedPath string `short:"c" long:"canned-path" description:"Path to directory of pre-canned application layouts (/usr/share/chronograf/canned)" env:"CANNED_PATH" default:"canned"` + TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"` + AuthDuration time.Duration `long:"auth-duration" default:"720h" description:"Total duration of cookie life for authentication (in hours). 0 means authentication expires on browser close." env:"AUTH_DURATION"` + SuperAdminFirstUserOnly bool `long:"superadmin-first-user-only" description:"All new users will not be given the SuperAdmin status" env:"SUPERADMIN_FIRST_USER_ONLY"` GithubClientID string `short:"i" long:"github-client-id" description:"Github Client ID for OAuth 2 support" env:"GH_CLIENT_ID"` GithubClientSecret string `short:"s" long:"github-client-secret" description:"Github Client Secret for OAuth 2 support" env:"GH_CLIENT_SECRET"` @@ -302,6 +303,7 @@ func (s *Server) Serve(ctx context.Context) error { return err } service := openService(ctx, s.BoltPath, layoutBuilder, sourcesBuilder, kapacitorBuilder, logger, s.useAuth()) + service.SuperAdminFirstUserOnly = s.SuperAdminFirstUserOnly if err := service.HandleNewSources(ctx, s.NewSources); err != nil { logger. WithField("component", "server"). @@ -406,11 +408,11 @@ func openService(ctx context.Context, boltPath string, lBuilder LayoutBuilder, s os.Exit(1) } - layouts, err := lBuilder.Build(db.LayoutStore) + layouts, err := lBuilder.Build(db.LayoutsStore) if err != nil { logger. - WithField("component", "LayoutStore"). - Error("Unable to construct a MultiLayoutStore", err) + WithField("component", "LayoutsStore"). + Error("Unable to construct a MultiLayoutsStore", err) os.Exit(1) } @@ -432,14 +434,18 @@ func openService(ctx context.Context, boltPath string, lBuilder LayoutBuilder, s return Service{ TimeSeriesClient: &InfluxClient{}, - SourcesStore: sources, - ServersStore: kapacitors, - UsersStore: db.UsersStore, - LayoutStore: layouts, - DashboardsStore: db.DashboardsStore, - Logger: logger, - UseAuth: useAuth, - Databases: &influx.Client{Logger: logger}, + Store: &Store{ + SourcesStore: sources, + ServersStore: kapacitors, + UsersStore: db.UsersStore, + OrganizationsStore: db.OrganizationsStore, + LayoutsStore: layouts, + DashboardsStore: db.DashboardsStore, + //OrganizationUsersStore: organizations.NewUsersStore(db.UsersStore), + }, + Logger: logger, + UseAuth: useAuth, + Databases: &influx.Client{Logger: logger}, } } diff --git a/server/service.go b/server/service.go index d332711aa7..3b127835df 100644 --- a/server/service.go +++ b/server/service.go @@ -11,15 +11,12 @@ import ( // Service handles REST calls to the persistence type Service struct { - SourcesStore chronograf.SourcesStore - ServersStore chronograf.ServersStore - LayoutStore chronograf.LayoutStore - UsersStore chronograf.UsersStore - DashboardsStore chronograf.DashboardsStore - TimeSeriesClient TimeSeriesClient - Logger chronograf.Logger - UseAuth bool - Databases chronograf.Databases + Store DataStore + TimeSeriesClient TimeSeriesClient + Logger chronograf.Logger + UseAuth bool + SuperAdminFirstUserOnly bool + Databases chronograf.Databases } // TimeSeriesClient returns the correct client for a time series database. diff --git a/server/sources.go b/server/sources.go index f6b52d84f1..fe31039ed0 100644 --- a/server/sources.go +++ b/server/sources.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" + "github.com/bouk/httprouter" "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/influx" ) @@ -63,15 +64,22 @@ func newSourceResponse(src chronograf.Source) sourceResponse { } // NewSource adds a new valid source to the store -func (h *Service) NewSource(w http.ResponseWriter, r *http.Request) { +func (s *Service) NewSource(w http.ResponseWriter, r *http.Request) { var src chronograf.Source if err := json.NewDecoder(r.Body).Decode(&src); err != nil { - invalidJSON(w, h.Logger) + invalidJSON(w, s.Logger) return } - if err := ValidSourceRequest(src); err != nil { - invalidData(w, err, h.Logger) + ctx := r.Context() + defaultOrg, err := s.Store.Organizations(ctx).DefaultOrganization(ctx) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + + if err := ValidSourceRequest(&src, fmt.Sprintf("%d", defaultOrg.ID)); err != nil { + invalidData(w, err, s.Logger) return } @@ -80,28 +88,27 @@ func (h *Service) NewSource(w http.ResponseWriter, r *http.Request) { src.Telegraf = "telegraf" } - ctx := r.Context() - dbType, err := h.tsdbType(ctx, &src) + dbType, err := s.tsdbType(ctx, &src) if err != nil { - Error(w, http.StatusBadRequest, "Error contacting source", h.Logger) + Error(w, http.StatusBadRequest, "Error contacting source", s.Logger) return } src.Type = dbType - if src, err = h.SourcesStore.Add(ctx, src); err != nil { + if src, err = s.Store.Sources(ctx).Add(ctx, src); err != nil { msg := fmt.Errorf("Error storing source %v: %v", src, err) - unknownErrorWithMessage(w, msg, h.Logger) + unknownErrorWithMessage(w, msg, s.Logger) return } res := newSourceResponse(src) - w.Header().Add("Location", res.Links.Self) - encodeJSON(w, http.StatusCreated, res, h.Logger) + location(w, res.Links.Self) + encodeJSON(w, http.StatusCreated, res, s.Logger) } -func (h *Service) tsdbType(ctx context.Context, src *chronograf.Source) (string, error) { +func (s *Service) tsdbType(ctx context.Context, src *chronograf.Source) (string, error) { cli := &influx.Client{ - Logger: h.Logger, + Logger: s.Logger, } if err := cli.Connect(ctx, src); err != nil { @@ -115,11 +122,11 @@ type getSourcesResponse struct { } // Sources returns all sources from the store. -func (h *Service) Sources(w http.ResponseWriter, r *http.Request) { +func (s *Service) Sources(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - srcs, err := h.SourcesStore.All(ctx) + srcs, err := s.Store.Sources(ctx).All(ctx) if err != nil { - Error(w, http.StatusInternalServerError, "Error loading sources", h.Logger) + Error(w, http.StatusInternalServerError, "Error loading sources", s.Logger) return } @@ -131,50 +138,50 @@ func (h *Service) Sources(w http.ResponseWriter, r *http.Request) { res.Sources[i] = newSourceResponse(src) } - encodeJSON(w, http.StatusOK, res, h.Logger) + encodeJSON(w, http.StatusOK, res, s.Logger) } // SourcesID retrieves a source from the store -func (h *Service) SourcesID(w http.ResponseWriter, r *http.Request) { +func (s *Service) SourcesID(w http.ResponseWriter, r *http.Request) { id, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } ctx := r.Context() - src, err := h.SourcesStore.Get(ctx, id) + src, err := s.Store.Sources(ctx).Get(ctx, id) if err != nil { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } res := newSourceResponse(src) - encodeJSON(w, http.StatusOK, res, h.Logger) + encodeJSON(w, http.StatusOK, res, s.Logger) } // RemoveSource deletes the source from the store -func (h *Service) RemoveSource(w http.ResponseWriter, r *http.Request) { +func (s *Service) RemoveSource(w http.ResponseWriter, r *http.Request) { id, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } src := chronograf.Source{ID: id} ctx := r.Context() - if err = h.SourcesStore.Delete(ctx, src); err != nil { + if err = s.Store.Sources(ctx).Delete(ctx, src); err != nil { if err == chronograf.ErrSourceNotFound { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) } else { - unknownErrorWithMessage(w, err, h.Logger) + unknownErrorWithMessage(w, err, s.Logger) } return } // Remove all the associated kapacitors for this source - if err = h.removeSrcsKapa(ctx, id); err != nil { - unknownErrorWithMessage(w, err, h.Logger) + if err = s.removeSrcsKapa(ctx, id); err != nil { + unknownErrorWithMessage(w, err, s.Logger) return } @@ -183,8 +190,8 @@ func (h *Service) RemoveSource(w http.ResponseWriter, r *http.Request) { // removeSrcsKapa will remove all kapacitors and kapacitor rules from the stores. // However, it will not remove the kapacitor tickscript from kapacitor itself. -func (h *Service) removeSrcsKapa(ctx context.Context, srcID int) error { - kapas, err := h.ServersStore.All(ctx) +func (s *Service) removeSrcsKapa(ctx context.Context, srcID int) error { + kapas, err := s.Store.Servers(ctx).All(ctx) if err != nil { return err } @@ -201,9 +208,9 @@ func (h *Service) removeSrcsKapa(ctx context.Context, srcID int) error { kapa := chronograf.Server{ ID: kapaID, } - h.Logger.Debug("Deleting kapacitor resource id ", kapa.ID) + s.Logger.Debug("Deleting kapacitor resource id ", kapa.ID) - if err := h.ServersStore.Delete(ctx, kapa); err != nil { + if err := s.Store.Servers(ctx).Delete(ctx, kapa); err != nil { return err } } @@ -212,23 +219,23 @@ func (h *Service) removeSrcsKapa(ctx context.Context, srcID int) error { } // UpdateSource handles incremental updates of a data source -func (h *Service) UpdateSource(w http.ResponseWriter, r *http.Request) { +func (s *Service) UpdateSource(w http.ResponseWriter, r *http.Request) { id, err := paramID("id", r) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) return } ctx := r.Context() - src, err := h.SourcesStore.Get(ctx, id) + src, err := s.Store.Sources(ctx).Get(ctx, id) if err != nil { - notFound(w, id, h.Logger) + notFound(w, id, s.Logger) return } var req chronograf.Source if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - invalidJSON(w, h.Logger) + invalidJSON(w, s.Logger) return } @@ -258,28 +265,37 @@ func (h *Service) UpdateSource(w http.ResponseWriter, r *http.Request) { src.Telegraf = req.Telegraf } - if err := ValidSourceRequest(src); err != nil { - invalidData(w, err, h.Logger) + defaultOrg, err := s.Store.Organizations(ctx).DefaultOrganization(ctx) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) return } - dbType, err := h.tsdbType(ctx, &src) + if err := ValidSourceRequest(&src, fmt.Sprintf("%d", defaultOrg.ID)); err != nil { + invalidData(w, err, s.Logger) + return + } + + dbType, err := s.tsdbType(ctx, &src) if err != nil { - Error(w, http.StatusBadRequest, "Error contacting source", h.Logger) + Error(w, http.StatusBadRequest, "Error contacting source", s.Logger) return } src.Type = dbType - if err := h.SourcesStore.Update(ctx, src); err != nil { + if err := s.Store.Sources(ctx).Update(ctx, src); err != nil { msg := fmt.Sprintf("Error updating source ID %d", id) - Error(w, http.StatusInternalServerError, msg, h.Logger) + Error(w, http.StatusInternalServerError, msg, s.Logger) return } - encodeJSON(w, http.StatusOK, newSourceResponse(src), h.Logger) + encodeJSON(w, http.StatusOK, newSourceResponse(src), s.Logger) } -// ValidSourceRequest checks if name, url and type are valid -func ValidSourceRequest(s chronograf.Source) error { +// ValidSourceRequest checks if name, url, type, and role are valid +func ValidSourceRequest(s *chronograf.Source, defaultOrgID string) error { + if s == nil { + return fmt.Errorf("source must be non-nil") + } // Name and URL areq required if s.URL == "" { return fmt.Errorf("url required") @@ -291,6 +307,10 @@ func ValidSourceRequest(s chronograf.Source) error { } } + if s.Organization == "" { + s.Organization = defaultOrgID + } + url, err := url.ParseRequestURI(s.URL) if err != nil { return fmt.Errorf("invalid source URI: %v", err) @@ -298,11 +318,12 @@ func ValidSourceRequest(s chronograf.Source) error { if len(url.Scheme) == 0 { return fmt.Errorf("Invalid URL; no URL scheme defined") } + return nil } // HandleNewSources parses and persists new sources passed in via server flag -func (h *Service) HandleNewSources(ctx context.Context, input string) error { +func (s *Service) HandleNewSources(ctx context.Context, input string) error { if input == "" { return nil } @@ -312,21 +333,26 @@ func (h *Service) HandleNewSources(ctx context.Context, input string) error { Kapacitor chronograf.Server `json:"kapacitor"` } if err := json.Unmarshal([]byte(input), &srcsKaps); err != nil { - h.Logger. + s.Logger. WithField("component", "server"). WithField("NewSources", "invalid"). Error(err) return err } + defaultOrg, err := s.Store.Organizations(ctx).DefaultOrganization(ctx) + if err != nil { + return err + } + for _, sk := range srcsKaps { - if err := ValidSourceRequest(sk.Source); err != nil { + if err := ValidSourceRequest(&sk.Source, fmt.Sprintf("%d", defaultOrg.ID)); err != nil { return err } // Add any new sources and kapacitors as specified via server flag - if err := h.newSourceKapacitor(ctx, sk.Source, sk.Kapacitor); err != nil { + if err := s.newSourceKapacitor(ctx, sk.Source, sk.Kapacitor); err != nil { // Continue with server run even if adding NewSource fails - h.Logger. + s.Logger. WithField("component", "server"). WithField("NewSource", "invalid"). Error(err) @@ -337,32 +363,552 @@ func (h *Service) HandleNewSources(ctx context.Context, input string) error { } // newSourceKapacitor adds sources to BoltDB idempotently by name, as well as respective kapacitors -func (h *Service) newSourceKapacitor(ctx context.Context, src chronograf.Source, kapa chronograf.Server) error { - srcs, err := h.SourcesStore.All(ctx) +func (s *Service) newSourceKapacitor(ctx context.Context, src chronograf.Source, kapa chronograf.Server) error { + srcs, err := s.Store.Sources(ctx).All(ctx) if err != nil { return err } - for _, s := range srcs { + for _, source := range srcs { // If source already exists, do nothing - if s.Name == src.Name { - h.Logger. + if source.Name == src.Name { + s.Logger. WithField("component", "server"). - WithField("NewSource", s.Name). + WithField("NewSource", source.Name). Info("Source already exists") return nil } } - src, err = h.SourcesStore.Add(ctx, src) + src, err = s.Store.Sources(ctx).Add(ctx, src) if err != nil { return err } kapa.SrcID = src.ID - if _, err := h.ServersStore.Add(ctx, kapa); err != nil { + if _, err := s.Store.Servers(ctx).Add(ctx, kapa); err != nil { return err } return nil } + +// NewSourceUser adds user to source +func (s *Service) NewSourceUser(w http.ResponseWriter, r *http.Request) { + var req sourceUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, s.Logger) + return + } + + if err := req.ValidCreate(); err != nil { + invalidData(w, err, s.Logger) + return + } + + ctx := r.Context() + srcID, ts, err := s.sourcesSeries(ctx, w, r) + if err != nil { + return + } + + store := ts.Users(ctx) + user := &chronograf.User{ + Name: req.Username, + Passwd: req.Password, + Permissions: req.Permissions, + Roles: req.Roles, + } + + res, err := store.Add(ctx, user) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + su := newSourceUserResponse(srcID, res.Name).WithPermissions(res.Permissions) + if _, hasRoles := s.hasRoles(ctx, ts); hasRoles { + su.WithRoles(srcID, res.Roles) + } + location(w, su.Links.Self) + encodeJSON(w, http.StatusCreated, su, s.Logger) +} + +// SourceUsers retrieves all users from source. +func (s *Service) SourceUsers(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + srcID, ts, err := s.sourcesSeries(ctx, w, r) + if err != nil { + return + } + + store := ts.Users(ctx) + users, err := store.All(ctx) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + _, hasRoles := s.hasRoles(ctx, ts) + ur := make([]sourceUserResponse, len(users)) + for i, u := range users { + usr := newSourceUserResponse(srcID, u.Name).WithPermissions(u.Permissions) + if hasRoles { + usr.WithRoles(srcID, u.Roles) + } + ur[i] = *usr + } + + res := sourceUsersResponse{ + Users: ur, + } + + encodeJSON(w, http.StatusOK, res, s.Logger) +} + +// SourceUserID retrieves a user with ID from store. +// In InfluxDB, a User's Name is their UID, hence the semantic below. +func (s *Service) SourceUserID(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid := httprouter.GetParamFromContext(ctx, "uid") + + srcID, ts, err := s.sourcesSeries(ctx, w, r) + if err != nil { + return + } + store := ts.Users(ctx) + u, err := store.Get(ctx, chronograf.UserQuery{Name: &uid}) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + res := newSourceUserResponse(srcID, u.Name).WithPermissions(u.Permissions) + if _, hasRoles := s.hasRoles(ctx, ts); hasRoles { + res.WithRoles(srcID, u.Roles) + } + encodeJSON(w, http.StatusOK, res, s.Logger) +} + +// RemoveSourceUser removes the user from the InfluxDB source +func (s *Service) RemoveSourceUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid := httprouter.GetParamFromContext(ctx, "uid") + + _, store, err := s.sourceUsersStore(ctx, w, r) + if err != nil { + return + } + + if err := store.Delete(ctx, &chronograf.User{Name: uid}); err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// UpdateSourceUser changes the password or permissions of a source user +func (s *Service) UpdateSourceUser(w http.ResponseWriter, r *http.Request) { + var req sourceUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, s.Logger) + return + } + if err := req.ValidUpdate(); err != nil { + invalidData(w, err, s.Logger) + return + } + + ctx := r.Context() + uid := httprouter.GetParamFromContext(ctx, "uid") + srcID, ts, err := s.sourcesSeries(ctx, w, r) + if err != nil { + return + } + + user := &chronograf.User{ + Name: uid, + Passwd: req.Password, + Permissions: req.Permissions, + Roles: req.Roles, + } + store := ts.Users(ctx) + + if err := store.Update(ctx, user); err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + u, err := store.Get(ctx, chronograf.UserQuery{Name: &uid}) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + res := newSourceUserResponse(srcID, u.Name).WithPermissions(u.Permissions) + if _, hasRoles := s.hasRoles(ctx, ts); hasRoles { + res.WithRoles(srcID, u.Roles) + } + location(w, res.Links.Self) + encodeJSON(w, http.StatusOK, res, s.Logger) +} + +func (s *Service) sourcesSeries(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.TimeSeries, error) { + srcID, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return 0, nil, err + } + + src, err := s.Store.Sources(ctx).Get(ctx, srcID) + if err != nil { + notFound(w, srcID, s.Logger) + return 0, nil, err + } + + ts, err := s.TimeSeries(src) + if err != nil { + msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err) + Error(w, http.StatusBadRequest, msg, s.Logger) + return 0, nil, err + } + + if err = ts.Connect(ctx, &src); err != nil { + msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err) + Error(w, http.StatusBadRequest, msg, s.Logger) + return 0, nil, err + } + return srcID, ts, nil +} + +func (s *Service) sourceUsersStore(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.UsersStore, error) { + srcID, ts, err := s.sourcesSeries(ctx, w, r) + if err != nil { + return 0, nil, err + } + + store := ts.Users(ctx) + return srcID, store, nil +} + +// hasRoles checks if the influx source has roles or not +func (s *Service) hasRoles(ctx context.Context, ts chronograf.TimeSeries) (chronograf.RolesStore, bool) { + store, err := ts.Roles(ctx) + if err != nil { + return nil, false + } + return store, true +} + +type sourceUserRequest struct { + Username string `json:"name,omitempty"` // Username for new account + Password string `json:"password,omitempty"` // Password for new account + Permissions chronograf.Permissions `json:"permissions,omitempty"` // Optional permissions + Roles []chronograf.Role `json:"roles,omitempty"` // Optional roles +} + +func (r *sourceUserRequest) ValidCreate() error { + if r.Username == "" { + return fmt.Errorf("Username required") + } + if r.Password == "" { + return fmt.Errorf("Password required") + } + return validPermissions(&r.Permissions) +} + +type sourceUsersResponse struct { + Users []sourceUserResponse `json:"users"` +} + +func (r *sourceUserRequest) ValidUpdate() error { + if r.Password == "" && len(r.Permissions) == 0 && len(r.Roles) == 0 { + return fmt.Errorf("No fields to update") + } + return validPermissions(&r.Permissions) +} + +type sourceUserResponse struct { + Name string // Username for new account + Permissions chronograf.Permissions // Account's permissions + Roles []sourceRoleResponse // Roles if source uses them + Links selfLinks // Links are URI locations related to user + hasPermissions bool + hasRoles bool +} + +func (u *sourceUserResponse) MarshalJSON() ([]byte, error) { + res := map[string]interface{}{ + "name": u.Name, + "links": u.Links, + } + if u.hasRoles { + res["roles"] = u.Roles + } + if u.hasPermissions { + res["permissions"] = u.Permissions + } + return json.Marshal(res) +} + +// newSourceUserResponse creates an HTTP JSON response for a user w/o roles +func newSourceUserResponse(srcID int, name string) *sourceUserResponse { + self := newSelfLinks(srcID, "users", name) + return &sourceUserResponse{ + Name: name, + Links: self, + } +} + +func (u *sourceUserResponse) WithPermissions(perms chronograf.Permissions) *sourceUserResponse { + u.hasPermissions = true + if perms == nil { + perms = make(chronograf.Permissions, 0) + } + u.Permissions = perms + return u +} + +// WithRoles adds roles to the HTTP JSON response for a user +func (u *sourceUserResponse) WithRoles(srcID int, roles []chronograf.Role) *sourceUserResponse { + u.hasRoles = true + rr := make([]sourceRoleResponse, len(roles)) + for i, role := range roles { + rr[i] = newSourceRoleResponse(srcID, &role) + } + u.Roles = rr + return u +} + +type selfLinks struct { + Self string `json:"self"` // Self link mapping to this resource +} + +func newSelfLinks(id int, parent, resource string) selfLinks { + httpAPISrcs := "/chronograf/v1/sources" + u := &url.URL{Path: resource} + encodedResource := u.String() + return selfLinks{ + Self: fmt.Sprintf("%s/%d/%s/%s", httpAPISrcs, id, parent, encodedResource), + } +} + +// NewSourceRole adds role to source +func (s *Service) NewSourceRole(w http.ResponseWriter, r *http.Request) { + var req sourceRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, s.Logger) + return + } + + if err := req.ValidCreate(); err != nil { + invalidData(w, err, s.Logger) + return + } + + ctx := r.Context() + srcID, ts, err := s.sourcesSeries(ctx, w, r) + if err != nil { + return + } + + roles, ok := s.hasRoles(ctx, ts) + if !ok { + Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), s.Logger) + return + } + + if _, err := roles.Get(ctx, req.Name); err == nil { + Error(w, http.StatusBadRequest, fmt.Sprintf("Source %d already has role %s", srcID, req.Name), s.Logger) + return + } + + res, err := roles.Add(ctx, &req.Role) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + rr := newSourceRoleResponse(srcID, res) + location(w, rr.Links.Self) + encodeJSON(w, http.StatusCreated, rr, s.Logger) +} + +// UpdateSourceRole changes the permissions or users of a role +func (s *Service) UpdateSourceRole(w http.ResponseWriter, r *http.Request) { + var req sourceRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, s.Logger) + return + } + if err := req.ValidUpdate(); err != nil { + invalidData(w, err, s.Logger) + return + } + + ctx := r.Context() + srcID, ts, err := s.sourcesSeries(ctx, w, r) + if err != nil { + return + } + + roles, ok := s.hasRoles(ctx, ts) + if !ok { + Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), s.Logger) + return + } + + rid := httprouter.GetParamFromContext(ctx, "rid") + req.Name = rid + + if err := roles.Update(ctx, &req.Role); err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + role, err := roles.Get(ctx, req.Name) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + rr := newSourceRoleResponse(srcID, role) + location(w, rr.Links.Self) + encodeJSON(w, http.StatusOK, rr, s.Logger) +} + +// SourceRoleID retrieves a role with ID from store. +func (s *Service) SourceRoleID(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + srcID, ts, err := s.sourcesSeries(ctx, w, r) + if err != nil { + return + } + + roles, ok := s.hasRoles(ctx, ts) + if !ok { + Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), s.Logger) + return + } + + rid := httprouter.GetParamFromContext(ctx, "rid") + role, err := roles.Get(ctx, rid) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + rr := newSourceRoleResponse(srcID, role) + encodeJSON(w, http.StatusOK, rr, s.Logger) +} + +// SourceRoles retrieves all roles from the store +func (s *Service) SourceRoles(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + srcID, ts, err := s.sourcesSeries(ctx, w, r) + if err != nil { + return + } + + store, ok := s.hasRoles(ctx, ts) + if !ok { + Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), s.Logger) + return + } + + roles, err := store.All(ctx) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + rr := make([]sourceRoleResponse, len(roles)) + for i, role := range roles { + rr[i] = newSourceRoleResponse(srcID, &role) + } + + res := struct { + Roles []sourceRoleResponse `json:"roles"` + }{rr} + encodeJSON(w, http.StatusOK, res, s.Logger) +} + +// RemoveSourceRole removes role from data source. +func (s *Service) RemoveSourceRole(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + srcID, ts, err := s.sourcesSeries(ctx, w, r) + if err != nil { + return + } + + roles, ok := s.hasRoles(ctx, ts) + if !ok { + Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), s.Logger) + return + } + + rid := httprouter.GetParamFromContext(ctx, "rid") + if err := roles.Delete(ctx, &chronograf.Role{Name: rid}); err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// sourceRoleRequest is the format used for both creating and updating roles +type sourceRoleRequest struct { + chronograf.Role +} + +func (r *sourceRoleRequest) ValidCreate() error { + if r.Name == "" || len(r.Name) > 254 { + return fmt.Errorf("Name is required for a role") + } + for _, user := range r.Users { + if user.Name == "" { + return fmt.Errorf("Username required") + } + } + return validPermissions(&r.Permissions) +} + +func (r *sourceRoleRequest) ValidUpdate() error { + if len(r.Name) > 254 { + return fmt.Errorf("Username too long; must be less than 254 characters") + } + for _, user := range r.Users { + if user.Name == "" { + return fmt.Errorf("Username required") + } + } + return validPermissions(&r.Permissions) +} + +type sourceRoleResponse struct { + Users []*sourceUserResponse `json:"users"` + Name string `json:"name"` + Permissions chronograf.Permissions `json:"permissions"` + Links selfLinks `json:"links"` +} + +func newSourceRoleResponse(srcID int, res *chronograf.Role) sourceRoleResponse { + su := make([]*sourceUserResponse, len(res.Users)) + for i := range res.Users { + name := res.Users[i].Name + su[i] = newSourceUserResponse(srcID, name) + } + + if res.Permissions == nil { + res.Permissions = make(chronograf.Permissions, 0) + } + return sourceRoleResponse{ + Name: res.Name, + Permissions: res.Permissions, + Users: su, + Links: newSelfLinks(srcID, "roles", res.Name), + } +} diff --git a/server/sources_test.go b/server/sources_test.go index b85524e1a8..2ee19146c3 100644 --- a/server/sources_test.go +++ b/server/sources_test.go @@ -1,15 +1,161 @@ package server import ( + "bytes" "context" "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" "reflect" "testing" + "github.com/bouk/httprouter" + "github.com/google/go-cmp/cmp" "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/log" "github.com/influxdata/chronograf/mocks" ) +func Test_ValidSourceRequest(t *testing.T) { + type args struct { + source *chronograf.Source + defaultOrgID string + } + type wants struct { + err error + source *chronograf.Source + } + tests := []struct { + name string + args args + wants wants + }{ + { + name: "nil source", + args: args{}, + wants: wants{ + err: fmt.Errorf("source must be non-nil"), + }, + }, + { + name: "missing url", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDB, + Username: "fancy", + Password: "i'm so", + SharedSecret: "supersecret", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + Organization: "0", + }, + }, + wants: wants{ + err: fmt.Errorf("url required"), + }, + }, + { + name: "invalid source type", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: "non-existent-type", + Username: "fancy", + Password: "i'm so", + SharedSecret: "supersecret", + URL: "http://www.any.url.com", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + Organization: "0", + }, + }, + wants: wants{ + err: fmt.Errorf("invalid source type non-existent-type"), + }, + }, + { + name: "set organization to be default org if not specified", + args: args{ + defaultOrgID: "2", + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDB, + Username: "fancy", + Password: "i'm so", + SharedSecret: "supersecret", + URL: "http://www.any.url.com", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDB, + Username: "fancy", + Password: "i'm so", + SharedSecret: "supersecret", + URL: "http://www.any.url.com", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Default: true, + Organization: "2", + Telegraf: "telegraf", + }, + }, + }, + { + name: "bad url", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDB, + Username: "fancy", + Password: "i'm so", + SharedSecret: "supersecret", + URL: "im a bad url", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Organization: "0", + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + err: fmt.Errorf("invalid source URI: parse im a bad url: invalid URI for request"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidSourceRequest(tt.args.source, tt.args.defaultOrgID) + if err == nil && tt.wants.err == nil { + if diff := cmp.Diff(tt.args.source, tt.wants.source); diff != "" { + t.Errorf("%q. ValidSourceRequest():\n-got/+want\ndiff %s", tt.name, diff) + } + return + } + if err.Error() != tt.wants.err.Error() { + t.Errorf("%q. ValidSourceRequest() = %q, want %q", tt.name, err, tt.wants.err) + } + }) + } +} + func Test_newSourceResponse(t *testing.T) { tests := []struct { name string @@ -233,9 +379,11 @@ func TestService_newSourceKapacitor(t *testing.T) { srcCount = 0 srvCount = 0 h := &Service{ - SourcesStore: tt.fields.SourcesStore, - ServersStore: tt.fields.ServersStore, - Logger: tt.fields.Logger, + Store: &mocks.Store{ + SourcesStore: tt.fields.SourcesStore, + ServersStore: tt.fields.ServersStore, + }, + Logger: tt.fields.Logger, } if err := h.newSourceKapacitor(tt.args.ctx, tt.args.src, tt.args.kapa); (err != nil) != tt.wantErr { t.Errorf("Service.newSourceKapacitor() error = %v, wantErr %v", err, tt.wantErr) @@ -246,3 +394,1617 @@ func TestService_newSourceKapacitor(t *testing.T) { }) } } + +func TestService_NewSourceUser(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries TimeSeriesClient + Logger chronograf.Logger + UseAuth bool + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "New user for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + } + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, fmt.Errorf("no roles") + }, + }, + }, + ID: "1", + wantStatus: http.StatusCreated, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[]} +`, + }, + { + name: "New user for data source with roles", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + } + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, nil + }, + }, + }, + ID: "1", + wantStatus: http.StatusCreated, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[],"roles":[]} +`, + }, + { + name: "Error adding user", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return nil, fmt.Errorf("Weight Has Nothing to Do With It") + }, + } + }, + }, + }, + ID: "1", + wantStatus: http.StatusBadRequest, + wantContentType: "application/json", + wantBody: `{"code":400,"message":"Weight Has Nothing to Do With It"}`, + }, + { + name: "Failure connecting to user store", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return fmt.Errorf("Biff just happens to be my supervisor") + }, + }, + }, + ID: "1", + wantStatus: http.StatusBadRequest, + wantContentType: "application/json", + wantBody: `{"code":400,"message":"Unable to connect to source 1: Biff just happens to be my supervisor"}`, + }, + { + name: "Failure getting source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{}, fmt.Errorf("No McFly ever amounted to anything in the history of Hill Valley") + }, + }, + }, + ID: "1", + wantStatus: http.StatusNotFound, + wantContentType: "application/json", + wantBody: `{"code":404,"message":"ID 1 not found"}`, + }, + { + name: "Bad ID", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + }, + ID: "BAD", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"Error converting ID BAD"}`, + }, + { + name: "Bad name", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + }, + ID: "BAD", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"Username required"}`, + }, + { + name: "Bad JSON", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{password}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + }, + ID: "BAD", + wantStatus: http.StatusBadRequest, + wantContentType: "application/json", + wantBody: `{"code":400,"message":"Unparsable JSON"}`, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + + h := &Service{ + Store: &mocks.Store{ + SourcesStore: tt.fields.SourcesStore, + }, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + + h.NewSourceUser(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. NewSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. NewSourceUser() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. NewSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_SourceUsers(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries TimeSeriesClient + Logger chronograf.Logger + UseAuth bool + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "All users for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://local/chronograf/v1/sources/1", + nil), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, fmt.Errorf("no roles") + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + AllF: func(ctx context.Context) ([]chronograf.User, error) { + return []chronograf.User{ + { + Name: "strickland", + Passwd: "discipline", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"READ"}, + }, + }, + }, + }, nil + }, + } + }, + }, + }, + ID: "1", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}]}]} +`, + }, + { + name: "All users for data source with roles", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://local/chronograf/v1/sources/1", + nil), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + AllF: func(ctx context.Context) ([]chronograf.User, error) { + return []chronograf.User{ + { + Name: "strickland", + Passwd: "discipline", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"READ"}, + }, + }, + }, + }, nil + }, + } + }, + }, + }, + ID: "1", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"roles":[]}]} +`, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + h := &Service{ + Store: &mocks.Store{ + SourcesStore: tt.fields.SourcesStore, + }, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + + h.SourceUsers(tt.args.w, tt.args.r) + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. SourceUsers() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. SourceUsers() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. SourceUsers() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_SourceUserID(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries TimeSeriesClient + Logger chronograf.Logger + UseAuth bool + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + UID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Single user for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://local/chronograf/v1/sources/1", + nil), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, fmt.Errorf("no roles") + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return &chronograf.User{ + Name: "strickland", + Passwd: "discipline", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"READ"}, + }, + }, + }, nil + }, + } + }, + }, + }, + ID: "1", + UID: "strickland", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}]} +`, + }, + { + name: "Single user for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://local/chronograf/v1/sources/1", + nil), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return &chronograf.User{ + Name: "strickland", + Passwd: "discipline", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"READ"}, + }, + }, + }, nil + }, + } + }, + }, + }, + ID: "1", + UID: "strickland", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"roles":[]} +`, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + h := &Service{ + Store: &mocks.Store{ + SourcesStore: tt.fields.SourcesStore, + }, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + + h.SourceUserID(tt.args.w, tt.args.r) + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. SourceUserID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. SourceUserID() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. SourceUserID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_RemoveSourceUser(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries TimeSeriesClient + Logger chronograf.Logger + UseAuth bool + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + UID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Delete user for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://local/chronograf/v1/sources/1", + nil), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + DeleteF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + } + }, + }, + }, + ID: "1", + UID: "strickland", + wantStatus: http.StatusNoContent, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + h := &Service{ + Store: &mocks.Store{ + SourcesStore: tt.fields.SourcesStore, + }, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + h.RemoveSourceUser(tt.args.w, tt.args.r) + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. RemoveSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. RemoveSourceUser() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. RemoveSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_UpdateSourceUser(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries TimeSeriesClient + Logger chronograf.Logger + UseAuth bool + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + UID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Update user password for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, fmt.Errorf("no roles") + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return &chronograf.User{ + Name: "marty", + }, nil + }, + } + }, + }, + }, + ID: "1", + UID: "marty", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[]} +`, + }, + { + name: "Update user password for data source with roles", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return &chronograf.User{ + Name: "marty", + }, nil + }, + } + }, + }, + }, + ID: "1", + UID: "marty", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[],"roles":[]} +`, + }, + { + name: "Invalid update JSON", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + }, + ID: "1", + UID: "marty", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"No fields to update"}`, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + { + Key: "uid", + Value: tt.UID, + }, + })) + h := &Service{ + Store: &mocks.Store{ + SourcesStore: tt.fields.SourcesStore, + }, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + h.UpdateSourceUser(tt.args.w, tt.args.r) + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. UpdateSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. UpdateSourceUser() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. UpdateSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_NewSourceRole(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries TimeSeriesClient + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Bad JSON", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1/roles", + ioutil.NopCloser( + bytes.NewReader([]byte(`{BAD}`)))), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + }, + wantStatus: http.StatusBadRequest, + wantContentType: "application/json", + wantBody: `{"code":400,"message":"Unparsable JSON"}`, + }, + { + name: "Invalid request", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1/roles", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": ""}`)))), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + }, + ID: "1", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"Name is required for a role"}`, + }, + { + name: "Invalid source ID", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1/roles", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "newrole"}`)))), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + }, + ID: "BADROLE", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"Error converting ID BADROLE"}`, + }, + { + name: "Source doesn't support roles", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1/roles", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "role"}`)))), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, fmt.Errorf("roles not supported") + }, + }, + }, + ID: "1", + wantStatus: http.StatusNotFound, + wantContentType: "application/json", + wantBody: `{"code":404,"message":"Source 1 does not have role capability"}`, + }, + { + name: "Unable to add role to server", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1/roles", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "role"}`)))), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return &mocks.RolesStore{ + AddF: func(ctx context.Context, u *chronograf.Role) (*chronograf.Role, error) { + return nil, fmt.Errorf("server had and issue") + }, + GetF: func(ctx context.Context, name string) (*chronograf.Role, error) { + return nil, fmt.Errorf("No such role") + }, + }, nil + }, + }, + }, + ID: "1", + wantStatus: http.StatusBadRequest, + wantContentType: "application/json", + wantBody: `{"code":400,"message":"server had and issue"}`, + }, + { + name: "New role for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1/roles", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "biffsgang","users": [{"name": "match"},{"name": "skinhead"},{"name": "3-d"}]}`)))), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return &mocks.RolesStore{ + AddF: func(ctx context.Context, u *chronograf.Role) (*chronograf.Role, error) { + return u, nil + }, + GetF: func(ctx context.Context, name string) (*chronograf.Role, error) { + return nil, fmt.Errorf("no such role") + }, + }, nil + }, + }, + }, + ID: "1", + wantStatus: http.StatusCreated, + wantContentType: "application/json", + wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}} +`, + }, + } + for _, tt := range tests { + h := &Service{ + Store: &mocks.Store{ + SourcesStore: tt.fields.SourcesStore, + }, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + } + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + + h.NewSourceRole(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. NewSourceRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. NewSourceRole() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. NewSourceRole() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_UpdateSourceRole(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries TimeSeriesClient + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + RoleID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Update role for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1/roles", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "biffsgang","users": [{"name": "match"},{"name": "skinhead"},{"name": "3-d"}]}`)))), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return &mocks.RolesStore{ + UpdateF: func(ctx context.Context, u *chronograf.Role) error { + return nil + }, + GetF: func(ctx context.Context, name string) (*chronograf.Role, error) { + return &chronograf.Role{ + Name: "biffsgang", + Users: []chronograf.User{ + { + Name: "match", + }, + { + Name: "skinhead", + }, + { + Name: "3-d", + }, + }, + }, nil + }, + }, nil + }, + }, + }, + ID: "1", + RoleID: "biffsgang", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}} +`, + }, + } + for _, tt := range tests { + h := &Service{ + Store: &mocks.Store{ + SourcesStore: tt.fields.SourcesStore, + }, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + } + + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + { + Key: "rid", + Value: tt.RoleID, + }, + })) + + h.UpdateSourceRole(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. UpdateSourceRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. UpdateSourceRole() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. UpdateSourceRole() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_SourceRoleID(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries TimeSeriesClient + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + RoleID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Get role for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://server.local/chronograf/v1/sources/1/roles/biffsgang", + nil), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return &mocks.RolesStore{ + GetF: func(ctx context.Context, name string) (*chronograf.Role, error) { + return &chronograf.Role{ + Name: "biffsgang", + Permissions: chronograf.Permissions{ + { + Name: "grays_sports_almanac", + Scope: "DBScope", + Allowed: chronograf.Allowances{ + "ReadData", + }, + }, + }, + Users: []chronograf.User{ + { + Name: "match", + }, + { + Name: "skinhead", + }, + { + Name: "3-d", + }, + }, + }, nil + }, + }, nil + }, + }, + }, + ID: "1", + RoleID: "biffsgang", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[{"scope":"DBScope","name":"grays_sports_almanac","allowed":["ReadData"]}],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}} +`, + }, + } + for _, tt := range tests { + h := &Service{ + Store: &mocks.Store{ + SourcesStore: tt.fields.SourcesStore, + }, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + } + + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + { + Key: "rid", + Value: tt.RoleID, + }, + })) + + h.SourceRoleID(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. SourceRoleID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. SourceRoleID() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. SourceRoleID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_RemoveSourceRole(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries TimeSeriesClient + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + RoleID string + wantStatus int + }{ + { + name: "remove role for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://server.local/chronograf/v1/sources/1/roles/biffsgang", + nil), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return &mocks.RolesStore{ + DeleteF: func(context.Context, *chronograf.Role) error { + return nil + }, + }, nil + }, + }, + }, + ID: "1", + RoleID: "biffsgang", + wantStatus: http.StatusNoContent, + }, + } + for _, tt := range tests { + h := &Service{ + Store: &mocks.Store{ + SourcesStore: tt.fields.SourcesStore, + }, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + } + + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + { + Key: "rid", + Value: tt.RoleID, + }, + })) + + h.RemoveSourceRole(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. RemoveSourceRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + } +} + +func TestService_SourceRoles(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries TimeSeriesClient + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + RoleID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Get roles for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://server.local/chronograf/v1/sources/1/roles", + nil), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return &mocks.RolesStore{ + AllF: func(ctx context.Context) ([]chronograf.Role, error) { + return []chronograf.Role{ + chronograf.Role{ + Name: "biffsgang", + Permissions: chronograf.Permissions{ + { + Name: "grays_sports_almanac", + Scope: "DBScope", + Allowed: chronograf.Allowances{ + "ReadData", + }, + }, + }, + Users: []chronograf.User{ + { + Name: "match", + }, + { + Name: "skinhead", + }, + { + Name: "3-d", + }, + }, + }, + }, nil + }, + }, nil + }, + }, + }, + ID: "1", + RoleID: "biffsgang", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"roles":[{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[{"scope":"DBScope","name":"grays_sports_almanac","allowed":["ReadData"]}],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}]} +`, + }, + } + for _, tt := range tests { + h := &Service{ + Store: &mocks.Store{ + SourcesStore: tt.fields.SourcesStore, + }, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + } + + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + { + Key: "rid", + Value: tt.RoleID, + }, + })) + + h.SourceRoles(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. SourceRoles() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. SourceRoles() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. SourceRoles() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} diff --git a/server/stores.go b/server/stores.go new file mode 100644 index 0000000000..2c9d811b7b --- /dev/null +++ b/server/stores.go @@ -0,0 +1,180 @@ +package server + +import ( + "context" + + "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 +// under the organizations.ContextKey +func hasOrganizationContext(ctx context.Context) (string, bool) { + // prevents panic in case of nil context + if ctx == nil { + return "", false + } + orgID, ok := ctx.Value(organizations.ContextKey).(string) + // should never happen + if !ok { + return "", false + } + if orgID == "" { + return "", false + } + 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 { + case roles.MemberRoleName, roles.ViewerRoleName, roles.EditorRoleName, roles.AdminRoleName: + return role, true + default: + return "", false + } +} + +type userContextKey string + +// UserContextKey is the context key for retrieving the user off of context +const UserContextKey = userContextKey("user") + +// hasUserContext speficies if the context contains +// the UserContextKey and that the value stored there is chronograf.User +func hasUserContext(ctx context.Context) (*chronograf.User, bool) { + // prevents panic in case of nil context + if ctx == nil { + return nil, false + } + u, ok := ctx.Value(UserContextKey).(*chronograf.User) + // should never happen + if !ok { + return nil, false + } + if u == nil { + return nil, false + } + return u, true +} + +// hasSuperAdminContext speficies if the context contains +// the UserContextKey user is a super admin +func hasSuperAdminContext(ctx context.Context) bool { + u, ok := hasUserContext(ctx) + if !ok { + return false + } + return u.SuperAdmin +} + +// DataStore is collection of resources that are used by the Service +// Abstracting this into an interface was useful for isolated testing +type DataStore interface { + Sources(ctx context.Context) chronograf.SourcesStore + Servers(ctx context.Context) chronograf.ServersStore + Layouts(ctx context.Context) chronograf.LayoutsStore + Users(ctx context.Context) chronograf.UsersStore + Organizations(ctx context.Context) chronograf.OrganizationsStore + Dashboards(ctx context.Context) chronograf.DashboardsStore +} + +// ensure that Store implements a DataStore +var _ DataStore = &Store{} + +// Store implements the DataStore interface +type Store struct { + SourcesStore chronograf.SourcesStore + ServersStore chronograf.ServersStore + LayoutsStore chronograf.LayoutsStore + UsersStore chronograf.UsersStore + DashboardsStore chronograf.DashboardsStore + OrganizationsStore chronograf.OrganizationsStore +} + +// Sources returns a noop.SourcesStore if the context has no organization specified +// and a organization.SourcesStore otherwise. +func (s *Store) Sources(ctx context.Context) chronograf.SourcesStore { + if isServer := hasServerContext(ctx); isServer { + return s.SourcesStore + } + if org, ok := hasOrganizationContext(ctx); ok { + return organizations.NewSourcesStore(s.SourcesStore, org) + } + + return &noop.SourcesStore{} +} + +// Servers returns a noop.ServersStore if the context has no organization specified +// and a organization.ServersStore otherwise. +func (s *Store) Servers(ctx context.Context) chronograf.ServersStore { + if isServer := hasServerContext(ctx); isServer { + return s.ServersStore + } + if org, ok := hasOrganizationContext(ctx); ok { + return organizations.NewServersStore(s.ServersStore, org) + } + + return &noop.ServersStore{} +} + +// Layouts returns all layouts in the underlying layouts store. +func (s *Store) Layouts(ctx context.Context) chronograf.LayoutsStore { + return s.LayoutsStore +} + +// Users returns a chronograf.UsersStore. +// If the context is a server context, then the underlying chronograf.UsersStore +// is returned. +// If there is an organization specified on context, then an organizations.UsersStore +// is returned. +// If niether are specified, a noop.UsersStore is returned. +func (s *Store) Users(ctx context.Context) chronograf.UsersStore { + if isServer := hasServerContext(ctx); isServer { + return s.UsersStore + } + if org, ok := hasOrganizationContext(ctx); ok { + return organizations.NewUsersStore(s.UsersStore, org) + } + + return &noop.UsersStore{} +} + +// Dashboards returns a noop.DashboardsStore if the context has no organization specified +// and a organization.DashboardsStore otherwise. +func (s *Store) Dashboards(ctx context.Context) chronograf.DashboardsStore { + if isServer := hasServerContext(ctx); isServer { + return s.DashboardsStore + } + if org, ok := hasOrganizationContext(ctx); ok { + return organizations.NewDashboardsStore(s.DashboardsStore, org) + } + + return &noop.DashboardsStore{} +} + +// Organizations returns the underlying OrganizationsStore. +func (s *Store) Organizations(ctx context.Context) chronograf.OrganizationsStore { + if isServer := hasServerContext(ctx); isServer { + return s.OrganizationsStore + } + if isSuperAdmin := hasSuperAdminContext(ctx); isSuperAdmin { + return s.OrganizationsStore + } + if org, ok := hasOrganizationContext(ctx); ok { + return organizations.NewOrganizationsStore(s.OrganizationsStore, org) + } + return &noop.OrganizationsStore{} +} diff --git a/server/stores_test.go b/server/stores_test.go new file mode 100644 index 0000000000..dc22daea25 --- /dev/null +++ b/server/stores_test.go @@ -0,0 +1,428 @@ +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" +) + +func TestStore_SourcesGet(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + } + type args struct { + organization string + id int + } + type wants struct { + source chronograf.Source + err bool + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "Get source", + 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", + }, nil + }, + }, + }, + args: args{ + organization: "0", + }, + wants: wants{ + source: chronograf.Source{ + ID: 1, + Name: "my sweet name", + Organization: "0", + }, + }, + }, + { + name: "Get source - no organization specified on 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", + }, nil + }, + }, + }, + args: args{}, + 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.organization != "" { + ctx = context.WithValue(ctx, organizations.ContextKey, tt.args.organization) + } + + 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) + } + }) + } +} + +func TestStore_SourcesAll(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + } + type args struct { + organization string + } + type wants struct { + sources []chronograf.Source + err bool + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "Get sources", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + AllF: func(ctx context.Context) ([]chronograf.Source, error) { + return []chronograf.Source{ + { + ID: 1, + Name: "my sweet name", + Organization: "0", + }, + }, nil + }, + }, + }, + args: args{ + organization: "0", + }, + wants: wants{ + sources: []chronograf.Source{ + { + ID: 1, + Name: "my sweet name", + Organization: "0", + }, + }, + }, + }, + { + name: "Get sources - multiple orgs", + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + AllF: func(ctx context.Context) ([]chronograf.Source, error) { + return []chronograf.Source{ + { + ID: 1, + Name: "my sweet name", + Organization: "0", + }, + { + ID: 2, + Name: "A bad source", + Organization: "0", + }, + { + ID: 3, + Name: "A good source", + Organization: "0", + }, + { + ID: 4, + Name: "a source I can has", + Organization: "0", + }, + { + ID: 5, + Name: "i'm in the wrong org", + Organization: "1", + }, + }, nil + }, + }, + }, + args: args{ + organization: "0", + }, + wants: wants{ + sources: []chronograf.Source{ + { + ID: 1, + Name: "my sweet name", + Organization: "0", + }, + { + ID: 2, + Name: "A bad source", + Organization: "0", + }, + { + ID: 3, + Name: "A good source", + Organization: "0", + }, + { + ID: 4, + Name: "a source I can has", + Organization: "0", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := &Store{ + SourcesStore: tt.fields.SourcesStore, + } + + ctx := context.Background() + + if tt.args.organization != "" { + ctx = context.WithValue(ctx, organizations.ContextKey, tt.args.organization) + } + + sources, err := store.Sources(ctx).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) + } + }) + } +} + +func TestStore_OrganizationsAdd(t *testing.T) { + type fields struct { + OrganizationsStore chronograf.OrganizationsStore + } + type args struct { + orgID uint64 + serverContext bool + organization string + user *chronograf.User + } + type wants struct { + organization *chronograf.Organization + err bool + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "Get organization with server context", + fields: fields{ + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 21, + Name: "my sweet name", + DefaultRole: "viewer", + }, nil + }, + }, + }, + args: args{ + serverContext: true, + orgID: 21, + }, + wants: wants{ + organization: &chronograf.Organization{ + ID: 21, + Name: "my sweet name", + DefaultRole: "viewer", + }, + }, + }, + { + name: "Get organization with super admin", + fields: fields{ + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 21, + Name: "my sweet name", + DefaultRole: "viewer", + }, nil + }, + }, + }, + args: args{ + user: &chronograf.User{ + ID: 1337, + Name: "bobbetta", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + }, + orgID: 21, + }, + wants: wants{ + organization: &chronograf.Organization{ + ID: 21, + Name: "my sweet name", + DefaultRole: "viewer", + }, + }, + }, + { + name: "Get organization not as super admin no organization", + fields: fields{ + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 21, + Name: "my sweet name", + DefaultRole: "viewer", + }, nil + }, + }, + }, + args: args{ + user: &chronograf.User{ + ID: 1337, + Name: "bobbetta", + Provider: "github", + Scheme: "oauth2", + }, + orgID: 21, + }, + wants: wants{ + err: true, + }, + }, + { + name: "Get organization not as super admin with organization", + fields: fields{ + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 21, + Name: "my sweet name", + DefaultRole: "viewer", + }, nil + }, + }, + }, + args: args{ + user: &chronograf.User{ + ID: 1337, + Name: "bobbetta", + Provider: "github", + Scheme: "oauth2", + }, + organization: "21", + orgID: 21, + }, + wants: wants{ + organization: &chronograf.Organization{ + ID: 21, + Name: "my sweet name", + DefaultRole: "viewer", + }, + }, + }, + { + name: "Get different organization not as super admin with organization", + fields: fields{ + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: 22, + Name: "my sweet name", + DefaultRole: "viewer", + }, nil + }, + }, + }, + args: args{ + user: &chronograf.User{ + ID: 1337, + Name: "bobbetta", + Provider: "github", + Scheme: "oauth2", + }, + organization: "21", + orgID: 21, + }, + wants: wants{ + err: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := &Store{ + OrganizationsStore: tt.fields.OrganizationsStore, + } + + ctx := context.Background() + + if tt.args.serverContext { + ctx = serverContext(ctx) + } + + if tt.args.organization != "" { + ctx = context.WithValue(ctx, organizations.ContextKey, tt.args.organization) + } + + if tt.args.user != nil { + ctx = context.WithValue(ctx, UserContextKey, tt.args.user) + } + + organization, err := store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &tt.args.orgID}) + if (err != nil) != tt.wants.err { + t.Errorf("%q. Store.Organizations().Get() error = %v, wantErr %v", tt.name, err, tt.wants.err) + return + } + if diff := cmp.Diff(organization, tt.wants.organization); diff != "" { + t.Errorf("%q. Store.Organizations().Get():\n-got/+want\ndiff %s", tt.name, diff) + } + }) + } +} diff --git a/server/swagger.json b/server/swagger.json index 0ce6510424..8204ea9d39 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -3,7 +3,7 @@ "info": { "title": "Chronograf", "description": "API endpoints for Chronograf", - "version": "1.3.10.0" + "version": "1.4.0.0-beta1" }, "schemes": ["http"], "basePath": "/chronograf/v1", diff --git a/server/templates.go b/server/templates.go index 16500e97a0..f16a29ebab 100644 --- a/server/templates.go +++ b/server/templates.go @@ -73,7 +73,7 @@ func (s *Service) Templates(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - d, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + d, err := s.Store.Dashboards(ctx).Get(ctx, chronograf.DashboardID(id)) if err != nil { notFound(w, id, s.Logger) return @@ -94,7 +94,7 @@ func (s *Service) NewTemplate(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + dash, err := s.Store.Dashboards(ctx).Get(ctx, chronograf.DashboardID(id)) if err != nil { notFound(w, id, s.Logger) return @@ -121,7 +121,7 @@ func (s *Service) NewTemplate(w http.ResponseWriter, r *http.Request) { template.ID = chronograf.TemplateID(tid) dash.Templates = append(dash.Templates, template) - if err := s.DashboardsStore.Update(ctx, dash); err != nil { + if err := s.Store.Dashboards(ctx).Update(ctx, dash); err != nil { msg := fmt.Sprintf("Error adding template %s to dashboard %d: %v", tid, id, err) Error(w, http.StatusInternalServerError, msg, s.Logger) return @@ -140,7 +140,7 @@ func (s *Service) TemplateID(w http.ResponseWriter, r *http.Request) { return } - dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + dash, err := s.Store.Dashboards(ctx).Get(ctx, chronograf.DashboardID(id)) if err != nil { notFound(w, id, s.Logger) return @@ -167,7 +167,7 @@ func (s *Service) RemoveTemplate(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + dash, err := s.Store.Dashboards(ctx).Get(ctx, chronograf.DashboardID(id)) if err != nil { notFound(w, id, s.Logger) return @@ -187,7 +187,7 @@ func (s *Service) RemoveTemplate(w http.ResponseWriter, r *http.Request) { } dash.Templates = append(dash.Templates[:pos], dash.Templates[pos+1:]...) - if err := s.DashboardsStore.Update(ctx, dash); err != nil { + if err := s.Store.Dashboards(ctx).Update(ctx, dash); err != nil { msg := fmt.Sprintf("Error removing template %s from dashboard %d: %v", tid, id, err) Error(w, http.StatusInternalServerError, msg, s.Logger) return @@ -205,7 +205,7 @@ func (s *Service) ReplaceTemplate(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + dash, err := s.Store.Dashboards(ctx).Get(ctx, chronograf.DashboardID(id)) if err != nil { notFound(w, id, s.Logger) return @@ -237,7 +237,7 @@ func (s *Service) ReplaceTemplate(w http.ResponseWriter, r *http.Request) { template.ID = chronograf.TemplateID(tid) dash.Templates[pos] = template - if err := s.DashboardsStore.Update(ctx, dash); err != nil { + if err := s.Store.Dashboards(ctx).Update(ctx, dash); err != nil { msg := fmt.Sprintf("Error updating template %s in dashboard %d: %v", tid, id, err) Error(w, http.StatusInternalServerError, msg, s.Logger) return diff --git a/server/test_helpers.go b/server/test_helpers.go new file mode 100644 index 0000000000..e53c953aad --- /dev/null +++ b/server/test_helpers.go @@ -0,0 +1,20 @@ +package server + +import ( + "encoding/json" + + "github.com/google/go-cmp/cmp" +) + +func jsonEqual(s1, s2 string) (eq bool, err error) { + var o1, o2 interface{} + + if err = json.Unmarshal([]byte(s1), &o1); err != nil { + return + } + if err = json.Unmarshal([]byte(s2), &o2); err != nil { + return + } + + return cmp.Equal(o1, o2), nil +} diff --git a/server/users.go b/server/users.go index cacf5da01b..ab2c13bc87 100644 --- a/server/users.go +++ b/server/users.go @@ -5,313 +5,315 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" + "sort" + "strconv" "github.com/bouk/httprouter" "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/roles" ) -// NewSourceUser adds user to source -func (h *Service) NewSourceUser(w http.ResponseWriter, r *http.Request) { +type userRequest struct { + ID uint64 `json:"id,string"` + Name string `json:"name"` + Provider string `json:"provider"` + Scheme string `json:"scheme"` + SuperAdmin bool `json:"superAdmin"` + Roles []chronograf.Role `json:"roles"` +} + +func (r *userRequest) ValidCreate() error { + if r.Name == "" { + return fmt.Errorf("Name required on Chronograf User request body") + } + if r.Provider == "" { + return fmt.Errorf("Provider required on Chronograf User request body") + } + if r.Scheme == "" { + return fmt.Errorf("Scheme required on Chronograf User request body") + } + + // TODO: This Scheme value is hard-coded temporarily since we only currently + // support OAuth2. This hard-coding should be removed whenever we add + // support for other authentication schemes. + r.Scheme = "oauth2" + return r.ValidRoles() +} + +func (r *userRequest) ValidUpdate() error { + if len(r.Roles) == 0 { + return fmt.Errorf("No Roles to update") + } + return r.ValidRoles() +} + +func (r *userRequest) ValidRoles() error { + orgs := map[string]bool{} + if len(r.Roles) > 0 { + for _, r := range r.Roles { + if r.Organization == "" { + return fmt.Errorf("no organization was provided") + } + if _, err := parseOrganizationID(r.Organization); err != nil { + return fmt.Errorf("failed to parse organization ID: %v", err) + } + if _, ok := orgs[r.Organization]; ok { + return fmt.Errorf("duplicate organization %q in roles", r.Organization) + } + orgs[r.Organization] = true + switch r.Name { + case roles.MemberRoleName, roles.ViewerRoleName, roles.EditorRoleName, roles.AdminRoleName: + continue + default: + return fmt.Errorf("Unknown role %s. Valid roles are 'member', 'viewer', 'editor', and 'admin'", r.Name) + } + } + } + return nil +} + +type userResponse struct { + Links selfLinks `json:"links"` + ID uint64 `json:"id,string"` + Name string `json:"name"` + Provider string `json:"provider"` + Scheme string `json:"scheme"` + SuperAdmin bool `json:"superAdmin"` + Roles []chronograf.Role `json:"roles"` +} + +func newUserResponse(u *chronograf.User) *userResponse { + // This ensures that any user response with no roles returns an empty array instead of + // null when marshaled into JSON. That way, JavaScript doesn't need any guard on the + // key existing and it can simply be iterated over. + if u.Roles == nil { + u.Roles = []chronograf.Role{} + } + return &userResponse{ + ID: u.ID, + Name: u.Name, + Provider: u.Provider, + Scheme: u.Scheme, + Roles: u.Roles, + SuperAdmin: u.SuperAdmin, + Links: selfLinks{ + Self: fmt.Sprintf("/chronograf/v1/users/%d", u.ID), + }, + } +} + +type usersResponse struct { + Links selfLinks `json:"links"` + Users []*userResponse `json:"users"` +} + +func newUsersResponse(users []chronograf.User) *usersResponse { + usersResp := make([]*userResponse, len(users)) + for i, user := range users { + usersResp[i] = newUserResponse(&user) + } + sort.Slice(usersResp, func(i, j int) bool { + return usersResp[i].ID < usersResp[j].ID + }) + return &usersResponse{ + Users: usersResp, + Links: selfLinks{ + Self: "/chronograf/v1/users", + }, + } +} + +// UserID retrieves a Chronograf user with ID from store +func (s *Service) UserID(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + idStr := httprouter.GetParamFromContext(ctx, "id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + Error(w, http.StatusBadRequest, fmt.Sprintf("invalid user id: %s", err.Error()), s.Logger) + return + } + user, err := s.Store.Users(ctx).Get(ctx, chronograf.UserQuery{ID: &id}) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + res := newUserResponse(user) + encodeJSON(w, http.StatusOK, res, s.Logger) +} + +// NewUser adds a new Chronograf user to store +func (s *Service) NewUser(w http.ResponseWriter, r *http.Request) { var req userRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - invalidJSON(w, h.Logger) + invalidJSON(w, s.Logger) return } if err := req.ValidCreate(); err != nil { - invalidData(w, err, h.Logger) + invalidData(w, err, s.Logger) return } ctx := r.Context() - srcID, ts, err := h.sourcesSeries(ctx, w, r) - if err != nil { - return - } - - store := ts.Users(ctx) user := &chronograf.User{ - Name: req.Username, - Passwd: req.Password, - Permissions: req.Permissions, - Roles: req.Roles, + Name: req.Name, + Provider: req.Provider, + Scheme: req.Scheme, + Roles: req.Roles, } - res, err := store.Add(ctx, user) - if err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) + if err := setSuperAdmin(ctx, req, user); err != nil { + Error(w, http.StatusUnauthorized, err.Error(), s.Logger) return } + res, err := s.Store.Users(ctx).Add(ctx, user) if err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) + Error(w, http.StatusBadRequest, err.Error(), s.Logger) return } - su := newUserResponse(srcID, res.Name).WithPermissions(res.Permissions) - if _, hasRoles := h.hasRoles(ctx, ts); hasRoles { - su.WithRoles(srcID, res.Roles) - } - w.Header().Add("Location", su.Links.Self) - encodeJSON(w, http.StatusCreated, su, h.Logger) + cu := newUserResponse(res) + location(w, cu.Links.Self) + encodeJSON(w, http.StatusCreated, cu, s.Logger) } -// SourceUsers retrieves all users from source. -func (h *Service) SourceUsers(w http.ResponseWriter, r *http.Request) { +// RemoveUser deletes a Chronograf user from store +func (s *Service) RemoveUser(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - srcID, ts, err := h.sourcesSeries(ctx, w, r) + idStr := httprouter.GetParamFromContext(ctx, "id") + id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { + Error(w, http.StatusBadRequest, fmt.Sprintf("invalid user id: %s", err.Error()), s.Logger) return } - store := ts.Users(ctx) - users, err := store.All(ctx) + u, err := s.Store.Users(ctx).Get(ctx, chronograf.UserQuery{ID: &id}) if err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) + Error(w, http.StatusNotFound, err.Error(), s.Logger) return } - - _, hasRoles := h.hasRoles(ctx, ts) - ur := make([]userResponse, len(users)) - for i, u := range users { - usr := newUserResponse(srcID, u.Name).WithPermissions(u.Permissions) - if hasRoles { - usr.WithRoles(srcID, u.Roles) - } - ur[i] = *usr - } - - res := usersResponse{ - Users: ur, - } - - encodeJSON(w, http.StatusOK, res, h.Logger) -} - -// SourceUserID retrieves a user with ID from store. -func (h *Service) SourceUserID(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - uid := httprouter.GetParamFromContext(ctx, "uid") - - srcID, ts, err := h.sourcesSeries(ctx, w, r) - if err != nil { + ctxUser, ok := hasUserContext(ctx) + if !ok { + Error(w, http.StatusBadRequest, "failed to retrieve user from context", s.Logger) return } - store := ts.Users(ctx) - u, err := store.Get(ctx, uid) - if err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) + if ctxUser.ID == u.ID { + Error(w, http.StatusForbidden, "user cannot delete themselves", s.Logger) return } - - res := newUserResponse(srcID, u.Name).WithPermissions(u.Permissions) - if _, hasRoles := h.hasRoles(ctx, ts); hasRoles { - res.WithRoles(srcID, u.Roles) - } - encodeJSON(w, http.StatusOK, res, h.Logger) -} - -// RemoveSourceUser removes the user from the InfluxDB source -func (h *Service) RemoveSourceUser(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - uid := httprouter.GetParamFromContext(ctx, "uid") - - _, store, err := h.sourceUsersStore(ctx, w, r) - if err != nil { - return - } - - if err := store.Delete(ctx, &chronograf.User{Name: uid}); err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) + if err := s.Store.Users(ctx).Delete(ctx, u); err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) return } w.WriteHeader(http.StatusNoContent) } -// UpdateSourceUser changes the password or permissions of a source user -func (h *Service) UpdateSourceUser(w http.ResponseWriter, r *http.Request) { +// UpdateUser updates a Chronograf user in store +func (s *Service) UpdateUser(w http.ResponseWriter, r *http.Request) { var req userRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - invalidJSON(w, h.Logger) - return - } - if err := req.ValidUpdate(); err != nil { - invalidData(w, err, h.Logger) + invalidJSON(w, s.Logger) return } ctx := r.Context() - uid := httprouter.GetParamFromContext(ctx, "uid") - srcID, ts, err := h.sourcesSeries(ctx, w, r) + idStr := httprouter.GetParamFromContext(ctx, "id") + id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { + Error(w, http.StatusBadRequest, fmt.Sprintf("invalid user id: %s", err.Error()), s.Logger) return } - user := &chronograf.User{ - Name: uid, - Passwd: req.Password, - Permissions: req.Permissions, - Roles: req.Roles, - } - store := ts.Users(ctx) - - if err := store.Update(ctx, user); err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) + if err := req.ValidUpdate(); err != nil { + invalidData(w, err, s.Logger) return } - u, err := store.Get(ctx, uid) + u, err := s.Store.Users(ctx).Get(ctx, chronograf.UserQuery{ID: &id}) if err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) + Error(w, http.StatusNotFound, err.Error(), s.Logger) return } - res := newUserResponse(srcID, u.Name).WithPermissions(u.Permissions) - if _, hasRoles := h.hasRoles(ctx, ts); hasRoles { - res.WithRoles(srcID, u.Roles) - } - w.Header().Add("Location", res.Links.Self) - encodeJSON(w, http.StatusOK, res, h.Logger) -} + // ValidUpdate should ensure that req.Roles is not nil + u.Roles = req.Roles -func (h *Service) sourcesSeries(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.TimeSeries, error) { - srcID, err := paramID("id", r) + // If the request contains a name, it must be the same as the + // one on the user. This is particularly useful to the front-end + // because they would like to provide the whole user object, + // including the name, provider, and scheme in update requests. + // But currently, it is not possible to change name, provider, or + // scheme via the API. + if req.Name != "" && req.Name != u.Name { + err := fmt.Errorf("Cannot update Name") + invalidData(w, err, s.Logger) + return + } + if req.Provider != "" && req.Provider != u.Provider { + err := fmt.Errorf("Cannot update Provider") + invalidData(w, err, s.Logger) + return + } + if req.Scheme != "" && req.Scheme != u.Scheme { + err := fmt.Errorf("Cannot update Scheme") + invalidData(w, err, s.Logger) + return + } + + if err := setSuperAdmin(ctx, req, u); err != nil { + Error(w, http.StatusUnauthorized, err.Error(), s.Logger) + return + } + + err = s.Store.Users(ctx).Update(ctx, u) if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) - return 0, nil, err + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return } - src, err := h.SourcesStore.Get(ctx, srcID) + cu := newUserResponse(u) + location(w, cu.Links.Self) + encodeJSON(w, http.StatusOK, cu, s.Logger) +} + +// Users retrieves all Chronograf users from store +func (s *Service) Users(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + users, err := s.Store.Users(ctx).All(ctx) if err != nil { - notFound(w, srcID, h.Logger) - return 0, nil, err + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return } - ts, err := h.TimeSeries(src) - if err != nil { - msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err) - Error(w, http.StatusBadRequest, msg, h.Logger) - return 0, nil, err - } - - if err = ts.Connect(ctx, &src); err != nil { - msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err) - Error(w, http.StatusBadRequest, msg, h.Logger) - return 0, nil, err - } - return srcID, ts, nil + res := newUsersResponse(users) + encodeJSON(w, http.StatusOK, res, s.Logger) } -func (h *Service) sourceUsersStore(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.UsersStore, error) { - srcID, ts, err := h.sourcesSeries(ctx, w, r) - if err != nil { - return 0, nil, err +func setSuperAdmin(ctx context.Context, req userRequest, user *chronograf.User) error { + // At a high level, this function checks the following + // 1. Is the user making the request a SuperAdmin. + // If they are, allow them to make whatever changes they please. + // + // 2. Is the user making the request trying to change the SuperAdmin + // status. If so, return an error. + // + // 3. If none of the above are the case, let the user make whichever + // changes were requested. + + // Only allow users to set SuperAdmin if they have the superadmin context + // TODO(desa): Refactor this https://github.com/influxdata/chronograf/issues/2207 + if isSuperAdmin := hasSuperAdminContext(ctx); isSuperAdmin { + user.SuperAdmin = req.SuperAdmin + } else if !isSuperAdmin && (user.SuperAdmin != req.SuperAdmin) { + // If req.SuperAdmin has been set, and the request was not made with the SuperAdmin + // context, return error + return fmt.Errorf("User does not have authorization required to set SuperAdmin status") } - store := ts.Users(ctx) - return srcID, store, nil -} - -// hasRoles checks if the influx source has roles or not -func (h *Service) hasRoles(ctx context.Context, ts chronograf.TimeSeries) (chronograf.RolesStore, bool) { - store, err := ts.Roles(ctx) - if err != nil { - return nil, false - } - return store, true -} - -type userRequest struct { - Username string `json:"name,omitempty"` // Username for new account - Password string `json:"password,omitempty"` // Password for new account - Permissions chronograf.Permissions `json:"permissions,omitempty"` // Optional permissions - Roles []chronograf.Role `json:"roles,omitempty"` // Optional roles -} - -func (r *userRequest) ValidCreate() error { - if r.Username == "" { - return fmt.Errorf("Username required") - } - if r.Password == "" { - return fmt.Errorf("Password required") - } - return validPermissions(&r.Permissions) -} - -type usersResponse struct { - Users []userResponse `json:"users"` -} - -func (r *userRequest) ValidUpdate() error { - if r.Password == "" && len(r.Permissions) == 0 && len(r.Roles) == 0 { - return fmt.Errorf("No fields to update") - } - return validPermissions(&r.Permissions) -} - -type userResponse struct { - Name string // Username for new account - Permissions chronograf.Permissions // Account's permissions - Roles []roleResponse // Roles if source uses them - Links selfLinks // Links are URI locations related to user - hasPermissions bool - hasRoles bool -} - -func (u *userResponse) MarshalJSON() ([]byte, error) { - res := map[string]interface{}{ - "name": u.Name, - "links": u.Links, - } - if u.hasRoles { - res["roles"] = u.Roles - } - if u.hasPermissions { - res["permissions"] = u.Permissions - } - return json.Marshal(res) -} - -// newUserResponse creates an HTTP JSON response for a user w/o roles -func newUserResponse(srcID int, name string) *userResponse { - self := newSelfLinks(srcID, "users", name) - return &userResponse{ - Name: name, - Links: self, - } -} - -func (u *userResponse) WithPermissions(perms chronograf.Permissions) *userResponse { - u.hasPermissions = true - if perms == nil { - perms = make(chronograf.Permissions, 0) - } - u.Permissions = perms - return u -} - -// WithRoles adds roles to the HTTP JSON response for a user -func (u *userResponse) WithRoles(srcID int, roles []chronograf.Role) *userResponse { - u.hasRoles = true - rr := make([]roleResponse, len(roles)) - for i, role := range roles { - rr[i] = newRoleResponse(srcID, &role) - } - u.Roles = rr - return u -} - -type selfLinks struct { - Self string `json:"self"` // Self link mapping to this resource -} - -func newSelfLinks(id int, parent, resource string) selfLinks { - httpAPISrcs := "/chronograf/v1/sources" - u := &url.URL{Path: resource} - encodedResource := u.String() - return selfLinks{ - Self: fmt.Sprintf("%s/%d/%s/%s", httpAPISrcs, id, parent, encodedResource), - } + return nil } diff --git a/server/users_test.go b/server/users_test.go index c46106d667..17155ecbec 100644 --- a/server/users_test.go +++ b/server/users_test.go @@ -1,8 +1,9 @@ -package server_test +package server import ( "bytes" "context" + "encoding/json" "fmt" "io/ioutil" "net/http" @@ -13,15 +14,13 @@ import ( "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/log" "github.com/influxdata/chronograf/mocks" - "github.com/influxdata/chronograf/server" + "github.com/influxdata/chronograf/roles" ) -func TestService_NewSourceUser(t *testing.T) { +func TestService_UserID(t *testing.T) { type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries server.TimeSeriesClient - Logger chronograf.Logger - UseAuth bool + UsersStore chronograf.UsersStore + Logger chronograf.Logger } type args struct { w *httptest.ResponseRecorder @@ -31,728 +30,905 @@ func TestService_NewSourceUser(t *testing.T) { name string fields fields args args - ID string + id string wantStatus int wantContentType string wantBody string }{ { - name: "New user for data source", + name: "Get Single Chronograf User", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + switch *q.ID { + case 1337: + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + roles.ViewerRole, + }, + }, nil + default: + return nil, fmt.Errorf("User with ID %d not found", *q.ID) + } + }, + }, + }, + id: "1337", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"id":"1337","superAdmin":false,"name":"billysteve","provider":"google","scheme":"oauth2","links":{"self":"/chronograf/v1/users/1337"},"roles":[{"name":"viewer"}]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + UsersStore: tt.fields.UsersStore, + }, + Logger: tt.fields.Logger, + } + + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.id, + }, + })) + + s.UserID(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. UserID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. UserID() = %v, want %v", tt.name, content, tt.wantContentType) + } + if eq, _ := jsonEqual(string(body), tt.wantBody); tt.wantBody != "" && !eq { + t.Errorf("%q. UserID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + }) + } +} + +func TestService_NewUser(t *testing.T) { + type fields struct { + UsersStore chronograf.UsersStore + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + user *userRequest + userKeyUser *chronograf.User + } + tests := []struct { + name string + fields fields + args args + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Create a new Chronograf User", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest( "POST", - "http://server.local/chronograf/v1/sources/1", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + "http://any.url", + nil, + ), + user: &userRequest{ + Name: "bob", + Provider: "github", + Scheme: "oauth2", + }, }, fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, user *chronograf.User) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1338, + Name: "bob", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{}, }, nil }, }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { - return u, nil - }, - } - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return nil, fmt.Errorf("no roles") - }, - }, }, - ID: "1", wantStatus: http.StatusCreated, wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[]} -`, + wantBody: `{"id":"1338","superAdmin":false,"name":"bob","provider":"github","scheme":"oauth2","roles":[],"links":{"self":"/chronograf/v1/users/1338"}}`, }, { - name: "New user for data source with roles", + name: "Create a new Chronograf User with multiple roles", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest( "POST", - "http://server.local/chronograf/v1/sources/1", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + "http://any.url", + nil, + ), + user: &userRequest{ + Name: "bob", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + { + Name: roles.ViewerRoleName, + Organization: "2", + }, + }, + }, }, fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, user *chronograf.User) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1338, + Name: "bob", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + { + Name: roles.ViewerRoleName, + Organization: "2", + }, + }, }, nil }, }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { - return u, nil - }, - } - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return nil, nil - }, - }, }, - ID: "1", wantStatus: http.StatusCreated, wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[],"roles":[]} -`, + wantBody: `{"id":"1338","superAdmin":false,"name":"bob","provider":"github","scheme":"oauth2","roles":[{"name":"admin","organization":"1"},{"name":"viewer","organization":"2"}],"links":{"self":"/chronograf/v1/users/1338"}}`, }, { - name: "Error adding user", + name: "Create a new Chronograf User with multiple roles same org", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest( "POST", - "http://server.local/chronograf/v1/sources/1", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), - }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", - }, nil + "http://any.url", + nil, + ), + user: &userRequest{ + Name: "bob", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + { + Name: roles.ViewerRoleName, + Organization: "1", + }, }, }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { - return nil, fmt.Errorf("Weight Has Nothing to Do With It") + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, user *chronograf.User) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1338, + Name: "bob", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + { + Name: roles.ViewerRoleName, + Organization: "1", + }, }, - } - }, - }, - }, - ID: "1", - wantStatus: http.StatusBadRequest, - wantContentType: "application/json", - wantBody: `{"code":400,"message":"Weight Has Nothing to Do With It"}`, - }, - { - name: "Failure connecting to user store", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), - }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", }, nil }, }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return fmt.Errorf("Biff just happens to be my supervisor") - }, - }, }, - ID: "1", - wantStatus: http.StatusBadRequest, - wantContentType: "application/json", - wantBody: `{"code":400,"message":"Unable to connect to source 1: Biff just happens to be my supervisor"}`, - }, - { - name: "Failure getting source", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), - }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{}, fmt.Errorf("No McFly ever amounted to anything in the history of Hill Valley") - }, - }, - }, - ID: "1", - wantStatus: http.StatusNotFound, - wantContentType: "application/json", - wantBody: `{"code":404,"message":"ID 1 not found"}`, - }, - { - name: "Bad ID", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), - }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - }, - ID: "BAD", wantStatus: http.StatusUnprocessableEntity, wantContentType: "application/json", - wantBody: `{"code":422,"message":"Error converting ID BAD"}`, + wantBody: `{"code":422,"message":"duplicate organization \"1\" in roles"}`, }, { - name: "Bad name", + name: "Create a new SuperAdmin User - Not as superadmin", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest( "POST", - "http://server.local/chronograf/v1/sources/1", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"password": "the_lake"}`)))), + "http://any.url", + nil, + ), + user: &userRequest{ + Name: "bob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + }, + userKeyUser: &chronograf.User{ + ID: 0, + Name: "coolUser", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + }, }, fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, user *chronograf.User) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1338, + Name: "bob", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{}, + }, nil + }, + }, }, - ID: "BAD", - wantStatus: http.StatusUnprocessableEntity, + wantStatus: http.StatusUnauthorized, wantContentType: "application/json", - wantBody: `{"code":422,"message":"Username required"}`, + wantBody: `{"code":401,"message":"User does not have authorization required to set SuperAdmin status"}`, }, { - name: "Bad JSON", + name: "Create a new SuperAdmin User - as superadmin", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest( "POST", - "http://server.local/chronograf/v1/sources/1", - ioutil.NopCloser( - bytes.NewReader([]byte(`{password}`)))), + "http://any.url", + nil, + ), + user: &userRequest{ + Name: "bob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + }, + userKeyUser: &chronograf.User{ + ID: 0, + Name: "coolUser", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + }, }, fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, user *chronograf.User) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1338, + Name: "bob", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{}, + SuperAdmin: true, + }, nil + }, + }, }, - ID: "BAD", - wantStatus: http.StatusBadRequest, + wantStatus: http.StatusCreated, wantContentType: "application/json", - wantBody: `{"code":400,"message":"Unparsable JSON"}`, + wantBody: `{"id":"1338","superAdmin":true,"name":"bob","provider":"github","scheme":"oauth2","roles":[],"links":{"self":"/chronograf/v1/users/1338"}}`, }, } + for _, tt := range tests { - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + UsersStore: tt.fields.UsersStore, }, - })) + Logger: tt.fields.Logger, + } - h := &server.Service{ - SourcesStore: tt.fields.SourcesStore, - TimeSeriesClient: tt.fields.TimeSeries, - Logger: tt.fields.Logger, - UseAuth: tt.fields.UseAuth, - } + buf, _ := json.Marshal(tt.args.user) + tt.args.r.Body = ioutil.NopCloser(bytes.NewReader(buf)) - h.NewSourceUser(tt.args.w, tt.args.r) + ctx := tt.args.r.Context() + if tt.args.userKeyUser != nil { + ctx = context.WithValue(ctx, UserContextKey, tt.args.userKeyUser) + } - resp := tt.args.w.Result() - content := resp.Header.Get("Content-Type") - body, _ := ioutil.ReadAll(resp.Body) + tt.args.r = tt.args.r.WithContext(ctx) - if resp.StatusCode != tt.wantStatus { - t.Errorf("%q. NewSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - if tt.wantContentType != "" && content != tt.wantContentType { - t.Errorf("%q. NewSourceUser() = %v, want %v", tt.name, content, tt.wantContentType) - } - if tt.wantBody != "" && string(body) != tt.wantBody { - t.Errorf("%q. NewSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) - } + s.NewUser(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. UserID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. UserID() = %v, want %v", tt.name, content, tt.wantContentType) + } + if eq, _ := jsonEqual(string(body), tt.wantBody); tt.wantBody != "" && !eq { + t.Errorf("%q. UserID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + }) } } -func TestService_SourceUsers(t *testing.T) { +func TestService_RemoveUser(t *testing.T) { type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries server.TimeSeriesClient - Logger chronograf.Logger - UseAuth bool + UsersStore chronograf.UsersStore + Logger chronograf.Logger } type args struct { - w *httptest.ResponseRecorder - r *http.Request + w *httptest.ResponseRecorder + r *http.Request + user *chronograf.User + id string } tests := []struct { - name string - fields fields - args args - ID string - wantStatus int - wantContentType string - wantBody string + name string + fields fields + args args + wantStatus int + wantBody string }{ { - name: "All users for data source", + name: "Delete a Chronograf User", + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + switch *q.ID { + case 1339: + return &chronograf.User{ + ID: 1339, + Name: "helena", + Provider: "heroku", + Scheme: "oauth2", + }, nil + default: + return nil, fmt.Errorf("User with ID %d not found", *q.ID) + } + }, + DeleteF: func(ctx context.Context, user *chronograf.User) error { + return nil + }, + }, + }, args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest( - "GET", - "http://server.local/chronograf/v1/sources/1", - nil), + "DELETE", + "http://any.url", + nil, + ), + user: &chronograf.User{ + ID: 1338, + Name: "helena", + Provider: "heroku", + Scheme: "oauth2", + }, + id: "1339", }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", - }, nil - }, - }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return nil, fmt.Errorf("no roles") - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - AllF: func(ctx context.Context) ([]chronograf.User, error) { - return []chronograf.User{ - { - Name: "strickland", - Passwd: "discipline", - Permissions: chronograf.Permissions{ - { - Scope: chronograf.AllScope, - Allowed: chronograf.Allowances{"READ"}, - }, - }, - }, - }, nil - }, - } - }, - }, - }, - ID: "1", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}]}]} -`, - }, - { - name: "All users for data source with roles", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "GET", - "http://server.local/chronograf/v1/sources/1", - nil), - }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", - }, nil - }, - }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return nil, nil - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - AllF: func(ctx context.Context) ([]chronograf.User, error) { - return []chronograf.User{ - { - Name: "strickland", - Passwd: "discipline", - Permissions: chronograf.Permissions{ - { - Scope: chronograf.AllScope, - Allowed: chronograf.Allowances{"READ"}, - }, - }, - }, - }, nil - }, - } - }, - }, - }, - ID: "1", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"roles":[]}]} -`, - }, - } - for _, tt := range tests { - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, - }, - })) - h := &server.Service{ - SourcesStore: tt.fields.SourcesStore, - TimeSeriesClient: tt.fields.TimeSeries, - Logger: tt.fields.Logger, - UseAuth: tt.fields.UseAuth, - } - - h.SourceUsers(tt.args.w, tt.args.r) - resp := tt.args.w.Result() - content := resp.Header.Get("Content-Type") - body, _ := ioutil.ReadAll(resp.Body) - - if resp.StatusCode != tt.wantStatus { - t.Errorf("%q. SourceUsers() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - if tt.wantContentType != "" && content != tt.wantContentType { - t.Errorf("%q. SourceUsers() = %v, want %v", tt.name, content, tt.wantContentType) - } - if tt.wantBody != "" && string(body) != tt.wantBody { - t.Errorf("%q. SourceUsers() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) - } - } -} - -func TestService_SourceUserID(t *testing.T) { - type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries server.TimeSeriesClient - Logger chronograf.Logger - UseAuth bool - } - type args struct { - w *httptest.ResponseRecorder - r *http.Request - } - tests := []struct { - name string - fields fields - args args - ID string - UID string - wantStatus int - wantContentType string - wantBody string - }{ - { - name: "Single user for data source", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "GET", - "http://server.local/chronograf/v1/sources/1", - nil), - }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", - }, nil - }, - }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return nil, fmt.Errorf("no roles") - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - GetF: func(ctx context.Context, uid string) (*chronograf.User, error) { - return &chronograf.User{ - Name: "strickland", - Passwd: "discipline", - Permissions: chronograf.Permissions{ - { - Scope: chronograf.AllScope, - Allowed: chronograf.Allowances{"READ"}, - }, - }, - }, nil - }, - } - }, - }, - }, - ID: "1", - UID: "strickland", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}]} -`, - }, - { - name: "Single user for data source", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "GET", - "http://server.local/chronograf/v1/sources/1", - nil), - }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", - }, nil - }, - }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return nil, nil - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - GetF: func(ctx context.Context, uid string) (*chronograf.User, error) { - return &chronograf.User{ - Name: "strickland", - Passwd: "discipline", - Permissions: chronograf.Permissions{ - { - Scope: chronograf.AllScope, - Allowed: chronograf.Allowances{"READ"}, - }, - }, - }, nil - }, - } - }, - }, - }, - ID: "1", - UID: "strickland", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"roles":[]} -`, - }, - } - for _, tt := range tests { - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, - }, - })) - h := &server.Service{ - SourcesStore: tt.fields.SourcesStore, - TimeSeriesClient: tt.fields.TimeSeries, - Logger: tt.fields.Logger, - UseAuth: tt.fields.UseAuth, - } - - h.SourceUserID(tt.args.w, tt.args.r) - resp := tt.args.w.Result() - content := resp.Header.Get("Content-Type") - body, _ := ioutil.ReadAll(resp.Body) - - if resp.StatusCode != tt.wantStatus { - t.Errorf("%q. SourceUserID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - if tt.wantContentType != "" && content != tt.wantContentType { - t.Errorf("%q. SourceUserID() = %v, want %v", tt.name, content, tt.wantContentType) - } - if tt.wantBody != "" && string(body) != tt.wantBody { - t.Errorf("%q. SourceUserID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) - } - } -} - -func TestService_RemoveSourceUser(t *testing.T) { - type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries server.TimeSeriesClient - Logger chronograf.Logger - UseAuth bool - } - type args struct { - w *httptest.ResponseRecorder - r *http.Request - } - tests := []struct { - name string - fields fields - args args - ID string - UID string - wantStatus int - wantContentType string - wantBody string - }{ - { - name: "Delete user for data source", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "GET", - "http://server.local/chronograf/v1/sources/1", - nil), - }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", - }, nil - }, - }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - DeleteF: func(ctx context.Context, u *chronograf.User) error { - return nil - }, - } - }, - }, - }, - ID: "1", - UID: "strickland", wantStatus: http.StatusNoContent, }, + { + name: "Deleting yourself", + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + switch *q.ID { + case 1339: + return &chronograf.User{ + ID: 1339, + Name: "helena", + Provider: "heroku", + Scheme: "oauth2", + }, nil + default: + return nil, fmt.Errorf("User with ID %d not found", *q.ID) + } + }, + DeleteF: func(ctx context.Context, user *chronograf.User) error { + return nil + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "DELETE", + "http://any.url", + nil, + ), + user: &chronograf.User{ + ID: 1339, + Name: "helena", + Provider: "heroku", + Scheme: "oauth2", + }, + id: "1339", + }, + wantStatus: http.StatusForbidden, + wantBody: `{"code":403,"message":"user cannot delete themselves"}`, + }, } for _, tt := range tests { - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + UsersStore: tt.fields.UsersStore, }, - })) - h := &server.Service{ - SourcesStore: tt.fields.SourcesStore, - TimeSeriesClient: tt.fields.TimeSeries, - Logger: tt.fields.Logger, - UseAuth: tt.fields.UseAuth, - } - h.RemoveSourceUser(tt.args.w, tt.args.r) - resp := tt.args.w.Result() - content := resp.Header.Get("Content-Type") - body, _ := ioutil.ReadAll(resp.Body) + Logger: tt.fields.Logger, + } - if resp.StatusCode != tt.wantStatus { - t.Errorf("%q. RemoveSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - if tt.wantContentType != "" && content != tt.wantContentType { - t.Errorf("%q. RemoveSourceUser() = %v, want %v", tt.name, content, tt.wantContentType) - } - if tt.wantBody != "" && string(body) != tt.wantBody { - t.Errorf("%q. RemoveSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) - } + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.args.id, + }, + }, + )) + + if tt.args.user != nil { + ctx := tt.args.r.Context() + ctx = context.WithValue(ctx, UserContextKey, tt.args.user) + tt.args.r = tt.args.r.WithContext(ctx) + } + + s.RemoveUser(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. RemoveUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantStatus == http.StatusNoContent { + return + } + if eq, _ := jsonEqual(string(body), tt.wantBody); !eq { + t.Errorf("%q. RemoveUser() = %v, want %v", tt.name, string(body), tt.wantBody) + } + }) } } -func TestService_UpdateSourceUser(t *testing.T) { +func TestService_UpdateUser(t *testing.T) { type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries server.TimeSeriesClient - Logger chronograf.Logger - UseAuth bool + UsersStore chronograf.UsersStore + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + user *userRequest + userKeyUser *chronograf.User + } + tests := []struct { + name string + fields fields + args args + id string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Update a Chronograf user", + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + UpdateF: func(ctx context.Context, user *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + switch *q.ID { + case 1336: + return &chronograf.User{ + ID: 1336, + Name: "bobbetta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1", + }, + }, + }, nil + default: + return nil, fmt.Errorf("User with ID %d not found", *q.ID) + } + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "PATCH", + "http://any.url", + nil, + ), + user: &userRequest{ + ID: 1336, + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + }, + }, + }, + id: "1336", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"id":"1336","superAdmin":false,"name":"bobbetta","provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/1336"},"roles":[{"name":"admin","organization":"1"}]}`, + }, + { + name: "Update a Chronograf user roles different orgs", + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + UpdateF: func(ctx context.Context, user *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + switch *q.ID { + case 1336: + return &chronograf.User{ + ID: 1336, + Name: "bobbetta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + roles.EditorRole, + }, + }, nil + default: + return nil, fmt.Errorf("User with ID %d not found", *q.ID) + } + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "PATCH", + "http://any.url", + nil, + ), + user: &userRequest{ + ID: 1336, + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + { + Name: roles.ViewerRoleName, + Organization: "2", + }, + }, + }, + }, + id: "1336", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"id":"1336","superAdmin":false,"name":"bobbetta","provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/1336"},"roles":[{"name":"admin","organization":"1"},{"name":"viewer","organization":"2"}]}`, + }, + { + name: "Update a Chronograf user roles same org", + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + UpdateF: func(ctx context.Context, user *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + switch *q.ID { + case 1336: + return &chronograf.User{ + ID: 1336, + Name: "bobbetta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + roles.EditorRole, + }, + }, nil + default: + return nil, fmt.Errorf("User with ID %d not found", *q.ID) + } + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "PATCH", + "http://any.url", + nil, + ), + user: &userRequest{ + ID: 1336, + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + { + Name: roles.ViewerRoleName, + Organization: "1", + }, + }, + }, + }, + id: "1336", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"duplicate organization \"1\" in roles"}`, + }, + { + name: "Update a SuperAdmin's Roles - without super admin context", + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + UpdateF: func(ctx context.Context, user *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + switch *q.ID { + case 1336: + return &chronograf.User{ + ID: 1336, + Name: "bobbetta", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1", + }, + }, + }, nil + default: + return nil, fmt.Errorf("User with ID %d not found", *q.ID) + } + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "PATCH", + "http://any.url", + nil, + ), + user: &userRequest{ + ID: 1336, + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + }, + }, + userKeyUser: &chronograf.User{ + ID: 0, + Name: "coolUser", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + }, + }, + id: "1336", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/users/1336"},"id":"1336","name":"bobbetta","provider":"github","scheme":"oauth2","superAdmin":true,"roles":[{"name":"admin","organization":"1"}]}`, + }, + { + name: "Update a Chronograf user to super admin - without super admin context", + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + UpdateF: func(ctx context.Context, user *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + switch *q.ID { + case 1336: + return &chronograf.User{ + ID: 1336, + Name: "bobbetta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + roles.EditorRole, + }, + }, nil + default: + return nil, fmt.Errorf("User with ID %d not found", *q.ID) + } + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "PATCH", + "http://any.url", + nil, + ), + user: &userRequest{ + ID: 1336, + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + }, + }, + userKeyUser: &chronograf.User{ + ID: 0, + Name: "coolUser", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + }, + }, + id: "1336", + wantStatus: http.StatusUnauthorized, + wantContentType: "application/json", + wantBody: `{"code":401,"message":"User does not have authorization required to set SuperAdmin status"}`, + }, + { + name: "Update a Chronograf user to super admin - with super admin context", + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + UpdateF: func(ctx context.Context, user *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + switch *q.ID { + case 1336: + return &chronograf.User{ + ID: 1336, + Name: "bobbetta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + roles.EditorRole, + }, + }, nil + default: + return nil, fmt.Errorf("User with ID %d not found", *q.ID) + } + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "PATCH", + "http://any.url", + nil, + ), + user: &userRequest{ + ID: 1336, + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + }, + }, + userKeyUser: &chronograf.User{ + ID: 0, + Name: "coolUser", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + }, + }, + id: "1336", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"id":"1336","superAdmin":true,"name":"bobbetta","provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/1336"},"roles":[{"name":"admin","organization":"1"}]}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + UsersStore: tt.fields.UsersStore, + }, + Logger: tt.fields.Logger, + } + + tt.args.r = tt.args.r.WithContext(httprouter.WithParams(context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.id, + }, + })) + buf, _ := json.Marshal(tt.args.user) + tt.args.r.Body = ioutil.NopCloser(bytes.NewReader(buf)) + + ctx := tt.args.r.Context() + if tt.args.userKeyUser != nil { + ctx = context.WithValue(ctx, UserContextKey, tt.args.userKeyUser) + } + + tt.args.r = tt.args.r.WithContext(ctx) + + s.UpdateUser(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. UpdateUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. UpdateUser() = %v, want %v", tt.name, content, tt.wantContentType) + } + if eq, _ := jsonEqual(string(body), tt.wantBody); tt.wantBody != "" && !eq { + t.Errorf("%q. UpdateUser()\ngot:%v\n,\nwant:%v", tt.name, string(body), tt.wantBody) + } + }) + } +} + +func TestService_Users(t *testing.T) { + type fields struct { + UsersStore chronograf.UsersStore + Logger chronograf.Logger } type args struct { w *httptest.ResponseRecorder @@ -762,169 +938,384 @@ func TestService_UpdateSourceUser(t *testing.T) { name string fields fields args args - ID string - UID string wantStatus int wantContentType string wantBody string }{ { - name: "Update user password for data source", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), - }, + name: "Get all Chronograf users", fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + AllF: func(ctx context.Context) ([]chronograf.User, error) { + return []chronograf.User{ + { + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + roles.EditorRole, + }, + }, + { + ID: 1338, + Name: "bobbettastuhvetta", + Provider: "auth0", + Scheme: "oauth2", + }, }, nil }, }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return nil, fmt.Errorf("no roles") - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - UpdateF: func(ctx context.Context, u *chronograf.User) error { - return nil - }, - GetF: func(ctx context.Context, name string) (*chronograf.User, error) { - return &chronograf.User{ - Name: "marty", - }, nil - }, - } - }, - }, }, - ID: "1", - UID: "marty", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[]} -`, - }, - { - name: "Update user password for data source with roles", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), }, + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"users":[{"id":"1337","superAdmin":false,"name":"billysteve","provider":"google","scheme":"oauth2","roles":[{"name":"editor"}],"links":{"self":"/chronograf/v1/users/1337"}},{"id":"1338","superAdmin":false,"name":"bobbettastuhvetta","provider":"auth0","scheme":"oauth2","roles":[],"links":{"self":"/chronograf/v1/users/1338"}}],"links":{"self":"/chronograf/v1/users"}}`, + }, + { + name: "Get all Chronograf users, ensuring order of users in response", fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - SourcesStore: &mocks.SourcesStore{ - GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { - return chronograf.Source{ - ID: 1, - Name: "muh source", - Username: "name", - Password: "hunter2", - URL: "http://localhost:8086", + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + AllF: func(ctx context.Context) ([]chronograf.User, error) { + return []chronograf.User{ + { + ID: 1338, + Name: "bobbettastuhvetta", + Provider: "auth0", + Scheme: "oauth2", + }, + { + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + roles.EditorRole, + }, + }, }, nil }, }, - TimeSeries: &mocks.TimeSeries{ - ConnectF: func(ctx context.Context, src *chronograf.Source) error { - return nil - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return nil, nil - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - UpdateF: func(ctx context.Context, u *chronograf.User) error { - return nil - }, - GetF: func(ctx context.Context, name string) (*chronograf.User, error) { - return &chronograf.User{ - Name: "marty", - }, nil - }, - } - }, - }, }, - ID: "1", - UID: "marty", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[],"roles":[]} -`, - }, - { - name: "Invalid update JSON", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": "marty"}`)))), + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - }, - ID: "1", - UID: "marty", - wantStatus: http.StatusUnprocessableEntity, + wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"code":422,"message":"No fields to update"}`, + wantBody: `{"users":[{"id":"1337","superAdmin":false,"name":"billysteve","provider":"google","scheme":"oauth2","roles":[{"name":"editor"}],"links":{"self":"/chronograf/v1/users/1337"}},{"id":"1338","superAdmin":false,"name":"bobbettastuhvetta","provider":"auth0","scheme":"oauth2","roles":[],"links":{"self":"/chronograf/v1/users/1338"}}],"links":{"self":"/chronograf/v1/users"}}`, }, } - for _, tt := range tests { - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, - }, - { - Key: "uid", - Value: tt.UID, - }, - })) - h := &server.Service{ - SourcesStore: tt.fields.SourcesStore, - TimeSeriesClient: tt.fields.TimeSeries, - Logger: tt.fields.Logger, - UseAuth: tt.fields.UseAuth, - } - h.UpdateSourceUser(tt.args.w, tt.args.r) - resp := tt.args.w.Result() - content := resp.Header.Get("Content-Type") - body, _ := ioutil.ReadAll(resp.Body) - if resp.StatusCode != tt.wantStatus { - t.Errorf("%q. UpdateSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - if tt.wantContentType != "" && content != tt.wantContentType { - t.Errorf("%q. UpdateSourceUser() = %v, want %v", tt.name, content, tt.wantContentType) - } - if tt.wantBody != "" && string(body) != tt.wantBody { - t.Errorf("%q. UpdateSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + UsersStore: tt.fields.UsersStore, + }, + Logger: tt.fields.Logger, + } + + s.Users(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. Users() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. Users() = %v, want %v", tt.name, content, tt.wantContentType) + } + if eq, _ := jsonEqual(string(body), tt.wantBody); tt.wantBody != "" && !eq { + t.Errorf("%q. Users() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + }) + } +} + +func TestUserRequest_ValidCreate(t *testing.T) { + type args struct { + u *userRequest + } + tests := []struct { + name string + args args + wantErr bool + err error + }{ + { + name: "Valid", + args: args{ + u: &userRequest{ + ID: 1337, + Name: "billietta", + Provider: "auth0", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1", + }, + }, + }, + }, + wantErr: false, + err: nil, + }, + { + name: "Invalid - bad organization", + args: args{ + u: &userRequest{ + ID: 1337, + Name: "billietta", + Provider: "auth0", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "l", // this is the character L not integer One + }, + }, + }, + }, + wantErr: true, + err: fmt.Errorf("failed to parse organization ID: strconv.ParseUint: parsing \"l\": invalid syntax"), + }, + { + name: "Invalid – Name missing", + args: args{ + u: &userRequest{ + ID: 1337, + Provider: "auth0", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1", + }, + }, + }, + }, + wantErr: true, + err: fmt.Errorf("Name required on Chronograf User request body"), + }, + { + name: "Invalid – Provider missing", + args: args{ + u: &userRequest{ + ID: 1337, + Name: "billietta", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1", + }, + }, + }, + }, + wantErr: true, + err: fmt.Errorf("Provider required on Chronograf User request body"), + }, + { + name: "Invalid – Scheme missing", + args: args{ + u: &userRequest{ + ID: 1337, + Name: "billietta", + Provider: "auth0", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1", + }, + }, + }, + }, + wantErr: true, + err: fmt.Errorf("Scheme required on Chronograf User request body"), + }, + { + name: "Invalid roles - bad role name", + args: args{ + u: &userRequest{ + ID: 1337, + Name: "billietta", + Provider: "auth0", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "BilliettaSpecialRole", + Organization: "1", + }, + }, + }, + }, + wantErr: true, + err: fmt.Errorf("Unknown role BilliettaSpecialRole. Valid roles are 'member', 'viewer', 'editor', and 'admin'"), + }, + { + name: "Invalid roles - missing organization", + args: args{ + u: &userRequest{ + ID: 1337, + Name: "billietta", + Provider: "auth0", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + }, + }, + }, + }, + wantErr: true, + err: fmt.Errorf("no organization was provided"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.u.ValidCreate() + + if tt.wantErr { + if err == nil || err.Error() != tt.err.Error() { + t.Errorf("%q. ValidCreate(): wantErr %v,\nwant %v,\ngot %v", tt.name, tt.wantErr, tt.err, err) + } + } else { + if err != nil { + t.Errorf("%q. ValidCreate(): wantErr %v,\nwant %v,\ngot %v", tt.name, tt.wantErr, tt.err, err) + } + } + }) + } +} + +func TestUserRequest_ValidUpdate(t *testing.T) { + type args struct { + u *userRequest + } + tests := []struct { + name string + args args + wantErr bool + err error + }{ + { + name: "Valid", + args: args{ + u: &userRequest{ + ID: 1337, + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1", + }, + }, + }, + }, + wantErr: false, + err: nil, + }, + { + name: "Invalid – roles missing", + args: args{ + u: &userRequest{}, + }, + wantErr: true, + err: fmt.Errorf("No Roles to update"), + }, + { + name: "Invalid - bad organization", + args: args{ + u: &userRequest{ + ID: 1337, + Name: "billietta", + Provider: "auth0", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "l", // this is the character L not integer One + }, + }, + }, + }, + wantErr: true, + err: fmt.Errorf("failed to parse organization ID: strconv.ParseUint: parsing \"l\": invalid syntax"), + }, + { + name: "Invalid - bad role name", + args: args{ + u: &userRequest{ + ID: 1337, + Name: "billietta", + Provider: "auth0", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "BillietaSpecialOrg", + Organization: "0", + }, + }, + }, + }, + wantErr: true, + err: fmt.Errorf("Unknown role BillietaSpecialOrg. Valid roles are 'member', 'viewer', 'editor', and 'admin'"), + }, + { + name: "Invalid - duplicate organization", + args: args{ + u: &userRequest{ + ID: 1337, + Name: "billietta", + Provider: "auth0", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "0", + }, + { + Name: roles.ViewerRoleName, + Organization: "0", + }, + }, + }, + }, + wantErr: true, + err: fmt.Errorf("duplicate organization \"0\" in roles"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.u.ValidUpdate() + + if tt.wantErr { + if err == nil || err.Error() != tt.err.Error() { + t.Errorf("%q. ValidUpdate(): wantErr %v,\nwant %v,\ngot %v", tt.name, tt.wantErr, tt.err, err) + } + } else { + if err != nil { + t.Errorf("%q. ValidUpdate(): wantErr %v,\nwant %v,\ngot %v", tt.name, tt.wantErr, tt.err, err) + } + } + }) } } diff --git a/ui/package.json b/ui/package.json index 6bb2a3bfd2..88c6fea61f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "chronograf-ui", - "version": "1.3.10-0", + "version": "1.4.0-0beta1", "private": false, "license": "AGPL-3.0", "description": "", @@ -103,7 +103,7 @@ "bootstrap": "^3.3.7", "calculate-size": "^1.1.1", "classnames": "^2.2.3", - "dygraphs": "influxdata/dygraphs", + "dygraphs": "2.1.0", "eslint-plugin-babel": "^4.1.2", "fast.js": "^0.1.1", "fixed-data-table": "^0.6.1", diff --git a/ui/spec/admin/reducers/chronografSpec.js b/ui/spec/admin/reducers/chronografSpec.js new file mode 100644 index 0000000000..7cb3d6dd8c --- /dev/null +++ b/ui/spec/admin/reducers/chronografSpec.js @@ -0,0 +1,141 @@ +import reducer from 'src/admin/reducers/chronograf' + +import {loadUsers} from 'src/admin/actions/chronograf' + +import { + MEMBER_ROLE, + VIEWER_ROLE, + EDITOR_ROLE, + ADMIN_ROLE, +} from 'src/auth/Authorized' + +let state + +const users = [ + { + id: '666', + name: 'bob@billietta.com', + roles: [ + { + name: 'admin', + organization: '0', + }, + { + name: 'member', + organization: '667', + }, + ], + provider: 'github', + scheme: 'oauth2', + superAdmin: true, + links: { + self: '/chronograf/v1/users/666', + }, + organizations: [ + { + id: '0', + name: 'Default', + }, + { + id: '667', + name: 'Engineering', + defaultRole: 'member', + }, + ], + }, + { + id: '831', + name: 'billybob@gmail.com', + roles: [ + { + name: 'member', + organization: '0', + }, + { + name: 'viewer', + organization: '667', + }, + { + name: 'editor', + organization: '1236', + }, + ], + provider: 'github', + scheme: 'oauth2', + superAdmin: false, + links: { + self: '/chronograf/v1/users/831', + }, + organizations: [ + { + id: '0', + name: 'Default', + }, + { + id: '667', + name: 'Engineering', + defaultRole: 'member', + }, + { + id: '1236', + name: 'PsyOps', + defaultRole: 'editor', + }, + ], + }, + { + id: '720', + name: 'shorty@gmail.com', + roles: [ + { + name: 'admin', + organization: '667', + }, + { + name: 'viewer', + organization: '1236', + }, + ], + provider: 'github', + scheme: 'oauth2', + superAdmin: false, + links: { + self: '/chronograf/v1/users/720', + }, + organizations: [ + { + id: '667', + name: 'Engineering', + defaultRole: 'member', + }, + { + id: '1236', + name: 'PsyOps', + defaultRole: 'editor', + }, + ], + }, + { + id: '271', + name: 'shawn.ofthe.dead@altavista.yop', + roles: [], + provider: 'github', + scheme: 'oauth2', + superAdmin: false, + links: { + self: '/chronograf/v1/users/271', + }, + organizations: [], + }, +] + +describe('Admin.Chronograf.Reducers', () => { + it('it can load all users', () => { + const actual = reducer(state, loadUsers({users})) + const expected = { + users, + } + + expect(actual.users).to.deep.equal(expected.users) + }) +}) diff --git a/ui/spec/admin/reducers/adminSpec.js b/ui/spec/admin/reducers/influxdbSpec.js similarity index 98% rename from ui/spec/admin/reducers/adminSpec.js rename to ui/spec/admin/reducers/influxdbSpec.js index 24941cf6d6..adef72a72e 100644 --- a/ui/spec/admin/reducers/adminSpec.js +++ b/ui/spec/admin/reducers/influxdbSpec.js @@ -1,4 +1,4 @@ -import reducer from 'src/admin/reducers/admin' +import reducer from 'src/admin/reducers/influxdb' import { addUser, @@ -21,7 +21,7 @@ import { filterUsers, addDatabaseDeleteCode, removeDatabaseDeleteCode, -} from 'src/admin/actions' +} from 'src/admin/actions/influxdb' import { NEW_DEFAULT_USER, @@ -138,7 +138,7 @@ const db2 = { deleteCode: 'DELETE', } -describe('Admin.Reducers', () => { +describe('Admin.InfluxDB.Reducers', () => { describe('Databases', () => { const state = {databases: [db1, db2]} diff --git a/ui/spec/shared/reducers/authSpec.js b/ui/spec/shared/reducers/authSpec.js index 170244f35f..6db9f79cf9 100644 --- a/ui/spec/shared/reducers/authSpec.js +++ b/ui/spec/shared/reducers/authSpec.js @@ -4,8 +4,8 @@ import { authExpired, authRequested, authReceived, - meRequested, - meReceived, + meGetRequested, + meGetCompleted, } from 'shared/actions/auth' const defaultAuth = { @@ -22,7 +22,6 @@ const defaultAuth = { const defaultMe = { name: 'wishful_modal@overlay.technology', - password: '', links: { self: '/chronograf/v1/users/wishful_modal@overlay.technology', }, @@ -44,25 +43,58 @@ describe('Shared.Reducers.authReducer', () => { expect(reducedState.isAuthLoading).to.equal(true) }) - it('should handle AUTH_RECEIVED', () => { - const loadingState = Object.assign({}, initialState, {isAuthLoading: true}) - const reducedState = authReducer(loadingState, authReceived(defaultAuth)) - - expect(reducedState.links[0]).to.deep.equal(defaultAuth.links[0]) - expect(reducedState.isAuthLoading).to.equal(false) - }) - - it('should handle ME_REQUESTED', () => { - const reducedState = authReducer(initialState, meRequested()) + it('should handle ME_GET_REQUESTED', () => { + const reducedState = authReducer(initialState, meGetRequested()) expect(reducedState.isMeLoading).to.equal(true) }) - it('should handle ME_RECEIVED', () => { - const loadingState = Object.assign({}, initialState, {isMeLoading: true}) - const reducedState = authReducer(loadingState, meReceived(defaultMe)) + it('should handle ME_GET_COMPLETED with auth', () => { + const loadingState = { + ...initialState, + isAuthLoading: true, + isMeLoading: true, + } + + const meWithAuth = { + ...defaultMe, + roles: [{name: 'member', organization: '1'}], + role: 'member', + currentOrganization: {name: 'bob', id: '1'}, + } + + const reducedState = authReducer( + loadingState, + meGetCompleted({ + me: meWithAuth, + auth: defaultAuth, + logoutLink: '/oauth/logout', + }) + ) + + expect(reducedState.me).to.deep.equal(meWithAuth) + expect(reducedState.links[0]).to.deep.equal(defaultAuth.links[0]) + expect(reducedState.isAuthLoading).to.equal(false) + expect(reducedState.isMeLoading).to.equal(false) + }) + + it('should handle ME_GET_COMPLETED without auth', () => { + const loadingState = { + ...initialState, + isAuthLoading: true, + isMeLoading: true, + } + const reducedState = authReducer( + loadingState, + meGetCompleted({ + me: defaultMe, + auth: defaultAuth, + }) + ) expect(reducedState.me).to.deep.equal(defaultMe) + expect(reducedState.links[0]).to.deep.equal(defaultAuth.links[0]) expect(reducedState.isAuthLoading).to.equal(false) + expect(reducedState.isMeLoading).to.equal(false) }) }) diff --git a/ui/src/CheckSources.js b/ui/src/CheckSources.js index 6d2cfd03cc..d7aea0e607 100644 --- a/ui/src/CheckSources.js +++ b/ui/src/CheckSources.js @@ -1,97 +1,118 @@ -import React, {PropTypes} from 'react' +import React, {Component, PropTypes} from 'react' import {withRouter} from 'react-router' import {connect} from 'react-redux' import {bindActionCreators} from 'redux' -import {getSources} from 'shared/apis' +import { + isUserAuthorized, + VIEWER_ROLE, + EDITOR_ROLE, + ADMIN_ROLE, +} from 'src/auth/Authorized' + import {showDatabases} from 'shared/apis/metaQuery' -import {loadSources as loadSourcesAction} from 'shared/actions/sources' +import {getSourcesAsync} from 'shared/actions/sources' import {errorThrown as errorThrownAction} from 'shared/actions/errors' +import {publishNotification} from 'shared/actions/notifications' import {DEFAULT_HOME_PAGE} from 'shared/constants' // Acts as a 'router middleware'. The main `App` component is responsible for // getting the list of data nodes, but not every page requires them to function. // Routes that do require data nodes can be nested under this component. -const {arrayOf, func, node, shape, string} = PropTypes -const CheckSources = React.createClass({ - propTypes: { - sources: arrayOf( - shape({ - links: shape({ - proxy: string.isRequired, - self: string.isRequired, - kapacitors: string.isRequired, - queries: string.isRequired, - permissions: string.isRequired, - users: string.isRequired, - databases: string.isRequired, - }).isRequired, - }) - ), - children: node, - params: shape({ - sourceID: string, - }).isRequired, - router: shape({ - push: func.isRequired, - }).isRequired, - location: shape({ - pathname: string.isRequired, - }).isRequired, - loadSources: func.isRequired, - errorThrown: func.isRequired, - }, +class CheckSources extends Component { + constructor(props) { + super(props) - childContextTypes: { - source: shape({ - links: shape({ - proxy: string.isRequired, - self: string.isRequired, - kapacitors: string.isRequired, - queries: string.isRequired, - permissions: string.isRequired, - users: string.isRequired, - databases: string.isRequired, - }).isRequired, - }), - }, + this.state = { + isFetching: true, + } + } getChildContext() { const {sources, params: {sourceID}} = this.props return {source: sources.find(s => s.id === sourceID)} - }, - - getInitialState() { - return { - isFetching: true, - } - }, + } async componentWillMount() { - const {loadSources, errorThrown} = this.props + const {auth: {isUsingAuth, me}} = this.props - try { - const {data: {sources}} = await getSources() - loadSources(sources) - this.setState({isFetching: false}) - } catch (error) { - errorThrown(error, 'Unable to connect to Chronograf server') - this.setState({isFetching: false}) + if (!isUsingAuth || isUserAuthorized(me.role, VIEWER_ROLE)) { + await this.props.getSources() } - }, + + this.setState({isFetching: false}) + } + + shouldComponentUpdate(nextProps) { + const {auth: {isUsingAuth, me}} = nextProps + // don't update this component if currentOrganization is what has changed, + // or else the app will try to call showDatabases in componentWillUpdate, + // which will fail unless sources have been refreshed + if ( + isUsingAuth && + me.currentOrganization.id !== this.props.auth.me.currentOrganization.id + ) { + return false + } + return true + } async componentWillUpdate(nextProps, nextState) { - const {router, location, params, errorThrown, sources} = nextProps + const { + router, + location, + params, + errorThrown, + sources, + auth: {isUsingAuth, me, me: {organizations, currentOrganization}}, + notify, + getSources, + } = nextProps const {isFetching} = nextState const source = sources.find(s => s.id === params.sourceID) const defaultSource = sources.find(s => s.default === true) + if ( + isUserAuthorized(this.props.auth.me.role, ADMIN_ROLE) && + !isUserAuthorized(nextProps.auth.me.role, ADMIN_ROLE) + ) { + return router.push('/') + } + + if ( + me.superAdmin && + !organizations.find(o => o.id === currentOrganization.id) + ) { + notify('error', 'You were removed from your current organization') + return router.push('/purgatory') + } + + if (!isFetching && isUsingAuth && !isUserAuthorized(me.role, VIEWER_ROLE)) { + // if you're a member, go to purgatory. + return router.push('/purgatory') + } + + // TODO: At this point, the sources we have in Redux may be out of sync with what's on the server + // Do we need to refresh this data more frequently? Does it need to come as frequently as the `me` response? if (!isFetching && !source) { const rest = location.pathname.match(/\/sources\/\d+?\/(.+)/) const restString = rest === null ? DEFAULT_HOME_PAGE : rest[1] + if (isUsingAuth && !isUserAuthorized(me.role, EDITOR_ROLE)) { + if (defaultSource) { + return router.push(`/sources/${defaultSource.id}/${restString}`) + } else if (sources[0]) { + return router.push(`/sources/${sources[0].id}/${restString}`) + } + // if you're a viewer and there are no sources, go to purgatory. + notify('error', 'Organization has no sources configured') + return router.push('/purgatory') + } + + // if you're an editor or not using auth, try for sources or otherwise + // create one if (defaultSource) { return router.push(`/sources/${defaultSource.id}/${restString}`) } else if (sources[0]) { @@ -104,22 +125,45 @@ const CheckSources = React.createClass({ if (!isFetching && !location.pathname.includes('/manage-sources')) { // Do simple query to proxy to see if the source is up. try { + // the guard around currentOrganization prevents this showDatabases + // invocation since sources haven't been refreshed yet await showDatabases(source.links.proxy) } catch (error) { - errorThrown(error, 'Unable to connect to source') + try { + const newSources = await getSources() + if (newSources.length) { + errorThrown( + error, + `Source ${source.name} is no longer available. Successfully connected to another source.` + ) + } else { + errorThrown( + error, + `Unable to connect to source ${source.name}. No other sources available.` + ) + } + } catch (error2) { + errorThrown(error2, 'Unable to retrieve sources') + } } } - }, + } render() { - const {params, sources} = this.props + const { + params, + sources, + auth: {isUsingAuth, me: {currentOrganization}}, + } = this.props const {isFetching} = this.state const source = sources.find(s => s.id === params.sourceID) - if (isFetching || !source) { + if (isFetching || !source || (isUsingAuth && !currentOrganization)) { return
} + // TODO: guard against invalid resource access + return ( this.props.children && React.cloneElement( @@ -129,16 +173,71 @@ const CheckSources = React.createClass({ }) ) ) - }, -}) + } +} -const mapStateToProps = ({sources}) => ({ +const {arrayOf, bool, func, node, shape, string} = PropTypes + +CheckSources.propTypes = { + getSources: func.isRequired, + sources: arrayOf( + shape({ + links: shape({ + proxy: string.isRequired, + self: string.isRequired, + kapacitors: string.isRequired, + queries: string.isRequired, + permissions: string.isRequired, + users: string.isRequired, + databases: string.isRequired, + }).isRequired, + }) + ), + children: node, + params: shape({ + sourceID: string, + }).isRequired, + router: shape({ + push: func.isRequired, + }).isRequired, + location: shape({ + pathname: string.isRequired, + }).isRequired, + auth: shape({ + isUsingAuth: bool, + me: shape({ + currentOrganization: shape({ + name: string.isRequired, + id: string.isRequired, + }), + }), + }), + notify: func.isRequired, +} + +CheckSources.childContextTypes = { + source: shape({ + links: shape({ + proxy: string.isRequired, + self: string.isRequired, + kapacitors: string.isRequired, + queries: string.isRequired, + permissions: string.isRequired, + users: string.isRequired, + databases: string.isRequired, + }).isRequired, + }), +} + +const mapStateToProps = ({sources, auth}) => ({ sources, + auth, }) const mapDispatchToProps = dispatch => ({ - loadSources: bindActionCreators(loadSourcesAction, dispatch), + getSources: bindActionCreators(getSourcesAsync, dispatch), errorThrown: bindActionCreators(errorThrownAction, dispatch), + notify: bindActionCreators(publishNotification, dispatch), }) export default connect(mapStateToProps, mapDispatchToProps)( diff --git a/ui/src/admin/actions/chronograf.js b/ui/src/admin/actions/chronograf.js new file mode 100644 index 0000000000..d7a64731ef --- /dev/null +++ b/ui/src/admin/actions/chronograf.js @@ -0,0 +1,233 @@ +import _ from 'lodash' +import uuid from 'node-uuid' + +import { + getUsers as getUsersAJAX, + getOrganizations as getOrganizationsAJAX, + createUser as createUserAJAX, + updateUser as updateUserAJAX, + deleteUser as deleteUserAJAX, + createOrganization as createOrganizationAJAX, + updateOrganization as updateOrganizationAJAX, + deleteOrganization as deleteOrganizationAJAX, +} from 'src/admin/apis/chronograf' + +import {publishAutoDismissingNotification} from 'shared/dispatchers' +import {errorThrown} from 'shared/actions/errors' + +import {REVERT_STATE_DELAY} from 'shared/constants' + +// action creators + +// response contains `users` and `links` +export const loadUsers = ({users}) => ({ + type: 'CHRONOGRAF_LOAD_USERS', + payload: { + users, + }, +}) + +export const loadOrganizations = ({organizations}) => ({ + type: 'CHRONOGRAF_LOAD_ORGANIZATIONS', + payload: { + organizations, + }, +}) + +export const addUser = user => ({ + type: 'CHRONOGRAF_ADD_USER', + payload: { + user, + }, +}) + +export const updateUser = (user, updatedUser) => ({ + type: 'CHRONOGRAF_UPDATE_USER', + payload: { + user, + updatedUser, + }, +}) + +export const syncUser = (staleUser, syncedUser) => ({ + type: 'CHRONOGRAF_SYNC_USER', + payload: { + staleUser, + syncedUser, + }, +}) + +export const removeUser = user => ({ + type: 'CHRONOGRAF_REMOVE_USER', + payload: { + user, + }, +}) + +export const addOrganization = organization => ({ + type: 'CHRONOGRAF_ADD_ORGANIZATION', + payload: { + organization, + }, +}) + +export const renameOrganization = (organization, newName) => ({ + type: 'CHRONOGRAF_RENAME_ORGANIZATION', + payload: { + organization, + newName, + }, +}) + +export const syncOrganization = (staleOrganization, syncedOrganization) => ({ + type: 'CHRONOGRAF_SYNC_ORGANIZATION', + payload: { + staleOrganization, + syncedOrganization, + }, +}) + +export const removeOrganization = organization => ({ + type: 'CHRONOGRAF_REMOVE_ORGANIZATION', + payload: { + organization, + }, +}) + +// async actions (thunks) +export const loadUsersAsync = url => async dispatch => { + try { + const {data} = await getUsersAJAX(url) + dispatch(loadUsers(data)) + } catch (error) { + dispatch(errorThrown(error)) + } +} + +export const loadOrganizationsAsync = url => async dispatch => { + try { + const {data} = await getOrganizationsAJAX(url) + dispatch(loadOrganizations(data)) + } catch (error) { + dispatch(errorThrown(error)) + } +} + +export const createUserAsync = (url, user) => async dispatch => { + // temp uuid is added to be able to disambiguate a created user that has the + // same scheme, provider, and name as an existing user + const userWithTempID = {...user, _tempID: uuid.v4()} + dispatch(addUser(userWithTempID)) + try { + const {data} = await createUserAJAX(url, user) + dispatch(syncUser(userWithTempID, data)) + } catch (error) { + const message = `${_.upperFirst( + _.toLower(error.data.message) + )}: ${user.scheme}::${user.provider}::${user.name}` + dispatch(errorThrown(error, message)) + // undo optimistic update + setTimeout(() => dispatch(removeUser(userWithTempID)), REVERT_STATE_DELAY) + } +} + +export const updateUserAsync = (user, updatedUser) => async dispatch => { + dispatch(updateUser(user, updatedUser)) + try { + // currently the request will be rejected if name, provider, or scheme, or + // no roles are sent with the request. + // TODO: remove the null assignments below so that the user request can have + // the original name, provider, and scheme once the change to allow this is + // implemented server-side + const {data} = await updateUserAJAX({ + ...updatedUser, + name: null, + provider: null, + scheme: null, + }) + dispatch( + publishAutoDismissingNotification( + 'success', + `User updated: ${user.scheme}::${user.provider}::${user.name}` + ) + ) + // it's not necessary to syncUser again but it's useful for good + // measure and for the clarity of insight in the redux story + dispatch(syncUser(user, data)) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(syncUser(user, user)) + } +} + +export const deleteUserAsync = user => async dispatch => { + dispatch(removeUser(user)) + try { + await deleteUserAJAX(user) + dispatch( + publishAutoDismissingNotification( + 'success', + `User removed from organization: ${user.scheme}::${user.provider}::${user.name}` + ) + ) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(addUser(user)) + } +} + +export const createOrganizationAsync = ( + url, + organization +) => async dispatch => { + // temp uuid is added to be able to disambiguate a created organization with + // the same name as an existing organization + const organizationWithTempID = {...organization, _tempID: uuid.v4()} + dispatch(addOrganization(organizationWithTempID)) + try { + const {data} = await createOrganizationAJAX(url, organization) + dispatch(syncOrganization(organization, data)) + } catch (error) { + const message = `${_.upperFirst( + _.toLower(error.data.message) + )}: ${organization.name}` + dispatch(errorThrown(error, message)) + // undo optimistic update + setTimeout( + () => dispatch(removeOrganization(organizationWithTempID)), + REVERT_STATE_DELAY + ) + } +} + +export const updateOrganizationAsync = ( + organization, + updatedOrganization +) => async dispatch => { + dispatch(renameOrganization(organization, updatedOrganization.name)) + try { + const {data} = await updateOrganizationAJAX(updatedOrganization) + // it's not necessary to syncOrganization again but it's useful for good + // measure and for the clarity of insight in the redux story + dispatch(syncOrganization(updatedOrganization, data)) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(syncOrganization(organization, organization)) // restore if fail + } +} + +export const deleteOrganizationAsync = organization => async dispatch => { + dispatch(removeOrganization(organization)) + try { + await deleteOrganizationAJAX(organization) + dispatch( + publishAutoDismissingNotification( + 'success', + `Organization deleted: ${organization.name}` + ) + ) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(addOrganization(organization)) + } +} diff --git a/ui/src/admin/actions/index.js b/ui/src/admin/actions/influxdb.js similarity index 91% rename from ui/src/admin/actions/index.js rename to ui/src/admin/actions/influxdb.js index c4f905b235..954e408dac 100644 --- a/ui/src/admin/actions/index.js +++ b/ui/src/admin/actions/influxdb.js @@ -14,7 +14,7 @@ import { updateRole as updateRoleAJAX, updateUser as updateUserAJAX, updateRetentionPolicy as updateRetentionPolicyAJAX, -} from 'src/admin/apis' +} from 'src/admin/apis/influxdb' import {killQuery as killQueryProxy} from 'shared/apis/metaQuery' @@ -25,54 +25,54 @@ import {REVERT_STATE_DELAY} from 'shared/constants' import _ from 'lodash' export const loadUsers = ({users}) => ({ - type: 'LOAD_USERS', + type: 'INFLUXDB_LOAD_USERS', payload: { users, }, }) export const loadRoles = ({roles}) => ({ - type: 'LOAD_ROLES', + type: 'INFLUXDB_LOAD_ROLES', payload: { roles, }, }) export const loadPermissions = ({permissions}) => ({ - type: 'LOAD_PERMISSIONS', + type: 'INFLUXDB_LOAD_PERMISSIONS', payload: { permissions, }, }) export const loadDatabases = databases => ({ - type: 'LOAD_DATABASES', + type: 'INFLUXDB_LOAD_DATABASES', payload: { databases, }, }) export const addUser = () => ({ - type: 'ADD_USER', + type: 'INFLUXDB_ADD_USER', }) export const addRole = () => ({ - type: 'ADD_ROLE', + type: 'INFLUXDB_ADD_ROLE', }) export const addDatabase = () => ({ - type: 'ADD_DATABASE', + type: 'INFLUXDB_ADD_DATABASE', }) export const addRetentionPolicy = database => ({ - type: 'ADD_RETENTION_POLICY', + type: 'INFLUXDB_ADD_RETENTION_POLICY', payload: { database, }, }) export const syncUser = (staleUser, syncedUser) => ({ - type: 'SYNC_USER', + type: 'INFLUXDB_SYNC_USER', payload: { staleUser, syncedUser, @@ -80,7 +80,7 @@ export const syncUser = (staleUser, syncedUser) => ({ }) export const syncRole = (staleRole, syncedRole) => ({ - type: 'SYNC_ROLE', + type: 'INFLUXDB_SYNC_ROLE', payload: { staleRole, syncedRole, @@ -88,7 +88,7 @@ export const syncRole = (staleRole, syncedRole) => ({ }) export const syncDatabase = (stale, synced) => ({ - type: 'SYNC_DATABASE', + type: 'INFLUXDB_SYNC_DATABASE', payload: { stale, synced, @@ -96,7 +96,7 @@ export const syncDatabase = (stale, synced) => ({ }) export const syncRetentionPolicy = (database, stale, synced) => ({ - type: 'SYNC_RETENTION_POLICY', + type: 'INFLUXDB_SYNC_RETENTION_POLICY', payload: { database, stale, @@ -105,7 +105,7 @@ export const syncRetentionPolicy = (database, stale, synced) => ({ }) export const editUser = (user, updates) => ({ - type: 'EDIT_USER', + type: 'INFLUXDB_EDIT_USER', payload: { user, updates, @@ -113,7 +113,7 @@ export const editUser = (user, updates) => ({ }) export const editRole = (role, updates) => ({ - type: 'EDIT_ROLE', + type: 'INFLUXDB_EDIT_ROLE', payload: { role, updates, @@ -121,7 +121,7 @@ export const editRole = (role, updates) => ({ }) export const editDatabase = (database, updates) => ({ - type: 'EDIT_DATABASE', + type: 'INFLUXDB_EDIT_DATABASE', payload: { database, updates, @@ -129,21 +129,21 @@ export const editDatabase = (database, updates) => ({ }) export const killQuery = queryID => ({ - type: 'KILL_QUERY', + type: 'INFLUXDB_KILL_QUERY', payload: { queryID, }, }) export const setQueryToKill = queryIDToKill => ({ - type: 'SET_QUERY_TO_KILL', + type: 'INFLUXDB_SET_QUERY_TO_KILL', payload: { queryIDToKill, }, }) export const loadQueries = queries => ({ - type: 'LOAD_QUERIES', + type: 'INFLUXDB_LOAD_QUERIES', payload: { queries, }, @@ -151,7 +151,7 @@ export const loadQueries = queries => ({ // TODO: change to 'removeUser' export const deleteUser = user => ({ - type: 'DELETE_USER', + type: 'INFLUXDB_DELETE_USER', payload: { user, }, @@ -159,21 +159,21 @@ export const deleteUser = user => ({ // TODO: change to 'removeRole' export const deleteRole = role => ({ - type: 'DELETE_ROLE', + type: 'INFLUXDB_DELETE_ROLE', payload: { role, }, }) export const removeDatabase = database => ({ - type: 'REMOVE_DATABASE', + type: 'INFLUXDB_REMOVE_DATABASE', payload: { database, }, }) export const removeRetentionPolicy = (database, retentionPolicy) => ({ - type: 'REMOVE_RETENTION_POLICY', + type: 'INFLUXDB_REMOVE_RETENTION_POLICY', payload: { database, retentionPolicy, @@ -181,35 +181,35 @@ export const removeRetentionPolicy = (database, retentionPolicy) => ({ }) export const filterUsers = text => ({ - type: 'FILTER_USERS', + type: 'INFLUXDB_FILTER_USERS', payload: { text, }, }) export const filterRoles = text => ({ - type: 'FILTER_ROLES', + type: 'INFLUXDB_FILTER_ROLES', payload: { text, }, }) export const addDatabaseDeleteCode = database => ({ - type: 'ADD_DATABASE_DELETE_CODE', + type: 'INFLUXDB_ADD_DATABASE_DELETE_CODE', payload: { database, }, }) export const removeDatabaseDeleteCode = database => ({ - type: 'REMOVE_DATABASE_DELETE_CODE', + type: 'INFLUXDB_REMOVE_DATABASE_DELETE_CODE', payload: { database, }, }) export const editRetentionPolicy = (database, retentionPolicy, updates) => ({ - type: 'EDIT_RETENTION_POLICY', + type: 'INFLUXDB_EDIT_RETENTION_POLICY', payload: { database, retentionPolicy, diff --git a/ui/src/admin/apis/chronograf.js b/ui/src/admin/apis/chronograf.js new file mode 100644 index 0000000000..3008849280 --- /dev/null +++ b/ui/src/admin/apis/chronograf.js @@ -0,0 +1,104 @@ +import AJAX from 'src/utils/ajax' + +export const getUsers = async url => { + try { + return await AJAX({ + method: 'GET', + url, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const getOrganizations = async url => { + try { + return await AJAX({ + method: 'GET', + url, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const createUser = async (url, user) => { + try { + return await AJAX({ + method: 'POST', + url, + data: user, + }) + } catch (error) { + console.error(error) + throw error + } +} + +// TODO: change updatedUserWithRolesOnly to a whole user that can have the +// original name, provider, and scheme once the change to allow this is +// implemented server-side +export const updateUser = async updatedUserWithRolesOnly => { + try { + return await AJAX({ + method: 'PATCH', + url: updatedUserWithRolesOnly.links.self, + data: updatedUserWithRolesOnly, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const deleteUser = async user => { + try { + return await AJAX({ + method: 'DELETE', + url: user.links.self, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const createOrganization = async (url, organization) => { + try { + return await AJAX({ + method: 'POST', + url, + data: organization, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const updateOrganization = async organization => { + try { + return await AJAX({ + method: 'PATCH', + url: organization.links.self, + data: organization, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const deleteOrganization = async organization => { + try { + return await AJAX({ + method: 'DELETE', + url: organization.links.self, + }) + } catch (error) { + console.error(error) + throw error + } +} diff --git a/ui/src/admin/apis/index.js b/ui/src/admin/apis/influxdb.js similarity index 100% rename from ui/src/admin/apis/index.js rename to ui/src/admin/apis/influxdb.js diff --git a/ui/src/admin/components/chronograf/AdminTabs.js b/ui/src/admin/components/chronograf/AdminTabs.js new file mode 100644 index 0000000000..0f592e61cb --- /dev/null +++ b/ui/src/admin/components/chronograf/AdminTabs.js @@ -0,0 +1,107 @@ +import React, {PropTypes} from 'react' + +import { + isUserAuthorized, + ADMIN_ROLE, + SUPERADMIN_ROLE, +} from 'src/auth/Authorized' + +import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'shared/components/Tabs' +import OrganizationsPage from 'src/admin/containers/OrganizationsPage' +import UsersTable from 'src/admin/components/chronograf/UsersTable' + +const ORGANIZATIONS_TAB_NAME = 'Organizations' +const USERS_TAB_NAME = 'Users' + +const AdminTabs = ({ + meRole, + // UsersTable + users, + organization, + onCreateUser, + onUpdateUserRole, + onUpdateUserSuperAdmin, + onDeleteUser, + meID, + notify, +}) => { + const tabs = [ + { + requiredRole: SUPERADMIN_ROLE, + type: ORGANIZATIONS_TAB_NAME, + component: , + }, + { + requiredRole: ADMIN_ROLE, + type: USERS_TAB_NAME, + component: ( + + ), + }, + ].filter(t => isUserAuthorized(meRole, t.requiredRole)) + + return ( + + + {tabs.map((t, i) => + + {tabs[i].type} + + )} + + + {tabs.map((t, i) => + + {t.component} + + )} + + + ) +} + +const {arrayOf, bool, func, shape, string} = PropTypes + +AdminTabs.propTypes = { + meRole: string.isRequired, + meID: string.isRequired, + // UsersTable + users: arrayOf( + shape({ + id: string, + links: shape({ + self: string.isRequired, + }), + name: string.isRequired, + provider: string.isRequired, + roles: arrayOf( + shape({ + name: string.isRequired, + organization: string.isRequired, + }) + ), + scheme: string.isRequired, + superAdmin: bool, + }) + ).isRequired, + organization: shape({ + name: string.isRequired, + id: string.isRequired, + }).isRequired, + onCreateUser: func.isRequired, + onUpdateUserRole: func.isRequired, + onUpdateUserSuperAdmin: func.isRequired, + onDeleteUser: func.isRequired, + notify: func.isRequired, +} + +export default AdminTabs diff --git a/ui/src/admin/components/chronograf/OrganizationsTable.js b/ui/src/admin/components/chronograf/OrganizationsTable.js new file mode 100644 index 0000000000..997c7470c8 --- /dev/null +++ b/ui/src/admin/components/chronograf/OrganizationsTable.js @@ -0,0 +1,117 @@ +import React, {Component, PropTypes} from 'react' + +import uuid from 'node-uuid' + +import OrganizationsTableRow from 'src/admin/components/chronograf/OrganizationsTableRow' +import OrganizationsTableRowNew from 'src/admin/components/chronograf/OrganizationsTableRowNew' +import QuestionMarkTooltip from 'shared/components/QuestionMarkTooltip' + +import {PUBLIC_TOOLTIP} from 'src/admin/constants/index' + +class OrganizationsTable extends Component { + constructor(props) { + super(props) + + this.state = { + isCreatingOrganization: false, + } + } + + handleClickCreateOrganization = () => { + this.setState({isCreatingOrganization: true}) + } + + handleCancelCreateOrganization = () => { + this.setState({isCreatingOrganization: false}) + } + + handleCreateOrganization = organization => { + const {onCreateOrg} = this.props + onCreateOrg(organization) + this.setState({isCreatingOrganization: false}) + } + + render() { + const { + organizations, + onDeleteOrg, + onRenameOrg, + onChooseDefaultRole, + onTogglePublic, + currentOrganization, + } = this.props + const {isCreatingOrganization} = this.state + + const tableTitle = `${organizations.length} Organization${organizations.length === + 1 + ? '' + : 's'}` + + return ( +
+
+

+ {tableTitle} +

+ +
+
+
+
+
Name
+
+ Public{' '} + +
+
Default Role
+
+
+ {isCreatingOrganization + ? + : null} + {organizations.map(org => + + )} +
+
+ ) + } +} + +const {arrayOf, func, shape, string} = PropTypes + +OrganizationsTable.propTypes = { + organizations: arrayOf( + shape({ + id: string, // when optimistically created, organization will not have an id + name: string.isRequired, + }) + ).isRequired, + currentOrganization: shape({ + name: string.isRequired, + id: string.isRequired, + }), + onCreateOrg: func.isRequired, + onDeleteOrg: func.isRequired, + onRenameOrg: func.isRequired, + onTogglePublic: func.isRequired, + onChooseDefaultRole: func.isRequired, +} +export default OrganizationsTable diff --git a/ui/src/admin/components/chronograf/OrganizationsTableRow.js b/ui/src/admin/components/chronograf/OrganizationsTableRow.js new file mode 100644 index 0000000000..93690854a2 --- /dev/null +++ b/ui/src/admin/components/chronograf/OrganizationsTableRow.js @@ -0,0 +1,249 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' +import {withRouter} from 'react-router' + +import SlideToggle from 'shared/components/SlideToggle' +import ConfirmButtons from 'shared/components/ConfirmButtons' +import Dropdown from 'shared/components/Dropdown' + +import {meChangeOrganizationAsync} from 'shared/actions/auth' + +import {DEFAULT_ORG_ID} from 'src/admin/constants/chronografAdmin' +import {USER_ROLES} from 'src/admin/constants/chronografAdmin' + +const OrganizationsTableRowDeleteButton = ({organization, onClickDelete}) => + organization.id === DEFAULT_ORG_ID + ? + : + +class OrganizationsTableRow extends Component { + constructor(props) { + super(props) + + this.state = { + isEditing: false, + isDeleting: false, + workingName: this.props.organization.name, + } + } + + handleChangeCurrentOrganization = async () => { + const {router, links, meChangeOrganization, organization} = this.props + + await meChangeOrganization(links.me, {organization: organization.id}) + router.push('') + } + + handleNameClick = () => { + this.setState({isEditing: true}) + } + + handleConfirmRename = () => { + const {onRename, organization} = this.props + const {workingName} = this.state + + onRename(organization, workingName) + this.setState({workingName, isEditing: false}) + } + + handleCancelRename = () => { + const {organization} = this.props + + this.setState({ + workingName: organization.name, + isEditing: false, + }) + } + + handleInputChange = e => { + this.setState({workingName: e.target.value}) + } + + handleInputBlur = () => { + const {organization} = this.props + const {workingName} = this.state + + if (organization.name === workingName) { + this.handleCancelRename() + } else { + this.handleConfirmRename() + } + } + + handleKeyDown = e => { + if (e.key === 'Enter') { + this.handleInputBlur() + } else if (e.key === 'Escape') { + this.handleCancelRename() + } + } + + handleFocus = e => { + e.target.select() + } + + handleDeleteClick = () => { + this.setState({isDeleting: true}) + } + + handleDismissDeleteConfirmation = () => { + this.setState({isDeleting: false}) + } + + handleDeleteOrg = organization => { + const {onDelete} = this.props + onDelete(organization) + } + + handleTogglePublic = () => { + const {organization, onTogglePublic} = this.props + onTogglePublic(organization) + } + + handleChooseDefaultRole = role => { + const {organization, onChooseDefaultRole} = this.props + onChooseDefaultRole(organization, role.name) + } + + render() { + const {workingName, isEditing, isDeleting} = this.state + const {organization, currentOrganization} = this.props + + const dropdownRolesItems = USER_ROLES.map(role => ({ + ...role, + text: role.name, + })) + + const defaultRoleClassName = isDeleting + ? 'orgs-table--default-role editing' + : 'orgs-table--default-role' + + return ( +
+
+ {organization.id === currentOrganization.id + ? + : } +
+ {isEditing + ? (this.inputRef = r)} + /> + :
+ {workingName} + +
} + {organization.id === DEFAULT_ORG_ID + ?
+ +
+ :
} +
+ +
+ {isDeleting + ? + : } +
+ ) + } +} + +const {arrayOf, func, shape, string} = PropTypes + +OrganizationsTableRow.propTypes = { + organization: shape({ + id: string, // when optimistically created, organization will not have an id + name: string.isRequired, + defaultRole: string.isRequired, + }).isRequired, + onDelete: func.isRequired, + onRename: func.isRequired, + onTogglePublic: func.isRequired, + onChooseDefaultRole: func.isRequired, + currentOrganization: shape({ + name: string.isRequired, + id: string.isRequired, + }), + router: shape({ + push: func.isRequired, + }).isRequired, + links: shape({ + me: string, + external: shape({ + custom: arrayOf( + shape({ + name: string.isRequired, + url: string.isRequired, + }) + ), + }), + }), + meChangeOrganization: func.isRequired, +} + +OrganizationsTableRowDeleteButton.propTypes = { + organization: shape({ + id: string, // when optimistically created, organization will not have an id + name: string.isRequired, + defaultRole: string.isRequired, + }).isRequired, + onClickDelete: func.isRequired, +} + +const mapDispatchToProps = dispatch => ({ + meChangeOrganization: bindActionCreators(meChangeOrganizationAsync, dispatch), +}) + +const mapStateToProps = ({links}) => ({ + links, +}) + +export default connect(mapStateToProps, mapDispatchToProps)( + withRouter(OrganizationsTableRow) +) diff --git a/ui/src/admin/components/chronograf/OrganizationsTableRowNew.js b/ui/src/admin/components/chronograf/OrganizationsTableRowNew.js new file mode 100644 index 0000000000..9e62c38fe3 --- /dev/null +++ b/ui/src/admin/components/chronograf/OrganizationsTableRowNew.js @@ -0,0 +1,100 @@ +import React, {Component, PropTypes} from 'react' + +import ConfirmButtons from 'shared/components/ConfirmButtons' +import Dropdown from 'shared/components/Dropdown' + +import {USER_ROLES} from 'src/admin/constants/chronografAdmin' +import {MEMBER_ROLE} from 'src/auth/Authorized' + +class OrganizationsTableRowNew extends Component { + constructor(props) { + super(props) + + this.state = { + name: 'Untitled Organization', + defaultRole: MEMBER_ROLE, + } + } + + handleKeyDown = e => { + const {onCancelCreateOrganization} = this.props + + if (e.key === 'Escape') { + onCancelCreateOrganization() + } + if (e.key === 'Enter') { + this.handleClickSave() + } + } + + handleInputChange = e => { + this.setState({name: e.target.value.trim()}) + } + + handleInputFocus = e => { + e.target.select() + } + + handleClickSave = () => { + const {onCreateOrganization} = this.props + const {name, defaultRole} = this.state + + onCreateOrganization({name, defaultRole}) + } + + handleChooseDefaultRole = role => { + this.setState({defaultRole: role.name}) + } + + render() { + const {name, defaultRole} = this.state + const {onCancelCreateOrganization} = this.props + + const isSaveDisabled = name === null || name === '' + + const dropdownRolesItems = USER_ROLES.map(role => ({ + ...role, + text: role.name, + })) + + return ( +
+
+ (this.inputRef = r)} + /> +
+ +
+ +
+ ) + } +} + +const {func} = PropTypes + +OrganizationsTableRowNew.propTypes = { + onCreateOrganization: func.isRequired, + onCancelCreateOrganization: func.isRequired, +} + +export default OrganizationsTableRowNew diff --git a/ui/src/admin/components/chronograf/UsersTable.js b/ui/src/admin/components/chronograf/UsersTable.js new file mode 100644 index 0000000000..60ced87ec7 --- /dev/null +++ b/ui/src/admin/components/chronograf/UsersTable.js @@ -0,0 +1,138 @@ +import React, {Component, PropTypes} from 'react' + +import uuid from 'node-uuid' + +import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' + +import UsersTableHeader from 'src/admin/components/chronograf/UsersTableHeader' +import UsersTableRowNew from 'src/admin/components/chronograf/UsersTableRowNew' +import UsersTableRow from 'src/admin/components/chronograf/UsersTableRow' + +import {USERS_TABLE} from 'src/admin/constants/chronografTableSizing' + +class UsersTable extends Component { + constructor(props) { + super(props) + + this.state = { + isCreatingUser: false, + } + } + + handleChangeUserRole = (user, currentRole) => newRole => { + this.props.onUpdateUserRole(user, currentRole, newRole) + } + + handleChangeSuperAdmin = user => newStatus => { + this.props.onUpdateUserSuperAdmin(user, newStatus) + } + + handleDeleteUser = user => { + this.props.onDeleteUser(user) + } + + handleClickCreateUser = () => { + this.setState({isCreatingUser: true}) + } + + handleBlurCreateUserRow = () => { + this.setState({isCreatingUser: false}) + } + + render() { + const {organization, users, onCreateUser, meID, notify} = this.props + + const {isCreatingUser} = this.state + const { + colRole, + colSuperAdmin, + colProvider, + colScheme, + colActions, + } = USERS_TABLE + + return ( +
+ +
+ + + + + + + + + + + + + + {isCreatingUser + ? + : null} + {users.length || !isCreatingUser + ? users.map(user => + + ) + : + +

No Users to display

+ + } + > +
+ + } + +
Username + Role + + SuperAdmin + ProviderScheme +
+

No Users to display

+
+
+
+ ) + } +} + +const {arrayOf, func, shape, string} = PropTypes + +UsersTable.propTypes = { + users: arrayOf(shape()), + organization: shape({ + name: string.isRequired, + id: string.isRequired, + }), + onCreateUser: func.isRequired, + onUpdateUserRole: func.isRequired, + onUpdateUserSuperAdmin: func.isRequired, + onDeleteUser: func.isRequired, + meID: string.isRequired, + notify: func.isRequired, +} +export default UsersTable diff --git a/ui/src/admin/components/chronograf/UsersTableHeader.js b/ui/src/admin/components/chronograf/UsersTableHeader.js new file mode 100644 index 0000000000..6ba33c7cb8 --- /dev/null +++ b/ui/src/admin/components/chronograf/UsersTableHeader.js @@ -0,0 +1,51 @@ +import React, {Component, PropTypes} from 'react' +import Authorized, {ADMIN_ROLE} from 'src/auth/Authorized' + +class UsersTableHeader extends Component { + constructor(props) { + super(props) + } + + render() { + const { + onClickCreateUser, + numUsers, + isCreatingUser, + organization, + } = this.props + + const panelTitle = numUsers === 1 ? `${numUsers} User` : `${numUsers} Users` + + return ( +
+

+ {panelTitle} in {organization.name} +

+ + + +
+ ) + } +} + +const {bool, func, shape, string, number} = PropTypes + +UsersTableHeader.propTypes = { + numUsers: number.isRequired, + onClickCreateUser: func.isRequired, + isCreatingUser: bool.isRequired, + organization: shape({ + name: string.isRequired, + id: string.isRequired, + }), +} + +export default UsersTableHeader diff --git a/ui/src/admin/components/chronograf/UsersTableRow.js b/ui/src/admin/components/chronograf/UsersTableRow.js new file mode 100644 index 0000000000..283b069599 --- /dev/null +++ b/ui/src/admin/components/chronograf/UsersTableRow.js @@ -0,0 +1,96 @@ +import React, {PropTypes} from 'react' + +import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' + +import Dropdown from 'shared/components/Dropdown' +import SlideToggle from 'shared/components/SlideToggle' +import DeleteConfirmTableCell from 'shared/components/DeleteConfirmTableCell' + +import {USER_ROLES} from 'src/admin/constants/chronografAdmin' +import {USERS_TABLE} from 'src/admin/constants/chronografTableSizing' + +const UsersTableRow = ({ + user, + organization, + onChangeUserRole, + onChangeSuperAdmin, + onDelete, + meID, +}) => { + const {colRole, colSuperAdmin, colProvider, colScheme} = USERS_TABLE + + const dropdownRolesItems = USER_ROLES.map(r => ({ + ...r, + text: r.name, + })) + const currentRole = user.roles.find( + role => role.organization === organization.id + ) + + const userIsMe = user.id === meID + + return ( + + + {userIsMe + ? + + {user.name} + + : + {user.name} + } + + + + + + + + + + + + + {user.provider} + + + {user.scheme} + + + + ) +} + +const {func, shape, string} = PropTypes + +UsersTableRow.propTypes = { + user: shape(), + organization: shape({ + name: string.isRequired, + id: string.isRequired, + }), + onChangeUserRole: func.isRequired, + onChangeSuperAdmin: func.isRequired, + onDelete: func.isRequired, + meID: string.isRequired, +} + +export default UsersTableRow diff --git a/ui/src/admin/components/chronograf/UsersTableRowNew.js b/ui/src/admin/components/chronograf/UsersTableRowNew.js new file mode 100644 index 0000000000..60aef76926 --- /dev/null +++ b/ui/src/admin/components/chronograf/UsersTableRowNew.js @@ -0,0 +1,175 @@ +import React, {Component, PropTypes} from 'react' + +import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' + +import Dropdown from 'shared/components/Dropdown' +import SlideToggle from 'shared/components/SlideToggle' + +import {USERS_TABLE} from 'src/admin/constants/chronografTableSizing' +import {USER_ROLES} from 'src/admin/constants/chronografAdmin' +import {MEMBER_ROLE} from 'src/auth/Authorized' + +class UsersTableRowNew extends Component { + constructor(props) { + super(props) + + this.state = { + name: '', + provider: '', + scheme: 'oauth2', + role: MEMBER_ROLE, + superAdmin: false, + } + } + + handleInputChange = fieldName => e => { + this.setState({[fieldName]: e.target.value.trim()}) + } + + handleConfirmCreateUser = () => { + const {onBlur, onCreateUser, organization} = this.props + const {name, provider, scheme, role, superAdmin} = this.state + + const newUser = { + name, + provider, + scheme, + superAdmin, + roles: [ + { + name: role, + organization: organization.id, + }, + ], + } + + onCreateUser(newUser) + onBlur() + } + + handleInputFocus = e => { + e.target.select() + } + + handleSelectRole = newRole => { + this.setState({role: newRole.text}) + } + + handleSelectSuperAdmin = superAdmin => { + this.setState({superAdmin}) + } + + handleKeyDown = e => { + const {name, provider} = this.state + const preventCreate = !name || !provider + + if (e.key === 'Escape') { + this.props.onBlur() + } + + if (e.key === 'Enter') { + if (preventCreate) { + return this.props.notify( + 'warning', + 'User must have a name and provider' + ) + } + this.handleConfirmCreateUser() + } + } + + render() { + const { + colRole, + colProvider, + colScheme, + colSuperAdmin, + colActions, + } = USERS_TABLE + const {onBlur} = this.props + const {name, provider, scheme, role, superAdmin} = this.state + + const dropdownRolesItems = USER_ROLES.map(r => ({...r, text: r.name})) + const preventCreate = !name || !provider + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ) + } +} + +const {func, shape, string} = PropTypes + +UsersTableRowNew.propTypes = { + organization: shape({ + id: string.isRequired, + name: string.isRequired, + }), + onBlur: func.isRequired, + onCreateUser: func.isRequired, + notify: func.isRequired, +} + +export default UsersTableRowNew diff --git a/ui/src/admin/constants/chronografAdmin.js b/ui/src/admin/constants/chronografAdmin.js new file mode 100644 index 0000000000..69079c0611 --- /dev/null +++ b/ui/src/admin/constants/chronografAdmin.js @@ -0,0 +1,15 @@ +import { + MEMBER_ROLE, + VIEWER_ROLE, + EDITOR_ROLE, + ADMIN_ROLE, +} from 'src/auth/Authorized' + +export const USER_ROLES = [ + {name: MEMBER_ROLE}, + {name: VIEWER_ROLE}, + {name: EDITOR_ROLE}, + {name: ADMIN_ROLE}, +] + +export const DEFAULT_ORG_ID = '0' diff --git a/ui/src/admin/constants/chronografTableSizing.js b/ui/src/admin/constants/chronografTableSizing.js new file mode 100644 index 0000000000..738821483b --- /dev/null +++ b/ui/src/admin/constants/chronografTableSizing.js @@ -0,0 +1,7 @@ +export const USERS_TABLE = { + colRole: 120, + colSuperAdmin: 90, + colProvider: 170, + colScheme: 90, + colActions: 80, +} diff --git a/ui/src/admin/constants/index.js b/ui/src/admin/constants/index.js index b55fe2dab6..244f1bac54 100644 --- a/ui/src/admin/constants/index.js +++ b/ui/src/admin/constants/index.js @@ -46,3 +46,6 @@ export const NEW_DEFAULT_DATABASE = { isNew: true, retentionPolicies: [NEW_DEFAULT_RP], } + +export const PUBLIC_TOOLTIP = + 'If set to false, users cannot
authenticate unless an Admin explicitly
adds them to the organization.' diff --git a/ui/src/admin/containers/AdminChronografPage.js b/ui/src/admin/containers/AdminChronografPage.js new file mode 100644 index 0000000000..f5fa35be32 --- /dev/null +++ b/ui/src/admin/containers/AdminChronografPage.js @@ -0,0 +1,150 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' + +import * as adminChronografActionCreators from 'src/admin/actions/chronograf' +import {publishAutoDismissingNotification} from 'shared/dispatchers' + +import AdminTabs from 'src/admin/components/chronograf/AdminTabs' +import FancyScrollbar from 'shared/components/FancyScrollbar' + +class AdminChronografPage extends Component { + // TODO: revisit this, possibly don't call setState if both are deep equal + componentWillReceiveProps(nextProps) { + const {meCurrentOrganization} = nextProps + + const hasChangedCurrentOrganization = + meCurrentOrganization.id !== this.props.meCurrentOrganization.id + + if (hasChangedCurrentOrganization) { + this.loadUsers() + } + } + + componentDidMount() { + this.loadUsers() + } + + loadUsers = () => { + const {links, actions: {loadUsersAsync}} = this.props + + loadUsersAsync(links.users) + } + + // SINGLE USER ACTIONS + handleCreateUser = user => { + const {links, actions: {createUserAsync}} = this.props + + createUserAsync(links.users, user) + } + + handleUpdateUserRole = (user, currentRole, {name}) => { + const {actions: {updateUserAsync}} = this.props + + const updatedRole = {...currentRole, name} + const newRoles = user.roles.map( + r => (r.organization === currentRole.organization ? updatedRole : r) + ) + + updateUserAsync(user, {...user, roles: newRoles}) + } + + handleUpdateUserSuperAdmin = (user, superAdmin) => { + const {actions: {updateUserAsync}} = this.props + + const updatedUser = {...user, superAdmin} + + updateUserAsync(user, updatedUser) + } + + handleDeleteUser = user => { + const {actions: {deleteUserAsync}} = this.props + + deleteUserAsync(user) + } + + render() { + const {users, meCurrentOrganization, meRole, meID, notify} = this.props + + return ( +
+
+
+
+

Chronograf Admin

+
+
+
+ + {users + ?
+
+ +
+
+ :
} + +
+ ) + } +} + +const {arrayOf, func, shape, string} = PropTypes + +AdminChronografPage.propTypes = { + links: shape({ + users: string.isRequired, + }), + users: arrayOf(shape), + meCurrentOrganization: shape({ + id: string.isRequired, + name: string.isRequired, + }).isRequired, + meRole: string.isRequired, + me: shape({ + name: string.isRequired, + id: string.isRequired, + }).isRequired, + meID: string.isRequired, + actions: shape({ + loadUsersAsync: func.isRequired, + createUserAsync: func.isRequired, + updateUserAsync: func.isRequired, + deleteUserAsync: func.isRequired, + }), + notify: func.isRequired, +} + +const mapStateToProps = ({ + links, + adminChronograf: {users}, + auth: { + me, + me: {currentOrganization: meCurrentOrganization, role: meRole, id: meID}, + }, +}) => ({ + links, + users, + meCurrentOrganization, + meRole, + me, + meID, +}) + +const mapDispatchToProps = dispatch => ({ + actions: bindActionCreators(adminChronografActionCreators, dispatch), + notify: bindActionCreators(publishAutoDismissingNotification, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(AdminChronografPage) diff --git a/ui/src/admin/containers/AdminPage.js b/ui/src/admin/containers/AdminInfluxDBPage.js similarity index 96% rename from ui/src/admin/containers/AdminPage.js rename to ui/src/admin/containers/AdminInfluxDBPage.js index 29d6d4c95b..22b9f6603c 100644 --- a/ui/src/admin/containers/AdminPage.js +++ b/ui/src/admin/containers/AdminInfluxDBPage.js @@ -22,7 +22,7 @@ import { updateUserPermissionsAsync, filterUsers as filterUsersAction, filterRoles as filterRolesAction, -} from 'src/admin/actions' +} from 'src/admin/actions/influxdb' import AdminTabs from 'src/admin/components/AdminTabs' import SourceIndicator from 'shared/components/SourceIndicator' @@ -40,7 +40,7 @@ const isValidRole = role => { return role.name.length >= minLen } -class AdminPage extends Component { +class AdminInfluxDBPage extends Component { constructor(props) { super(props) } @@ -151,7 +151,7 @@ class AdminPage extends Component {
-

Admin

+

InfluxDB Admin

@@ -198,7 +198,7 @@ class AdminPage extends Component { const {arrayOf, func, shape, string} = PropTypes -AdminPage.propTypes = { +AdminInfluxDBPage.propTypes = { source: shape({ id: string.isRequired, links: shape({ @@ -231,7 +231,7 @@ AdminPage.propTypes = { notify: func, } -const mapStateToProps = ({admin: {users, roles, permissions}}) => ({ +const mapStateToProps = ({adminInfluxDB: {users, roles, permissions}}) => ({ users, roles, permissions, @@ -267,4 +267,4 @@ const mapDispatchToProps = dispatch => ({ notify: bindActionCreators(publishAutoDismissingNotification, dispatch), }) -export default connect(mapStateToProps, mapDispatchToProps)(AdminPage) +export default connect(mapStateToProps, mapDispatchToProps)(AdminInfluxDBPage) diff --git a/ui/src/admin/containers/DatabaseManagerPage.js b/ui/src/admin/containers/DatabaseManagerPage.js index 71d7f36584..d5fc3d746f 100644 --- a/ui/src/admin/containers/DatabaseManagerPage.js +++ b/ui/src/admin/containers/DatabaseManagerPage.js @@ -5,7 +5,7 @@ import _ from 'lodash' import DatabaseManager from 'src/admin/components/DatabaseManager' -import * as adminActionCreators from 'src/admin/actions' +import * as adminActionCreators from 'src/admin/actions/influxdb' import {publishAutoDismissingNotification} from 'shared/dispatchers' class DatabaseManagerPage extends Component { @@ -155,7 +155,7 @@ DatabaseManagerPage.propTypes = { notify: func, } -const mapStateToProps = ({admin: {databases, retentionPolicies}}) => ({ +const mapStateToProps = ({adminInfluxDB: {databases, retentionPolicies}}) => ({ databases, retentionPolicies, }) diff --git a/ui/src/admin/containers/OrganizationsPage.js b/ui/src/admin/containers/OrganizationsPage.js new file mode 100644 index 0000000000..9ae5c5a5dd --- /dev/null +++ b/ui/src/admin/containers/OrganizationsPage.js @@ -0,0 +1,107 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' + +import * as adminChronografActionCreators from 'src/admin/actions/chronograf' +import {getMeAsync} from 'shared/actions/auth' + +import OrganizationsTable from 'src/admin/components/chronograf/OrganizationsTable' + +class OrganizationsPage extends Component { + componentDidMount() { + const {links, actions: {loadOrganizationsAsync}} = this.props + loadOrganizationsAsync(links.organizations) + } + + handleCreateOrganization = async organization => { + const {links, actions: {createOrganizationAsync}} = this.props + await createOrganizationAsync(links.organizations, organization) + this.refreshMe() + } + + handleRenameOrganization = async (organization, name) => { + const {actions: {updateOrganizationAsync}} = this.props + await updateOrganizationAsync(organization, {...organization, name}) + this.refreshMe() + } + + handleDeleteOrganization = organization => { + const {actions: {deleteOrganizationAsync}} = this.props + deleteOrganizationAsync(organization) + this.refreshMe() + } + + refreshMe = () => { + const {getMe} = this.props + getMe({shouldResetMe: false}) + } + + handleTogglePublic = organization => { + const {actions: {updateOrganizationAsync}} = this.props + updateOrganizationAsync(organization, { + ...organization, + public: !organization.public, + }) + } + + handleChooseDefaultRole = (organization, defaultRole) => { + const {actions: {updateOrganizationAsync}} = this.props + updateOrganizationAsync(organization, {...organization, defaultRole}) + // refreshMe is here to update the org's defaultRole in `me.organizations` + this.refreshMe() + } + + render() { + const {organizations, currentOrganization} = this.props + + return ( + + ) + } +} + +const {arrayOf, func, shape, string} = PropTypes + +OrganizationsPage.propTypes = { + links: shape({ + organizations: string.isRequired, + }), + organizations: arrayOf( + shape({ + id: string, // when optimistically created, it will not have an id + name: string.isRequired, + link: string, + }) + ), + actions: shape({ + loadOrganizationsAsync: func.isRequired, + createOrganizationAsync: func.isRequired, + updateOrganizationAsync: func.isRequired, + deleteOrganizationAsync: func.isRequired, + }), + getMe: func.isRequired, + currentOrganization: shape({ + name: string.isRequired, + id: string.isRequired, + }), +} + +const mapStateToProps = ({links, adminChronograf: {organizations}}) => ({ + links, + organizations, +}) + +const mapDispatchToProps = dispatch => ({ + actions: bindActionCreators(adminChronografActionCreators, dispatch), + getMe: bindActionCreators(getMeAsync, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(OrganizationsPage) diff --git a/ui/src/admin/containers/QueriesPage.js b/ui/src/admin/containers/QueriesPage.js index 53bf93b2d9..5cfc753407 100644 --- a/ui/src/admin/containers/QueriesPage.js +++ b/ui/src/admin/containers/QueriesPage.js @@ -15,7 +15,7 @@ import { loadQueries as loadQueriesAction, setQueryToKill as setQueryToKillAction, killQueryAsync, -} from 'src/admin/actions' +} from 'src/admin/actions/influxdb' import {publishAutoDismissingNotification} from 'shared/dispatchers' @@ -100,7 +100,7 @@ QueriesPage.propTypes = { notify: func, } -const mapStateToProps = ({admin: {queries, queryIDToKill}}) => ({ +const mapStateToProps = ({adminInfluxDB: {queries, queryIDToKill}}) => ({ queries, queryIDToKill, }) diff --git a/ui/src/admin/index.js b/ui/src/admin/index.js index ef6e719b35..5663e7aeb3 100644 --- a/ui/src/admin/index.js +++ b/ui/src/admin/index.js @@ -1,2 +1,5 @@ -import AdminPage from './containers/AdminPage' -export {AdminPage} +import AdminInfluxDBPage from './containers/AdminInfluxDBPage' +import AdminChronografPage from './containers/AdminChronografPage' +import OrganizationsPage from './containers/OrganizationsPage' + +export {AdminChronografPage, AdminInfluxDBPage, OrganizationsPage} diff --git a/ui/src/admin/reducers/chronograf.js b/ui/src/admin/reducers/chronograf.js new file mode 100644 index 0000000000..34d46dcceb --- /dev/null +++ b/ui/src/admin/reducers/chronograf.js @@ -0,0 +1,99 @@ +import {isSameUser} from 'shared/reducers/helpers/auth' + +const initialState = { + users: [], + organizations: [], +} + +const adminChronograf = (state = initialState, action) => { + switch (action.type) { + case 'CHRONOGRAF_LOAD_USERS': { + return {...state, ...action.payload} + } + + case 'CHRONOGRAF_LOAD_ORGANIZATIONS': { + return {...state, ...action.payload} + } + + case 'CHRONOGRAF_ADD_USER': { + const {user} = action.payload + return {...state, users: [user, ...state.users]} + } + + case 'CHRONOGRAF_UPDATE_USER': { + const {user, updatedUser} = action.payload + return { + ...state, + users: state.users.map( + u => (u.links.self === user.links.self ? {...updatedUser} : u) + ), + } + } + case 'CHRONOGRAF_SYNC_USER': { + const {staleUser, syncedUser} = action.payload + return { + ...state, + users: state.users.map( + // stale user does not have links, so uniqueness is on name, provider, & scheme + u => (isSameUser(u, staleUser) ? {...syncedUser} : u) + ), + } + } + + case 'CHRONOGRAF_REMOVE_USER': { + const {user} = action.payload + return { + ...state, + // stale user does not necessarily have links, so uniqueness is on name, + // provider, & scheme, except for a created users that is a duplicate + // of an existing user, in which case a temp uuid is used to match + users: state.users.filter( + u => (user._tempID ? u._tempID !== user._tempID : u.id !== user.id) + ), + } + } + + case 'CHRONOGRAF_ADD_ORGANIZATION': { + const {organization} = action.payload + return {...state, organizations: [organization, ...state.organizations]} + } + + case 'CHRONOGRAF_RENAME_ORGANIZATION': { + const {organization, newName} = action.payload + return { + ...state, + organizations: state.organizations.map( + o => + o.links.self === organization.links.self ? {...o, name: newName} : o + ), + } + } + + case 'CHRONOGRAF_SYNC_ORGANIZATION': { + const {staleOrganization, syncedOrganization} = action.payload + return { + ...state, + organizations: state.organizations.map( + o => (o.name === staleOrganization.name ? {...syncedOrganization} : o) + ), + } + } + + case 'CHRONOGRAF_REMOVE_ORGANIZATION': { + const {organization} = action.payload + return { + ...state, + organizations: state.organizations.filter( + o => + organization._tempID + ? o._tempID !== organization._tempID + : o.id !== organization.id + ), + } + } + } + + return state +} + +export default adminChronograf diff --git a/ui/src/admin/reducers/index.js b/ui/src/admin/reducers/index.js new file mode 100644 index 0000000000..7356e32f48 --- /dev/null +++ b/ui/src/admin/reducers/index.js @@ -0,0 +1,4 @@ +import adminChronograf from './chronograf' +import adminInfluxDB from './influxdb' + +export default {adminChronograf, adminInfluxDB} diff --git a/ui/src/admin/reducers/admin.js b/ui/src/admin/reducers/influxdb.js similarity index 85% rename from ui/src/admin/reducers/admin.js rename to ui/src/admin/reducers/influxdb.js index 7450a860fb..23b86492bd 100644 --- a/ui/src/admin/reducers/admin.js +++ b/ui/src/admin/reducers/influxdb.js @@ -16,25 +16,25 @@ const initialState = { databases: [], } -export default function admin(state = initialState, action) { +const adminInfluxDB = (state = initialState, action) => { switch (action.type) { - case 'LOAD_USERS': { + case 'INFLUXDB_LOAD_USERS': { return {...state, ...action.payload} } - case 'LOAD_ROLES': { + case 'INFLUXDB_LOAD_ROLES': { return {...state, ...action.payload} } - case 'LOAD_PERMISSIONS': { + case 'INFLUXDB_LOAD_PERMISSIONS': { return {...state, ...action.payload} } - case 'LOAD_DATABASES': { + case 'INFLUXDB_LOAD_DATABASES': { return {...state, ...action.payload} } - case 'ADD_USER': { + case 'INFLUXDB_ADD_USER': { const newUser = {...NEW_DEFAULT_USER, isEditing: true} return { ...state, @@ -42,7 +42,7 @@ export default function admin(state = initialState, action) { } } - case 'ADD_ROLE': { + case 'INFLUXDB_ADD_ROLE': { const newRole = {...NEW_DEFAULT_ROLE, isEditing: true} return { ...state, @@ -50,7 +50,7 @@ export default function admin(state = initialState, action) { } } - case 'ADD_DATABASE': { + case 'INFLUXDB_ADD_DATABASE': { const newDatabase = { ...NEW_DEFAULT_DATABASE, links: {self: `temp-ID${uuid.v4()}`}, @@ -63,7 +63,7 @@ export default function admin(state = initialState, action) { } } - case 'ADD_RETENTION_POLICY': { + case 'INFLUXDB_ADD_RETENTION_POLICY': { const {database} = action.payload const databases = state.databases.map( db => @@ -81,7 +81,7 @@ export default function admin(state = initialState, action) { return {...state, databases} } - case 'SYNC_USER': { + case 'INFLUXDB_SYNC_USER': { const {staleUser, syncedUser} = action.payload const newState = { users: state.users.map( @@ -91,7 +91,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'SYNC_ROLE': { + case 'INFLUXDB_SYNC_ROLE': { const {staleRole, syncedRole} = action.payload const newState = { roles: state.roles.map( @@ -101,7 +101,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'SYNC_DATABASE': { + case 'INFLUXDB_SYNC_DATABASE': { const {stale, synced} = action.payload const newState = { databases: state.databases.map( @@ -112,7 +112,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'SYNC_RETENTION_POLICY': { + case 'INFLUXDB_SYNC_RETENTION_POLICY': { const {database, stale, synced} = action.payload const newState = { databases: state.databases.map( @@ -132,7 +132,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'EDIT_USER': { + case 'INFLUXDB_EDIT_USER': { const {user, updates} = action.payload const newState = { users: state.users.map( @@ -142,7 +142,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'EDIT_ROLE': { + case 'INFLUXDB_EDIT_ROLE': { const {role, updates} = action.payload const newState = { roles: state.roles.map( @@ -152,7 +152,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'EDIT_DATABASE': { + case 'INFLUXDB_EDIT_DATABASE': { const {database, updates} = action.payload const newState = { databases: state.databases.map( @@ -164,7 +164,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'EDIT_RETENTION_POLICY': { + case 'INFLUXDB_EDIT_RETENTION_POLICY': { const {database, retentionPolicy, updates} = action.payload const newState = { @@ -187,7 +187,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'DELETE_USER': { + case 'INFLUXDB_DELETE_USER': { const {user} = action.payload const newState = { users: state.users.filter(u => u.links.self !== user.links.self), @@ -196,7 +196,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'DELETE_ROLE': { + case 'INFLUXDB_DELETE_ROLE': { const {role} = action.payload const newState = { roles: state.roles.filter(r => r.links.self !== role.links.self), @@ -205,7 +205,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'REMOVE_DATABASE': { + case 'INFLUXDB_REMOVE_DATABASE': { const {database} = action.payload const newState = { databases: state.databases.filter( @@ -216,7 +216,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'REMOVE_RETENTION_POLICY': { + case 'INFLUXDB_REMOVE_RETENTION_POLICY': { const {database, retentionPolicy} = action.payload const newState = { databases: state.databases.map( @@ -235,7 +235,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'ADD_DATABASE_DELETE_CODE': { + case 'INFLUXDB_ADD_DATABASE_DELETE_CODE': { const {database} = action.payload const newState = { databases: state.databases.map( @@ -247,7 +247,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'REMOVE_DATABASE_DELETE_CODE': { + case 'INFLUXDB_REMOVE_DATABASE_DELETE_CODE': { const {database} = action.payload delete database.deleteCode @@ -260,11 +260,11 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'LOAD_QUERIES': { + case 'INFLUXDB_LOAD_QUERIES': { return {...state, ...action.payload} } - case 'FILTER_USERS': { + case 'INFLUXDB_FILTER_USERS': { const {text} = action.payload const newState = { users: state.users.map(u => { @@ -275,7 +275,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'FILTER_ROLES': { + case 'INFLUXDB_FILTER_ROLES': { const {text} = action.payload const newState = { roles: state.roles.map(r => { @@ -286,7 +286,7 @@ export default function admin(state = initialState, action) { return {...state, ...newState} } - case 'KILL_QUERY': { + case 'INFLUXDB_KILL_QUERY': { const {queryID} = action.payload const nextState = { queries: reject(state.queries, q => +q.id === +queryID), @@ -295,10 +295,12 @@ export default function admin(state = initialState, action) { return {...state, ...nextState} } - case 'SET_QUERY_TO_KILL': { + case 'INFLUXDB_SET_QUERY_TO_KILL': { return {...state, ...action.payload} } } return state } + +export default adminInfluxDB diff --git a/ui/src/alerts/components/AlertsTable.js b/ui/src/alerts/components/AlertsTable.js index c813889b9d..5b6bf832fa 100644 --- a/ui/src/alerts/components/AlertsTable.js +++ b/ui/src/alerts/components/AlertsTable.js @@ -5,7 +5,7 @@ import classnames from 'classnames' import {Link} from 'react-router' import uuid from 'node-uuid' -import FancyScrollbar from 'shared/components/FancyScrollbar' +import InfiniteScroll from 'shared/components/InfiniteScroll' import {ALERTS_TABLE} from 'src/alerts/constants/tableSizing' @@ -49,7 +49,7 @@ class AlertsTable extends Component { } } - sortableClasses = key => () => { + sortableClasses = key => { if (this.state.sortKey === key) { if (this.state.sortDirection === 'asc') { return 'alert-history-table--th sortable-header sorting-ascending' @@ -117,11 +117,10 @@ class AlertsTable extends Component { Value
- - {alerts.map(({name, level, time, host, value}) => { + itemHeight={25} + items={alerts.map(({name, level, time, host, value}) => { return (
) })} - + />
: this.renderTableEmpty() } diff --git a/ui/src/auth/Authorized.js b/ui/src/auth/Authorized.js new file mode 100644 index 0000000000..c7d40c12ba --- /dev/null +++ b/ui/src/auth/Authorized.js @@ -0,0 +1,107 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux' +import {withRouter} from 'react-router' + +export const MEMBER_ROLE = 'member' +export const VIEWER_ROLE = 'viewer' +export const EDITOR_ROLE = 'editor' +export const ADMIN_ROLE = 'admin' +export const SUPERADMIN_ROLE = 'superadmin' + +export const isUserAuthorized = (meRole, requiredRole) => { + switch (requiredRole) { + case VIEWER_ROLE: + return ( + meRole === VIEWER_ROLE || + meRole === EDITOR_ROLE || + meRole === ADMIN_ROLE || + meRole === SUPERADMIN_ROLE + ) + case EDITOR_ROLE: + return ( + meRole === EDITOR_ROLE || + meRole === ADMIN_ROLE || + meRole === SUPERADMIN_ROLE + ) + case ADMIN_ROLE: + return meRole === ADMIN_ROLE || meRole === SUPERADMIN_ROLE + case SUPERADMIN_ROLE: + return meRole === SUPERADMIN_ROLE + // 'member' is the default role and has no authorization for anything currently + case MEMBER_ROLE: + default: + return false + } +} + +class Authorized extends Component { + componentWillUpdate() { + const {router, me} = this.props + + if (me === null) { + router.push('/login') + } + } + + render() { + const { + children, + me, + isUsingAuth, + requiredRole, + replaceWithIfNotAuthorized, + replaceWithIfNotUsingAuth, + replaceWithIfAuthorized, + propsOverride, + } = this.props + + if (me === null) { + return null + } + + // if me response has not been received yet, render nothing + if (typeof isUsingAuth !== 'boolean') { + return null + } + + // React.isValidElement guards against multiple children wrapped by Authorized + const firstChild = React.isValidElement(children) ? children : children[0] + + if (!isUsingAuth) { + return replaceWithIfNotUsingAuth || firstChild + } + + if (isUserAuthorized(me.role, requiredRole)) { + return replaceWithIfAuthorized || firstChild + } + + if (propsOverride) { + return React.cloneElement(firstChild, {...propsOverride}) + } + + return replaceWithIfNotAuthorized || null + } +} + +const {bool, node, shape, string} = PropTypes + +Authorized.propTypes = { + isUsingAuth: bool, + replaceWithIfNotUsingAuth: node, + replaceWithIfAuthorized: node, + replaceWithIfNotAuthorized: node, + children: node.isRequired, + router: shape().isRequired, + me: shape({ + role: string, + }), + requiredRole: string.isRequired, + propsOverride: shape(), +} + +const mapStateToProps = ({auth: {me, isUsingAuth}}) => ({ + me, + isUsingAuth, +}) + +export default connect(mapStateToProps)(withRouter(Authorized)) diff --git a/ui/src/auth/Login.js b/ui/src/auth/Login.js index 4edbf15b30..106469e6d8 100644 --- a/ui/src/auth/Login.js +++ b/ui/src/auth/Login.js @@ -3,6 +3,8 @@ import React, {PropTypes} from 'react' import Notifications from 'shared/components/Notifications' +import SplashPage from 'shared/components/SplashPage' + const Login = ({authData: {auth}}) => { if (auth.isAuthLoading) { return
@@ -11,26 +13,19 @@ const Login = ({authData: {auth}}) => { return (
-
-
-
-

Chronograf

-

- {VERSION} / Time-Series Data Visualization -

- {auth.links && - auth.links.map(({name, login, label}) => - - - Login with {label} - - )} -
-

- Made by InfluxData + +

Chronograf

+

+ {VERSION} / Time-Series Data Visualization

-
-
+ {auth.links && + auth.links.map(({name, login, label}) => + + + Login with {label} + + )} +
) } diff --git a/ui/src/auth/Purgatory.js b/ui/src/auth/Purgatory.js new file mode 100644 index 0000000000..51ca6cb49b --- /dev/null +++ b/ui/src/auth/Purgatory.js @@ -0,0 +1,149 @@ +import React, {PropTypes, Component} from 'react' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' +import {withRouter} from 'react-router' + +import {meChangeOrganizationAsync} from 'shared/actions/auth' + +import Notifications from 'shared/components/Notifications' +import SplashPage from 'shared/components/SplashPage' +import PurgatoryAuthItem from 'src/auth/PurgatoryAuthItem' + +const getRoleNameByOrgID = (id, roles) => { + const role = roles.find(r => r.organization === id) + return (role && role.name) || 'ghost' +} + +const handleClickLogin = props => organization => async e => { + e.preventDefault() + const {router, links, meChangeOrganization} = props + + await meChangeOrganization(links.me, {organization: organization.id}) + router.push('') +} + +class Purgatory extends Component { + componentWillUpdate() { + const {router, me} = this.props + + if (me === null) { + router.push('/login') + } + } + + render() { + const {me, meChangeOrganization, logoutLink, router, links} = this.props + + if (me === null) { + return null + } + + const { + name, + provider, + scheme, + currentOrganization, + roles, + organizations, + superAdmin, + } = me + + const rolesAndOrgs = organizations.map(organization => ({ + organization, + role: getRoleNameByOrgID(organization.id, roles), + currentOrganization: organization.id === currentOrganization.id, + })) + + const subHeading = + rolesAndOrgs.length === 1 + ? 'Authenticated in 1 Organization' + : `Authenticated in ${rolesAndOrgs.length} Organizations` + + return ( +
+ + +
+

+ {name} +

+
+ {subHeading}{' '} + + {scheme}/{provider} + +
+ {rolesAndOrgs.length + ?
+ {rolesAndOrgs.map((rag, i) => + + )} +
+ :

You are a Lost Soul

} + + Logout + +
+
+
+ ) + } +} + +const {arrayOf, bool, func, shape, string} = PropTypes + +Purgatory.propTypes = { + router: shape({ + push: func.isRequired, + }).isRequired, + links: shape({ + me: string, + }), + me: shape({ + name: string.isRequired, + provider: string.isRequired, + scheme: string.isRequired, + currentOrganization: shape({ + id: string.isRequired, + name: string.isRequired, + }).isRequired, + roles: arrayOf( + shape({ + name: string, + organization: string, + }) + ).isRequired, + organizations: arrayOf( + shape({ + id: string, + name: string, + }) + ).isRequired, + superAdmin: bool, + }), + logoutLink: string, + meChangeOrganization: func.isRequired, +} + +const mapStateToProps = ({links, auth: {me, logoutLink}}) => ({ + links, + logoutLink, + me, +}) + +const mapDispatchToProps = dispatch => ({ + meChangeOrganization: bindActionCreators(meChangeOrganizationAsync, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)( + withRouter(Purgatory) +) diff --git a/ui/src/auth/PurgatoryAuthItem.js b/ui/src/auth/PurgatoryAuthItem.js new file mode 100644 index 0000000000..5afabc98e8 --- /dev/null +++ b/ui/src/auth/PurgatoryAuthItem.js @@ -0,0 +1,48 @@ +import React, {PropTypes} from 'react' + +import {isUserAuthorized, VIEWER_ROLE} from 'src/auth/Authorized' + +const PurgatoryAuthItem = ({roleAndOrg, onClickLogin, superAdmin}) => +
+
+
+ {roleAndOrg.organization.name} +
+
+ {roleAndOrg.role} +
+
+ {superAdmin || isUserAuthorized(roleAndOrg.role, VIEWER_ROLE) + ? + : + Contact your Admin
for access +
} +
+ +const {bool, func, shape, string} = PropTypes + +PurgatoryAuthItem.propTypes = { + roleAndOrg: shape({ + organization: shape({ + name: string, + id: string, + }), + role: string, + currentOrganization: bool, + }).isRequired, + superAdmin: bool, + onClickLogin: func.isRequired, +} + +export default PurgatoryAuthItem diff --git a/ui/src/auth/index.js b/ui/src/auth/index.js index 0b9eb546a7..ba7cad7576 100644 --- a/ui/src/auth/index.js +++ b/ui/src/auth/index.js @@ -1,7 +1,6 @@ import Login from './Login' -import { - UserIsAuthenticated, - Authenticated, - UserIsNotAuthenticated, -} from './Authenticated' -export {Login, UserIsAuthenticated, Authenticated, UserIsNotAuthenticated} +import Purgatory from './Purgatory' + +import {UserIsAuthenticated, UserIsNotAuthenticated} from './Authenticated' + +export {Login, Purgatory, UserIsAuthenticated, UserIsNotAuthenticated} diff --git a/ui/src/dashboards/components/DashboardHeader.js b/ui/src/dashboards/components/DashboardHeader.js index 18b59d4d04..bf101c0373 100644 --- a/ui/src/dashboards/components/DashboardHeader.js +++ b/ui/src/dashboards/components/DashboardHeader.js @@ -1,6 +1,8 @@ import React, {PropTypes} from 'react' import classnames from 'classnames' +import Authorized, {EDITOR_ROLE} from 'src/auth/Authorized' + import AutoRefreshDropdown from 'shared/components/AutoRefreshDropdown' import TimeRangeDropdown from 'shared/components/TimeRangeDropdown' import SourceIndicator from 'shared/components/SourceIndicator' @@ -46,13 +48,22 @@ const DashboardHeader = ({ /> : null} {dashboard - ? + ? + {activeDashboard} + + } + > + + :

{activeDashboard}

} @@ -61,10 +72,15 @@ const DashboardHeader = ({ {dashboard - ? + ? + + : null} {dashboard ?
- d.name.includes(this.state.searchTerm) + d.name.toLowerCase().includes(searchTerm.toLowerCase()) ) return ( @@ -53,12 +55,14 @@ class DashboardsPageContents extends Component { placeholder="Filter by Name..." onSearch={this.filterDashboards} /> - + + +
diff --git a/ui/src/dashboards/components/DashboardsTable.js b/ui/src/dashboards/components/DashboardsTable.js index fc72d49a0d..c62f0bceff 100644 --- a/ui/src/dashboards/components/DashboardsTable.js +++ b/ui/src/dashboards/components/DashboardsTable.js @@ -2,8 +2,31 @@ import React, {PropTypes} from 'react' import {Link} from 'react-router' import _ from 'lodash' +import Authorized, {EDITOR_ROLE} from 'src/auth/Authorized' + import DeleteConfirmTableCell from 'shared/components/DeleteConfirmTableCell' +const AuthorizedEmptyState = ({onCreateDashboard}) => +
+

+ Looks like you don’t have any dashboards +

+
+ +
+ +const unauthorizedEmptyState = ( +
+

Looks like you don’t have any dashboards

+
+) + const DashboardsTable = ({ dashboards, onDeleteDashboard, @@ -36,27 +59,26 @@ const DashboardsTable = ({ ) : None} - + } + > + + )} - :
-

- Looks like you don’t have any dashboards -

- -
+ : + + } const {arrayOf, func, shape, string} = PropTypes @@ -68,4 +90,8 @@ DashboardsTable.propTypes = { dashboardLink: string.isRequired, } +AuthorizedEmptyState.propTypes = { + onCreateDashboard: func.isRequired, +} + export default DashboardsTable diff --git a/ui/src/dashboards/components/TemplateControlBar.js b/ui/src/dashboards/components/TemplateControlBar.js index dfaa4751ac..558335ef6a 100644 --- a/ui/src/dashboards/components/TemplateControlBar.js +++ b/ui/src/dashboards/components/TemplateControlBar.js @@ -2,6 +2,8 @@ import React, {PropTypes} from 'react' import classnames from 'classnames' import calculateSize from 'calculate-size' +import Authorized, {EDITOR_ROLE} from 'src/auth/Authorized' + import Dropdown from 'shared/components/Dropdown' const minTempVarDropdownWidth = 146 @@ -75,13 +77,15 @@ const TemplateControlBar = ({ This dashboard does not have any Template Variables
}
- + + +
diff --git a/ui/src/dashboards/containers/DashboardPage.js b/ui/src/dashboards/containers/DashboardPage.js index b490ab96ae..e21e37a86f 100644 --- a/ui/src/dashboards/containers/DashboardPage.js +++ b/ui/src/dashboards/containers/DashboardPage.js @@ -2,8 +2,11 @@ import React, {PropTypes, Component} from 'react' import {connect} from 'react-redux' import {bindActionCreators} from 'redux' +import _ from 'lodash' import Dygraph from 'src/external/dygraph' +import {isUserAuthorized, EDITOR_ROLE} from 'src/auth/Authorized' + import OverlayTechnologies from 'shared/components/OverlayTechnologies' import CellEditorOverlay from 'src/dashboards/components/CellEditorOverlay' import DashboardHeader from 'src/dashboards/components/DashboardHeader' @@ -52,6 +55,8 @@ class DashboardPage extends Component { putDashboardByID, }, source, + meRole, + isUsingAuth, } = this.props const dashboards = await getDashboardsAsync() @@ -59,9 +64,13 @@ class DashboardPage extends Component { d => d.id === idNormalizer(TYPE_ID, dashboardID) ) - // Refresh and persists influxql generated template variable values - await updateTempVarValues(source, dashboard) - await putDashboardByID(dashboardID) + // Refresh and persists influxql generated template variable values. + // If using auth and role is Viewer, temp vars will be stale until dashboard + // is refactored so as not to require a write operation (a PUT in this case) + if (!isUsingAuth || isUserAuthorized(meRole, EDITOR_ROLE)) { + await updateTempVarValues(source, dashboard) + await putDashboardByID(dashboardID) + } } handleOpenTemplateManager = () => { @@ -102,11 +111,16 @@ class DashboardPage extends Component { } handleUpdatePosition = cells => { - const {dashboardActions, dashboard} = this.props + const {dashboardActions, dashboard, meRole, isUsingAuth} = this.props const newDashboard = {...dashboard, cells} - dashboardActions.updateDashboard(newDashboard) - dashboardActions.putDashboard(newDashboard) + // GridLayout invokes onLayoutChange on first load, which bubbles up to + // invoke handleUpdatePosition. If using auth, Viewer is not authorized to + // PUT, so until the need for PUT is removed, this is prevented. + if (!isUsingAuth || isUserAuthorized(meRole, EDITOR_ROLE)) { + dashboardActions.updateDashboard(newDashboard) + dashboardActions.putDashboard(newDashboard) + } } handleAddCell = () => { @@ -440,6 +454,8 @@ DashboardPage.propTypes = { errorThrown: func, manualRefresh: number.isRequired, onManualRefresh: func.isRequired, + meRole: string, + isUsingAuth: bool.isRequired, } const mapStateToProps = (state, {params: {dashboardID}}) => { @@ -451,7 +467,9 @@ const mapStateToProps = (state, {params: {dashboardID}}) => { dashboardUI: {dashboards, cellQueryStatus}, sources, dashTimeV1, + auth: {me, isUsingAuth}, } = state + const meRole = _.get(me, 'role', null) const timeRange = dashTimeV1.ranges.find( @@ -471,6 +489,8 @@ const mapStateToProps = (state, {params: {dashboardID}}) => { inPresentationMode, cellQueryStatus, sources, + meRole, + isUsingAuth, } } diff --git a/ui/src/hosts/apis/index.js b/ui/src/hosts/apis/index.js index f8815f07db..584ca4b5a7 100644 --- a/ui/src/hosts/apis/index.js +++ b/ui/src/hosts/apis/index.js @@ -110,18 +110,17 @@ export async function getAllHosts(proxyLink, telegrafDB) { } } -export function getMappings() { - return AJAX({ +export const getLayouts = () => + AJAX({ method: 'GET', - resource: 'mappings', + resource: 'layouts', }) -} -export function getAppsForHosts(proxyLink, hosts, appMappings, telegrafDB) { - const measurements = appMappings.map(m => `^${m.measurement}$`).join('|') +export function getAppsForHosts(proxyLink, hosts, appLayouts, telegrafDB) { + const measurements = appLayouts.map(m => `^${m.measurement}$`).join('|') const measurementsToApps = _.zipObject( - appMappings.map(m => m.measurement), - appMappings.map(m => m.name) + appLayouts.map(m => m.measurement), + appLayouts.map(({app}) => app) ) return proxy({ diff --git a/ui/src/hosts/components/HostRow.js b/ui/src/hosts/components/HostRow.js index 16a7d42916..159decb398 100644 --- a/ui/src/hosts/components/HostRow.js +++ b/ui/src/hosts/components/HostRow.js @@ -20,13 +20,13 @@ class HostRow extends Component { const {colName, colStatus, colCPU, colLoad} = HOSTS_TABLE return ( - - +
+
{name} - - +
+
- - +
+
{isNaN(cpu) ? 'N/A' : `${cpu.toFixed(2)}%`} - - +
+
{isNaN(load) ? 'N/A' : `${load.toFixed(2)}`} - - +
+
{apps.map((app, index) => { return ( @@ -59,8 +59,8 @@ class HostRow extends Component { ) })} - - +
+
) } } diff --git a/ui/src/hosts/components/HostsTable.js b/ui/src/hosts/components/HostsTable.js index 3873da99a4..585f8877b3 100644 --- a/ui/src/hosts/components/HostsTable.js +++ b/ui/src/hosts/components/HostsTable.js @@ -3,6 +3,7 @@ import _ from 'lodash' import SearchBar from 'src/hosts/components/SearchBar' import HostRow from 'src/hosts/components/HostRow' +import InfiniteScroll from 'shared/components/InfiniteScroll' import {HOSTS_TABLE} from 'src/hosts/constants/tableSizing' @@ -67,11 +68,11 @@ class HostsTable extends Component { sortableClasses = key => { if (this.state.sortKey === key) { if (this.state.sortDirection === 'asc') { - return 'sortable-header sorting-ascending' + return 'hosts-table--th sortable-header sorting-ascending' } - return 'sortable-header sorting-descending' + return 'hosts-table--th sortable-header sorting-descending' } - return 'sortable-header' + return 'hosts-table--th sortable-header' } render() { @@ -110,47 +111,48 @@ class HostsTable extends Component {
{hostCount > 0 && !hostsError.length - ? - - - - - - - - - - - - {sortedHosts.map(h => + +
Apps
+ + + )} - -
+
+
+
Host -
+
Status -
+
CPU -
+
Load -
Apps
+ itemHeight={26} + className="hosts-table--tbody" + /> +
:

No Hosts found

} diff --git a/ui/src/hosts/containers/HostPage.js b/ui/src/hosts/containers/HostPage.js index 5e09fb1cb5..a215fc0b3d 100644 --- a/ui/src/hosts/containers/HostPage.js +++ b/ui/src/hosts/containers/HostPage.js @@ -13,12 +13,11 @@ import ManualRefresh from 'src/shared/components/ManualRefresh' import timeRanges from 'hson!shared/data/timeRanges.hson' import { - getMappings, + getLayouts, getAppsForHosts, getMeasurementsForHost, getAllHosts, } from 'src/hosts/apis' -import {fetchLayouts} from 'shared/apis' import {setAutoRefresh} from 'shared/actions/app' import {presentationButtonDispatcher} from 'shared/dispatchers' @@ -38,13 +37,12 @@ class HostPage extends Component { const {source, params, location} = this.props // fetching layouts and mappings can be done at the same time - const {data: {layouts}} = await fetchLayouts() - const {data: {mappings}} = await getMappings() + const {data: {layouts}} = await getLayouts() const hosts = await getAllHosts(source.links.proxy, source.telegraf) const newHosts = await getAppsForHosts( source.links.proxy, hosts, - mappings, + layouts, source.telegraf ) diff --git a/ui/src/hosts/containers/HostsPage.js b/ui/src/hosts/containers/HostsPage.js index 9d4be6bf47..b08e324448 100644 --- a/ui/src/hosts/containers/HostsPage.js +++ b/ui/src/hosts/containers/HostsPage.js @@ -2,10 +2,9 @@ import React, {PropTypes, Component} from 'react' import _ from 'lodash' import HostsTable from 'src/hosts/components/HostsTable' -import FancyScrollbar from 'shared/components/FancyScrollbar' import SourceIndicator from 'shared/components/SourceIndicator' -import {getCpuAndLoadForHosts, getMappings, getAppsForHosts} from '../apis' +import {getCpuAndLoadForHosts, getLayouts, getAppsForHosts} from '../apis' class HostsPage extends Component { constructor(props) { @@ -22,18 +21,18 @@ class HostsPage extends Component { const {source, addFlashMessage} = this.props Promise.all([ getCpuAndLoadForHosts(source.links.proxy, source.telegraf), - getMappings(), + getLayouts(), new Promise(resolve => { this.setState({hostsLoading: true}) resolve() }), ]) - .then(([hosts, {data: {mappings}}]) => { + .then(([hosts, {data: {layouts}}]) => { this.setState({ hosts, hostsLoading: false, }) - getAppsForHosts(source.links.proxy, hosts, mappings, source.telegraf) + getAppsForHosts(source.links.proxy, hosts, layouts, source.telegraf) .then(newHosts => { this.setState({ hosts: newHosts, @@ -66,7 +65,7 @@ class HostsPage extends Component { const {source} = this.props const {hosts, hostsLoading, hostsError} = this.state return ( -
+
@@ -77,7 +76,7 @@ class HostsPage extends Component {
- +
@@ -90,7 +89,7 @@ class HostsPage extends Component {
- +
) } diff --git a/ui/src/index.js b/ui/src/index.js index 8783417b8a..287d24a73d 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -6,12 +6,18 @@ import {Provider} from 'react-redux' import {Router, Route, useRouterHistory} from 'react-router' import {createHistory} from 'history' import {syncHistoryWithStore} from 'react-router-redux' +import {bindActionCreators} from 'redux' import configureStore from 'src/store/configureStore' import {loadLocalStorage} from 'src/localStorage' import App from 'src/App' -import {Login, UserIsAuthenticated, UserIsNotAuthenticated} from 'src/auth' +import { + Login, + UserIsAuthenticated, + UserIsNotAuthenticated, + Purgatory, +} from 'src/auth' import CheckSources from 'src/CheckSources' import {StatusPage} from 'src/status' import {HostsPage, HostPage} from 'src/hosts' @@ -25,21 +31,13 @@ import { KapacitorTasksPage, TickscriptPage, } from 'src/kapacitor' -import {AdminPage} from 'src/admin' +import {AdminChronografPage, AdminInfluxDBPage} from 'src/admin' import {SourcePage, ManageSources} from 'src/sources' import NotFound from 'shared/components/NotFound' -import {getMe} from 'shared/apis' +import {getMeAsync} from 'shared/actions/auth' import {disablePresentationMode} from 'shared/actions/app' -import { - authRequested, - authReceived, - meRequested, - meReceived, - logoutLinkReceived, -} from 'shared/actions/auth' -import {linksReceived} from 'shared/actions/links' import {errorThrown} from 'shared/actions/errors' import 'src/style/chronograf.scss' @@ -81,34 +79,23 @@ const Root = React.createClass({ }, async checkAuth() { - dispatch(authRequested()) - dispatch(meRequested()) try { - await this.startHeartbeat({shouldDispatchResponse: true}) + await this.performHeartbeat({shouldResetMe: true}) } catch (error) { dispatch(errorThrown(error)) } }, - async startHeartbeat({shouldDispatchResponse}) { - try { - // These non-me objects are added to every response by some AJAX trickery - const {data: me, auth, logoutLink, external} = await getMe() - if (shouldDispatchResponse) { - dispatch(authReceived(auth)) - dispatch(meReceived(me)) - dispatch(logoutLinkReceived(logoutLink)) - dispatch(linksReceived({external})) - } + getMe: bindActionCreators(getMeAsync, dispatch), - setTimeout(() => { - if (store.getState().auth.me !== null) { - this.startHeartbeat({shouldDispatchResponse: false}) - } - }, HEARTBEAT_INTERVAL) - } catch (error) { - dispatch(errorThrown(error)) - } + async performHeartbeat({shouldResetMe = false} = {}) { + await this.getMe({shouldResetMe}) + + setTimeout(() => { + if (store.getState().auth.me !== null) { + this.performHeartbeat() + } + }, HEARTBEAT_INTERVAL) }, flushErrorsQueue() { @@ -125,6 +112,7 @@ const Root = React.createClass({ + - + + diff --git a/ui/src/kapacitor/components/LogsTable.js b/ui/src/kapacitor/components/LogsTable.js index 0e49badd36..ad7945c1b0 100644 --- a/ui/src/kapacitor/components/LogsTable.js +++ b/ui/src/kapacitor/components/LogsTable.js @@ -1,6 +1,6 @@ import React, {PropTypes} from 'react' -import FancyScrollbar from 'shared/components/FancyScrollbar' +import InfiniteScroll from 'shared/components/InfiniteScroll' import LogsTableRow from 'src/kapacitor/components/LogsTableRow' const LogsTable = ({logs}) => @@ -8,18 +8,17 @@ const LogsTable = ({logs}) =>

Logs

- -
- {logs.length - ? logs.map((log, i) => +
+ {logs.length + ? - ) - :
} -
- + )} + /> + :
} +
const {arrayOf, shape, string} = PropTypes diff --git a/ui/src/kapacitor/containers/TickscriptPage.js b/ui/src/kapacitor/containers/TickscriptPage.js index 8abf937aa2..b73a11edf3 100644 --- a/ui/src/kapacitor/containers/TickscriptPage.js +++ b/ui/src/kapacitor/containers/TickscriptPage.js @@ -44,7 +44,8 @@ class TickscriptPage extends Component { }) notify( 'warning', - 'Could not use logging, requires Kapacitor version 1.4' + 'Could not use logging, requires Kapacitor version 1.4', + {once: true} ) return } @@ -101,13 +102,13 @@ class TickscriptPage extends Component { } this.setState({ - logs: [...this.state.logs, ...logs], + logs: [...logs, ...this.state.logs], failStr, }) } catch (err) { console.warn(err, failStr) this.setState({ - logs: [...this.state.logs, ...logs], + logs: [...logs, ...this.state.logs], failStr, }) } diff --git a/ui/src/localStorage.js b/ui/src/localStorage.js index 77cb2268f2..55c7aff557 100644 --- a/ui/src/localStorage.js +++ b/ui/src/localStorage.js @@ -52,6 +52,7 @@ export const saveToLocalStorage = ({ timeRange, dataExplorer, dashTimeV1: {ranges}, + dismissedNotifications, }) => { try { const appPersisted = Object.assign({}, {app: {persisted}}) @@ -66,6 +67,7 @@ export const saveToLocalStorage = ({ dataExplorer, VERSION, // eslint-disable-line no-undef dashTimeV1, + dismissedNotifications, }) ) } catch (err) { diff --git a/ui/src/shared/actions/auth.js b/ui/src/shared/actions/auth.js index dc0334f4f9..cf32026525 100644 --- a/ui/src/shared/actions/auth.js +++ b/ui/src/shared/actions/auth.js @@ -1,3 +1,10 @@ +import {getMe as getMeAJAX, updateMe as updateMeAJAX} from 'shared/apis/auth' + +import {linksReceived} from 'shared/actions/links' + +import {publishAutoDismissingNotification} from 'shared/dispatchers' +import {errorThrown} from 'shared/actions/errors' + export const authExpired = auth => ({ type: 'AUTH_EXPIRED', payload: { @@ -9,27 +16,89 @@ export const authRequested = () => ({ type: 'AUTH_REQUESTED', }) -export const authReceived = auth => ({ - type: 'AUTH_RECEIVED', - payload: { - auth, - }, +export const meGetRequested = () => ({ + type: 'ME_GET_REQUESTED', }) -export const meRequested = () => ({ - type: 'ME_REQUESTED', -}) - -export const meReceived = me => ({ - type: 'ME_RECEIVED', +export const meGetCompleted = ({me, auth, logoutLink}) => ({ + type: 'ME_GET_COMPLETED', payload: { me, - }, -}) - -export const logoutLinkReceived = logoutLink => ({ - type: 'LOGOUT_LINK_RECEIVED', - payload: { + auth, logoutLink, }, }) + +export const meGetFailed = () => ({ + type: 'ME_GET_FAILED', +}) + +export const meChangeOrganizationRequested = () => ({ + type: 'ME_CHANGE_ORGANIZATION_REQUESTED', +}) + +export const meChangeOrganizationCompleted = () => ({ + type: 'ME_CHANGE_ORGANIZATION_COMPLETED', +}) + +export const meChangeOrganizationFailed = () => ({ + type: 'ME_CHANGE_ORGANIZATION_FAILED', +}) + +// shouldResetMe protects against `me` being nullified in Redux temporarily, +// which currently causes the app to show a loading spinner until me is +// re-hydrated. if `getMeAsync` is only being used to refresh me after creating +// an organization, this is undesirable behavior +export const getMeAsync = ({shouldResetMe = false} = {}) => async dispatch => { + if (shouldResetMe) { + dispatch(authRequested()) + dispatch(meGetRequested()) + } + try { + // These non-me objects are added to every response by some AJAX trickery + const { + data: me, + auth, + logoutLink, + external, + users, + organizations, + meLink, + } = await getMeAJAX() + dispatch( + meGetCompleted({ + me, + auth, + logoutLink, + }) + ) + dispatch(linksReceived({external, users, organizations, me: meLink})) // TODO: put this before meGetCompleted... though for some reason it doesn't fire the first time then + } catch (error) { + dispatch(meGetFailed()) + dispatch(errorThrown(error)) + } +} + +export const meChangeOrganizationAsync = ( + url, + organization +) => async dispatch => { + dispatch(meChangeOrganizationRequested()) + try { + const {data: me, auth, logoutLink} = await updateMeAJAX(url, organization) + dispatch( + publishAutoDismissingNotification( + 'success', + `Now signed into ${me.currentOrganization.name}` + ) + ) + dispatch(meChangeOrganizationCompleted()) + dispatch(meGetCompleted({me, auth, logoutLink})) + // TODO: reload sources upon me change org if non-refresh behavior preferred + // instead of current behavior on both invocations of meChangeOrganization, + // which is to refresh index via router.push('') + } catch (error) { + dispatch(errorThrown(error)) + dispatch(meChangeOrganizationFailed()) + } +} diff --git a/ui/src/shared/actions/notifications.js b/ui/src/shared/actions/notifications.js index deab1e28bb..23c830ce6d 100644 --- a/ui/src/shared/actions/notifications.js +++ b/ui/src/shared/actions/notifications.js @@ -1,4 +1,4 @@ -export function publishNotification(type, message) { +export function publishNotification(type, message, options = {once: false}) { // this validator is purely for development purposes. It might make sense to move this to a middleware. const validTypes = ['error', 'success', 'warning'] if (!validTypes.includes(type) || message === undefined) { @@ -10,6 +10,7 @@ export function publishNotification(type, message) { payload: { type, message, + once: options.once, }, } } diff --git a/ui/src/shared/actions/sources.js b/ui/src/shared/actions/sources.js index f96417664f..e19710492e 100644 --- a/ui/src/shared/actions/sources.js +++ b/ui/src/shared/actions/sources.js @@ -1,11 +1,12 @@ import { deleteSource, - getSources, + getSources as getSourcesAJAX, getKapacitors as getKapacitorsAJAX, updateKapacitor as updateKapacitorAJAX, deleteKapacitor as deleteKapacitorAJAX, } from 'shared/apis' import {publishNotification} from './notifications' +import {errorThrown} from 'shared/actions/errors' import {HTTP_NOT_FOUND} from 'shared/constants' @@ -67,7 +68,7 @@ export const removeAndLoadSources = source => async dispatch => { } } - const {data: {sources: newSources}} = await getSources() + const {data: {sources: newSources}} = await getSourcesAJAX() dispatch(loadSources(newSources)) } catch (err) { dispatch( @@ -110,3 +111,13 @@ export const deleteKapacitorAsync = kapacitor => async dispatch => { ) } } + +export const getSourcesAsync = () => async dispatch => { + try { + const {data: {sources}} = await getSourcesAJAX() + dispatch(loadSources(sources)) + return sources + } catch (error) { + dispatch(errorThrown(error)) + } +} diff --git a/ui/src/shared/apis/auth.js b/ui/src/shared/apis/auth.js new file mode 100644 index 0000000000..deb364d6b5 --- /dev/null +++ b/ui/src/shared/apis/auth.js @@ -0,0 +1,21 @@ +import AJAX from 'src/utils/ajax' + +export function getMe() { + return AJAX({ + resource: 'me', + method: 'GET', + }) +} + +export const updateMe = async (url, updatedMe) => { + try { + return await AJAX({ + method: 'PUT', + url, + data: updatedMe, + }) + } catch (error) { + console.error(error) + throw error + } +} diff --git a/ui/src/shared/apis/index.js b/ui/src/shared/apis/index.js index 9ab2ac57b3..bec7eea7c3 100644 --- a/ui/src/shared/apis/index.js +++ b/ui/src/shared/apis/index.js @@ -1,20 +1,5 @@ import AJAX from 'utils/ajax' -export function fetchLayouts() { - return AJAX({ - url: '/chronograf/v1/layouts', - method: 'GET', - resource: 'layouts', - }) -} - -export function getMe() { - return AJAX({ - resource: 'me', - method: 'GET', - }) -} - export function getSources() { return AJAX({ resource: 'sources', diff --git a/ui/src/shared/components/ConfirmButtons.js b/ui/src/shared/components/ConfirmButtons.js index 0272bc6614..6a7f59cef4 100644 --- a/ui/src/shared/components/ConfirmButtons.js +++ b/ui/src/shared/components/ConfirmButtons.js @@ -1,6 +1,8 @@ import React, {PropTypes, Component} from 'react' import classnames from 'classnames' +import OnClickOutside from 'shared/components/OnClickOutside' + class ConfirmButtons extends Component { constructor(props) { super(props) @@ -14,31 +16,54 @@ class ConfirmButtons extends Component { this.props.onCancel(item) } - render() { - const {item, buttonSize, isDisabled} = this.props + handleClickOutside = () => { + this.props.onClickOutside(this.props.item) + } - return ( -
- - -
- ) + render() { + const {item, buttonSize, isDisabled, confirmLeft} = this.props + + return confirmLeft + ?
+ + +
+ :
+ + +
} } @@ -50,9 +75,12 @@ ConfirmButtons.propTypes = { onCancel: func.isRequired, buttonSize: string, isDisabled: bool, + onClickOutside: func, + confirmLeft: bool, } ConfirmButtons.defaultProps = { buttonSize: 'btn-sm', + onClickOutside: () => {}, } -export default ConfirmButtons +export default OnClickOutside(ConfirmButtons) diff --git a/ui/src/shared/components/DeleteConfirmButtons.js b/ui/src/shared/components/DeleteConfirmButtons.js index 14fdfa7334..69612d1fef 100644 --- a/ui/src/shared/components/DeleteConfirmButtons.js +++ b/ui/src/shared/components/DeleteConfirmButtons.js @@ -4,15 +4,24 @@ import classnames from 'classnames' import OnClickOutside from 'shared/components/OnClickOutside' import ConfirmButtons from 'shared/components/ConfirmButtons' -const DeleteButton = ({onClickDelete, buttonSize, icon, square}) => +const DeleteButton = ({ + onClickDelete, + buttonSize, + icon, + square, + text, + disabled, +}) => class DeleteConfirmButtons extends Component { @@ -38,9 +47,23 @@ class DeleteConfirmButtons extends Component { } render() { - const {onDelete, item, buttonSize, icon, square} = this.props + const { + onDelete, + item, + buttonSize, + icon, + square, + text, + disabled, + } = this.props const {isConfirming} = this.state + if (square && !icon) { + console.error( + 'DeleteButton component requires both icon if passing in square.' + ) + } + return isConfirming ? : {} : this.handleClickDelete} buttonSize={buttonSize} icon={icon} square={square} + disabled={disabled} /> } } @@ -64,14 +89,22 @@ DeleteButton.propTypes = { buttonSize: string, icon: string, square: bool, + disabled: bool, + text: string.isRequired, +} + +DeleteButton.defaultProps = { + text: 'Delete', } DeleteConfirmButtons.propTypes = { + text: string, item: oneOfType([(string, shape())]), onDelete: func.isRequired, buttonSize: string, square: bool, icon: string, + disabled: bool, } DeleteConfirmButtons.defaultProps = { diff --git a/ui/src/shared/components/Dropdown.js b/ui/src/shared/components/Dropdown.js index 92efa21028..19e6cd887c 100644 --- a/ui/src/shared/components/Dropdown.js +++ b/ui/src/shared/components/Dropdown.js @@ -22,6 +22,7 @@ class Dropdown extends Component { buttonColor: 'btn-default', menuWidth: '100%', useAutoComplete: false, + disabled: false, } handleClickOutside = () => { @@ -29,6 +30,11 @@ class Dropdown extends Component { } handleClick = e => { + const {disabled} = this.props + + if (disabled) { + return + } this.toggleMenu(e) if (this.props.onClick) { this.props.onClick(e) @@ -164,7 +170,7 @@ class Dropdown extends Component { > {item.text} - {actions.length > 0 + {actions && actions.length ?
{actions.map(action => { return ( @@ -208,10 +214,12 @@ class Dropdown extends Component { buttonColor, toggleStyle, useAutoComplete, + disabled, } = this.props const {isOpen, searchTerm, filteredItems} = this.state const menuItems = useAutoComplete ? filteredItems : items + const disabledClass = disabled ? 'disabled' : null return (
{useAutoComplete && isOpen ?
:
{iconName @@ -297,6 +305,7 @@ Dropdown.propTypes = { menuClass: string, useAutoComplete: bool, toggleStyle: shape(), + disabled: bool, } export default OnClickOutside(Dropdown) diff --git a/ui/src/shared/components/InfiniteScroll.js b/ui/src/shared/components/InfiniteScroll.js new file mode 100644 index 0000000000..9854ef1afa --- /dev/null +++ b/ui/src/shared/components/InfiniteScroll.js @@ -0,0 +1,131 @@ +import React, {Component, PropTypes} from 'react' +import classnames from 'classnames' +import {Scrollbars} from 'react-custom-scrollbars' +import _ from 'lodash' + +const {arrayOf, number, shape, string} = PropTypes + +class InfiniteScroll extends Component { + // Cache values from Scrollbars events that need to be independent of render + // Should not be setState as need not trigger a re-render + scrollbarsScrollTop = 0 + scrollbarsClientHeight = 0 + + state = { + topIndex: 0, + bottomIndex: 0, + topPadding: 0, + bottomPadding: 0, + windowHeight: window.innerHeight, + } + + windowing = (props, state) => { + const {itemHeight, items} = props + const {bottomIndex} = state + + const itemDistance = Math.round(this.scrollbarsScrollTop / itemHeight) + const itemCount = Math.round(this.scrollbarsClientHeight / itemHeight) + 1 + + // If state is the same, do not setState to the same value multiple times. + // Improves performance and prevents errors. + if (bottomIndex === itemDistance + itemCount) { + return + } + + this.setState({ + // Number of items from top + topIndex: itemDistance, + // Number of items that can fit inside the container div + bottomIndex: itemDistance + itemCount, + // Offset list from top + topPadding: itemDistance * itemHeight, + // Provide scrolling room at the bottom of the list + bottomPadding: (items.length - itemDistance - itemCount) * itemHeight, + }) + } + + handleScroll = ({clientHeight, scrollTop}) => { + let shouldUpdate = false + + if ( + (typeof clientHeight !== 'undefined' && + this.scrollbarsClientHeight !== clientHeight) || + (typeof scrollTop !== 'undefined' && + this.scrollbarsScrollTop !== scrollTop) + ) { + shouldUpdate = true + } + + this.scrollbarsClientHeight = clientHeight + this.scrollbarsScrollTop = scrollTop + + if (shouldUpdate) { + this.windowing(this.props, this.state) + } + } + + throttledHandleScroll = _.throttle(this.handleScroll, 100) + + handleResize = () => { + this.setState({windowHeight: window.innerHeight}) + } + + throttledHandleResize = _.throttle(this.handleResize, 100) + + handleMakeDiv = className => props => +
+ + componentDidMount() { + window.addEventListener('resize', this.handleResize, true) + } + + componentWillUnmount() { + window.removeEventListener('resize', this.handleResize, true) + } + + componentWillReceiveProps(nextProps, nextState) { + // Updates values if new items are added + this.windowing(nextProps, nextState) + } + + render() { + const {className, items} = this.props + const { + topIndex, + bottomIndex, + topPadding, + bottomPadding, + windowHeight, + } = this.state + + return ( + +
+ {items.filter((_item, i) => i >= topIndex && i <= bottomIndex)} +
+ + ) + } +} + +InfiniteScroll.propTypes = { + itemHeight: number.isRequired, + items: arrayOf(shape()).isRequired, + className: string, +} + +export default InfiniteScroll diff --git a/ui/src/shared/components/LayoutCell.js b/ui/src/shared/components/LayoutCell.js index 3303efd35c..556638e1c9 100644 --- a/ui/src/shared/components/LayoutCell.js +++ b/ui/src/shared/components/LayoutCell.js @@ -1,6 +1,8 @@ import React, {Component, PropTypes} from 'react' import _ from 'lodash' +import Authorized, {EDITOR_ROLE} from 'src/auth/Authorized' + import LayoutCellMenu from 'shared/components/LayoutCellMenu' import LayoutCellHeader from 'shared/components/LayoutCellHeader' import {errorThrown} from 'shared/actions/errors' @@ -52,17 +54,19 @@ class LayoutCell extends Component { return (
- + + + - + + +
}
diff --git a/ui/src/shared/components/LayoutRenderer.js b/ui/src/shared/components/LayoutRenderer.js index 42bcc22307..1faf1cca6e 100644 --- a/ui/src/shared/components/LayoutRenderer.js +++ b/ui/src/shared/components/LayoutRenderer.js @@ -4,6 +4,8 @@ import Resizeable from 'react-component-resizable' import _ from 'lodash' +import Authorized, {EDITOR_ROLE} from 'src/auth/Authorized' + import Layout from 'src/shared/components/Layout' import { @@ -87,43 +89,59 @@ class LayoutRenderer extends Component { return ( - - {cells.map(cell => -
- -
- )} -
+ + {cells.map(cell => +
+ + + +
+ )} +
+
) } diff --git a/ui/src/shared/components/NoKapacitorError.js b/ui/src/shared/components/NoKapacitorError.js index 04805efcaf..05cf2af522 100644 --- a/ui/src/shared/components/NoKapacitorError.js +++ b/ui/src/shared/components/NoKapacitorError.js @@ -1,6 +1,8 @@ import React, {PropTypes} from 'react' import {Link} from 'react-router' +import Authorized, {EDITOR_ROLE} from 'src/auth/Authorized' + const NoKapacitorError = React.createClass({ propTypes: { source: PropTypes.shape({ @@ -16,9 +18,11 @@ const NoKapacitorError = React.createClass({ The current source does not have an associated Kapacitor instance

- - Configure Kapacitor - + + + Configure Kapacitor + +

) diff --git a/ui/src/shared/components/Notifications.js b/ui/src/shared/components/Notifications.js index 7111e9f300..f305f52829 100644 --- a/ui/src/shared/components/Notifications.js +++ b/ui/src/shared/components/Notifications.js @@ -4,6 +4,8 @@ import {withRouter} from 'react-router' import {connect} from 'react-redux' import {bindActionCreators} from 'redux' +import {getNotificationID} from 'src/shared/reducers/notifications' + import { publishNotification as publishNotificationAction, dismissNotification as dismissNotificationAction, @@ -25,7 +27,10 @@ class Notifications extends Component { } renderNotification(type, message) { - if (!message) { + const isDismissed = this.props.dismissedNotifications[ + getNotificationID(message, type) + ] + if (!message || isDismissed) { return null } const cls = classnames('alert', { @@ -86,10 +91,12 @@ Notifications.propTypes = { error: string, warning: string, }), + dismissedNotifications: shape({}), } -const mapStateToProps = ({notifications}) => ({ +const mapStateToProps = ({notifications, dismissedNotifications}) => ({ notifications, + dismissedNotifications, }) const mapDispatchToProps = dispatch => ({ diff --git a/ui/src/shared/components/RoleIndicator.js b/ui/src/shared/components/RoleIndicator.js new file mode 100644 index 0000000000..e955ebb9fe --- /dev/null +++ b/ui/src/shared/components/RoleIndicator.js @@ -0,0 +1,59 @@ +import React, {PropTypes} from 'react' +import uuid from 'node-uuid' +import {connect} from 'react-redux' + +import ReactTooltip from 'react-tooltip' + +import {getMeRole} from 'shared/reducers/helpers/auth' + +const RoleIndicator = ({me, isUsingAuth}) => { + if (!isUsingAuth) { + return null + } + + const roleName = getMeRole(me) + + const RoleTooltip = `

Role: ${roleName}

` + const uuidTooltip = uuid.v4() + + return ( +
+ + +
+ ) +} + +const {arrayOf, bool, shape, string} = PropTypes + +RoleIndicator.propTypes = { + isUsingAuth: bool.isRequired, + me: shape({ + currentOrganization: shape({ + name: string.isRequired, + id: string.isRequired, + }), + roles: arrayOf( + shape({ + name: string.isRequired, + }) + ), + }), +} + +const mapStateToProps = ({auth: {me, isUsingAuth}}) => ({ + me, + isUsingAuth, +}) + +export default connect(mapStateToProps)(RoleIndicator) diff --git a/ui/src/shared/components/SlideToggle.js b/ui/src/shared/components/SlideToggle.js new file mode 100644 index 0000000000..f917ec7f88 --- /dev/null +++ b/ui/src/shared/components/SlideToggle.js @@ -0,0 +1,47 @@ +import React, {Component, PropTypes} from 'react' + +class SlideToggle extends Component { + constructor(props) { + super(props) + + this.state = { + active: this.props.active, + } + } + + handleClick = () => { + const {onToggle} = this.props + + this.setState({active: !this.state.active}, () => { + onToggle(this.state.active) + }) + } + + render() { + const {size} = this.props + const {active} = this.state + + const classNames = active + ? `slide-toggle slide-toggle__${size} active` + : `slide-toggle slide-toggle__${size}` + + return ( +
+
+
+ ) + } +} + +const {bool, func, string} = PropTypes + +SlideToggle.defaultProps = { + size: 'sm', +} +SlideToggle.propTypes = { + active: bool, + size: string, + onToggle: func.isRequired, +} + +export default SlideToggle diff --git a/ui/src/shared/components/SplashPage.js b/ui/src/shared/components/SplashPage.js new file mode 100644 index 0000000000..237dc2d1be --- /dev/null +++ b/ui/src/shared/components/SplashPage.js @@ -0,0 +1,20 @@ +import React, {PropTypes} from 'react' + +const SplashPage = ({children}) => +
+
+
+ {children} +
+

+ Made by InfluxData +

+
+
+ +const {node} = PropTypes +SplashPage.propTypes = { + children: node, +} + +export default SplashPage diff --git a/ui/src/shared/middleware/errors.js b/ui/src/shared/middleware/errors.js index 73c0e61022..012f3e28da 100644 --- a/ui/src/shared/middleware/errors.js +++ b/ui/src/shared/middleware/errors.js @@ -17,14 +17,36 @@ const errorsMiddleware = store => next => action => { const {auth: {me}} = store.getState() if (action.type === 'ERROR_THROWN') { - const {error: {status, auth}, altText, alertType = 'error'} = action + const { + error: {status, auth, data: {message}}, + altText, + alertType = 'error', + } = action if (status === HTTP_FORBIDDEN) { + const organizationWasRemoved = + message === `user's current organization was not found` // eslint-disable-line quotes const wasSessionTimeout = me !== null store.dispatch(authExpired(auth)) - if (wasSessionTimeout) { + if ( + message === + `This organization is private. To gain access, you must be explicitly added by an administrator.` // eslint-disable-line quotes + ) { + store.dispatch(notify(alertType, message)) + } + + if (organizationWasRemoved) { + store.dispatch( + notify(alertType, 'Your current organization was deleted.') + ) + + allowNotifications = false + setTimeout(() => { + allowNotifications = true + }, notificationsBlackoutDuration) + } else if (wasSessionTimeout) { store.dispatch( notify(alertType, 'Session timed out. Please login again.') ) diff --git a/ui/src/shared/reducers/auth.js b/ui/src/shared/reducers/auth.js index 889f1887fb..4bb1281181 100644 --- a/ui/src/shared/reducers/auth.js +++ b/ui/src/shared/reducers/auth.js @@ -6,8 +6,40 @@ const getInitialState = () => ({ logoutLink: null, }) +import {getMeRole} from 'shared/reducers/helpers/auth' + export const initialState = getInitialState() +const meGetCompleted = (state, {me}, isUsingAuth) => { + let newMe = me + + if (isUsingAuth) { + newMe = { + ...newMe, + role: getMeRole(me), + currentOrganization: me.currentOrganization, + } + } + + return { + ...state, + me: {...newMe}, + isMeLoading: false, + } +} + +const authReceived = (state, {auth: {links}}) => ({ + ...state, + links, + isAuthLoading: false, +}) + +const logoutLinkReceived = (state, {logoutLink}, isUsingAuth) => ({ + ...state, + logoutLink, + isUsingAuth, +}) + const authReducer = (state = initialState, action) => { switch (action.type) { case 'AUTH_EXPIRED': { @@ -17,20 +49,18 @@ const authReducer = (state = initialState, action) => { case 'AUTH_REQUESTED': { return {...state, isAuthLoading: true} } - case 'AUTH_RECEIVED': { - const {auth: {links}} = action.payload - return {...state, links, isAuthLoading: false} - } - case 'ME_REQUESTED': { + case 'ME_GET_REQUESTED': { return {...state, isMeLoading: true} } - case 'ME_RECEIVED': { - const {me} = action.payload - return {...state, me, isMeLoading: false} - } - case 'LOGOUT_LINK_RECEIVED': { + case 'ME_GET_COMPLETED': { const {logoutLink} = action.payload - return {...state, logoutLink} + const isUsingAuth = !!logoutLink + + let newState = meGetCompleted(state, action.payload, isUsingAuth) + newState = authReceived(newState, action.payload) + newState = logoutLinkReceived(newState, action.payload, isUsingAuth) + + return newState } } diff --git a/ui/src/shared/reducers/helpers/auth.js b/ui/src/shared/reducers/helpers/auth.js new file mode 100644 index 0000000000..e56ddf8308 --- /dev/null +++ b/ui/src/shared/reducers/helpers/auth.js @@ -0,0 +1,20 @@ +import _ from 'lodash' + +import {SUPERADMIN_ROLE, MEMBER_ROLE} from 'src/auth/Authorized' + +export const getMeRole = me => { + const currentRoleOrg = me.roles.find( + role => me.currentOrganization.id === role.organization + ) + const currentRole = _.get(currentRoleOrg, 'name', MEMBER_ROLE) + + return me.superAdmin ? SUPERADMIN_ROLE : currentRole +} + +export const isSameUser = (userA, userB) => { + return ( + userA.name === userB.name && + userA.provider === userB.provider && + userA.scheme === userB.scheme + ) +} diff --git a/ui/src/shared/reducers/index.js b/ui/src/shared/reducers/index.js index cfb032a7de..b8824ab25d 100644 --- a/ui/src/shared/reducers/index.js +++ b/ui/src/shared/reducers/index.js @@ -2,7 +2,7 @@ import app from './app' import auth from './auth' import errors from './errors' import links from './links' -import notifications from './notifications' +import {notifications, dismissedNotifications} from './notifications' import sources from './sources' export default { @@ -11,5 +11,6 @@ export default { errors, links, notifications, + dismissedNotifications, sources, } diff --git a/ui/src/shared/reducers/notifications.js b/ui/src/shared/reducers/notifications.js index c4275fcd8a..6767a6f289 100644 --- a/ui/src/shared/reducers/notifications.js +++ b/ui/src/shared/reducers/notifications.js @@ -1,11 +1,7 @@ import u from 'updeep' +import _ from 'lodash' -function getInitialState() { - return {} -} -const initialState = getInitialState() - -const notificationsReducer = (state = initialState, action) => { +export const notifications = (state = {}, action) => { switch (action.type) { case 'NOTIFICATION_RECEIVED': { const {type, message} = action.payload @@ -16,11 +12,38 @@ const notificationsReducer = (state = initialState, action) => { return u(u.omit(type), state) } case 'ALL_NOTIFICATIONS_DISMISSED': { - return getInitialState() + // Reset to initial state + return {} } } return state } -export default notificationsReducer +export const getNotificationID = (message, type) => _.snakeCase(message) + type + +export const dismissedNotifications = (state = {}, action) => { + switch (action.type) { + case 'NOTIFICATION_RECEIVED': { + const {type, message, once} = action.payload + if (once) { + // Create a message ID in a deterministic way, also with its type + const messageID = getNotificationID(message, type) + if (state[messageID]) { + // Message action called with once option but we've already seen it + return state + } + // Message action called with once option and it's not present on + // the persisted state + return { + ...state, + [messageID]: true, + } + } + // Message action not called with once option + return state + } + } + + return state +} diff --git a/ui/src/side_nav/components/NavItems.js b/ui/src/side_nav/components/NavItems.js index 4977b4b35f..c0761582a7 100644 --- a/ui/src/side_nav/components/NavItems.js +++ b/ui/src/side_nav/components/NavItems.js @@ -66,9 +66,8 @@ const NavBlock = React.createClass({ render() { const {location, className} = this.props - const isActive = React.Children.toArray(this.props.children).find(child => { - return location.startsWith(child.props.link) + return location.startsWith(child.props.link) // if location is undefined, this will fail silently }) const children = React.Children.map(this.props.children, child => { @@ -114,19 +113,11 @@ const NavBlock = React.createClass({ const NavBar = React.createClass({ propTypes: { children: node, - location: string.isRequired, }, render() { - const children = React.Children.map(this.props.children, child => { - if (child && child.type === NavBlock) { - return React.cloneElement(child, { - location: this.props.location, - }) - } + const {children} = this.props - return child - }) return (