diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b68717fab..3642bed1a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,26 @@ ## v1.2.0 [unreleased] ### Bug Fixes - 1. [#936](https://github.com/influxdata/chronograf/pull/936): Fix leaking sockets for InfluxQL queries - 2. [#967](https://github.com/influxdata/chronograf/pull/967): Fix flash of empty graph on auto-refresh when no results were previously returned from a query. - 3. [#968](https://github.com/influxdata/chronograf/issue/968): Fix wrong database used in dashboards ### Features +### UI Improvements + +## v1.2.0-beta5 [2017-03-10] + +### Bug Fixes + 1. [#936](https://github.com/influxdata/chronograf/pull/936): Fix leaking sockets for InfluxQL queries + 2. [#967](https://github.com/influxdata/chronograf/pull/967): Fix flash of empty graph on auto-refresh when no results were previously returned from a query + 3. [#968](https://github.com/influxdata/chronograf/issue/968): Fix wrong database used in dashboards + +### Features + 1. [#993](https://github.com/influxdata/chronograf/pull/993): Add Admin page for managing users, roles, and permissions for [OSS InfluxDB](https://github.com/influxdata/influxdb) and InfluxData's [Enterprise](https://docs.influxdata.com/enterprise/v1.2/) product + 2. [#993](https://github.com/influxdata/chronograf/pull/993): Add Query Management features including the ability to view active queries and stop queries + ### UI Improvements 1. [#989](https://github.com/influxdata/chronograf/pull/989) Add a canned dashboard for mesos + 2. [#993](https://github.com/influxdata/chronograf/pull/993): Improve the multi-select dropdown + 3. [#993](https://github.com/influxdata/chronograf/pull/993): Provide better error information to users ## v1.2.0-beta4 [2017-02-24] diff --git a/README.md b/README.md index f4050f0623..83c257c957 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,14 @@ A UI for [Kapacitor](https://github.com/influxdata/kapacitor) alert creation and * View all active alerts at a glance on the alerting dashboard * Enable and disable existing alert rules with the check of a box -### TLS/HTTPS support +### User and Query Management + +Manage users, roles, permissions for [OSS InfluxDB](https://github.com/influxdata/influxdb) and InfluxData's [Enterprise](https://docs.influxdata.com/enterprise/v1.2/) product. +View actively running queries and stop expensive queries on the Query Management page. + +These features are new in Chronograf version 1.2.0-beta5. We recommend using them in a non-production environment only. Should you come across any bugs or unexpected behavior please open [an issue](https://github.com/influxdata/chronograf/issues/new). We appreciate the feedback as we work to finalize and improve the user and query management features! + +### TLS/HTTPS Support See [Chronograf with TLS](https://github.com/influxdata/chronograf/blob/master/docs/tls.md) for more information. ### OAuth Login @@ -121,7 +128,7 @@ Change the default root path of the Chronograf server with the `--basepath` opti ## Versions -Chronograf v1.2.0-beta4 is a beta release. +Chronograf v1.2.0-beta5 is a beta release. We will be iterating quickly based on user feedback and recommend using the [nightly builds](https://www.influxdata.com/downloads/) for the time being. Spotted a bug or have a feature request? diff --git a/enterprise/enterprise.go b/enterprise/enterprise.go index bc7e3af731..189e5a1507 100644 --- a/enterprise/enterprise.go +++ b/enterprise/enterprise.go @@ -32,6 +32,8 @@ type Ctrl interface { DeleteRole(ctx context.Context, name string) error SetRolePerms(ctx context.Context, name string, perms Permissions) error SetRoleUsers(ctx context.Context, name string, users []string) error + AddRoleUsers(ctx context.Context, name string, users []string) error + RemoveRoleUsers(ctx context.Context, name string, users []string) error } // Client is a device for retrieving time series data from an Influx Enterprise diff --git a/enterprise/meta.go b/enterprise/meta.go index 399b057b33..cd08730b78 100644 --- a/enterprise/meta.go +++ b/enterprise/meta.go @@ -272,32 +272,52 @@ func (m *MetaClient) SetRolePerms(ctx context.Context, name string, perms Permis return m.Post(ctx, "/role", a, nil) } -// RemoveAllRoleUsers removes all users from a role -func (m *MetaClient) RemoveAllRoleUsers(ctx context.Context, name string) error { +// SetRoleUsers removes all users and then adds the requested users to role +func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []string) error { role, err := m.Role(ctx, name) if err != nil { return err } - - // No users to remove - if len(role.Users) == 0 { - return nil - } - - a := &RoleAction{ - Action: "remove-users", - Role: role, - } - return m.Post(ctx, "/role", a, nil) -} - -// SetRoleUsers removes all users and then adds the requested users to role -func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []string) error { - err := m.RemoveAllRoleUsers(ctx, name) - if err != nil { + revoke, add := Difference(users, role.Users) + if err := m.RemoveRoleUsers(ctx, name, revoke); err != nil { return err } + return m.AddRoleUsers(ctx, name, add) +} + +// Difference compares two sets and returns a set to be removed and a set to be added +func Difference(wants []string, haves []string) (revoke []string, add []string) { + for _, want := range wants { + found := false + for _, got := range haves { + if want != got { + continue + } + found = true + } + if !found { + add = append(add, want) + } + } + for _, got := range haves { + found := false + for _, want := range wants { + if want != got { + continue + } + found = true + break + } + if !found { + revoke = append(revoke, got) + } + } + return +} + +// AddRoleUsers updates a role to have additional users. +func (m *MetaClient) AddRoleUsers(ctx context.Context, name string, users []string) error { // No permissions to add, so, role is in the right state if len(users) == 0 { return nil @@ -313,6 +333,23 @@ func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []stri return m.Post(ctx, "/role", a, nil) } +// RemoveRoleUsers updates a role to remove some users. +func (m *MetaClient) RemoveRoleUsers(ctx context.Context, name string, users []string) error { + // No permissions to add, so, role is in the right state + if len(users) == 0 { + return nil + } + + a := &RoleAction{ + Action: "remove-users", + Role: &Role{ + Name: name, + Users: users, + }, + } + return m.Post(ctx, "/role", a, nil) +} + // Post is a helper function to POST to Influx Enterprise func (m *MetaClient) Post(ctx context.Context, path string, action interface{}, params map[string]string) error { b, err := json.Marshal(action) diff --git a/enterprise/meta_test.go b/enterprise/meta_test.go index 32d05bdeae..96150d054e 100644 --- a/enterprise/meta_test.go +++ b/enterprise/meta_test.go @@ -1252,12 +1252,11 @@ func TestMetaClient_SetRoleUsers(t *testing.T) { name string fields fields args args - wantRm string - wantAdd string + wants []string wantErr bool }{ { - name: "Successful set users role", + name: "Successful set users role (remove user from role)", fields: fields{ URL: &url.URL{ Host: "twinpinesmall.net:8091", @@ -1274,7 +1273,7 @@ func TestMetaClient_SetRoleUsers(t *testing.T) { ctx: context.Background(), name: "admin", }, - wantRm: `{"action":"remove-users","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`, + wants: []string{`{"action":"remove-users","role":{"name":"admin","users":["marty"]}}`}, }, { name: "Successful set single user role", @@ -1285,7 +1284,7 @@ func TestMetaClient_SetRoleUsers(t *testing.T) { }, client: NewMockClient( http.StatusOK, - []byte(`{"roles":[{"name":"admin","users":["marty"],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), + []byte(`{"roles":[{"name":"admin","users":[],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), nil, nil, ), @@ -1295,8 +1294,9 @@ func TestMetaClient_SetRoleUsers(t *testing.T) { name: "admin", users: []string{"marty"}, }, - wantRm: `{"action":"remove-users","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`, - wantAdd: `{"action":"add-users","role":{"name":"admin","users":["marty"]}}`, + wants: []string{ + `{"action":"add-users","role":{"name":"admin","users":["marty"]}}`, + }, }, } for _, tt := range tests { @@ -1312,8 +1312,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) { continue } reqs := tt.fields.client.(*MockClient).Requests - if len(reqs) < 2 { - t.Errorf("%q. MetaClient.SetRoleUsers() expected 2 but got %d", tt.name, len(reqs)) + if len(reqs) != len(tt.wants)+1 { + t.Errorf("%q. MetaClient.SetRoleUsers() expected %d but got %d", tt.name, len(tt.wants)+1, len(reqs)) continue } @@ -1324,21 +1324,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) { if usr.URL.Path != "/role" { t.Errorf("%q. MetaClient.SetRoleUsers() expected /user path but got %s", tt.name, usr.URL.Path) } - - prm := reqs[1] - if prm.Method != "POST" { - t.Errorf("%q. MetaClient.SetRoleUsers() expected GET method", tt.name) - } - if prm.URL.Path != "/role" { - t.Errorf("%q. MetaClient.SetRoleUsers() expected /role path but got %s", tt.name, prm.URL.Path) - } - - got, _ := ioutil.ReadAll(prm.Body) - if string(got) != tt.wantRm { - t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wantRm) - } - if tt.wantAdd != "" { - prm := reqs[2] + for i := range tt.wants { + prm := reqs[i+1] if prm.Method != "POST" { t.Errorf("%q. MetaClient.SetRoleUsers() expected GET method", tt.name) } @@ -1347,8 +1334,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) { } got, _ := ioutil.ReadAll(prm.Body) - if string(got) != tt.wantAdd { - t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wantAdd) + if string(got) != tt.wants[i] { + t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wants[i]) } } } diff --git a/enterprise/mocks_test.go b/enterprise/mocks_test.go index 08e0253624..6e9cc95cd5 100644 --- a/enterprise/mocks_test.go +++ b/enterprise/mocks_test.go @@ -88,6 +88,14 @@ func (cc *ControlClient) SetRoleUsers(ctx context.Context, name string, users [] return nil } +func (cc *ControlClient) AddRoleUsers(ctx context.Context, name string, users []string) error { + return nil +} + +func (cc *ControlClient) RemoveRoleUsers(ctx context.Context, name string, users []string) error { + return nil +} + type TimeSeries struct { URLs []string Response Response diff --git a/enterprise/roles.go b/enterprise/roles.go index de238dd556..73d28085f8 100644 --- a/enterprise/roles.go +++ b/enterprise/roles.go @@ -66,16 +66,20 @@ func (c *RolesStore) Get(ctx context.Context, name string) (*chronograf.Role, er // Update the Role's permissions and roles func (c *RolesStore) Update(ctx context.Context, u *chronograf.Role) error { - perms := ToEnterprise(u.Permissions) - if err := c.Ctrl.SetRolePerms(ctx, u.Name, perms); err != nil { - return err + if u.Permissions != nil { + perms := ToEnterprise(u.Permissions) + if err := c.Ctrl.SetRolePerms(ctx, u.Name, perms); err != nil { + return err + } } - - users := make([]string, len(u.Users)) - for i, u := range u.Users { - users[i] = u.Name + if u.Users != nil { + users := make([]string, len(u.Users)) + for i, u := range u.Users { + users[i] = u.Name + } + return c.Ctrl.SetRoleUsers(ctx, u.Name, users) } - return c.Ctrl.SetRoleUsers(ctx, u.Name, users) + return nil } // All is all Roles in influx diff --git a/enterprise/users.go b/enterprise/users.go index ff8059a680..68c04d193c 100644 --- a/enterprise/users.go +++ b/enterprise/users.go @@ -18,10 +18,17 @@ func (c *UserStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.Us return nil, err } perms := ToEnterprise(u.Permissions) + if err := c.Ctrl.SetUserPerms(ctx, u.Name, perms); err != nil { return nil, err } - return u, nil + for _, role := range u.Roles { + if err := c.Ctrl.AddRoleUsers(ctx, role.Name, []string{u.Name}); err != nil { + return nil, err + } + } + + return c.Get(ctx, u.Name) } // Delete the User from Influx Enterprise @@ -62,6 +69,43 @@ func (c *UserStore) Update(ctx context.Context, u *chronograf.User) error { if u.Passwd != "" { return c.Ctrl.ChangePassword(ctx, u.Name, u.Passwd) } + + // Make a list of the roles we want this user to have: + want := make([]string, len(u.Roles)) + for i, r := range u.Roles { + want[i] = r.Name + } + + // Find the list of all roles this user is currently in + userRoles, err := c.UserRoles(ctx) + if err != nil { + return nil + } + // Make a list of the roles the user currently has + roles := userRoles[u.Name] + have := make([]string, len(roles.Roles)) + for i, r := range roles.Roles { + have[i] = r.Name + } + + // Calculate the roles the user will be removed from and the roles the user + // will be added to. + revoke, add := Difference(want, have) + + // First, add the user to the new roles + for _, role := range add { + if err := c.Ctrl.AddRoleUsers(ctx, role, []string{u.Name}); err != nil { + return err + } + } + + // ... and now remove the user from an extra roles + for _, role := range revoke { + if err := c.Ctrl.RemoveRoleUsers(ctx, role, []string{u.Name}); err != nil { + return err + } + } + perms := ToEnterprise(u.Permissions) return c.Ctrl.SetUserPerms(ctx, u.Name, perms) } diff --git a/enterprise/users_test.go b/enterprise/users_test.go index 54dc7f5027..9cc0cddc50 100644 --- a/enterprise/users_test.go +++ b/enterprise/users_test.go @@ -36,6 +36,22 @@ func TestClient_Add(t *testing.T) { setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error { return nil }, + user: func(ctx context.Context, name string) (*enterprise.User, error) { + return &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{ @@ -46,8 +62,82 @@ func TestClient_Add(t *testing.T) { }, }, want: &chronograf.User{ - Name: "marty", - Passwd: "johnny be good", + Name: "marty", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"ViewChronograf", "ReadData", "WriteData"}, + }, + }, + Roles: []chronograf.Role{}, + }, + }, + { + name: "Successful Create User with roles", + fields: fields{ + Ctrl: &mockCtrl{ + createUser: func(ctx context.Context, name, passwd string) error { + return nil + }, + setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error { + return nil + }, + user: func(ctx context.Context, name string) (*enterprise.User, error) { + return &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{ + "marty": enterprise.Roles{ + Roles: []enterprise.Role{ + { + Name: "admin", + }, + }, + }, + }, nil + }, + addRoleUsers: func(ctx context.Context, name string, users []string) error { + return nil + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "marty", + Passwd: "johnny be good", + Roles: []chronograf.Role{ + { + Name: "admin", + }, + }, + }, + }, + want: &chronograf.User{ + Name: "marty", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"ViewChronograf", "ReadData", "WriteData"}, + }, + }, + Roles: []chronograf.Role{ + { + Name: "admin", + Users: []chronograf.User{}, + Permissions: chronograf.Permissions{}, + }, + }, }, }, { @@ -80,7 +170,7 @@ func TestClient_Add(t *testing.T) { continue } if !reflect.DeepEqual(got, tt.want) { - t.Errorf("%q. Client.Add() = %v, want %v", tt.name, got, tt.want) + t.Errorf("%q. Client.Add() = \n%#v\n, want \n%#v\n", tt.name, got, tt.want) } } } @@ -353,6 +443,9 @@ func TestClient_Update(t *testing.T) { setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error { return nil }, + userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) { + return map[string]enterprise.Roles{}, nil + }, }, }, args: args{ @@ -369,6 +462,40 @@ func TestClient_Update(t *testing.T) { }, wantErr: false, }, + { + name: "Success setting permissions and roles for user", + fields: fields{ + Ctrl: &mockCtrl{ + setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error { + return nil + }, + addRoleUsers: func(ctx context.Context, name string, users []string) error { + return nil + }, + userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) { + return map[string]enterprise.Roles{}, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "marty", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"ViewChronograf", "KapacitorAPI"}, + }, + }, + Roles: []chronograf.Role{ + { + Name: "adminrole", + }, + }, + }, + }, + wantErr: false, + }, { name: "Failure setting permissions User", fields: fields{ @@ -376,6 +503,9 @@ func TestClient_Update(t *testing.T) { setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error { return fmt.Errorf("They found me, I don't know how, but they found me.") }, + userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) { + return map[string]enterprise.Roles{}, nil + }, }, }, args: args{ @@ -573,12 +703,14 @@ type mockCtrl struct { userRoles func(ctx context.Context) (map[string]enterprise.Roles, error) - roles func(ctx context.Context, name *string) (*enterprise.Roles, error) - role func(ctx context.Context, name string) (*enterprise.Role, error) - createRole func(ctx context.Context, name string) error - deleteRole func(ctx context.Context, name string) error - setRolePerms func(ctx context.Context, name string, perms enterprise.Permissions) error - setRoleUsers func(ctx context.Context, name string, users []string) error + roles func(ctx context.Context, name *string) (*enterprise.Roles, error) + role func(ctx context.Context, name string) (*enterprise.Role, error) + createRole func(ctx context.Context, name string) error + deleteRole func(ctx context.Context, name string) error + setRolePerms func(ctx context.Context, name string, perms enterprise.Permissions) error + setRoleUsers func(ctx context.Context, name string, users []string) error + addRoleUsers func(ctx context.Context, name string, users []string) error + removeRoleUsers func(ctx context.Context, name string, users []string) error } func (m *mockCtrl) ShowCluster(ctx context.Context) (*enterprise.Cluster, error) { @@ -636,3 +768,11 @@ func (m *mockCtrl) SetRolePerms(ctx context.Context, name string, perms enterpri func (m *mockCtrl) SetRoleUsers(ctx context.Context, name string, users []string) error { return m.setRoleUsers(ctx, name, users) } + +func (m *mockCtrl) AddRoleUsers(ctx context.Context, name string, users []string) error { + return m.addRoleUsers(ctx, name, users) +} + +func (m *mockCtrl) RemoveRoleUsers(ctx context.Context, name string, users []string) error { + return m.removeRoleUsers(ctx, name, users) +} diff --git a/influx/permissions.go b/influx/permissions.go index 1548e794f2..809aff9535 100644 --- a/influx/permissions.go +++ b/influx/permissions.go @@ -8,8 +8,10 @@ import ( ) var ( - // AllowAll means a user gets both read and write permissions - AllowAll = chronograf.Allowances{"WRITE", "READ"} + // AllowAllDB means a user gets both read and write permissions for a db + AllowAllDB = chronograf.Allowances{"WRITE", "READ"} + // AllowAllAdmin means a user gets both read and write permissions for an admin + AllowAllAdmin = chronograf.Allowances{"ALL"} // AllowRead means a user is only able to read the database. AllowRead = chronograf.Allowances{"READ"} // AllowWrite means a user is able to only write to the database @@ -31,11 +33,11 @@ func (c *Client) Permissions(context.Context) chronograf.Permissions { return chronograf.Permissions{ { Scope: chronograf.AllScope, - Allowed: AllowAll, + Allowed: AllowAllAdmin, }, { Scope: chronograf.DBScope, - Allowed: AllowAll, + Allowed: AllowAllDB, }, } } @@ -90,7 +92,7 @@ func (r *showResults) Permissions() chronograf.Permissions { } switch priv { case AllPrivileges, All: - c.Allowed = AllowAll + c.Allowed = AllowAllDB case Read: c.Allowed = AllowRead case Write: @@ -111,7 +113,7 @@ func adminPerms() chronograf.Permissions { return []chronograf.Permission{ { Scope: chronograf.AllScope, - Allowed: AllowAll, + Allowed: AllowAllAdmin, }, } } diff --git a/influx/permissions_test.go b/influx/permissions_test.go index 956e706a89..9aca7aa74e 100644 --- a/influx/permissions_test.go +++ b/influx/permissions_test.go @@ -318,7 +318,7 @@ func Test_showResults_Users(t *testing.T) { Permissions: chronograf.Permissions{ { Scope: chronograf.AllScope, - Allowed: chronograf.Allowances{"WRITE", "READ"}, + Allowed: chronograf.Allowances{"ALL"}, }, }, }, diff --git a/influx/users.go b/influx/users.go index bca83fec31..a8e10bcfa1 100644 --- a/influx/users.go +++ b/influx/users.go @@ -16,8 +16,12 @@ func (c *Client) Add(ctx context.Context, u *chronograf.User) (*chronograf.User, if err != nil { return nil, err } - - return u, nil + for _, p := range u.Permissions { + if err := c.grantPermission(ctx, u.Name, p); err != nil { + return nil, err + } + } + return c.Get(ctx, u.Name) } // Delete the User from InfluxDB diff --git a/influx/users_test.go b/influx/users_test.go index f486e13a95..9922d4e517 100644 --- a/influx/users_test.go +++ b/influx/users_test.go @@ -97,12 +97,12 @@ func TestClient_Add(t *testing.T) { u *chronograf.User } tests := []struct { - name string - args args - status int - want *chronograf.User - wantQuery string - wantErr bool + name string + args args + status int + want *chronograf.User + wantQueries []string + wantErr bool }{ { name: "Create User", @@ -114,10 +114,57 @@ func TestClient_Add(t *testing.T) { Passwd: "Dont Need Roads", }, }, - wantQuery: `CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`, + wantQueries: []string{ + `CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`, + `SHOW USERS`, + `SHOW GRANTS FOR "docbrown"`, + }, want: &chronograf.User{ - Name: "docbrown", - Passwd: "Dont Need Roads", + Name: "docbrown", + Permissions: chronograf.Permissions{ + chronograf.Permission{ + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{ + "ALL", + }, + }, + }, + }, + }, + { + name: "Create User with permissions", + status: http.StatusOK, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + Passwd: "Dont Need Roads", + Permissions: chronograf.Permissions{ + chronograf.Permission{ + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{ + "ALL", + }, + }, + }, + }, + }, + wantQueries: []string{ + `CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`, + `GRANT ALL PRIVILEGES TO "docbrown"`, + `SHOW USERS`, + `SHOW GRANTS FOR "docbrown"`, + }, + want: &chronograf.User{ + Name: "docbrown", + Permissions: chronograf.Permissions{ + chronograf.Permission{ + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{ + "ALL", + }, + }, + }, }, }, { @@ -130,19 +177,19 @@ func TestClient_Add(t *testing.T) { Passwd: "Dont Need Roads", }, }, - wantQuery: `CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`, - wantErr: true, + wantQueries: []string{`CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`}, + wantErr: true, }, } for _, tt := range tests { - query := "" + queries := []string{} 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") + queries = append(queries, r.URL.Query().Get("q")) rw.WriteHeader(tt.status) - rw.Write([]byte(`{"results":[{}]}`)) + rw.Write([]byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`)) })) u, _ := url.Parse(ts.URL) c := &Client{ @@ -155,9 +202,16 @@ func TestClient_Add(t *testing.T) { t.Errorf("%q. Client.Add() error = %v, wantErr %v", tt.name, err, tt.wantErr) continue } - if tt.wantQuery != query { - t.Errorf("%q. Client.Add() query = %v, want %v", tt.name, query, tt.wantQuery) + if len(tt.wantQueries) != len(queries) { + t.Errorf("%q. Client.Add() queries = %v, want %v", tt.name, queries, tt.wantQueries) + continue } + for i := range tt.wantQueries { + if tt.wantQueries[i] != queries[i] { + t.Errorf("%q. Client.Add() query = %v, want %v", tt.name, queries[i], tt.wantQueries[i]) + } + } + if !reflect.DeepEqual(got, tt.want) { t.Errorf("%q. Client.Add() = %v, want %v", tt.name, got, tt.want) } @@ -275,7 +329,7 @@ func TestClient_Get(t *testing.T) { Permissions: chronograf.Permissions{ chronograf.Permission{ Scope: "all", - Allowed: []string{"WRITE", "READ"}, + Allowed: []string{"ALL"}, }, chronograf.Permission{ Scope: "database", @@ -548,7 +602,7 @@ func TestClient_All(t *testing.T) { Permissions: chronograf.Permissions{ chronograf.Permission{ Scope: "all", - Allowed: []string{"WRITE", "READ"}, + Allowed: []string{"ALL"}, }, chronograf.Permission{ Scope: "database", @@ -562,7 +616,7 @@ func TestClient_All(t *testing.T) { Permissions: chronograf.Permissions{ chronograf.Permission{ Scope: "all", - Allowed: []string{"WRITE", "READ"}, + Allowed: []string{"ALL"}, }, chronograf.Permission{ Scope: "database", @@ -688,7 +742,7 @@ func TestClient_Update(t *testing.T) { Permissions: chronograf.Permissions{ { Scope: "all", - Allowed: []string{"WRITE", "READ"}, + Allowed: []string{"all"}, }, { Scope: "database", @@ -743,7 +797,7 @@ func TestClient_Update(t *testing.T) { Permissions: chronograf.Permissions{ { Scope: "all", - Allowed: []string{"WRITE", "READ"}, + Allowed: []string{"all"}, }, { Scope: "database", @@ -800,6 +854,34 @@ func TestClient_Update(t *testing.T) { `REVOKE ALL PRIVILEGES FROM "docbrown"`, }, }, + { + name: "Revoke some", + statusUsers: http.StatusOK, + showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",false],["reader",false]]}]}]}`), + statusGrants: http.StatusOK, + showGrants: []byte(`{"results":[]}`), + statusRevoke: http.StatusOK, + revoke: []byte(`{"results":[]}`), + statusGrant: http.StatusOK, + grant: []byte(`{"results":[]}`), + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + Permissions: chronograf.Permissions{ + { + Scope: "all", + Allowed: []string{"ALL"}, + }, + }, + }, + }, + want: []string{ + `SHOW USERS`, + `SHOW GRANTS FOR "docbrown"`, + `GRANT ALL PRIVILEGES TO "docbrown"`, + }, + }, { name: "Fail users", statusUsers: http.StatusBadRequest, diff --git a/server/admin.go b/server/admin.go deleted file mode 100644 index 6cc84e0b91..0000000000 --- a/server/admin.go +++ /dev/null @@ -1,545 +0,0 @@ -package server - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - - "github.com/bouk/httprouter" - "github.com/influxdata/chronograf" -) - -func validPermissions(perms *chronograf.Permissions) error { - if perms == nil { - return nil - } - for _, perm := range *perms { - if perm.Scope != chronograf.AllScope && perm.Scope != chronograf.DBScope { - return fmt.Errorf("Invalid permission scope") - } - if perm.Scope == chronograf.DBScope && perm.Name == "" { - return fmt.Errorf("Database scoped permission requires a name") - } - } - return nil -} - -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 -} - -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) -} - -func (r *sourceUserRequest) ValidUpdate() error { - if r.Password == "" && len(r.Permissions) == 0 { - return fmt.Errorf("No fields to update") - } - return validPermissions(&r.Permissions) -} - -type sourceUser struct { - Username string `json:"name,omitempty"` // Username for new account - Permissions chronograf.Permissions `json:"permissions,omitempty"` // Account's permissions - Roles []roleResponse `json:"roles,omitempty"` // Roles if source uses them - Links selfLinks `json:"links"` // Links are URI locations related to user -} - -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), - } -} - -// NewSourceUser adds user to source -func (h *Service) NewSourceUser(w http.ResponseWriter, r *http.Request) { - var req sourceUserRequest - 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, store, err := h.sourceUsersStore(ctx, w, r) - if err != nil { - return - } - - user := &chronograf.User{ - Name: req.Username, - Passwd: req.Password, - } - - res, err := store.Add(ctx, user) - if err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) - return - } - - su := sourceUser{ - Username: res.Name, - Permissions: req.Permissions, - Links: newSelfLinks(srcID, "users", res.Name), - } - w.Header().Add("Location", su.Links.Self) - encodeJSON(w, http.StatusCreated, su, h.Logger) -} - -type sourceUsers struct { - Users []sourceUser `json:"users"` -} - -// SourceUsers retrieves all users from source. -func (h *Service) SourceUsers(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - srcID, store, err := h.sourceUsersStore(ctx, w, r) - if err != nil { - return - } - - users, err := store.All(ctx) - if err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) - return - } - - su := []sourceUser{} - for _, u := range users { - res := sourceUser{ - Username: u.Name, - Permissions: u.Permissions, - Links: newSelfLinks(srcID, "users", u.Name), - } - if len(u.Roles) > 0 { - rr := make([]roleResponse, len(u.Roles)) - for i, role := range u.Roles { - rr[i] = newRoleResponse(srcID, &role) - } - res.Roles = rr - } - su = append(su, res) - } - - res := sourceUsers{ - Users: su, - } - - 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, store, err := h.sourceUsersStore(ctx, w, r) - if err != nil { - return - } - - u, err := store.Get(ctx, uid) - if err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) - return - } - - res := sourceUser{ - Username: u.Name, - Permissions: u.Permissions, - Links: newSelfLinks(srcID, "users", u.Name), - } - if len(u.Roles) > 0 { - rr := make([]roleResponse, len(u.Roles)) - for i, role := range u.Roles { - rr[i] = newRoleResponse(srcID, &role) - } - res.Roles = rr - } - 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) - 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) { - var req sourceUserRequest - 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() - uid := httprouter.GetParamFromContext(ctx, "uid") - srcID, store, err := h.sourceUsersStore(ctx, w, r) - if err != nil { - return - } - - user := &chronograf.User{ - Name: uid, - Passwd: req.Password, - Permissions: req.Permissions, - } - - if err := store.Update(ctx, user); err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) - return - } - - su := sourceUser{ - Username: user.Name, - Permissions: user.Permissions, - Links: newSelfLinks(srcID, "users", user.Name), - } - w.Header().Add("Location", su.Links.Self) - encodeJSON(w, http.StatusOK, su, h.Logger) -} - -func (h *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(), h.Logger) - return 0, nil, err - } - - src, err := h.SourcesStore.Get(ctx, srcID) - if err != nil { - notFound(w, srcID, h.Logger) - return 0, nil, err - } - - 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 -} - -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 - } - - 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 -} - -// Permissions returns all possible permissions for this source. -func (h *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) - return - } - - src, err := h.SourcesStore.Get(ctx, srcID) - if err != nil { - notFound(w, srcID, h.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 - } - - 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 - } - - perms := ts.Permissions(ctx) - if err != nil { - Error(w, http.StatusBadRequest, err.Error(), h.Logger) - return - } - httpAPISrcs := "/chronograf/v1/sources" - res := struct { - Permissions chronograf.Permissions `json:"permissions"` - Links map[string]string `json:"links"` // Links are URI locations related to user - }{ - Permissions: perms, - Links: map[string]string{ - "self": fmt.Sprintf("%s/%d/permissions", httpAPISrcs, srcID), - "source": fmt.Sprintf("%s/%d", httpAPISrcs, srcID), - }, - } - encodeJSON(w, http.StatusOK, res, h.Logger) -} - -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 []sourceUser `json:"users,omitempty"` - Name string `json:"name"` - Permissions chronograf.Permissions `json:"permissions"` - Links selfLinks `json:"links"` -} - -func newRoleResponse(srcID int, res *chronograf.Role) roleResponse { - su := make([]sourceUser, len(res.Users)) - for i := range res.Users { - name := res.Users[i].Name - su[i] = sourceUser{ - Username: name, - Links: newSelfLinks(srcID, "users", 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), - } -} - -// 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 - } - - 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) -} diff --git a/server/admin_test.go b/server/admin_test.go deleted file mode 100644 index ac65f5146b..0000000000 --- a/server/admin_test.go +++ /dev/null @@ -1,1476 +0,0 @@ -package server_test - -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" - "github.com/influxdata/chronograf/server" -) - -func TestService_NewSourceUser(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 - wantStatus int - wantContentType string - wantBody string - }{ - { - name: "New user 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"}`)))), - }, - 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 - }, - } - }, - }, - }, - ID: "1", - wantStatus: http.StatusCreated, - wantContentType: "application/json", - wantBody: `{"name":"marty","links":{"self":"/chronograf/v1/sources/1/users/marty"}} -`, - }, - { - name: "Error adding 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"}`)))), - }, - 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://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"}`, - }, - { - name: "Bad name", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "POST", - "http://server.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://server.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 := &server.Service{ - 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 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 - wantStatus int - wantContentType string - wantBody string - }{ - { - name: "All users 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{ - 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":[{"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"links":{"self":"/chronograf/v1/sources/1/users/strickland"}}]} -`, - }, - } - 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 - }, - 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: `{"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"links":{"self":"/chronograf/v1/sources/1/users/strickland"}} -`, - }, - } - 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, - }, - } - 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.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 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: "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"}`)))), - }, - 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{ - UpdateF: func(ctx context.Context, u *chronograf.User) error { - return nil - }, - } - }, - }, - }, - ID: "1", - UID: "marty", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"name":"marty","links":{"self":"/chronograf/v1/sources/1/users/marty"}} -`, - }, - { - 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"}`)))), - }, - 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 := &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) - } - } -} - -func TestService_Permissions(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 - wantStatus int - wantContentType string - wantBody string - }{ - { - name: "New user 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"}`)))), - }, - 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 - }, - PermissionsF: func(ctx context.Context) chronograf.Permissions { - return chronograf.Permissions{ - { - Scope: chronograf.AllScope, - Allowed: chronograf.Allowances{"READ", "WRITE"}, - }, - } - }, - }, - }, - ID: "1", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"permissions":[{"scope":"all","allowed":["READ","WRITE"]}],"links":{"self":"/chronograf/v1/sources/1/permissions","source":"/chronograf/v1/sources/1"}} -`, - }, - } - 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.Permissions(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. Permissions() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - if tt.wantContentType != "" && content != tt.wantContentType { - t.Errorf("%q. Permissions() = %v, want %v", tt.name, content, tt.wantContentType) - } - if tt.wantBody != "" && string(body) != tt.wantBody { - t.Errorf("%q. Permissions() = \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 server.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") - }, - }, 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 - }, - }, nil - }, - }, - }, - ID: "1", - wantStatus: http.StatusCreated, - wantContentType: "application/json", - wantBody: `{"users":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/3-d"}}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}} -`, - }, - } - for _, tt := range tests { - h := &server.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 server.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":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/3-d"}}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}} -`, - }, - } - for _, tt := range tests { - h := &server.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 server.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":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/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 := &server.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 server.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 := &server.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 server.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":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/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 := &server.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/me.go b/server/me.go new file mode 100644 index 0000000000..48d66ed6b2 --- /dev/null +++ b/server/me.go @@ -0,0 +1,99 @@ +package server + +import ( + "fmt" + "net/http" + "net/url" + + "golang.org/x/net/context" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/oauth2" +) + +type meLinks struct { + Self string `json:"self"` // Self link mapping to this resource +} + +type meResponse struct { + *chronograf.User + Links meLinks `json:"links"` +} + +// If new user response is nil, return an empty meResponse because it +// indicates authentication is not needed +func newMeResponse(usr *chronograf.User) meResponse { + base := "/chronograf/v1/users" + name := "me" + if usr != nil { + // TODO: Change to urls.PathEscape for go 1.8 + u := &url.URL{Path: usr.Name} + name = u.String() + } + + return meResponse{ + User: usr, + Links: meLinks{ + Self: fmt.Sprintf("%s/%s", base, name), + }, + } +} + +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 +} + +func getPrincipal(ctx context.Context) (oauth2.Principal, error) { + principal, ok := ctx.Value(oauth2.PrincipalKey).(oauth2.Principal) + if !ok { + return oauth2.Principal{}, fmt.Errorf("Token not found") + } + + return principal, nil +} + +// Me does a findOrCreate based on the email in the context +func (h *Service) Me(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !h.UseAuth { + // If there's no authentication, return an empty user + res := newMeResponse(nil) + encodeJSON(w, http.StatusOK, res, h.Logger) + return + } + + email, err := getEmail(ctx) + if err != nil { + invalidData(w, err, h.Logger) + return + } + + usr, err := h.UsersStore.Get(ctx, email) + if err == nil { + res := newMeResponse(usr) + encodeJSON(w, http.StatusOK, res, h.Logger) + return + } + + // Because we didnt find a user, making a new one + user := &chronograf.User{ + Name: email, + } + + newUser, err := h.UsersStore.Add(ctx, user) + if err != nil { + msg := fmt.Errorf("error storing user %s: %v", user.Name, err) + unknownErrorWithMessage(w, msg, h.Logger) + return + } + + res := newMeResponse(newUser) + encodeJSON(w, http.StatusOK, res, h.Logger) +} diff --git a/server/me_test.go b/server/me_test.go new file mode 100644 index 0000000000..147bf8f3ae --- /dev/null +++ b/server/me_test.go @@ -0,0 +1,168 @@ +package server + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/mocks" + "github.com/influxdata/chronograf/oauth2" +) + +type MockUsers struct{} + +func TestService_Me(t *testing.T) { + type fields struct { + UsersStore chronograf.UsersStore + Logger chronograf.Logger + UseAuth bool + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + principal oauth2.Principal + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Existing user", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: true, + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, name string) (*chronograf.User, error) { + return &chronograf.User{ + Name: "me", + Passwd: "hunter2", + }, nil + }, + }, + }, + principal: oauth2.Principal{ + Subject: "me", + }, + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"name":"me","password":"hunter2","links":{"self":"/chronograf/v1/users/me"}} +`, + }, + { + name: "New user", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: true, + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, name string) (*chronograf.User, error) { + return nil, fmt.Errorf("Unknown User") + }, + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + }, + }, + principal: oauth2.Principal{ + Subject: "secret", + }, + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"name":"secret","password":"","links":{"self":"/chronograf/v1/users/secret"}} +`, + }, + { + name: "Error adding user", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: true, + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, name string) (*chronograf.User, error) { + return nil, fmt.Errorf("Unknown User") + }, + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return nil, fmt.Errorf("Why Heavy?") + }, + }, + Logger: log.New(log.DebugLevel), + }, + principal: oauth2.Principal{ + Subject: "secret", + }, + wantStatus: http.StatusInternalServerError, + wantContentType: "application/json", + wantBody: `{"code":500,"message":"Unknown error: error storing user secret: Why Heavy?"}`, + }, + { + name: "No Auth", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: false, + Logger: log.New(log.DebugLevel), + }, + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/users/me"}} +`, + }, + { + name: "Empty Principal", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + }, + wantStatus: http.StatusUnprocessableEntity, + principal: oauth2.Principal{ + Subject: "", + }, + }, + } + 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, + } + + h.Me(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. Me() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + 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 { + t.Errorf("%q. Me() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} diff --git a/server/permissions.go b/server/permissions.go new file mode 100644 index 0000000000..cd353d8b24 --- /dev/null +++ b/server/permissions.go @@ -0,0 +1,70 @@ +package server + +import ( + "fmt" + "net/http" + + "github.com/influxdata/chronograf" +) + +// Permissions returns all possible permissions for this source. +func (h *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) + return + } + + src, err := h.SourcesStore.Get(ctx, srcID) + if err != nil { + notFound(w, srcID, h.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 + } + + 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 + } + + perms := ts.Permissions(ctx) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.Logger) + return + } + httpAPISrcs := "/chronograf/v1/sources" + res := struct { + Permissions chronograf.Permissions `json:"permissions"` + Links map[string]string `json:"links"` // Links are URI locations related to user + }{ + Permissions: perms, + Links: map[string]string{ + "self": fmt.Sprintf("%s/%d/permissions", httpAPISrcs, srcID), + "source": fmt.Sprintf("%s/%d", httpAPISrcs, srcID), + }, + } + encodeJSON(w, http.StatusOK, res, h.Logger) +} + +func validPermissions(perms *chronograf.Permissions) error { + if perms == nil { + return nil + } + for _, perm := range *perms { + if perm.Scope != chronograf.AllScope && perm.Scope != chronograf.DBScope { + return fmt.Errorf("Invalid permission scope") + } + if perm.Scope == chronograf.DBScope && perm.Name == "" { + return fmt.Errorf("Database scoped permission requires a name") + } + } + return nil +} diff --git a/server/permissions_test.go b/server/permissions_test.go new file mode 100644 index 0000000000..092a89c45b --- /dev/null +++ b/server/permissions_test.go @@ -0,0 +1,112 @@ +package server + +import ( + "bytes" + "context" + "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_Permissions(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://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 nil + }, + PermissionsF: func(ctx context.Context) chronograf.Permissions { + return chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"READ", "WRITE"}, + }, + } + }, + }, + }, + ID: "1", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"permissions":[{"scope":"all","allowed":["READ","WRITE"]}],"links":{"self":"/chronograf/v1/sources/1/permissions","source":"/chronograf/v1/sources/1"}} +`, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + h := &Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + h.Permissions(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. Permissions() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. Permissions() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. Permissions() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} diff --git a/server/roles.go b/server/roles.go new file mode 100644 index 0000000000..d738cbc42f --- /dev/null +++ b/server/roles.go @@ -0,0 +1,224 @@ +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 new file mode 100644 index 0000000000..7f6da27c27 --- /dev/null +++ b/server/roles_test.go @@ -0,0 +1,697 @@ +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/users.go b/server/users.go index bbf4f61db1..cacf5da01b 100644 --- a/server/users.go +++ b/server/users.go @@ -1,99 +1,317 @@ package server import ( + "context" + "encoding/json" "fmt" "net/http" "net/url" - "golang.org/x/net/context" - + "github.com/bouk/httprouter" "github.com/influxdata/chronograf" - "github.com/influxdata/chronograf/oauth2" ) -type userLinks struct { - Self string `json:"self"` // Self link mapping to this resource -} - -type userResponse struct { - *chronograf.User - Links userLinks `json:"links"` -} - -// If new user response is nil, return an empty userResponse because it -// indicates authentication is not needed -func newUserResponse(usr *chronograf.User) userResponse { - base := "/chronograf/v1/users" - name := "me" - if usr != nil { - // TODO: Change to usrl.PathEscape for go 1.8 - u := &url.URL{Path: usr.Name} - name = u.String() - } - - return userResponse{ - User: usr, - Links: userLinks{ - Self: fmt.Sprintf("%s/%s", base, name), - }, - } -} - -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 -} - -func getPrincipal(ctx context.Context) (oauth2.Principal, error) { - principal, ok := ctx.Value(oauth2.PrincipalKey).(oauth2.Principal) - if !ok { - return oauth2.Principal{}, fmt.Errorf("Token not found") - } - - return principal, nil -} - -// Me does a findOrCreate based on the email in the context -func (h *Service) Me(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - if !h.UseAuth { - // If there's no authentication, return an empty user - res := newUserResponse(nil) - encodeJSON(w, http.StatusOK, res, h.Logger) +// NewSourceUser adds user to source +func (h *Service) NewSourceUser(w http.ResponseWriter, r *http.Request) { + var req userRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, h.Logger) return } - email, err := getEmail(ctx) - if err != nil { + if err := req.ValidCreate(); err != nil { invalidData(w, err, h.Logger) return } - usr, err := h.UsersStore.Get(ctx, email) - if err == nil { - res := newUserResponse(usr) - encodeJSON(w, http.StatusOK, res, h.Logger) - return - } - - // Because we didnt find a user, making a new one - user := &chronograf.User{ - Name: email, - } - - newUser, err := h.UsersStore.Add(ctx, user) + ctx := r.Context() + srcID, ts, err := h.sourcesSeries(ctx, w, r) if err != nil { - msg := fmt.Errorf("error storing user %s: %v", user.Name, err) - unknownErrorWithMessage(w, msg, h.Logger) return } - res := newUserResponse(newUser) + 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(), h.Logger) + return + } + + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.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) +} + +// SourceUsers retrieves all users from source. +func (h *Service) SourceUsers(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + srcID, ts, err := h.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(), h.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 { + return + } + store := ts.Users(ctx) + u, err := store.Get(ctx, uid) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.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) + 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) { + 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) + return + } + + ctx := r.Context() + uid := httprouter.GetParamFromContext(ctx, "uid") + srcID, ts, err := h.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(), h.Logger) + return + } + + u, err := store.Get(ctx, uid) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.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) +} + +func (h *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(), h.Logger) + return 0, nil, err + } + + src, err := h.SourcesStore.Get(ctx, srcID) + if err != nil { + notFound(w, srcID, h.Logger) + return 0, nil, err + } + + 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 +} + +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 + } + + 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), + } +} diff --git a/server/users_test.go b/server/users_test.go index 147bf8f3ae..c46106d667 100644 --- a/server/users_test.go +++ b/server/users_test.go @@ -1,6 +1,7 @@ -package server +package server_test import ( + "bytes" "context" "fmt" "io/ioutil" @@ -8,19 +9,19 @@ import ( "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/oauth2" + "github.com/influxdata/chronograf/server" ) -type MockUsers struct{} - -func TestService_Me(t *testing.T) { +func TestService_NewSourceUser(t *testing.T) { type fields struct { - UsersStore chronograf.UsersStore - Logger chronograf.Logger - UseAuth bool + SourcesStore chronograf.SourcesStore + TimeSeries server.TimeSeriesClient + Logger chronograf.Logger + UseAuth bool } type args struct { w *httptest.ResponseRecorder @@ -30,139 +31,900 @@ func TestService_Me(t *testing.T) { name string fields fields args args - principal oauth2.Principal + ID string wantStatus int wantContentType string wantBody string }{ { - name: "Existing user", + name: "New user for data source", args: args{ w: httptest.NewRecorder(), - r: httptest.NewRequest("GET", "http://example.com/foo", nil), + 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, - 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), + 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 }, }, - }, - principal: oauth2.Principal{ - Subject: "me", - }, - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"name":"me","password":"hunter2","links":{"self":"/chronograf/v1/users/me"}} -`, - }, - { - name: "New user", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest("GET", "http://example.com/foo", nil), - }, - fields: fields{ - UseAuth: true, - UsersStore: &mocks.UsersStore{ - GetF: func(ctx context.Context, name string) (*chronograf.User, error) { - return nil, fmt.Errorf("Unknown User") + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil }, - AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { - return u, 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") }, }, }, - principal: oauth2.Principal{ - Subject: "secret", - }, - wantStatus: http.StatusOK, + ID: "1", + wantStatus: http.StatusCreated, wantContentType: "application/json", - wantBody: `{"name":"secret","password":"","links":{"self":"/chronograf/v1/users/secret"}} + 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://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 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("GET", "http://example.com/foo", nil), + 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, - UsersStore: &mocks.UsersStore{ - GetF: func(ctx context.Context, name string) (*chronograf.User, error) { - return nil, fmt.Errorf("Unknown User") - }, - AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { - return nil, fmt.Errorf("Why Heavy?") + 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") + }, + } }, }, - Logger: log.New(log.DebugLevel), }, - principal: oauth2.Principal{ - Subject: "secret", - }, - wantStatus: http.StatusInternalServerError, + ID: "1", + wantStatus: http.StatusBadRequest, wantContentType: "application/json", - wantBody: `{"code":500,"message":"Unknown error: error storing user secret: Why Heavy?"}`, + wantBody: `{"code":400,"message":"Weight Has Nothing to Do With It"}`, }, { - name: "No Auth", + name: "Failure connecting to user store", args: args{ w: httptest.NewRecorder(), - r: httptest.NewRequest("GET", "http://example.com/foo", nil), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), }, fields: fields{ - UseAuth: false, + 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") + }, + }, }, - wantStatus: http.StatusOK, + ID: "1", + wantStatus: http.StatusBadRequest, wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/users/me"}} -`, + wantBody: `{"code":400,"message":"Unable to connect to source 1: Biff just happens to be my supervisor"}`, }, { - name: "Empty Principal", + name: "Failure getting source", args: args{ w: httptest.NewRecorder(), - r: httptest.NewRequest("GET", "http://example.com/foo", nil), + 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), }, - wantStatus: http.StatusUnprocessableEntity, - principal: oauth2.Principal{ - Subject: "", + 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://server.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://server.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(context.WithValue(context.Background(), oauth2.PrincipalKey, tt.principal)) - h := &Service{ - UsersStore: tt.fields.UsersStore, - Logger: tt.fields.Logger, - UseAuth: tt.fields.UseAuth, + 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.Me(tt.args.w, tt.args.r) + 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. Me() = %v, want %v", tt.name, 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. Me() = %v, want %v", tt.name, 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. Me() = \n***%v***\n,\nwant\n***%v***", tt.name, 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 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 + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "All users 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{ + 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, + }, + } + 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.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 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: "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"}`)))), + }, + 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, 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"}`)))), + }, + 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, 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"}`)))), + }, + 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 := &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) } } } diff --git a/ui/.eslintrc b/ui/.eslintrc index a0605ff3a0..564d38c56a 100644 --- a/ui/.eslintrc +++ b/ui/.eslintrc @@ -46,7 +46,7 @@ 'arrow-parens': 0, 'comma-dangle': [2, 'always-multiline'], 'no-cond-assign': 2, - 'no-console': 2, + 'no-console': ['error', {allow: ['error']}], 'no-constant-condition': 2, 'no-control-regex': 2, 'no-debugger': 2, diff --git a/ui/corsless.js b/ui/corsless.js deleted file mode 100644 index 8db30d407b..0000000000 --- a/ui/corsless.js +++ /dev/null @@ -1,28 +0,0 @@ -const express = require('express'); -const request = require('request'); - -const app = express(); - -app.use('/', (req, res) => { - console.log(`${req.method} ${req.url}`); - - const headers = {}; - headers['Access-Control-Allow-Origin'] = '*'; - headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, DELETE, OPTIONS'; - headers['Access-Control-Allow-Credentials'] = false; - headers['Access-Control-Max-Age'] = '86400'; // 24 hours - headers['Access-Control-Allow-Headers'] = 'X-Requested-With, X-HTTP-Method-Override, Content-Type, Accept'; - res.writeHead(200, headers); - - if (req.method === 'OPTIONS') { - res.end(); - } - else { - const url = 'http://localhost:8888' + req.url; - req.pipe(request(url)).pipe(res); - } -}); - -app.listen(3888, () => { - console.log('corsless proxy server now running') -}); diff --git a/ui/package.json b/ui/package.json index 765e675060..a969d82787 100644 --- a/ui/package.json +++ b/ui/package.json @@ -17,9 +17,7 @@ "test:lint": "npm run lint; npm run test", "test:dev": "nodemon --exec npm run test:lint", "clean": "rm -rf build", - "storybook": "start-storybook -p 6006", - "build-storybook": "build-storybook", - "proxy": "node ./corsless" + "storybook": "node ./storybook" }, "author": "", "eslintConfig": { diff --git a/ui/spec/admin/reducers/adminSpec.js b/ui/spec/admin/reducers/adminSpec.js new file mode 100644 index 0000000000..9c293b3ddd --- /dev/null +++ b/ui/spec/admin/reducers/adminSpec.js @@ -0,0 +1,292 @@ +import reducer from 'src/admin/reducers/admin' + +import { + addUser, + addRole, + syncUser, + syncRole, + editUser, + editRole, + loadRoles, + loadPermissions, + deleteRole, + deleteUser, + filterRoles, + filterUsers, +} from 'src/admin/actions' + +let state = undefined + +// Users +const u1 = { + name: 'acidburn', + roles: [ + { + name: 'hax0r', + permissions: { + allowed: [ + 'ViewAdmin', + 'ViewChronograf', + 'CreateDatabase', + 'CreateUserAndRole', + 'AddRemoveNode', + 'DropDatabase', + 'DropData', + 'ReadData', + 'WriteData', + 'Rebalance', + 'ManageShard', + 'ManageContinuousQuery', + 'ManageQuery', + 'ManageSubscription', + 'Monitor', + 'CopyShard', + 'KapacitorAPI', + 'KapacitorConfigAPI' + ], + scope: 'all', + }, + } + ], + permissions: [], + links: {self: '/chronograf/v1/sources/1/users/acidburn'}, +} +const u2 = { + name: 'zerocool', + roles: [], + permissions: [], + links: {self: '/chronograf/v1/sources/1/users/zerocool'}, +} +const users = [u1, u2] +const newDefaultUser = { + name: '', + password: '', + roles: [], + permissions: [], + links: {self: ''}, + isNew: true, +} + +// Roles +const r1 = { + name: 'hax0r', + users: [], + permissions: [ + { + allowed: [ + 'ViewAdmin', + 'ViewChronograf', + 'CreateDatabase', + 'CreateUserAndRole', + 'AddRemoveNode', + 'DropDatabase', + 'DropData', + 'ReadData', + 'WriteData', + 'Rebalance', + 'ManageShard', + 'ManageContinuousQuery', + 'ManageQuery', + 'ManageSubscription', + 'Monitor', + 'CopyShard', + 'KapacitorAPI', + 'KapacitorConfigAPI' + ], + scope: 'all', + }, + ], + links: {self: '/chronograf/v1/sources/1/roles/hax0r'} +} +const r2 = { + name: 'l33tus3r', + links: {self: '/chronograf/v1/sources/1/roles/l33tus3r'} +} +const roles = [r1, r2] +const newDefaultRole = { + name: '', + users: [], + permissions: [], + links: {self: ''}, + isNew: true, +} + +// Permissions +const global = {scope: 'all', allowed: ['p1', 'p2']} +const scoped = {scope: 'db1', allowed: ['p1', 'p3']} +const permissions = [global, scoped] + +describe('Admin.Reducers', () => { + it('it can add a user', () => { + state = { + users: [ + u1, + ] + } + + const actual = reducer(state, addUser()) + const expected = { + users: [ + {...newDefaultUser, isEditing: true}, + u1, + ], + } + + expect(actual.users).to.deep.equal(expected.users) + }) + + it('it can sync a stale user', () => { + const staleUser = {...u1, roles: []} + state = {users: [u2, staleUser]} + + const actual = reducer(state, syncUser(staleUser, u1)) + const expected = { + users: [u2, u1], + } + + expect(actual.users).to.deep.equal(expected.users) + }) + + it('it can edit a user', () => { + const updates = {name: 'onecool'} + state = { + users: [u2, u1], + } + + const actual = reducer(state, editUser(u2, updates)) + const expected = { + users: [{...u2, ...updates}, u1] + } + + expect(actual.users).to.deep.equal(expected.users) + }) + + it('it can add a role', () => { + state = { + roles: [ + r1, + ] + } + + const actual = reducer(state, addRole()) + const expected = { + roles: [ + {...newDefaultRole, isEditing: true}, + r1, + ], + } + + expect(actual.roles).to.deep.equal(expected.roles) + }) + + it('it can sync a stale role', () => { + const staleRole = {...r1, permissions: []} + state = {roles: [r2, staleRole]} + + const actual = reducer(state, syncRole(staleRole, r1)) + const expected = { + roles: [r2, r1], + } + + expect(actual.roles).to.deep.equal(expected.roles) + }) + + it('it can edit a role', () => { + const updates = {name: 'onecool'} + state = { + roles: [r2, r1], + } + + const actual = reducer(state, editRole(r2, updates)) + const expected = { + roles: [{...r2, ...updates}, r1] + } + + expect(actual.roles).to.deep.equal(expected.roles) + }) + + it('it can load the roles', () => { + const actual = reducer(state, loadRoles({roles})) + const expected = { + roles, + } + + expect(actual.roles).to.deep.equal(expected.roles) + }) + + it('it can delete a role', () => { + state = { + roles: [ + r1, + ] + } + + const actual = reducer(state, deleteRole(r1)) + const expected = { + roles: [], + } + + expect(actual.roles).to.deep.equal(expected.roles) + }) + + it('it can delete a user', () => { + state = { + users: [ + u1, + ] + } + + const actual = reducer(state, deleteUser(u1)) + const expected = { + users: [], + } + + expect(actual.users).to.deep.equal(expected.users) + }) + + it('can filter roles w/ "x" text', () => { + state = { + roles, + } + + const text = 'x' + + const actual = reducer(state, filterRoles(text)) + const expected = { + roles: [ + {...r1, hidden: false}, + {...r2, hidden: true}, + ], + } + + expect(actual.roles).to.deep.equal(expected.roles) + }) + + it('can filter users w/ "zero" text', () => { + state = { + users, + } + + const text = 'zero' + + const actual = reducer(state, filterUsers(text)) + const expected = { + users: [ + {...u1, hidden: true}, + {...u2, hidden: false}, + ], + } + + expect(actual.users).to.deep.equal(expected.users) + }) + + // Permissions + it('it can load the permissions', () => { + const actual = reducer(state, loadPermissions({permissions})) + const expected = { + permissions, + } + + expect(actual.permissions).to.deep.equal(expected.permissions) + }) +}) diff --git a/ui/src/admin/actions/index.js b/ui/src/admin/actions/index.js new file mode 100644 index 0000000000..a662ca8acd --- /dev/null +++ b/ui/src/admin/actions/index.js @@ -0,0 +1,230 @@ +import { + getUsers as getUsersAJAX, + getRoles as getRolesAJAX, + getPermissions as getPermissionsAJAX, + createUser as createUserAJAX, + createRole as createRoleAJAX, + deleteUser as deleteUserAJAX, + deleteRole as deleteRoleAJAX, + updateRole as updateRoleAJAX, + updateUser as updateUserAJAX, +} from 'src/admin/apis' + +import {killQuery as killQueryProxy} from 'shared/apis/metaQuery' +import {publishNotification} from 'src/shared/actions/notifications'; +import {ADMIN_NOTIFICATION_DELAY} from 'src/admin/constants' + +export const loadUsers = ({users}) => ({ + type: 'LOAD_USERS', + payload: { + users, + }, +}) + +export const loadRoles = ({roles}) => ({ + type: 'LOAD_ROLES', + payload: { + roles, + }, +}) + +export const loadPermissions = ({permissions}) => ({ + type: 'LOAD_PERMISSIONS', + payload: { + permissions, + }, +}) + +export const addUser = () => ({ + type: 'ADD_USER', +}) + +export const addRole = () => ({ + type: 'ADD_ROLE', +}) + +export const syncUser = (staleUser, syncedUser) => ({ + type: 'SYNC_USER', + payload: { + staleUser, + syncedUser, + }, +}) + +export const syncRole = (staleRole, syncedRole) => ({ + type: 'SYNC_ROLE', + payload: { + staleRole, + syncedRole, + }, +}) + +export const editUser = (user, updates) => ({ + type: 'EDIT_USER', + payload: { + user, + updates, + }, +}) + +export const editRole = (role, updates) => ({ + type: 'EDIT_ROLE', + payload: { + role, + updates, + }, +}) + +export const killQuery = (queryID) => ({ + type: 'KILL_QUERY', + payload: { + queryID, + }, +}) + +export const setQueryToKill = (queryIDToKill) => ({ + type: 'SET_QUERY_TO_KILL', + payload: { + queryIDToKill, + }, +}) + +export const loadQueries = (queries) => ({ + type: 'LOAD_QUERIES', + payload: { + queries, + }, +}) + +export const deleteUser = (user) => ({ + type: 'DELETE_USER', + payload: { + user, + }, +}) + +export const deleteRole = (role) => ({ + type: 'DELETE_ROLE', + payload: { + role, + }, +}) + +export const filterUsers = (text) => ({ + type: 'FILTER_USERS', + payload: { + text, + }, +}) + +export const filterRoles = (text) => ({ + type: 'FILTER_ROLES', + payload: { + text, + }, +}) + +// async actions +export const loadUsersAsync = (url) => async (dispatch) => { + const {data} = await getUsersAJAX(url) + dispatch(loadUsers(data)) +} + +export const loadRolesAsync = (url) => async (dispatch) => { + const {data} = await getRolesAJAX(url) + dispatch(loadRoles(data)) +} + +export const loadPermissionsAsync = (url) => async (dispatch) => { + const {data} = await getPermissionsAJAX(url) + dispatch(loadPermissions(data)) +} + +export const createUserAsync = (url, user) => async (dispatch) => { + try { + const {data} = await createUserAJAX(url, user) + dispatch(publishNotification('success', 'User created successfully')) + dispatch(syncUser(user, data)) + } catch (error) { + // undo optimistic update + dispatch(publishNotification('error', `Failed to create user: ${error.data.message}`)) + setTimeout(() => dispatch(deleteUser(user)), ADMIN_NOTIFICATION_DELAY) + } +} + +export const createRoleAsync = (url, role) => async (dispatch) => { + try { + const {data} = await createRoleAJAX(url, role) + dispatch(publishNotification('success', 'Role created successfully')) + dispatch(syncRole(role, data)) + } catch (error) { + // undo optimistic update + dispatch(publishNotification('error', `Failed to create role: ${error.data.message}`)) + setTimeout(() => dispatch(deleteRole(role)), ADMIN_NOTIFICATION_DELAY) + } +} + +export const killQueryAsync = (source, queryID) => (dispatch) => { + // optimistic update + dispatch(killQuery(queryID)) + dispatch(setQueryToKill(null)) + + // kill query on server + killQueryProxy(source, queryID) +} + +export const deleteRoleAsync = (role, addFlashMessage) => (dispatch) => { + // optimistic update + dispatch(deleteRole(role)) + + // delete role on server + deleteRoleAJAX(role.links.self, addFlashMessage, role.name) +} + +export const deleteUserAsync = (user, addFlashMessage) => (dispatch) => { + // optimistic update + dispatch(deleteUser(user)) + + // delete user on server + deleteUserAJAX(user.links.self, addFlashMessage, user.name) +} + +export const updateRoleUsersAsync = (role, users) => async (dispatch) => { + try { + const {data} = await updateRoleAJAX(role.links.self, users, role.permissions) + dispatch(publishNotification('success', 'Role users updated')) + dispatch(syncRole(role, data)) + } catch (error) { + dispatch(publishNotification('error', `Failed to update role: ${error.data.message}`)) + } +} + +export const updateRolePermissionsAsync = (role, permissions) => async (dispatch) => { + try { + const {data} = await updateRoleAJAX(role.links.self, role.users, permissions) + dispatch(publishNotification('success', 'Role permissions updated')) + dispatch(syncRole(role, data)) + } catch (error) { + dispatch(publishNotification('error', `Failed to updated role: ${error.data.message}`)) + } +} + +export const updateUserPermissionsAsync = (user, permissions) => async (dispatch) => { + try { + const {data} = await updateUserAJAX(user.links.self, user.roles, permissions) + dispatch(publishNotification('success', 'User permissions updated')) + dispatch(syncUser(user, data)) + } catch (error) { + dispatch(publishNotification('error', `Failed to updated user: ${error.data.message}`)) + } +} + +export const updateUserRolesAsync = (user, roles) => async (dispatch) => { + try { + const {data} = await updateUserAJAX(user.links.self, roles, user.permissions) + dispatch(publishNotification('success', 'User roles updated')) + dispatch(syncUser(user, data)) + } catch (error) { + dispatch(publishNotification('error', `Failed to updated user: ${error.data.message}`)) + } +} diff --git a/ui/src/admin/apis/index.js b/ui/src/admin/apis/index.js new file mode 100644 index 0000000000..6c7d747ae1 --- /dev/null +++ b/ui/src/admin/apis/index.js @@ -0,0 +1,133 @@ +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 getRoles = async (url) => { + try { + return await AJAX({ + method: 'GET', + url, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const getPermissions = 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) { + throw error + } +} + +export const createRole = async (url, role) => { + try { + return await AJAX({ + method: 'POST', + url, + data: role, + }) + } catch (error) { + throw error + } +} + +export const deleteRole = async (url, addFlashMessage, rolename) => { + try { + const response = await AJAX({ + method: 'DELETE', + url, + }) + addFlashMessage({ + type: 'success', + text: `${rolename} successfully deleted.`, + }) + return response + } catch (error) { + console.error(error) + addFlashMessage({ + type: 'error', + text: `Error deleting: ${rolename}.`, + }) + } +} + +export const deleteUser = async (url, addFlashMessage, username) => { + try { + const response = await AJAX({ + method: 'DELETE', + url, + }) + addFlashMessage({ + type: 'success', + text: `${username} successfully deleted.`, + }) + return response + } catch (error) { + console.error(error) + addFlashMessage({ + type: 'error', + text: `Error deleting: ${username}.`, + }) + } +} + +export const updateRole = async (url, users, permissions) => { + try { + return await AJAX({ + method: 'PATCH', + url, + data: { + users, + permissions, + }, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const updateUser = async (url, roles, permissions) => { + try { + return await AJAX({ + method: 'PATCH', + url, + data: { + roles, + permissions, + }, + }) + } catch (error) { + console.error(error) + throw error + } +} diff --git a/ui/src/admin/components/AdminTabs.js b/ui/src/admin/components/AdminTabs.js new file mode 100644 index 0000000000..da9e1f0d21 --- /dev/null +++ b/ui/src/admin/components/AdminTabs.js @@ -0,0 +1,135 @@ +import React, {PropTypes} from 'react' +import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'src/shared/components/Tabs'; +import UsersTable from 'src/admin/components/UsersTable' +import RolesTable from 'src/admin/components/RolesTable' +import QueriesPage from 'src/admin/containers/QueriesPage' + +const AdminTabs = ({ + users, + roles, + permissions, + source, + hasRoles, + isEditingUsers, + isEditingRoles, + onClickCreate, + onEditUser, + onSaveUser, + onCancelEditUser, + onEditRole, + onSaveRole, + onCancelEditRole, + onDeleteRole, + onDeleteUser, + onFilterRoles, + onFilterUsers, + onUpdateRoleUsers, + onUpdateRolePermissions, + onUpdateUserRoles, + onUpdateUserPermissions, +}) => { + let tabs = [ + { + type: 'Users', + component: ( + + ), + }, + { + type: 'Roles', + component: ( + + ), + }, + { + type: 'Queries', + component: (), + }, + ] + + if (!hasRoles) { + tabs = tabs.filter(t => t.type !== 'Roles') + } + + return ( + + + { + tabs.map((t, i) => ({tabs[i].type})) + } + + + { + tabs.map((t, i) => ({t.component})) + } + + + ) +} + +const { + arrayOf, + bool, + func, + shape, + string, +} = PropTypes + +AdminTabs.propTypes = { + users: arrayOf(shape({ + name: string.isRequired, + roles: arrayOf(shape({ + name: string, + })), + })), + roles: arrayOf(shape()), + source: shape(), + permissions: arrayOf(string), + isEditingUsers: bool, + isEditingRoles: bool, + onClickCreate: func.isRequired, + onEditUser: func.isRequired, + onSaveUser: func.isRequired, + onCancelEditUser: func.isRequired, + onEditRole: func.isRequired, + onSaveRole: func.isRequired, + onCancelEditRole: func.isRequired, + onDeleteRole: func.isRequired, + onDeleteUser: func.isRequired, + onFilterRoles: func.isRequired, + onFilterUsers: func.isRequired, + onUpdateRoleUsers: func.isRequired, + onUpdateRolePermissions: func.isRequired, + hasRoles: bool.isRequired, + onUpdateUserPermissions: func, + onUpdateUserRoles: func, +} + +export default AdminTabs diff --git a/ui/src/admin/components/ConfirmButtons.js b/ui/src/admin/components/ConfirmButtons.js new file mode 100644 index 0000000000..a1f93218ce --- /dev/null +++ b/ui/src/admin/components/ConfirmButtons.js @@ -0,0 +1,31 @@ +import React, {PropTypes} from 'react' + +const ConfirmButtons = ({onConfirm, item, onCancel}) => ( +
+ + +
+) + +const { + func, + shape, +} = PropTypes + +ConfirmButtons.propTypes = { + onConfirm: func.isRequired, + item: shape({}).isRequired, + onCancel: func.isRequired, +} + +export default ConfirmButtons diff --git a/ui/src/admin/components/DeleteRow.js b/ui/src/admin/components/DeleteRow.js new file mode 100644 index 0000000000..a22c2ee0c2 --- /dev/null +++ b/ui/src/admin/components/DeleteRow.js @@ -0,0 +1,71 @@ +import React, {PropTypes, Component} from 'react' + +import OnClickOutside from 'shared/components/OnClickOutside' +import ConfirmButtons from 'src/admin/components/ConfirmButtons' + +const DeleteButton = ({onConfirm}) => ( + +) + +class DeleteRow extends Component { + constructor(props) { + super(props) + this.state = { + isConfirmed: false, + } + this.handleConfirm = ::this.handleConfirm + this.handleCancel = ::this.handleCancel + } + + handleConfirm() { + this.setState({isConfirmed: true}) + } + + handleCancel() { + this.setState({isConfirmed: false}) + } + + handleClickOutside() { + this.setState({isConfirmed: false}) + } + + render() { + const {onDelete, item} = this.props + const {isConfirmed} = this.state + + if (isConfirmed) { + return ( + + ) + } + + return ( + + ) + } +} + +const { + func, + shape, +} = PropTypes + +DeleteButton.propTypes = { + onConfirm: func.isRequired, +} + +DeleteRow.propTypes = { + item: shape({}), + onDelete: func.isRequired, +} + +export default OnClickOutside(DeleteRow) diff --git a/ui/src/admin/components/EmptyRow.js b/ui/src/admin/components/EmptyRow.js new file mode 100644 index 0000000000..402e4eb4ca --- /dev/null +++ b/ui/src/admin/components/EmptyRow.js @@ -0,0 +1,19 @@ +import React, {PropTypes} from 'react' + +const EmptyRow = ({tableName}) => ( + + +

You don't have any {tableName},
why not create one?

+ + +) + +const { + string, +} = PropTypes + +EmptyRow.propTypes = { + tableName: string.isRequired, +} + +export default EmptyRow \ No newline at end of file diff --git a/ui/src/admin/components/FilterBar.js b/ui/src/admin/components/FilterBar.js new file mode 100644 index 0000000000..e8cdae1a7b --- /dev/null +++ b/ui/src/admin/components/FilterBar.js @@ -0,0 +1,59 @@ +import React, {Component, PropTypes} from 'react' + +class FilterBar extends Component { + constructor(props) { + super(props) + this.state = { + filterText: '', + } + + this.handleText = ::this.handleText + } + + handleText(e) { + this.setState( + {filterText: e.target.value}, + this.props.onFilter(e.target.value) + ) + } + + componentWillUnmount() { + this.props.onFilter('') + } + + render() { + const {type, isEditing, onClickCreate} = this.props + return ( +
+
+ +
+ +
+
+ +
+ ) + } +} + +const { + bool, + func, + string, +} = PropTypes + +FilterBar.propTypes = { + onFilter: func.isRequired, + type: string, + isEditing: bool, + onClickCreate: func, +} + +export default FilterBar diff --git a/ui/src/admin/components/QueriesTable.js b/ui/src/admin/components/QueriesTable.js new file mode 100644 index 0000000000..902df2b0ce --- /dev/null +++ b/ui/src/admin/components/QueriesTable.js @@ -0,0 +1,67 @@ +import React, {PropTypes} from 'react' + +const QueriesTable = ({queries, onKillQuery, onConfirm}) => ( +
+
+
+ + + + + + + + + + + {queries.map((q) => { + return ( + + + + + + + ); + })} + +
DatabaseQueryRunning
{q.database}{q.query}{q.duration} + +
+
+
+ + +
+) + +const { + arrayOf, + func, + shape, +} = PropTypes + +QueriesTable.propTypes = { + queries: arrayOf(shape()), + onConfirm: func, + onKillQuery: func, +} + +export default QueriesTable diff --git a/ui/src/admin/components/RoleEditingRow.js b/ui/src/admin/components/RoleEditingRow.js new file mode 100644 index 0000000000..8545fe0205 --- /dev/null +++ b/ui/src/admin/components/RoleEditingRow.js @@ -0,0 +1,59 @@ +import React, {Component, PropTypes} from 'react' + +class RoleEditingRow extends Component { + constructor(props) { + super(props) + + this.handleKeyPress = ::this.handleKeyPress + this.handleEdit = ::this.handleEdit + } + + handleKeyPress(role) { + return (e) => { + if (e.key === 'Enter') { + this.props.onSave(role) + } + } + } + + handleEdit(role) { + return (e) => { + this.props.onEdit(role, {[e.target.name]: e.target.value}) + } + } + + render() { + const {role} = this.props + return ( + +
+ +
+ + ) + } +} + +const { + bool, + func, + shape, +} = PropTypes + +RoleEditingRow.propTypes = { + role: shape().isRequired, + isNew: bool, + onEdit: func.isRequired, + onSave: func.isRequired, +} + +export default RoleEditingRow diff --git a/ui/src/admin/components/RoleRow.js b/ui/src/admin/components/RoleRow.js new file mode 100644 index 0000000000..669a76e568 --- /dev/null +++ b/ui/src/admin/components/RoleRow.js @@ -0,0 +1,109 @@ +import React, {PropTypes} from 'react' + +import _ from 'lodash' + +import RoleEditingRow from 'src/admin/components/RoleEditingRow' +import MultiSelectDropdown from 'shared/components/MultiSelectDropdown' +import ConfirmButtons from 'src/admin/components/ConfirmButtons' +import DeleteRow from 'src/admin/components/DeleteRow' + +const RoleRow = ({ + role: {name, permissions, users}, + role, + allUsers, + allPermissions, + isNew, + isEditing, + onEdit, + onSave, + onCancel, + onDelete, + onUpdateRoleUsers, + onUpdateRolePermissions, +}) => { + const handleUpdateUsers = (u) => { + onUpdateRoleUsers(role, u.map((n) => ({name: n}))) + } + + const handleUpdatePermissions = (allowed) => { + onUpdateRolePermissions(role, [{scope: 'all', allowed}]) + } + + const perms = _.get(permissions, ['0', 'allowed'], []) + + if (isEditing) { + return ( + + + + + + + + + ) + } + + return ( + + {name} + + { + allPermissions && allPermissions.length ? + : null + } + + + { + allUsers && allUsers.length ? + u.name)} + selectedItems={users === undefined ? [] : users.map((u) => u.name)} + label={users && users.length ? '' : 'Select Users'} + onApply={handleUpdateUsers} + /> : null + } + + + + + + ) +} + +const { + arrayOf, + bool, + func, + shape, + string, +} = PropTypes + +RoleRow.propTypes = { + role: shape({ + name: string, + permissions: arrayOf(shape({ + name: string, + })), + users: arrayOf(shape({ + name: string, + })), + }).isRequired, + isNew: bool, + isEditing: bool, + onCancel: func, + onEdit: func, + onSave: func, + onDelete: func.isRequired, + allUsers: arrayOf(shape()), + allPermissions: arrayOf(string), + onUpdateRoleUsers: func.isRequired, + onUpdateRolePermissions: func.isRequired, +} + +export default RoleRow diff --git a/ui/src/admin/components/RolesTable.js b/ui/src/admin/components/RolesTable.js new file mode 100644 index 0000000000..73409a7b6f --- /dev/null +++ b/ui/src/admin/components/RolesTable.js @@ -0,0 +1,90 @@ +import React, {PropTypes} from 'react' +import RoleRow from 'src/admin/components/RoleRow' +import EmptyRow from 'src/admin/components/EmptyRow' +import FilterBar from 'src/admin/components/FilterBar' + +const RolesTable = ({ + roles, + allUsers, + permissions, + isEditing, + onClickCreate, + onEdit, + onSave, + onCancel, + onDelete, + onFilter, + onUpdateRoleUsers, + onUpdateRolePermissions, +}) => ( +
+ +
+ + + + + + + + + + + { + roles.length ? + roles.filter(r => !r.hidden).map((role) => + + ) : + } + +
NamePermissionsUsers
+
+
+) + +const { + arrayOf, + bool, + func, + shape, + string, +} = PropTypes + +RolesTable.propTypes = { + roles: arrayOf(shape({ + name: string.isRequired, + permissions: arrayOf(shape({ + name: string, + scope: string.isRequired, + })), + users: arrayOf(shape({ + name: string, + })), + })), + isEditing: bool, + onClickCreate: func.isRequired, + onEdit: func.isRequired, + onSave: func.isRequired, + onCancel: func.isRequired, + onDelete: func.isRequired, + onFilter: func, + allUsers: arrayOf(shape()), + permissions: arrayOf(string), + onUpdateRoleUsers: func.isRequired, + onUpdateRolePermissions: func.isRequired, +} + +export default RolesTable diff --git a/ui/src/admin/components/UserEditingRow.js b/ui/src/admin/components/UserEditingRow.js new file mode 100644 index 0000000000..01e1083182 --- /dev/null +++ b/ui/src/admin/components/UserEditingRow.js @@ -0,0 +1,72 @@ +import React, {Component, PropTypes} from 'react' + +class UserEditingRow extends Component { + constructor(props) { + super(props) + + this.handleKeyPress = ::this.handleKeyPress + this.handleEdit = ::this.handleEdit + } + + handleKeyPress(user) { + return (e) => { + if (e.key === 'Enter') { + this.props.onSave(user) + } + } + } + + handleEdit(user) { + return (e) => { + this.props.onEdit(user, {[e.target.name]: e.target.value}) + } + } + + render() { + const {user, isNew} = this.props + return ( + +
+ + { + isNew ? + : + null + } +
+ + ) + } +} + +const { + bool, + func, + shape, +} = PropTypes + +UserEditingRow.propTypes = { + user: shape().isRequired, + isNew: bool, + onEdit: func.isRequired, + onSave: func.isRequired, +} + +export default UserEditingRow diff --git a/ui/src/admin/components/UserRow.js b/ui/src/admin/components/UserRow.js new file mode 100644 index 0000000000..6d5e513c30 --- /dev/null +++ b/ui/src/admin/components/UserRow.js @@ -0,0 +1,110 @@ +import React, {PropTypes} from 'react' + +import _ from 'lodash' + +import UserEditingRow from 'src/admin/components/UserEditingRow' +import MultiSelectDropdown from 'shared/components/MultiSelectDropdown' +import ConfirmButtons from 'src/admin/components/ConfirmButtons' +import DeleteRow from 'src/admin/components/DeleteRow' + +const UserRow = ({ + user: {name, roles, permissions}, + user, + allRoles, + allPermissions, + hasRoles, + isNew, + isEditing, + onEdit, + onSave, + onCancel, + onDelete, + onUpdatePermissions, + onUpdateRoles, +}) => { + const handleUpdatePermissions = (allowed) => { + onUpdatePermissions(user, [{scope: 'all', allowed}]) + } + + const handleUpdateRoles = (roleNames) => { + onUpdateRoles(user, allRoles.filter(r => roleNames.find(rn => rn === r.name))) + } + + if (isEditing) { + return ( + + + {hasRoles ? : null} + + + + + + ) + } + + return ( + + {name} + { + hasRoles ? + + r.name)} + selectedItems={roles ? roles.map((r) => r.name) : []/* TODO remove check when server returns empty list */} + label={roles && roles.length ? '' : 'Select Roles'} + onApply={handleUpdateRoles} + /> + : + null + } + + { + allPermissions && allPermissions.length ? + : null + } + + + + + + ) +} + +const { + arrayOf, + bool, + func, + shape, + string, +} = PropTypes + +UserRow.propTypes = { + user: shape({ + name: string, + roles: arrayOf(shape({ + name: string, + })), + permissions: arrayOf(shape({ + name: string, + })), + }).isRequired, + allRoles: arrayOf(shape()), + allPermissions: arrayOf(string), + hasRoles: bool, + isNew: bool, + isEditing: bool, + onCancel: func, + onEdit: func, + onSave: func, + onDelete: func.isRequired, + onUpdatePermissions: func, + onUpdateRoles: func, +} + +export default UserRow diff --git a/ui/src/admin/components/UsersTable.js b/ui/src/admin/components/UsersTable.js new file mode 100644 index 0000000000..a8bd24e834 --- /dev/null +++ b/ui/src/admin/components/UsersTable.js @@ -0,0 +1,94 @@ +import React, {PropTypes} from 'react' + +import UserRow from 'src/admin/components/UserRow' +import EmptyRow from 'src/admin/components/EmptyRow' +import FilterBar from 'src/admin/components/FilterBar' + +const UsersTable = ({ + users, + allRoles, + hasRoles, + permissions, + isEditing, + onClickCreate, + onEdit, + onSave, + onCancel, + onDelete, + onFilter, + onUpdatePermissions, + onUpdateRoles, +}) => ( +
+ +
+ + + + + {hasRoles && } + + + + + + { + users.length ? + users.filter(u => !u.hidden).map(user => + ) : + + } + +
UserRolesPermissions
+
+
+) + +const { + arrayOf, + bool, + func, + shape, + string, +} = PropTypes + +UsersTable.propTypes = { + users: arrayOf(shape({ + name: string.isRequired, + roles: arrayOf(shape({ + name: string, + })), + permissions: arrayOf(shape({ + name: string, + scope: string.isRequired, + })), + })), + isEditing: bool, + onClickCreate: func.isRequired, + onEdit: func.isRequired, + onSave: func.isRequired, + onCancel: func.isRequired, + onDelete: func.isRequired, + onFilter: func, + allRoles: arrayOf(shape()), + permissions: arrayOf(string), + hasRoles: bool.isRequired, + onUpdatePermissions: func, + onUpdateRoles: func, +} + +export default UsersTable diff --git a/ui/src/admin/constants/index.js b/ui/src/admin/constants/index.js new file mode 100644 index 0000000000..6ed9a315d1 --- /dev/null +++ b/ui/src/admin/constants/index.js @@ -0,0 +1,11 @@ +export const TIMES = [ + {test: /ns/, magnitude: 0}, + {test: /µs/, magnitude: 1}, + {test: /u/, magnitude: 1}, + {test: /^\d*ms/, magnitude: 2}, + {test: /^\d*s/, magnitude: 3}, + {test: /^\d*m\d*s/, magnitude: 4}, + {test: /^\d*h\d*m\d*s/, magnitude: 5}, +]; + +export const ADMIN_NOTIFICATION_DELAY = 1500 // milliseconds diff --git a/ui/src/admin/containers/AdminPage.js b/ui/src/admin/containers/AdminPage.js new file mode 100644 index 0000000000..3ec74b6350 --- /dev/null +++ b/ui/src/admin/containers/AdminPage.js @@ -0,0 +1,263 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' +import { + loadUsersAsync, + loadRolesAsync, + loadPermissionsAsync, + addUser, + addRole, + deleteUser, // TODO rename to removeUser throughout + tests + deleteRole, // TODO rename to removeUser throughout + tests + editUser, + editRole, + createUserAsync, + createRoleAsync, + deleteUserAsync, + deleteRoleAsync, + updateRoleUsersAsync, + updateRolePermissionsAsync, + updateUserPermissionsAsync, + updateUserRolesAsync, + filterUsers as filterUsersAction, + filterRoles as filterRolesAction, +} from 'src/admin/actions' + +import AdminTabs from 'src/admin/components/AdminTabs' + +const isValidUser = (user) => { + const minLen = 3 + return (user.name.length >= minLen && user.password.length >= minLen) +} + +const isValidRole = (role) => { + const minLen = 3 + return (role.name.length >= minLen) +} + +class AdminPage extends Component { + constructor(props) { + super(props) + + this.handleClickCreate = ::this.handleClickCreate + this.handleEditUser = ::this.handleEditUser + this.handleEditRole = ::this.handleEditRole + this.handleSaveUser = ::this.handleSaveUser + this.handleSaveRole = ::this.handleSaveRole + this.handleCancelEditUser = ::this.handleCancelEditUser + this.handleCancelEditRole = ::this.handleCancelEditRole + this.handleDeleteRole = ::this.handleDeleteRole + this.handleDeleteUser = ::this.handleDeleteUser + this.handleUpdateRoleUsers = ::this.handleUpdateRoleUsers + this.handleUpdateRolePermissions = ::this.handleUpdateRolePermissions + this.handleUpdateUserPermissions = ::this.handleUpdateUserPermissions + this.handleUpdateUserRoles = ::this.handleUpdateUserRoles + } + + componentDidMount() { + const {source, loadUsers, loadRoles, loadPermissions} = this.props + + loadUsers(source.links.users) + loadPermissions(source.links.permissions) + if (source.links.roles) { + loadRoles(source.links.roles) + } + } + + handleClickCreate(type) { + if (type === 'users') { + this.props.addUser() + } else if (type === 'roles') { + this.props.addRole() + } + } + + handleEditUser(user, updates) { + this.props.editUser(user, updates) + } + + handleEditRole(role, updates) { + this.props.editRole(role, updates) + } + + async handleSaveUser(user) { + if (!isValidUser(user)) { + this.props.addFlashMessage({type: 'error', text: 'Username and/or password too short'}) + return + } + if (user.isNew) { + this.props.createUser(this.props.source.links.users, user) + } else { + // TODO update user + } + } + + async handleSaveRole(role) { + if (!isValidRole(role)) { + this.props.addFlashMessage({type: 'error', text: 'Role name too short'}) + return + } + if (role.isNew) { + this.props.createRole(this.props.source.links.roles, role) + } else { + // TODO update role + // console.log('update') + } + } + + handleCancelEditUser(user) { + this.props.removeUser(user) + } + + handleCancelEditRole(role) { + this.props.removeRole(role) + } + + handleDeleteRole(role) { + this.props.deleteRole(role, this.props.addFlashMessage) + } + + handleDeleteUser(user) { + this.props.deleteUser(user, this.props.addFlashMessage) + } + + handleUpdateRoleUsers(role, users) { + this.props.updateRoleUsers(role, users) + } + + handleUpdateRolePermissions(role, permissions) { + this.props.updateRolePermissions(role, permissions) + } + + handleUpdateUserPermissions(user, permissions) { + this.props.updateUserPermissions(user, permissions) + } + + handleUpdateUserRoles(user, roles) { + this.props.updateUserRoles(user, roles) + } + + render() { + const {users, roles, source, permissions, filterUsers, filterRoles} = this.props + const hasRoles = !!source.links.roles + const globalPermissions = permissions.find((p) => p.scope === 'all') + const allowed = globalPermissions ? globalPermissions.allowed : [] + + return ( +
+
+
+
+

+ Admin +

+
+
+
+
+
+
+ { + users ? + u.isEditing)} + isEditingRoles={roles.some(r => r.isEditing)} + onClickCreate={this.handleClickCreate} + onEditUser={this.handleEditUser} + onEditRole={this.handleEditRole} + onSaveUser={this.handleSaveUser} + onSaveRole={this.handleSaveRole} + onCancelEditUser={this.handleCancelEditUser} + onCancelEditRole={this.handleCancelEditRole} + onDeleteUser={this.handleDeleteUser} + onDeleteRole={this.handleDeleteRole} + onFilterUsers={filterUsers} + onFilterRoles={filterRoles} + onUpdateRoleUsers={this.handleUpdateRoleUsers} + onUpdateRolePermissions={this.handleUpdateRolePermissions} + onUpdateUserPermissions={this.handleUpdateUserPermissions} + onUpdateUserRoles={this.handleUpdateUserRoles} + /> : + Loading... + } +
+
+
+
+ ) + } +} + +const { + arrayOf, + func, + shape, + string, +} = PropTypes + +AdminPage.propTypes = { + source: shape({ + id: string.isRequired, + links: shape({ + users: string.isRequired, + }), + }).isRequired, + users: arrayOf(shape()), + roles: arrayOf(shape()), + permissions: arrayOf(shape()), + loadUsers: func, + loadRoles: func, + loadPermissions: func, + addUser: func, + addRole: func, + removeUser: func, + removeRole: func, + editUser: func, + editRole: func, + createUser: func, + createRole: func, + deleteRole: func, + deleteUser: func, + addFlashMessage: func, + filterRoles: func, + filterUsers: func, + updateRoleUsers: func, + updateRolePermissions: func, + updateUserPermissions: func, + updateUserRoles: func, +} + +const mapStateToProps = ({admin: {users, roles, permissions}}) => ({ + users, + roles, + permissions, +}) + +const mapDispatchToProps = (dispatch) => ({ + loadUsers: bindActionCreators(loadUsersAsync, dispatch), + loadRoles: bindActionCreators(loadRolesAsync, dispatch), + loadPermissions: bindActionCreators(loadPermissionsAsync, dispatch), + addUser: bindActionCreators(addUser, dispatch), + addRole: bindActionCreators(addRole, dispatch), + removeUser: bindActionCreators(deleteUser, dispatch), + removeRole: bindActionCreators(deleteRole, dispatch), + editUser: bindActionCreators(editUser, dispatch), + editRole: bindActionCreators(editRole, dispatch), + createUser: bindActionCreators(createUserAsync, dispatch), + createRole: bindActionCreators(createRoleAsync, dispatch), + deleteUser: bindActionCreators(deleteUserAsync, dispatch), + deleteRole: bindActionCreators(deleteRoleAsync, dispatch), + filterUsers: bindActionCreators(filterUsersAction, dispatch), + filterRoles: bindActionCreators(filterRolesAction, dispatch), + updateRoleUsers: bindActionCreators(updateRoleUsersAsync, dispatch), + updateRolePermissions: bindActionCreators(updateRolePermissionsAsync, dispatch), + updateUserPermissions: bindActionCreators(updateUserPermissionsAsync, dispatch), + updateUserRoles: bindActionCreators(updateUserRolesAsync, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(AdminPage) diff --git a/ui/src/admin/containers/QueriesPage.js b/ui/src/admin/containers/QueriesPage.js new file mode 100644 index 0000000000..868df3816e --- /dev/null +++ b/ui/src/admin/containers/QueriesPage.js @@ -0,0 +1,134 @@ +import React, {PropTypes, Component} from 'react' +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; + +import flatten from 'lodash/flatten' +import uniqBy from 'lodash/uniqBy' + +import { + showDatabases, + showQueries, +} from 'shared/apis/metaQuery' + +import QueriesTable from 'src/admin/components/QueriesTable' +import showDatabasesParser from 'shared/parsing/showDatabases' +import showQueriesParser from 'shared/parsing/showQueries' +import {TIMES} from 'src/admin/constants' +import { + loadQueries as loadQueriesAction, + setQueryToKill as setQueryToKillAction, + killQueryAsync, +} from 'src/admin/actions' + +class QueriesPage extends Component { + constructor(props) { + super(props) + this.updateQueries = ::this.updateQueries + this.handleConfirmKillQuery = ::this.handleConfirmKillQuery + this.handleKillQuery = ::this.handleKillQuery + } + + componentDidMount() { + this.updateQueries() + const updateInterval = 5000 + this.intervalID = setInterval(this.updateQueries, updateInterval) + } + + componentWillUnmount() { + clearInterval(this.intervalID) + } + + render() { + const {queries} = this.props; + + return ( + + ); + } + + updateQueries() { + const {source, addFlashMessage, loadQueries} = this.props + showDatabases(source.links.proxy).then((resp) => { + const {databases, errors} = showDatabasesParser(resp.data) + if (errors.length) { + errors.forEach((message) => addFlashMessage({type: 'error', text: message})) + return; + } + + const fetches = databases.map((db) => showQueries(source.links.proxy, db)) + + Promise.all(fetches).then((queryResponses) => { + const allQueries = []; + queryResponses.forEach((queryResponse) => { + const result = showQueriesParser(queryResponse.data); + if (result.errors.length) { + result.erorrs.forEach((message) => this.props.addFlashMessage({type: 'error', text: message})); + } + + allQueries.push(...result.queries); + }); + + const queries = uniqBy(flatten(allQueries), (q) => q.id); + + // sorting queries by magnitude, so generally longer queries will appear atop the list + const sortedQueries = queries.sort((a, b) => { + const aTime = TIMES.find((t) => a.duration.match(t.test)); + const bTime = TIMES.find((t) => b.duration.match(t.test)); + return +aTime.magnitude <= +bTime.magnitude; + }); + + loadQueries(sortedQueries) + }); + }); + } + + handleKillQuery(e) { + e.stopPropagation(); + const id = e.target.dataset.queryId; + + this.props.setQueryToKill(id) + } + + handleConfirmKillQuery() { + const {queryIDToKill, source, killQuery} = this.props; + if (queryIDToKill === null) { + return; + } + + killQuery(source.links.proxy, queryIDToKill) + } +} + +const { + arrayOf, + func, + string, + shape, +} = PropTypes + +QueriesPage.propTypes = { + source: shape({ + links: shape({ + proxy: string, + }), + }), + queries: arrayOf(shape()), + addFlashMessage: func, + loadQueries: func, + queryIDToKill: string, + setQueryToKill: func, + killQuery: func, +} + +const mapStateToProps = ({admin: {queries, queryIDToKill}}) => ({ + queries, + queryIDToKill, +}) + +const mapDispatchToProps = (dispatch) => ({ + loadQueries: bindActionCreators(loadQueriesAction, dispatch), + setQueryToKill: bindActionCreators(setQueryToKillAction, dispatch), + killQuery: bindActionCreators(killQueryAsync, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(QueriesPage) diff --git a/ui/src/admin/index.js b/ui/src/admin/index.js new file mode 100644 index 0000000000..f17834228c --- /dev/null +++ b/ui/src/admin/index.js @@ -0,0 +1,2 @@ +import AdminPage from './containers/AdminPage'; +export {AdminPage}; diff --git a/ui/src/admin/reducers/admin.js b/ui/src/admin/reducers/admin.js new file mode 100644 index 0000000000..189a18a599 --- /dev/null +++ b/ui/src/admin/reducers/admin.js @@ -0,0 +1,154 @@ +import reject from 'lodash/reject' + +const newDefaultUser = { + name: '', + password: '', + roles: [], + permissions: [], + links: {self: ''}, + isNew: true, +} +const newDefaultRole = { + name: '', + permissions: [], + users: [], + links: {self: ''}, + isNew: true, +} + +const initialState = { + users: null, + roles: [], + permissions: [], + queries: [], + queryIDToKill: null, +} + +export default function admin(state = initialState, action) { + switch (action.type) { + case 'LOAD_USERS': { + return {...state, ...action.payload} + } + + case 'LOAD_ROLES': { + return {...state, ...action.payload} + } + + case 'LOAD_PERMISSIONS': { + return {...state, ...action.payload} + } + + case 'ADD_USER': { + const newUser = {...newDefaultUser, isEditing: true} + return { + ...state, + users: [ + newUser, + ...state.users, + ], + } + } + + case 'ADD_ROLE': { + const newRole = {...newDefaultRole, isEditing: true} + return { + ...state, + roles: [ + newRole, + ...state.roles, + ], + } + } + + case 'SYNC_USER': { + const {staleUser, syncedUser} = action.payload + const newState = { + users: state.users.map(u => u.links.self === staleUser.links.self ? {...syncedUser} : u), + } + return {...state, ...newState} + } + + case 'SYNC_ROLE': { + const {staleRole, syncedRole} = action.payload + const newState = { + roles: state.roles.map(r => r.links.self === staleRole.links.self ? {...syncedRole} : r), + } + return {...state, ...newState} + } + + case 'EDIT_USER': { + const {user, updates} = action.payload + const newState = { + users: state.users.map(u => u.links.self === user.links.self ? {...u, ...updates} : u), + } + return {...state, ...newState} + } + + case 'EDIT_ROLE': { + const {role, updates} = action.payload + const newState = { + roles: state.roles.map(r => r.links.self === role.links.self ? {...r, ...updates} : r), + } + return {...state, ...newState} + } + + case 'DELETE_USER': { + const {user} = action.payload + const newState = { + users: state.users.filter(u => u.links.self !== user.links.self), + } + + return {...state, ...newState} + } + + case 'DELETE_ROLE': { + const {role} = action.payload + const newState = { + roles: state.roles.filter(r => r.links.self !== role.links.self), + } + + return {...state, ...newState} + } + + case 'LOAD_QUERIES': { + return {...state, ...action.payload} + } + + case 'FILTER_USERS': { + const {text} = action.payload + const newState = { + users: state.users.map(u => { + u.hidden = !u.name.toLowerCase().includes(text) + return u + }), + } + return {...state, ...newState} + } + + case 'FILTER_ROLES': { + const {text} = action.payload + const newState = { + roles: state.roles.map(r => { + r.hidden = !r.name.toLowerCase().includes(text) + return r + }), + } + return {...state, ...newState} + } + + case 'KILL_QUERY': { + const {queryID} = action.payload + const nextState = { + queries: reject(state.queries, (q) => +q.id === +queryID), + } + + return {...state, ...nextState} + } + + case 'SET_QUERY_TO_KILL': { + return {...state, ...action.payload} + } + } + + return state +} diff --git a/ui/src/alerts/containers/AlertsApp.js b/ui/src/alerts/containers/AlertsApp.js index 3e27cd5c68..d58752bee8 100644 --- a/ui/src/alerts/containers/AlertsApp.js +++ b/ui/src/alerts/containers/AlertsApp.js @@ -6,10 +6,6 @@ import AJAX from 'utils/ajax'; import _ from 'lodash'; import NoKapacitorError from '../../shared/components/NoKapacitorError'; -// Kevin: because we were getting strange errors saying -// "Failed prop type: Required prop `source` was not specified in `AlertsApp`." -// Tim and I decided to make the source and addFlashMessage props not required. -// FIXME: figure out why that wasn't working const AlertsApp = React.createClass({ propTypes: { source: PropTypes.shape({ diff --git a/ui/src/data_explorer/components/FieldListItem.js b/ui/src/data_explorer/components/FieldListItem.js index 5a0de1c6dd..c532d37891 100644 --- a/ui/src/data_explorer/components/FieldListItem.js +++ b/ui/src/data_explorer/components/FieldListItem.js @@ -2,7 +2,7 @@ import React, {PropTypes} from 'react'; import classNames from 'classnames'; import _ from 'lodash'; -import MultiSelectDropdown from './MultiSelectDropdown'; +import MultiSelectDropdown from 'src/shared/components/MultiSelectDropdown'; import Dropdown from 'src/shared/components/Dropdown'; import {INFLUXQL_FUNCTIONS} from '../constants'; diff --git a/ui/src/data_explorer/components/MultiSelectDropdown.js b/ui/src/data_explorer/components/MultiSelectDropdown.js deleted file mode 100644 index d48f7c7dbd..0000000000 --- a/ui/src/data_explorer/components/MultiSelectDropdown.js +++ /dev/null @@ -1,106 +0,0 @@ -import React, {PropTypes} from 'react'; -import OnClickOutside from 'shared/components/OnClickOutside'; -import classNames from 'classnames'; -import _ from 'lodash'; - -const {func, arrayOf, string} = PropTypes; -const MultiSelectDropdown = React.createClass({ - propTypes: { - onApply: func.isRequired, - items: arrayOf(PropTypes.string.isRequired).isRequired, - selectedItems: arrayOf(string.isRequired).isRequired, - }, - - getInitialState() { - return { - isOpen: false, - localSelectedItems: this.props.selectedItems, - }; - }, - - componentWillReceiveProps(nextProps) { - if (!_.isEqual(this.state.localSelectedItems, nextProps.selectedItems)) { - this.setState({ - localSelectedItems: nextProps.selectedItems, - }); - } - }, - - handleClickOutside() { - this.setState({isOpen: false}); - }, - - toggleMenu(e) { - e.stopPropagation(); - this.setState({isOpen: !this.state.isOpen}); - }, - - onSelect(item, e) { - e.stopPropagation(); - - const {localSelectedItems} = this.state; - - let nextItems; - if (this.isSelected(item)) { - nextItems = localSelectedItems.filter((i) => i !== item); - } else { - nextItems = localSelectedItems.concat(item); - } - - this.setState({localSelectedItems: nextItems}); - }, - - isSelected(item) { - return this.state.localSelectedItems.indexOf(item) > -1; - }, - - onApplyFunctions(e) { - e.stopPropagation(); - - this.setState({isOpen: false}); - this.props.onApply(this.state.localSelectedItems); - }, - - render() { - const {localSelectedItems} = this.state; - const {isOpen} = this.state; - const labelText = isOpen ? "0 Selected" : "Apply Function"; - - return ( -
-
- - { - localSelectedItems.length ? localSelectedItems.map((s) => s).join(', ') : labelText - } - - -
- {this.renderMenu()} -
- ); - }, - - renderMenu() { - const {items} = this.props; - - return ( -
-
  • -
    Apply
    -
  • -
      - {items.map((listItem, i) => { - return ( -
    • - {listItem} -
    • - ); - })} -
    -
    - ); - }, -}); - -export default OnClickOutside(MultiSelectDropdown); diff --git a/ui/src/index.js b/ui/src/index.js index e45201ad6d..d8eb4fde54 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -14,6 +14,7 @@ import {KapacitorPage, KapacitorRulePage, KapacitorRulesPage, KapacitorTasksPage import DataExplorer from 'src/data_explorer'; import {DashboardsPage, DashboardPage} from 'src/dashboards'; import {CreateSource, SourcePage, ManageSources} from 'src/sources'; +import {AdminPage} from 'src/admin'; import NotFound from 'src/shared/components/NotFound'; import configureStore from 'src/store/configureStore'; import {getMe, getSources} from 'shared/apis'; @@ -127,6 +128,7 @@ const Root = React.createClass({ + diff --git a/ui/src/shared/apis/metaQuery.js b/ui/src/shared/apis/metaQuery.js index 6074ae63c4..076ddca2e9 100644 --- a/ui/src/shared/apis/metaQuery.js +++ b/ui/src/shared/apis/metaQuery.js @@ -7,18 +7,16 @@ export function showDatabases(source) { return proxy({source, query}); } -export function showQueries(host, db, clusterID) { - const statement = 'SHOW QUERIES'; - const url = buildInfluxUrl({host, statement, database: db}); +export function showQueries(source, db) { + const query = 'SHOW QUERIES'; - return proxy(url, clusterID); + return proxy({source, query, db}); } -export function killQuery(host, queryId, clusterID) { - const statement = `KILL QUERY ${queryId}`; - const url = buildInfluxUrl({host, statement}); +export function killQuery(source, queryId) { + const query = `KILL QUERY ${queryId}`; - return proxy(url, clusterID); + return proxy({source, query}); } export function showMeasurements(source, db) { diff --git a/ui/src/shared/components/MultiSelectDropdown.js b/ui/src/shared/components/MultiSelectDropdown.js new file mode 100644 index 0000000000..6235cc1c19 --- /dev/null +++ b/ui/src/shared/components/MultiSelectDropdown.js @@ -0,0 +1,134 @@ +import React, {Component, PropTypes} from 'react' +import OnClickOutside from 'shared/components/OnClickOutside' +import classNames from 'classnames' +import _ from 'lodash' + +const labelText = ({localSelectedItems, isOpen, label}) => { + if (label) { + return label + } else if (localSelectedItems.length) { + return localSelectedItems.map((s) => s).join(', ') + } + + // TODO: be smarter about the text displayed here + if (isOpen) { + return '0 Selected' + } + return 'None' +} + +class MultiSelectDropdown extends Component { + constructor(props) { + super(props) + + this.state = { + isOpen: false, + localSelectedItems: this.props.selectedItems, + } + + this.onSelect = ::this.onSelect + this.onApplyFunctions = ::this.onApplyFunctions + } + + componentWillReceiveProps(nextProps) { + if (!_.isEqual(this.state.localSelectedItems, nextProps.selectedItems)) { + this.setState({ + localSelectedItems: nextProps.selectedItems, + }) + } + } + + handleClickOutside() { + this.setState({isOpen: false}) + } + + toggleMenu(e) { + e.stopPropagation() + this.setState({isOpen: !this.state.isOpen}) + } + + onSelect(item, e) { + e.stopPropagation() + + const {localSelectedItems} = this.state + + let nextItems + if (this.isSelected(item)) { + nextItems = localSelectedItems.filter((i) => i !== item) + } else { + nextItems = localSelectedItems.concat(item) + } + + this.setState({localSelectedItems: nextItems}) + } + + isSelected(item) { + return this.state.localSelectedItems.indexOf(item) > -1 + } + + onApplyFunctions(e) { + e.stopPropagation() + + this.setState({isOpen: false}) + this.props.onApply(this.state.localSelectedItems) + } + + render() { + const {localSelectedItems, isOpen} = this.state + const {label} = this.props + + return ( +
    +
    +
    + { + labelText({localSelectedItems, isOpen, label}) + } +
    + +
    + {this.renderMenu()} +
    + ) + } + + renderMenu() { + const {items} = this.props + + return ( +
    +
  • +
    Apply
    +
  • +
      + {items.map((listItem, i) => { + return ( +
    • + {listItem} +
    • + ) + })} +
    +
    + ) + } +} + +const { + arrayOf, + func, + string, +} = PropTypes + +MultiSelectDropdown.propTypes = { + onApply: func.isRequired, + items: arrayOf(string.isRequired).isRequired, + selectedItems: arrayOf(string.isRequired).isRequired, + label: string, +} + +export default OnClickOutside(MultiSelectDropdown) diff --git a/ui/src/shared/components/Tabs.js b/ui/src/shared/components/Tabs.js index 7d451d8651..de396c9b96 100644 --- a/ui/src/shared/components/Tabs.js +++ b/ui/src/shared/components/Tabs.js @@ -28,6 +28,7 @@ export const TabList = React.createClass({ activeIndex: number, onActivate: func, isKapacitorTabs: string, + customClass: string, }, getDefaultProps() { @@ -53,6 +54,14 @@ export const TabList = React.createClass({ ); } + if (this.props.customClass) { + return ( +
    +
    {children}
    +
    + ); + } + return (
    {children}
    ); @@ -63,11 +72,13 @@ export const TabPanels = React.createClass({ propTypes: { children: node.isRequired, activeIndex: number, + customClass: string, }, + // if only 1 child, children array index lookup will fail render() { return ( -
    +
    {this.props.children[this.props.activeIndex]}
    ); diff --git a/ui/src/shared/components/Tooltip.js b/ui/src/shared/components/Tooltip.js new file mode 100644 index 0000000000..d8d0205528 --- /dev/null +++ b/ui/src/shared/components/Tooltip.js @@ -0,0 +1,21 @@ +import React, {PropTypes} from 'react' +import ReactTooltip from 'react-tooltip' + +const Tooltip = ({tip, children}) => ( +
    +
    {children}
    + +
    +) + +const { + shape, + string, +} = PropTypes + +Tooltip.propTypes = { + tip: string, + children: shape({}), +} + +export default Tooltip diff --git a/ui/src/shared/constants/index.js b/ui/src/shared/constants/index.js index f6f5bb7b68..d238e7bb7a 100644 --- a/ui/src/shared/constants/index.js +++ b/ui/src/shared/constants/index.js @@ -471,4 +471,6 @@ export const STROKE_WIDTH = { export const PRESENTATION_MODE_ANIMATION_DELAY = 0 // In milliseconds. export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds. +export const RES_UNAUTHORIZED = 401 + export const AUTOREFRESH_DEFAULT = 15000 // in milliseconds diff --git a/ui/src/side_nav/components/SideNav.js b/ui/src/side_nav/components/SideNav.js index 72c45d1bac..2318af3abc 100644 --- a/ui/src/side_nav/components/SideNav.js +++ b/ui/src/side_nav/components/SideNav.js @@ -47,6 +47,9 @@ const SideNav = React.createClass({ InfluxDB Kapacitor + + + {loggedIn ? ( Logout diff --git a/ui/src/store/configureStore.js b/ui/src/store/configureStore.js index 5aa6b77eee..e4ef15996d 100644 --- a/ui/src/store/configureStore.js +++ b/ui/src/store/configureStore.js @@ -3,6 +3,7 @@ import {combineReducers} from 'redux'; import thunkMiddleware from 'redux-thunk'; import makeQueryExecuter from 'src/shared/middleware/queryExecuter'; import resizeLayout from 'src/shared/middleware/resizeLayout'; +import adminReducer from 'src/admin/reducers/admin'; import sharedReducers from 'src/shared/reducers'; import dataExplorerReducers from 'src/data_explorer/reducers'; import rulesReducer from 'src/kapacitor/reducers/rules'; @@ -12,6 +13,7 @@ import persistStateEnhancer from './persistStateEnhancer'; const rootReducer = combineReducers({ ...sharedReducers, ...dataExplorerReducers, + admin: adminReducer, rules: rulesReducer, dashboardUI, }); diff --git a/ui/src/style/chronograf.scss b/ui/src/style/chronograf.scss index e45e32ba48..0ab6ebb92d 100644 --- a/ui/src/style/chronograf.scss +++ b/ui/src/style/chronograf.scss @@ -47,6 +47,7 @@ @import 'pages/kapacitor'; @import 'pages/data-explorer'; @import 'pages/dashboards'; +@import 'pages/admin'; // TODO @import 'unsorted'; diff --git a/ui/src/style/components/multi-select-dropdown.scss b/ui/src/style/components/multi-select-dropdown.scss index d349c2633c..7d9502015a 100644 --- a/ui/src/style/components/multi-select-dropdown.scss +++ b/ui/src/style/components/multi-select-dropdown.scss @@ -1,6 +1,80 @@ +$ms-normal-left-padding: 9px; +$ms-item-height: 26px; +$ms-checkbox-size: 14px; +$ms-checkbox-dot-size: 6px; +$ms-checkbox-bg: $c-sapphire; +$ms-checkbox-bg-hover: $c-ocean; +$ms-checkbox-dot: $g20-white; + .multi-select-dropdown { + .multi-select-dropdown__item > a { + color: $c-neutrino !important; + height: $ms-item-height; + line-height: $ms-item-height; + position: relative; + padding-top: 0; + padding-bottom: 0; + padding-right: $ms-normal-left-padding; + padding-left: ($ms-normal-left-padding + $ms-checkbox-size + ($ms-normal-left-padding - 2px)); + + &, + &:focus, + &:active, + &:active:focus { + background: none !important; + + &:hover { + background: $c-pool; + background: -moz-linear-gradient(left, $c-pool 0%, $c-pool 100%) !important; + background: -webkit-linear-gradient(left, $c-pool 0%,$c-pool 100%) !important; + background: linear-gradient(to right, $c-pool 0%,$c-pool 100%) !important; + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$c-pool', endColorstr='$c-pool',GradientType=1 ) !important; + } + } + /* Shared Checkbox Styles */ + &:before, + &:after { + content: ''; + position: absolute; + display: block; + top: 50%; + } + /* Before = Checkbox */ + &:before { + width: $ms-checkbox-size; + height: $ms-checkbox-size; + border-radius: $radius-small; + background-color: $ms-checkbox-bg; + left: $ms-normal-left-padding; + transform: translateY(-50%); + } + /* After = Dot */ + &:after { + width: $ms-checkbox-dot-size; + height: $ms-checkbox-dot-size; + background-color: $ms-checkbox-dot; + border-radius: 50%; + transform: translate(-50%,-50%) scale(2,2); + opacity: 0; + left: ($ms-normal-left-padding + ($ms-checkbox-size / 2)); + transition: + opacity 0.25s ease, + transform 0.25s ease; + } + /* Hover State */ + &:hover { + color: $g20-white !important; + } + } .dropdown-toggle { width: 110px; + + &.btn-xs { + height: 22px; + line-height: 22px; + padding-left: 0; + padding-right: 0; + } } &__apply { margin: 0; @@ -45,6 +119,22 @@ } } } + +/* Checked State */ +.multi-select-dropdown li.multi-select-dropdown__item.active > a { + &, + &:focus, + &:active, + &:active:focus { + background: none !important; + } + color: $g20-white !important; + + &:after { + transform: translate(-50%,-50%) scale(1,1); + opacity: 1; + } +} /* Open State */ .multi-select-dropdown.open { .dropdown-options { @@ -56,3 +146,14 @@ opacity: 1; } } +.multi-select-dropdown__label { + top: 50%; + transform: translateY(-50%); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + padding-right: 10px; + position: absolute; + width: calc(100% - #{($ms-normal-left-padding * 2)}); + left: $ms-normal-left-padding; +} diff --git a/ui/src/style/components/search-widget.scss b/ui/src/style/components/search-widget.scss index d9ce2e6c38..28fbf225cf 100644 --- a/ui/src/style/components/search-widget.scss +++ b/ui/src/style/components/search-widget.scss @@ -2,10 +2,13 @@ Custom Search Widget ---------------------------------------------- */ +$search-widget-height: 36px; + .users__search-widget { position: relative; input.form-control { + height: $search-widget-height; position: relative; width: 100%; z-index: 1; @@ -19,7 +22,7 @@ .input-group-addon { padding: 0; text-align: center; - line-height: 38px; + line-height: calc(#{$search-widget-height} - 2px); position: absolute; color: $g10-wolf; top: 0; @@ -32,4 +35,7 @@ transition: color 0.25s ease; } -} \ No newline at end of file +} +.admin__search-widget { + width: 300px; +} diff --git a/ui/src/style/components/tables.scss b/ui/src/style/components/tables.scss index c2c3a0cdca..a98b38de57 100644 --- a/ui/src/style/components/tables.scss +++ b/ui/src/style/components/tables.scss @@ -2,6 +2,29 @@ Stuff for making Tables of Data more readable ---------------------------------------------- */ + + +table { + thead th { + color: $g17-whisper !important; + border-width: 1px; + border-color: $g5-pepper !important; + } + tbody td { + font-weight: 500; + color: $g14-chromium !important; + border: 0 !important; + padding: 4px 8px !important; + } + tbody tr:hover { + background-color: $g5-pepper; + + td { + color: $g19-ghost !important; + } + } +} + table .monotype { font-family: $code-font; letter-spacing: 0px; @@ -119,4 +142,4 @@ table .monotype { border-color: $g5-pepper; @include custom-scrollbar($g5-pepper, $c-pool); } -} \ No newline at end of file +} diff --git a/ui/src/style/pages/admin.scss b/ui/src/style/pages/admin.scss new file mode 100644 index 0000000000..a3a87812b9 --- /dev/null +++ b/ui/src/style/pages/admin.scss @@ -0,0 +1,122 @@ +/* + Styles for Admin Pages + ---------------------------------------------- +*/ + + + + +/* + Admin Tabs + ---------------------------------------------- +*/ +.admin-tabs { + padding-right: 0; + + & + div { + padding-left: 0; + + .panel { + border-top-left-radius: 0; + } + } +} +.admin-tabs .btn-group { + margin: 0; + width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + + .tab { + font-weight: 500 !important; + border-radius: $radius 0 0 $radius !important; + margin-bottom: 2px !important; + transition: + background-color 0.25s ease, + color 0.25s ease !important; + border: 0 !important; + text-align: left; + height: 60px !important; + line-height: 60px !important; + padding: 0 0 0 16px !important; + font-size: 17px; + background-color: transparent !important; + color: $g11-sidewalk !important; + + &:hover, + &:active, + &:active:hover { + background-color: $g3-castle !important; + color: $g15-platinum !important; + } + &.active { + background-color: $g3-castle !important; + color: $g18-cloud !important; + } + } +} + + +/* + Admin Table + ---------------------------------------------- +*/ +.admin-table { + .multi-select-dropdown { + width: 100%; + min-width: 150px; + } + .admin-table--delete { + visibility: hidden; + } + .dropdown-toggle { + background-color: transparent; + font-size: 14px; + font-weight: 500; + color: $g14-chromium; + width: 100%; + transition: none !important; + + .caret {opacity: 0;} + .multi-select-dropdown__label {left: 0;} + } + tbody tr:hover { + .admin-table--delete { + visibility: visible; + } + .dropdown-toggle { + color: $g20-white !important; + background-color: $c-pool; + font-weight: 600; + + .caret {opacity: 1;} + .multi-select-dropdown__label {left: 9px;} + + &:hover { + transition: background-color 0.25s ease; + background-color: $c-laser; + } + } + } +} +.admin-table--edit-row { + background-color: $g4-onyx; +} +.admin-table--edit-cell { + width: 100%; + margin: 0 !important; + display: flex !important; + justify-content: space-between; + + > input { + height: 30px; + padding: 0 9px; + flex-grow: 1; + margin: 0 2px; + min-width: 110px; + + &:first-child {margin-left: 0;} + &:last-child {margin-right: 0;} + } +} \ No newline at end of file diff --git a/ui/src/style/theme/theme-dark.scss b/ui/src/style/theme/theme-dark.scss index 8bf921302b..0a45f8067c 100644 --- a/ui/src/style/theme/theme-dark.scss +++ b/ui/src/style/theme/theme-dark.scss @@ -29,7 +29,6 @@ .panel-title { color: $g10-wolf !important; } - .panel-body { padding: 30px; background-color: $g3-castle; @@ -42,23 +41,30 @@ > *:last-child { margin-bottom: 0; } - - table { - th,td { - border-color: $g5-pepper; - } - th { - color: $g17-whisper; - } - td { - color: $g14-chromium; - } - tbody tr:last-child td { - border-bottom: 2px solid $g5-pepper; - } - } } } +.panel.panel-info { + background-color: $g3-castle; + border: 0; + + .panel-body, + .panel-heading { + background-color: transparent; + } + .panel-body { + padding: 30px; + } + .panel-heading { + padding: 0 30px; + height: 60px; + border: 0px; + .panel-title { color: $g14-chromium;} + } +} +.panel .panel-body table { + margin: 0; +} + table thead th { @include no-user-select(); } @@ -259,7 +265,6 @@ input { max-width: 100%; margin: 0 !important; padding: 0 !important; - min-height: 70px; max-height: 290px; overflow: auto; @include custom-scrollbar($c-pool, $c-laser); @@ -751,10 +756,6 @@ $form-static-checkbox-size: 16px; } } - - - - br { @include no-user-select(); -} \ No newline at end of file +} diff --git a/ui/src/utils/ajax.js b/ui/src/utils/ajax.js index 654e4dbfc2..e37f01cea1 100644 --- a/ui/src/utils/ajax.js +++ b/ui/src/utils/ajax.js @@ -2,7 +2,7 @@ import axios from 'axios'; let links -const UNAUTHORIZED = 401 +import {RES_UNAUTHORIZED} from 'shared/constants' export default async function AJAX({ url, @@ -13,10 +13,9 @@ export default async function AJAX({ params = {}, headers = {}, }) { - let response - try { const basepath = window.basepath || '' + let response url = `${basepath}${url}` @@ -47,9 +46,11 @@ export default async function AJAX({ ...response, } } catch (error) { - if (!response.status === UNAUTHORIZED) { + const {response} = error + if (!response.status === RES_UNAUTHORIZED) { console.error(error) // eslint-disable-line no-console } + // console.error(error) // eslint-disable-line no-console const {auth} = links throw {auth, ...response} // eslint-disable-line no-throw-literal } diff --git a/ui/src/utils/queryUrlGenerator.js b/ui/src/utils/queryUrlGenerator.js index 79c99ca061..eecf77e05c 100644 --- a/ui/src/utils/queryUrlGenerator.js +++ b/ui/src/utils/queryUrlGenerator.js @@ -1,19 +1,17 @@ import AJAX from 'utils/ajax'; -// TODO: delete this once all references -// to it have been removed -export function buildInfluxUrl() { - return "You dont need me anymore"; -} - -export function proxy({source, query, db, rp}) { - return AJAX({ - method: 'POST', - url: source, - data: { - query, - db, - rp, - }, - }); +export const proxy = async ({source, query, db, rp}) => { + try { + return await AJAX({ + method: 'POST', + url: source, + data: { + query, + db, + rp, + }, + }) + } catch (error) { + console.error(error) // eslint-disable-line no-console + } } diff --git a/ui/stories/admin.js b/ui/stories/admin.js new file mode 100644 index 0000000000..5f6d3a3914 --- /dev/null +++ b/ui/stories/admin.js @@ -0,0 +1,71 @@ +import React from 'react' +import {storiesOf, action, linkTo} from '@kadira/storybook' +import Center from './components/Center' + +import MultiSelectDropdown from 'shared/components/MultiSelectDropdown' +import Tooltip from 'shared/components/Tooltip' + +storiesOf('MultiSelectDropdown', module) + .add('Select Roles w/label', () => ( +
    + +
    + )) + .add('Selected Item list', () => ( +
    + +
    + )) + .add('0 selected items', () => ( +
    + +
    + )) + +storiesOf('Tooltip', module) + .add('Delete', () => ( +
    + +
    + Delete +
    +
    +
    + )) diff --git a/ui/stories/components/Center.js b/ui/stories/components/Center.js new file mode 100644 index 0000000000..e955c0c191 --- /dev/null +++ b/ui/stories/components/Center.js @@ -0,0 +1,14 @@ +import React from 'react' + +const Center = ({children}) => ( +
    + {children} +
    +) + +export default Center diff --git a/ui/stories/index.js b/ui/stories/index.js index 9bec78fd49..a4135a2cda 100644 --- a/ui/stories/index.js +++ b/ui/stories/index.js @@ -3,3 +3,4 @@ import 'src/style/chronograf.scss'; // Kapacitor Stories import './kapacitor' +import './admin' diff --git a/ui/stories/kapacitor.js b/ui/stories/kapacitor.js index f30c9b2a53..d7e3f22e7c 100644 --- a/ui/stories/kapacitor.js +++ b/ui/stories/kapacitor.js @@ -12,7 +12,7 @@ import queryConfigs from './stubs/queryConfigs'; // Actions for Spies import * as kapacitorActions from 'src/kapacitor/actions/view' -import * as queryActions from 'src/chronograf/actions/view'; +import * as queryActions from 'src/data_explorer/actions/view'; // Components import KapacitorRule from 'src/kapacitor/components/KapacitorRule'; diff --git a/ui/storybook.js b/ui/storybook.js new file mode 100644 index 0000000000..b12ace5625 --- /dev/null +++ b/ui/storybook.js @@ -0,0 +1,19 @@ +const express = require('express') +const request = require('request') +const {default: storybook} = require('@kadira/storybook/dist/server/middleware') + +const app = express() + +const handler = (req, res) => { + console.log(`${req.method} ${req.url}`) + const url = 'http://localhost:8888' + req.url + req.pipe(request(url)).pipe(res) +} + +app.use(storybook('./.storybook')) +app.get('/chronograf/v1/*', handler) +app.post('/chronograf/v1/*', handler) + +app.listen(6006, () => { + console.log('storybook proxy server now running') +})