diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 004b4f024..e01d66d40 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.4.0.1 +current_version = 1.4.1.3 files = README.md server/swagger.json parse = (?P\d+)\.(?P\d+)\.(?P\d+)\.(?P\d+) serialize = {major}.{minor}.{patch}.{release} diff --git a/.gitignore b/.gitignore index 7996088af..80332bd90 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ backup/ # Binaries /chronograf +/chronoctl # Dotfiles .pull-request diff --git a/CHANGELOG.md b/CHANGELOG.md index 867eb2472..c1264fd6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,29 +1,46 @@ -## v1.4.1.0 [unreleased] +## v1.4.2.0 [unreleased] ### Features -1. [#2409](https://github.com/influxdata/chronograf/pull/2409): Allow adding multiple event handlers to a rule -1. [#2709](https://github.com/influxdata/chronograf/pull/2709): Add "send test alert" button to test kapacitor alert configurations -1. [#2708](https://github.com/influxdata/chronograf/pull/2708): Link to specified kapacitor config panel from rule builder alert handlers -1. [#2722](https://github.com/influxdata/chronograf/pull/2722): Add auto refresh widget to hosts list page -1. [#2765](https://github.com/influxdata/chronograf/pull/2765): Update to go 1.9.3 and node 6.12.3 for releases -1. [#2784](https://github.com/influxdata/chronograf/pull/2784): Update to go 1.9.4 -1. [#2703](https://github.com/influxdata/chronograf/pull/2703): Add global users page visible only to super admins -1. [#2777](https://github.com/influxdata/chronograf/pull/2777): Allow user to delete themselves - -1. [#2703](https://github.com/influxdata/chronograf/pull/2703): Add global users page visible only to super admins -1. [#2781](https://github.com/influxdata/chronograf/pull/2781): Add commands to users & create super admin ### UI Improvements -1. [#2698](https://github.com/influxdata/chronograf/pull/2698): Improve clarity of terminology surrounding InfluxDB & Kapacitor connections +### Bug Fixes + +## v1.4.1.3 [2018-02-14] +### Bug Fixes +1. [#2818](https://github.com/influxdata/chronograf/pull/2818): Allow self-signed certificates for Enterprise InfluxDB Meta nodes + +## v1.4.1.2 [2018-02-13] +### Bug Fixes +1. [9321336](https://github.com/influxdata/chronograf/commit/9321336): Respect basepath when fetching server api routes +1. [#2812](https://github.com/influxdata/chronograf/pull/2812): Set default tempVar :interval: with data explorer csv download call. +1. [#2811](https://github.com/influxdata/chronograf/pull/2811): Display series with value of 0 in a cell legend + +## v1.4.1.1 [2018-02-12] +### Features +1. [#2409](https://github.com/influxdata/chronograf/pull/2409): Allow multiple event handlers per rule +1. [#2709](https://github.com/influxdata/chronograf/pull/2709): Add "send test alert" button to test kapacitor alert configurations +1. [#2708](https://github.com/influxdata/chronograf/pull/2708): Link to kapacitor config panel from alert rule builder +1. [#2722](https://github.com/influxdata/chronograf/pull/2722): Add auto refresh widget to hosts list page +1. [#2784](https://github.com/influxdata/chronograf/pull/2784): Update go from 1.9.3 to 1.9.4 +1. [#2765](https://github.com/influxdata/chronograf/pull/2765): Update to go 1.9.3 and node 6.12.3 for releases +1. [#2777](https://github.com/influxdata/chronograf/pull/2777): Allow user to delete themselves +1. [#2703](https://github.com/influxdata/chronograf/pull/2703): Add All Users page, visible only to super admins +1. [#2781](https://github.com/influxdata/chronograf/pull/2781): Introduce chronoctl binary for user CRUD operations +1. [#2699](https://github.com/influxdata/chronograf/pull/2699): Introduce Mappings to allow control over new user organization assignments +### UI Improvements +1. [#2698](https://github.com/influxdata/chronograf/pull/2698): Clarify terminology surrounding InfluxDB & Kapacitor connections 1. [#2746](https://github.com/influxdata/chronograf/pull/2746): Separate saving TICKscript from exiting editor page 1. [#2774](https://github.com/influxdata/chronograf/pull/2774): Enable Save (⌘ + Enter) and Cancel (Escape) hotkeys in Cell Editor Overlay +1. [#2788](https://github.com/influxdata/chronograf/pull/2788): Enable customization of Single Stat "Base Color" ### Bug Fixes 1. [#2684](https://github.com/influxdata/chronograf/pull/2684): Fix TICKscript Sensu alerts when no group by tags selected -1. [#2735](https://github.com/influxdata/chronograf/pull/2735): Remove cli options from systemd service file -1. [#2757](https://github.com/influxdata/chronograf/pull/2757): Added "TO" field to kapacitor SMTP config, and improved error messages for config saving and testing +1. [#2756](https://github.com/influxdata/chronograf/pull/2756): Display 200 most-recent TICKscript log messages; prevent overlapping +1. [#2757](https://github.com/influxdata/chronograf/pull/2757): Add "TO" to kapacitor SMTP config; improve config update error messages 1. [#2761](https://github.com/influxdata/chronograf/pull/2761): Remove cli options from sysvinit service file -1. [#2780](https://github.com/influxdata/chronograf/pull/2780): Fix routing on alert save +1. [#2735](https://github.com/influxdata/chronograf/pull/2735): Remove cli options from systemd service file +1. [#2788](https://github.com/influxdata/chronograf/pull/2788): Fix disappearance of text in Single Stat graphs during editing +1. [#2780](https://github.com/influxdata/chronograf/pull/2780): Redirect to Alerts page after saving Alert Rule -## v1.4.0.1 [2017-1-9] +## v1.4.0.1 [2018-1-9] ### Features 1. [#2690](https://github.com/influxdata/chronograf/pull/2690): Add separate CLI flag for canned sources, kapacitors, dashboards, and organizations 1. [#2672](https://github.com/influxdata/chronograf/pull/2672): Add telegraf interval configuration diff --git a/Dockerfile b/Dockerfile index e43b07bf7..ca42be159 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ RUN apk add --update ca-certificates && \ rm /var/cache/apk/* ADD chronograf /usr/bin/chronograf +ADD chronoctl /usr/bin/chronoctl ADD canned/*.json /usr/share/chronograf/canned/ ADD LICENSE /usr/share/chronograf/LICENSE ADD agpl-3.0.md /usr/share/chronograf/agpl-3.0.md diff --git a/Makefile b/Makefile index e226bb089..98f18eb23 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ UISOURCES := $(shell find ui -type f -not \( -path ui/build/\* -o -path ui/node_ unexport LDFLAGS LDFLAGS=-ldflags "-s -X main.version=${VERSION} -X main.commit=${COMMIT}" BINARY=chronograf +CTLBINARY=chronoctl .DEFAULT_GOAL := all @@ -22,6 +23,7 @@ dev: dep dev-assets ${BINARY} ${BINARY}: $(SOURCES) .bindata .jsdep .godep go build -o ${BINARY} ${LDFLAGS} ./cmd/chronograf/main.go + go build -o ${CTLBINARY} ${LDFLAGS} ./cmd/chronoctl define CHRONOGIRAFFE ._ o o diff --git a/README.md b/README.md index 1666ad28f..1ea9e7adb 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ option. ## Versions The most recent version of Chronograf is -[v1.4.0.1](https://www.influxdata.com/downloads/). +[v1.4.1.3](https://www.influxdata.com/downloads/). Spotted a bug or have a feature request? Please open [an issue](https://github.com/influxdata/chronograf/issues/new)! @@ -178,7 +178,7 @@ By default, chronograf runs on port `8888`. To get started right away with Docker, you can pull down our latest release: ```sh -docker pull chronograf:1.4.0.1 +docker pull chronograf:1.4.1.3 ``` ### From Source diff --git a/bolt/client.go b/bolt/client.go index fad811246..2515f1975 100644 --- a/bolt/client.go +++ b/bolt/client.go @@ -41,6 +41,7 @@ type Client struct { UsersStore *UsersStore OrganizationsStore *OrganizationsStore ConfigStore *ConfigStore + MappingsStore *MappingsStore } // NewClient initializes all stores @@ -60,6 +61,7 @@ func NewClient() *Client { c.UsersStore = &UsersStore{client: c} c.OrganizationsStore = &OrganizationsStore{client: c} c.ConfigStore = &ConfigStore{client: c} + c.MappingsStore = &MappingsStore{client: c} return c } @@ -151,6 +153,10 @@ func (c *Client) initialize(ctx context.Context) error { if _, err := tx.CreateBucketIfNotExists(BuildBucket); err != nil { return err } + // Always create Mapping bucket. + if _, err := tx.CreateBucketIfNotExists(MappingsBucket); err != nil { + return err + } return nil }); err != nil { return err @@ -184,6 +190,9 @@ func (c *Client) migrate(ctx context.Context, build chronograf.BuildInfo) error if err := c.BuildStore.Migrate(ctx, build); err != nil { return err } + if err := c.MappingsStore.Migrate(ctx); err != nil { + return err + } } return nil } diff --git a/bolt/internal/internal.go b/bolt/internal/internal.go index b75fc0e0c..729bb8627 100644 --- a/bolt/internal/internal.go +++ b/bolt/internal/internal.go @@ -586,11 +586,11 @@ func UnmarshalRolePB(data []byte, r *Role) error { // MarshalOrganization encodes a organization to binary protobuf format. func MarshalOrganization(o *chronograf.Organization) ([]byte, error) { + return MarshalOrganizationPB(&Organization{ ID: o.ID, Name: o.Name, DefaultRole: o.DefaultRole, - Public: o.Public, }) } @@ -608,7 +608,6 @@ func UnmarshalOrganization(data []byte, o *chronograf.Organization) error { o.ID = pb.ID o.Name = pb.Name o.DefaultRole = pb.DefaultRole - o.Public = pb.Public return nil } @@ -650,3 +649,41 @@ func UnmarshalConfig(data []byte, c *chronograf.Config) error { func UnmarshalConfigPB(data []byte, c *Config) error { return proto.Unmarshal(data, c) } + +// MarshalMapping encodes a mapping to binary protobuf format. +func MarshalMapping(m *chronograf.Mapping) ([]byte, error) { + + return MarshalMappingPB(&Mapping{ + Provider: m.Provider, + Scheme: m.Scheme, + ProviderOrganization: m.ProviderOrganization, + ID: m.ID, + Organization: m.Organization, + }) +} + +// MarshalMappingPB encodes a mapping to binary protobuf format. +func MarshalMappingPB(m *Mapping) ([]byte, error) { + return proto.Marshal(m) +} + +// UnmarshalMapping decodes a mapping from binary protobuf data. +func UnmarshalMapping(data []byte, m *chronograf.Mapping) error { + var pb Mapping + if err := UnmarshalMappingPB(data, &pb); err != nil { + return err + } + + m.Provider = pb.Provider + m.Scheme = pb.Scheme + m.ProviderOrganization = pb.ProviderOrganization + m.Organization = pb.Organization + m.ID = pb.ID + + return nil +} + +// UnmarshalMappingPB decodes a mapping from binary protobuf data. +func UnmarshalMappingPB(data []byte, m *Mapping) error { + return proto.Unmarshal(data, m) +} diff --git a/bolt/internal/internal.pb.go b/bolt/internal/internal.pb.go index 6e29bdcba..3fa596339 100644 --- a/bolt/internal/internal.pb.go +++ b/bolt/internal/internal.pb.go @@ -1,6 +1,5 @@ -// Code generated by protoc-gen-gogo. +// Code generated by protoc-gen-gogo. DO NOT EDIT. // source: internal.proto -// DO NOT EDIT! /* Package internal is a generated protocol buffer package. @@ -27,6 +26,7 @@ It has these top-level messages: AlertRule User Role + Mapping Organization Config AuthConfig @@ -1057,17 +1057,64 @@ func (m *Role) GetName() string { return "" } +type Mapping struct { + Provider string `protobuf:"bytes,1,opt,name=Provider,proto3" json:"Provider,omitempty"` + Scheme string `protobuf:"bytes,2,opt,name=Scheme,proto3" json:"Scheme,omitempty"` + ProviderOrganization string `protobuf:"bytes,3,opt,name=ProviderOrganization,proto3" json:"ProviderOrganization,omitempty"` + ID string `protobuf:"bytes,4,opt,name=ID,proto3" json:"ID,omitempty"` + Organization string `protobuf:"bytes,5,opt,name=Organization,proto3" json:"Organization,omitempty"` +} + +func (m *Mapping) Reset() { *m = Mapping{} } +func (m *Mapping) String() string { return proto.CompactTextString(m) } +func (*Mapping) ProtoMessage() {} +func (*Mapping) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{18} } + +func (m *Mapping) GetProvider() string { + if m != nil { + return m.Provider + } + return "" +} + +func (m *Mapping) GetScheme() string { + if m != nil { + return m.Scheme + } + return "" +} + +func (m *Mapping) GetProviderOrganization() string { + if m != nil { + return m.ProviderOrganization + } + return "" +} + +func (m *Mapping) GetID() string { + if m != nil { + return m.ID + } + return "" +} + +func (m *Mapping) GetOrganization() string { + if m != nil { + return m.Organization + } + return "" +} + type Organization struct { ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` DefaultRole string `protobuf:"bytes,3,opt,name=DefaultRole,proto3" json:"DefaultRole,omitempty"` - Public bool `protobuf:"varint,4,opt,name=Public,proto3" json:"Public,omitempty"` } func (m *Organization) Reset() { *m = Organization{} } func (m *Organization) String() string { return proto.CompactTextString(m) } func (*Organization) ProtoMessage() {} -func (*Organization) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{18} } +func (*Organization) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{19} } func (m *Organization) GetID() string { if m != nil { @@ -1090,13 +1137,6 @@ func (m *Organization) GetDefaultRole() string { return "" } -func (m *Organization) GetPublic() bool { - if m != nil { - return m.Public - } - return false -} - type Config struct { Auth *AuthConfig `protobuf:"bytes,1,opt,name=Auth" json:"Auth,omitempty"` } @@ -1104,7 +1144,7 @@ type Config struct { func (m *Config) Reset() { *m = Config{} } func (m *Config) String() string { return proto.CompactTextString(m) } func (*Config) ProtoMessage() {} -func (*Config) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{19} } +func (*Config) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{20} } func (m *Config) GetAuth() *AuthConfig { if m != nil { @@ -1120,7 +1160,7 @@ type AuthConfig struct { func (m *AuthConfig) Reset() { *m = AuthConfig{} } func (m *AuthConfig) String() string { return proto.CompactTextString(m) } func (*AuthConfig) ProtoMessage() {} -func (*AuthConfig) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{20} } +func (*AuthConfig) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{21} } func (m *AuthConfig) GetSuperAdminNewUsers() bool { if m != nil { @@ -1137,7 +1177,7 @@ type BuildInfo struct { func (m *BuildInfo) Reset() { *m = BuildInfo{} } func (m *BuildInfo) String() string { return proto.CompactTextString(m) } func (*BuildInfo) ProtoMessage() {} -func (*BuildInfo) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{21} } +func (*BuildInfo) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{22} } func (m *BuildInfo) GetVersion() string { if m != nil { @@ -1172,6 +1212,7 @@ func init() { proto.RegisterType((*AlertRule)(nil), "internal.AlertRule") proto.RegisterType((*User)(nil), "internal.User") proto.RegisterType((*Role)(nil), "internal.Role") + proto.RegisterType((*Mapping)(nil), "internal.Mapping") proto.RegisterType((*Organization)(nil), "internal.Organization") proto.RegisterType((*Config)(nil), "internal.Config") proto.RegisterType((*AuthConfig)(nil), "internal.AuthConfig") @@ -1181,92 +1222,93 @@ func init() { func init() { proto.RegisterFile("internal.proto", fileDescriptorInternal) } var fileDescriptorInternal = []byte{ - // 1379 bytes of a gzipped FileDescriptorProto + // 1406 bytes of a gzipped FileDescriptorProto 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xbc, 0x57, 0x5f, 0x8f, 0xdb, 0x44, - 0x10, 0x97, 0xe3, 0x38, 0x89, 0x27, 0xd7, 0x52, 0x99, 0x8a, 0x9a, 0x22, 0xa1, 0x60, 0x81, 0x08, - 0x82, 0x1e, 0xe8, 0x2a, 0x24, 0x84, 0xa0, 0x52, 0xee, 0x82, 0xca, 0xd1, 0x6b, 0x7b, 0xdd, 0xdc, - 0x1d, 0x4f, 0xa8, 0xda, 0x38, 0x93, 0xc4, 0xaa, 0x63, 0x9b, 0xb5, 0x7d, 0x17, 0xf3, 0x61, 0x90, - 0x90, 0xf8, 0x04, 0x88, 0x77, 0x5e, 0x11, 0x1f, 0x84, 0xaf, 0xc0, 0x13, 0x12, 0x9a, 0xdd, 0xf5, - 0x9f, 0x5c, 0x42, 0xd5, 0x07, 0xc4, 0xdb, 0xfe, 0x66, 0x36, 0xb3, 0xf3, 0xe7, 0x37, 0x33, 0x0e, - 0xdc, 0x0c, 0xa2, 0x0c, 0x45, 0xc4, 0xc3, 0xfd, 0x44, 0xc4, 0x59, 0xec, 0xf4, 0x4a, 0xec, 0xfd, - 0xd9, 0x82, 0xce, 0x24, 0xce, 0x85, 0x8f, 0xce, 0x4d, 0x68, 0x1d, 0x8f, 0x5d, 0x63, 0x60, 0x0c, - 0x4d, 0xd6, 0x3a, 0x1e, 0x3b, 0x0e, 0xb4, 0x9f, 0xf0, 0x15, 0xba, 0xad, 0x81, 0x31, 0xb4, 0x99, - 0x3c, 0x93, 0xec, 0xac, 0x48, 0xd0, 0x35, 0x95, 0x8c, 0xce, 0xce, 0x5d, 0xe8, 0x9d, 0xa7, 0x64, - 0x6d, 0x85, 0x6e, 0x5b, 0xca, 0x2b, 0x4c, 0xba, 0x53, 0x9e, 0xa6, 0x57, 0xb1, 0x98, 0xb9, 0x96, - 0xd2, 0x95, 0xd8, 0xb9, 0x05, 0xe6, 0x39, 0x3b, 0x71, 0x3b, 0x52, 0x4c, 0x47, 0xc7, 0x85, 0xee, - 0x18, 0xe7, 0x3c, 0x0f, 0x33, 0xb7, 0x3b, 0x30, 0x86, 0x3d, 0x56, 0x42, 0xb2, 0x73, 0x86, 0x21, - 0x2e, 0x04, 0x9f, 0xbb, 0x3d, 0x65, 0xa7, 0xc4, 0xce, 0x3e, 0x38, 0xc7, 0x51, 0x8a, 0x7e, 0x2e, - 0x70, 0xf2, 0x22, 0x48, 0x2e, 0x50, 0x04, 0xf3, 0xc2, 0xb5, 0xa5, 0x81, 0x1d, 0x1a, 0x7a, 0xe5, - 0x31, 0x66, 0x9c, 0xde, 0x06, 0x69, 0xaa, 0x84, 0x8e, 0x07, 0x7b, 0x93, 0x25, 0x17, 0x38, 0x9b, - 0xa0, 0x2f, 0x30, 0x73, 0xfb, 0x52, 0xbd, 0x21, 0xa3, 0x3b, 0x4f, 0xc5, 0x82, 0x47, 0xc1, 0x0f, - 0x3c, 0x0b, 0xe2, 0xc8, 0xdd, 0x53, 0x77, 0x9a, 0x32, 0xca, 0x12, 0x8b, 0x43, 0x74, 0x6f, 0xa8, - 0x2c, 0xd1, 0xd9, 0xfb, 0xd5, 0x00, 0x7b, 0xcc, 0xd3, 0xe5, 0x34, 0xe6, 0x62, 0xf6, 0x4a, 0xb9, - 0xbe, 0x07, 0x96, 0x8f, 0x61, 0x98, 0xba, 0xe6, 0xc0, 0x1c, 0xf6, 0x0f, 0xee, 0xec, 0x57, 0x45, - 0xac, 0xec, 0x1c, 0x61, 0x18, 0x32, 0x75, 0xcb, 0xf9, 0x04, 0xec, 0x0c, 0x57, 0x49, 0xc8, 0x33, - 0x4c, 0xdd, 0xb6, 0xfc, 0x89, 0x53, 0xff, 0xe4, 0x4c, 0xab, 0x58, 0x7d, 0x69, 0x2b, 0x14, 0x6b, - 0x3b, 0x14, 0xef, 0xef, 0x16, 0xdc, 0xd8, 0x78, 0xce, 0xd9, 0x03, 0x63, 0x2d, 0x3d, 0xb7, 0x98, - 0xb1, 0x26, 0x54, 0x48, 0xaf, 0x2d, 0x66, 0x14, 0x84, 0xae, 0x24, 0x37, 0x2c, 0x66, 0x5c, 0x11, - 0x5a, 0x4a, 0x46, 0x58, 0xcc, 0x58, 0x3a, 0x1f, 0x40, 0xf7, 0xfb, 0x1c, 0x45, 0x80, 0xa9, 0x6b, - 0x49, 0xef, 0x5e, 0xab, 0xbd, 0x7b, 0x96, 0xa3, 0x28, 0x58, 0xa9, 0xa7, 0x6c, 0x48, 0x36, 0x29, - 0x6a, 0xc8, 0x33, 0xc9, 0x32, 0x62, 0x5e, 0x57, 0xc9, 0xe8, 0xac, 0xb3, 0xa8, 0xf8, 0x40, 0x59, - 0xfc, 0x14, 0xda, 0x7c, 0x8d, 0xa9, 0x6b, 0x4b, 0xfb, 0xef, 0xfc, 0x4b, 0xc2, 0xf6, 0x47, 0x6b, - 0x4c, 0xbf, 0x8a, 0x32, 0x51, 0x30, 0x79, 0xdd, 0x79, 0x1f, 0x3a, 0x7e, 0x1c, 0xc6, 0x22, 0x75, - 0xe1, 0xba, 0x63, 0x47, 0x24, 0x67, 0x5a, 0xed, 0x0c, 0xa1, 0x13, 0xe2, 0x02, 0xa3, 0x99, 0x64, - 0x46, 0xff, 0xe0, 0x56, 0x7d, 0xf1, 0x44, 0xca, 0x99, 0xd6, 0xdf, 0x7d, 0x08, 0x76, 0xf5, 0x0a, - 0x11, 0xfd, 0x05, 0x16, 0x32, 0x67, 0x36, 0xa3, 0xa3, 0xf3, 0x2e, 0x58, 0x97, 0x3c, 0xcc, 0x55, - 0xbd, 0xfb, 0x07, 0x37, 0x6b, 0x3b, 0xa3, 0x75, 0x90, 0x32, 0xa5, 0xfc, 0xbc, 0xf5, 0x99, 0xe1, - 0x2d, 0xc0, 0x92, 0x3e, 0x34, 0x18, 0x63, 0x97, 0x8c, 0x91, 0x9d, 0xd8, 0x6a, 0x74, 0xe2, 0x2d, - 0x30, 0xbf, 0xc6, 0xb5, 0x6e, 0x4e, 0x3a, 0x56, 0xbc, 0x6a, 0x37, 0x78, 0x75, 0x1b, 0xac, 0x0b, - 0xf9, 0xb8, 0xaa, 0xb7, 0x02, 0xde, 0x03, 0xe8, 0xa8, 0x18, 0x2a, 0xcb, 0x46, 0xc3, 0xf2, 0x00, - 0xfa, 0x4f, 0x45, 0x80, 0x51, 0xa6, 0x98, 0xa2, 0x1e, 0x6d, 0x8a, 0xbc, 0x5f, 0x0c, 0x68, 0x93, - 0xf3, 0xc4, 0xaa, 0x10, 0x17, 0xdc, 0x2f, 0x0e, 0xe3, 0x3c, 0x9a, 0xa5, 0xae, 0x31, 0x30, 0x87, - 0x26, 0xdb, 0x90, 0x39, 0x6f, 0x40, 0x67, 0xaa, 0xb4, 0xad, 0x81, 0x39, 0xb4, 0x99, 0x46, 0xe4, - 0x5a, 0xc8, 0xa7, 0x18, 0xea, 0x10, 0x14, 0xa0, 0xdb, 0x89, 0xc0, 0x79, 0xb0, 0xd6, 0x61, 0x68, - 0x44, 0xf2, 0x34, 0x9f, 0x93, 0x5c, 0x45, 0xa2, 0x11, 0x05, 0x30, 0xe5, 0x69, 0x45, 0x1f, 0x3a, - 0x93, 0xe5, 0xd4, 0xe7, 0x61, 0xc9, 0x1f, 0x05, 0xbc, 0xdf, 0x0c, 0x9a, 0x2b, 0xaa, 0x1f, 0xb6, - 0x32, 0xfc, 0x26, 0xf4, 0xa8, 0x57, 0x9e, 0x5f, 0x72, 0xa1, 0x03, 0xee, 0x12, 0xbe, 0xe0, 0xc2, - 0xf9, 0x18, 0x3a, 0xb2, 0x44, 0x3b, 0x7a, 0xb3, 0x34, 0x27, 0xb3, 0xca, 0xf4, 0xb5, 0x8a, 0xbd, - 0xed, 0x06, 0x7b, 0xab, 0x60, 0xad, 0x66, 0xb0, 0xf7, 0xc0, 0xa2, 0x36, 0x28, 0xa4, 0xf7, 0x3b, - 0x2d, 0xab, 0x66, 0x51, 0xb7, 0xbc, 0x73, 0xb8, 0xb1, 0xf1, 0x62, 0xf5, 0x92, 0xb1, 0xf9, 0x52, - 0x4d, 0x37, 0x5b, 0xd3, 0x8b, 0x66, 0x6a, 0x8a, 0x21, 0xfa, 0x19, 0xce, 0x64, 0xbe, 0x7b, 0xac, - 0xc2, 0xde, 0x4f, 0x46, 0x6d, 0x57, 0xbe, 0x47, 0x53, 0xd3, 0x8f, 0x57, 0x2b, 0x1e, 0xcd, 0xb4, - 0xe9, 0x12, 0x52, 0xde, 0x66, 0x53, 0x6d, 0xba, 0x35, 0x9b, 0x12, 0x16, 0x89, 0xae, 0x60, 0x4b, - 0x24, 0xc4, 0x9d, 0x15, 0xf2, 0x34, 0x17, 0xb8, 0xc2, 0x28, 0xd3, 0x29, 0x68, 0x8a, 0x9c, 0x3b, - 0xd0, 0xcd, 0xf8, 0xe2, 0x39, 0x35, 0x89, 0xae, 0x64, 0xc6, 0x17, 0x8f, 0xb0, 0x70, 0xde, 0x02, - 0x7b, 0x1e, 0x60, 0x38, 0x93, 0x2a, 0x55, 0xce, 0x9e, 0x14, 0x3c, 0xc2, 0xc2, 0xfb, 0xdd, 0x80, - 0xce, 0x04, 0xc5, 0x25, 0x8a, 0x57, 0x1a, 0xa7, 0xcd, 0x35, 0x65, 0xbe, 0x64, 0x4d, 0xb5, 0x77, - 0xaf, 0x29, 0xab, 0x5e, 0x53, 0xb7, 0xc1, 0x9a, 0x08, 0xff, 0x78, 0x2c, 0x3d, 0x32, 0x99, 0x02, - 0xc4, 0xc6, 0x91, 0x9f, 0x05, 0x97, 0xa8, 0x77, 0x97, 0x46, 0x5b, 0x53, 0xb6, 0xb7, 0x63, 0xca, - 0xfe, 0x68, 0x40, 0xe7, 0x84, 0x17, 0x71, 0x9e, 0x6d, 0xb1, 0x70, 0x00, 0xfd, 0x51, 0x92, 0x84, - 0x81, 0xbf, 0xd1, 0x79, 0x0d, 0x11, 0xdd, 0x78, 0xdc, 0xc8, 0xaf, 0x8a, 0xad, 0x29, 0xa2, 0x71, - 0x73, 0x24, 0x37, 0x89, 0x5a, 0x0b, 0x8d, 0x71, 0xa3, 0x16, 0x88, 0x54, 0x52, 0x12, 0x46, 0x79, - 0x16, 0xcf, 0xc3, 0xf8, 0x4a, 0x46, 0xdb, 0x63, 0x15, 0xf6, 0xfe, 0x68, 0x41, 0xfb, 0xff, 0x9a, - 0xfe, 0x7b, 0x60, 0x04, 0xba, 0xd8, 0x46, 0x50, 0xed, 0x82, 0x6e, 0x63, 0x17, 0xb8, 0xd0, 0x2d, - 0x04, 0x8f, 0x16, 0x98, 0xba, 0x3d, 0x39, 0x5d, 0x4a, 0x28, 0x35, 0xb2, 0x8f, 0xd4, 0x12, 0xb0, - 0x59, 0x09, 0xab, 0xbe, 0x80, 0x46, 0x5f, 0x7c, 0xa4, 0xf7, 0x45, 0x5f, 0x7a, 0xe4, 0x6e, 0xa6, - 0xe5, 0xfa, 0x9a, 0xf8, 0xef, 0x66, 0xfa, 0x5f, 0x06, 0x58, 0x55, 0x53, 0x1d, 0x6d, 0x36, 0xd5, - 0x51, 0xdd, 0x54, 0xe3, 0xc3, 0xb2, 0xa9, 0xc6, 0x87, 0x84, 0xd9, 0x69, 0xd9, 0x54, 0xec, 0x94, - 0x8a, 0xf5, 0x50, 0xc4, 0x79, 0x72, 0x58, 0xa8, 0xaa, 0xda, 0xac, 0xc2, 0xc4, 0xc4, 0x6f, 0x97, - 0x28, 0x74, 0xaa, 0x6d, 0xa6, 0x11, 0xf1, 0xf6, 0x44, 0x0e, 0x1c, 0x95, 0x5c, 0x05, 0x9c, 0xf7, - 0xc0, 0x62, 0x94, 0x3c, 0x99, 0xe1, 0x8d, 0xba, 0x48, 0x31, 0x53, 0x5a, 0x32, 0xaa, 0xbe, 0x13, - 0x35, 0x81, 0xcb, 0xaf, 0xc6, 0x0f, 0xa1, 0x33, 0x59, 0x06, 0xf3, 0xac, 0xdc, 0xba, 0xaf, 0x37, - 0x06, 0x56, 0xb0, 0x42, 0xa9, 0x63, 0xfa, 0x8a, 0xf7, 0x0c, 0xec, 0x4a, 0x58, 0xbb, 0x63, 0x34, - 0xdd, 0x71, 0xa0, 0x7d, 0x1e, 0x05, 0x59, 0xd9, 0xba, 0x74, 0xa6, 0x60, 0x9f, 0xe5, 0x3c, 0xca, - 0x82, 0xac, 0x28, 0x5b, 0xb7, 0xc4, 0xde, 0x7d, 0xed, 0x3e, 0x99, 0x3b, 0x4f, 0x12, 0x14, 0x7a, - 0x0c, 0x28, 0x20, 0x1f, 0x89, 0xaf, 0x50, 0x4d, 0x70, 0x93, 0x29, 0xe0, 0x7d, 0x07, 0xf6, 0x28, - 0x44, 0x91, 0xb1, 0x3c, 0xc4, 0x5d, 0x9b, 0xf5, 0x9b, 0xc9, 0xd3, 0x27, 0xa5, 0x07, 0x74, 0xae, - 0x5b, 0xde, 0xbc, 0xd6, 0xf2, 0x8f, 0x78, 0xc2, 0x8f, 0xc7, 0x92, 0xe7, 0x26, 0xd3, 0xc8, 0xfb, - 0xd9, 0x80, 0x36, 0xcd, 0x96, 0x86, 0xe9, 0xf6, 0xcb, 0xe6, 0xd2, 0xa9, 0x88, 0x2f, 0x83, 0x19, - 0x8a, 0x32, 0xb8, 0x12, 0xcb, 0xa4, 0xfb, 0x4b, 0xac, 0x16, 0xb8, 0x46, 0xc4, 0x35, 0xfa, 0xa8, - 0x2c, 0x7b, 0xa9, 0xc1, 0x35, 0x12, 0x33, 0xa5, 0x74, 0xde, 0x06, 0x98, 0xe4, 0x09, 0x8a, 0xd1, - 0x6c, 0x15, 0x44, 0xb2, 0xe8, 0x3d, 0xd6, 0x90, 0x78, 0x0f, 0xd4, 0x67, 0xea, 0xd6, 0x84, 0x32, - 0x76, 0x7f, 0xd2, 0x5e, 0xf7, 0xdc, 0x0b, 0x37, 0x7f, 0xb7, 0x2b, 0x91, 0x5b, 0xd1, 0x0e, 0xa0, - 0xaf, 0xbf, 0xe9, 0xe5, 0x17, 0xb2, 0x1e, 0x56, 0x0d, 0x11, 0xc5, 0x7c, 0x9a, 0x4f, 0xc3, 0xc0, - 0x97, 0x31, 0xf7, 0x98, 0x46, 0xde, 0x01, 0x74, 0x8e, 0xe2, 0x68, 0x1e, 0x2c, 0x9c, 0x21, 0xb4, - 0x47, 0x79, 0xb6, 0x94, 0x2f, 0xf5, 0x0f, 0x6e, 0x37, 0x1a, 0x2d, 0xcf, 0x96, 0xea, 0x0e, 0x93, - 0x37, 0xbc, 0x2f, 0x00, 0x6a, 0x19, 0xfd, 0x51, 0xa8, 0xa3, 0x7f, 0x82, 0x57, 0x54, 0xa2, 0x54, - 0x5a, 0xe9, 0xb1, 0x1d, 0x1a, 0xef, 0x4b, 0xb0, 0x0f, 0xf3, 0x20, 0x9c, 0x1d, 0x47, 0xf3, 0x98, - 0x5a, 0xf5, 0x02, 0x45, 0x5a, 0xe7, 0xa7, 0x84, 0xe4, 0x30, 0x75, 0x6d, 0xc5, 0x59, 0x8d, 0xa6, - 0x1d, 0xf9, 0x5f, 0xeb, 0xfe, 0x3f, 0x01, 0x00, 0x00, 0xff, 0xff, 0x0f, 0x64, 0xa4, 0xff, 0x7d, - 0x0d, 0x00, 0x00, + 0x10, 0x97, 0x63, 0x3b, 0x89, 0x27, 0xd7, 0x52, 0x99, 0x13, 0x35, 0x45, 0x42, 0xc1, 0x02, 0x11, + 0x04, 0x3d, 0xd0, 0x55, 0x48, 0x08, 0x41, 0xa5, 0xdc, 0x05, 0x95, 0xa3, 0xd7, 0xf6, 0xba, 0xb9, + 0x3b, 0x9e, 0x50, 0xb5, 0x97, 0x4c, 0x12, 0xab, 0x8e, 0x6d, 0xd6, 0xf6, 0x5d, 0xcc, 0x87, 0x41, + 0x42, 0x82, 0x2f, 0x80, 0x78, 0xe7, 0x15, 0xf1, 0x41, 0xf8, 0x0a, 0x3c, 0x21, 0xa1, 0xd9, 0x5d, + 0xff, 0xc9, 0x25, 0xad, 0xfa, 0x80, 0x78, 0xdb, 0xdf, 0xcc, 0x66, 0x76, 0xfe, 0xfc, 0x66, 0xc6, + 0x81, 0x9b, 0x41, 0x94, 0xa1, 0x88, 0x78, 0xb8, 0x97, 0x88, 0x38, 0x8b, 0xdd, 0x6e, 0x89, 0xfd, + 0xbf, 0x5a, 0xd0, 0x1e, 0xc7, 0xb9, 0x98, 0xa0, 0x7b, 0x13, 0x5a, 0x47, 0x23, 0xcf, 0xe8, 0x1b, + 0x03, 0x93, 0xb5, 0x8e, 0x46, 0xae, 0x0b, 0xd6, 0x63, 0xbe, 0x44, 0xaf, 0xd5, 0x37, 0x06, 0x0e, + 0x93, 0x67, 0x92, 0x9d, 0x16, 0x09, 0x7a, 0xa6, 0x92, 0xd1, 0xd9, 0xbd, 0x03, 0xdd, 0xb3, 0x94, + 0xac, 0x2d, 0xd1, 0xb3, 0xa4, 0xbc, 0xc2, 0xa4, 0x3b, 0xe1, 0x69, 0x7a, 0x15, 0x8b, 0xa9, 0x67, + 0x2b, 0x5d, 0x89, 0xdd, 0x5b, 0x60, 0x9e, 0xb1, 0x63, 0xaf, 0x2d, 0xc5, 0x74, 0x74, 0x3d, 0xe8, + 0x8c, 0x70, 0xc6, 0xf3, 0x30, 0xf3, 0x3a, 0x7d, 0x63, 0xd0, 0x65, 0x25, 0x24, 0x3b, 0xa7, 0x18, + 0xe2, 0x5c, 0xf0, 0x99, 0xd7, 0x55, 0x76, 0x4a, 0xec, 0xee, 0x81, 0x7b, 0x14, 0xa5, 0x38, 0xc9, + 0x05, 0x8e, 0x9f, 0x07, 0xc9, 0x39, 0x8a, 0x60, 0x56, 0x78, 0x8e, 0x34, 0xb0, 0x45, 0x43, 0xaf, + 0x3c, 0xc2, 0x8c, 0xd3, 0xdb, 0x20, 0x4d, 0x95, 0xd0, 0xf5, 0x61, 0x67, 0xbc, 0xe0, 0x02, 0xa7, + 0x63, 0x9c, 0x08, 0xcc, 0xbc, 0x9e, 0x54, 0xaf, 0xc9, 0xe8, 0xce, 0x13, 0x31, 0xe7, 0x51, 0xf0, + 0x03, 0xcf, 0x82, 0x38, 0xf2, 0x76, 0xd4, 0x9d, 0xa6, 0x8c, 0xb2, 0xc4, 0xe2, 0x10, 0xbd, 0x1b, + 0x2a, 0x4b, 0x74, 0xf6, 0x7f, 0x33, 0xc0, 0x19, 0xf1, 0x74, 0x71, 0x11, 0x73, 0x31, 0x7d, 0xa5, + 0x5c, 0xdf, 0x05, 0x7b, 0x82, 0x61, 0x98, 0x7a, 0x66, 0xdf, 0x1c, 0xf4, 0xf6, 0x6f, 0xef, 0x55, + 0x45, 0xac, 0xec, 0x1c, 0x62, 0x18, 0x32, 0x75, 0xcb, 0xfd, 0x04, 0x9c, 0x0c, 0x97, 0x49, 0xc8, + 0x33, 0x4c, 0x3d, 0x4b, 0xfe, 0xc4, 0xad, 0x7f, 0x72, 0xaa, 0x55, 0xac, 0xbe, 0xb4, 0x11, 0x8a, + 0xbd, 0x19, 0x8a, 0xff, 0x4f, 0x0b, 0x6e, 0xac, 0x3d, 0xe7, 0xee, 0x80, 0xb1, 0x92, 0x9e, 0xdb, + 0xcc, 0x58, 0x11, 0x2a, 0xa4, 0xd7, 0x36, 0x33, 0x0a, 0x42, 0x57, 0x92, 0x1b, 0x36, 0x33, 0xae, + 0x08, 0x2d, 0x24, 0x23, 0x6c, 0x66, 0x2c, 0xdc, 0x0f, 0xa0, 0xf3, 0x7d, 0x8e, 0x22, 0xc0, 0xd4, + 0xb3, 0xa5, 0x77, 0xaf, 0xd5, 0xde, 0x3d, 0xcd, 0x51, 0x14, 0xac, 0xd4, 0x53, 0x36, 0x24, 0x9b, + 0x14, 0x35, 0xe4, 0x99, 0x64, 0x19, 0x31, 0xaf, 0xa3, 0x64, 0x74, 0xd6, 0x59, 0x54, 0x7c, 0xa0, + 0x2c, 0x7e, 0x0a, 0x16, 0x5f, 0x61, 0xea, 0x39, 0xd2, 0xfe, 0x3b, 0x2f, 0x48, 0xd8, 0xde, 0x70, + 0x85, 0xe9, 0x57, 0x51, 0x26, 0x0a, 0x26, 0xaf, 0xbb, 0xef, 0x43, 0x7b, 0x12, 0x87, 0xb1, 0x48, + 0x3d, 0xb8, 0xee, 0xd8, 0x21, 0xc9, 0x99, 0x56, 0xbb, 0x03, 0x68, 0x87, 0x38, 0xc7, 0x68, 0x2a, + 0x99, 0xd1, 0xdb, 0xbf, 0x55, 0x5f, 0x3c, 0x96, 0x72, 0xa6, 0xf5, 0x77, 0x1e, 0x80, 0x53, 0xbd, + 0x42, 0x44, 0x7f, 0x8e, 0x85, 0xcc, 0x99, 0xc3, 0xe8, 0xe8, 0xbe, 0x0b, 0xf6, 0x25, 0x0f, 0x73, + 0x55, 0xef, 0xde, 0xfe, 0xcd, 0xda, 0xce, 0x70, 0x15, 0xa4, 0x4c, 0x29, 0x3f, 0x6f, 0x7d, 0x66, + 0xf8, 0x73, 0xb0, 0xa5, 0x0f, 0x0d, 0xc6, 0x38, 0x25, 0x63, 0x64, 0x27, 0xb6, 0x1a, 0x9d, 0x78, + 0x0b, 0xcc, 0xaf, 0x71, 0xa5, 0x9b, 0x93, 0x8e, 0x15, 0xaf, 0xac, 0x06, 0xaf, 0x76, 0xc1, 0x3e, + 0x97, 0x8f, 0xab, 0x7a, 0x2b, 0xe0, 0xdf, 0x87, 0xb6, 0x8a, 0xa1, 0xb2, 0x6c, 0x34, 0x2c, 0xf7, + 0xa1, 0xf7, 0x44, 0x04, 0x18, 0x65, 0x8a, 0x29, 0xea, 0xd1, 0xa6, 0xc8, 0xff, 0xd5, 0x00, 0x8b, + 0x9c, 0x27, 0x56, 0x85, 0x38, 0xe7, 0x93, 0xe2, 0x20, 0xce, 0xa3, 0x69, 0xea, 0x19, 0x7d, 0x73, + 0x60, 0xb2, 0x35, 0x99, 0xfb, 0x06, 0xb4, 0x2f, 0x94, 0xb6, 0xd5, 0x37, 0x07, 0x0e, 0xd3, 0x88, + 0x5c, 0x0b, 0xf9, 0x05, 0x86, 0x3a, 0x04, 0x05, 0xe8, 0x76, 0x22, 0x70, 0x16, 0xac, 0x74, 0x18, + 0x1a, 0x91, 0x3c, 0xcd, 0x67, 0x24, 0x57, 0x91, 0x68, 0x44, 0x01, 0x5c, 0xf0, 0xb4, 0xa2, 0x0f, + 0x9d, 0xc9, 0x72, 0x3a, 0xe1, 0x61, 0xc9, 0x1f, 0x05, 0xfc, 0xdf, 0x0d, 0x9a, 0x2b, 0xaa, 0x1f, + 0x36, 0x32, 0xfc, 0x26, 0x74, 0xa9, 0x57, 0x9e, 0x5d, 0x72, 0xa1, 0x03, 0xee, 0x10, 0x3e, 0xe7, + 0xc2, 0xfd, 0x18, 0xda, 0xb2, 0x44, 0x5b, 0x7a, 0xb3, 0x34, 0x27, 0xb3, 0xca, 0xf4, 0xb5, 0x8a, + 0xbd, 0x56, 0x83, 0xbd, 0x55, 0xb0, 0x76, 0x33, 0xd8, 0xbb, 0x60, 0x53, 0x1b, 0x14, 0xd2, 0xfb, + 0xad, 0x96, 0x55, 0xb3, 0xa8, 0x5b, 0xfe, 0x19, 0xdc, 0x58, 0x7b, 0xb1, 0x7a, 0xc9, 0x58, 0x7f, + 0xa9, 0xa6, 0x9b, 0xa3, 0xe9, 0x45, 0x33, 0x35, 0xc5, 0x10, 0x27, 0x19, 0x4e, 0x65, 0xbe, 0xbb, + 0xac, 0xc2, 0xfe, 0x4f, 0x46, 0x6d, 0x57, 0xbe, 0x47, 0x53, 0x73, 0x12, 0x2f, 0x97, 0x3c, 0x9a, + 0x6a, 0xd3, 0x25, 0xa4, 0xbc, 0x4d, 0x2f, 0xb4, 0xe9, 0xd6, 0xf4, 0x82, 0xb0, 0x48, 0x74, 0x05, + 0x5b, 0x22, 0x21, 0xee, 0x2c, 0x91, 0xa7, 0xb9, 0xc0, 0x25, 0x46, 0x99, 0x4e, 0x41, 0x53, 0xe4, + 0xde, 0x86, 0x4e, 0xc6, 0xe7, 0xcf, 0xa8, 0x49, 0x74, 0x25, 0x33, 0x3e, 0x7f, 0x88, 0x85, 0xfb, + 0x16, 0x38, 0xb3, 0x00, 0xc3, 0xa9, 0x54, 0xa9, 0x72, 0x76, 0xa5, 0xe0, 0x21, 0x16, 0xfe, 0x1f, + 0x06, 0xb4, 0xc7, 0x28, 0x2e, 0x51, 0xbc, 0xd2, 0x38, 0x6d, 0xae, 0x29, 0xf3, 0x25, 0x6b, 0xca, + 0xda, 0xbe, 0xa6, 0xec, 0x7a, 0x4d, 0xed, 0x82, 0x3d, 0x16, 0x93, 0xa3, 0x91, 0xf4, 0xc8, 0x64, + 0x0a, 0x10, 0x1b, 0x87, 0x93, 0x2c, 0xb8, 0x44, 0xbd, 0xbb, 0x34, 0xda, 0x98, 0xb2, 0xdd, 0x2d, + 0x53, 0xf6, 0x47, 0x03, 0xda, 0xc7, 0xbc, 0x88, 0xf3, 0x6c, 0x83, 0x85, 0x7d, 0xe8, 0x0d, 0x93, + 0x24, 0x0c, 0x26, 0x6b, 0x9d, 0xd7, 0x10, 0xd1, 0x8d, 0x47, 0x8d, 0xfc, 0xaa, 0xd8, 0x9a, 0x22, + 0x1a, 0x37, 0x87, 0x72, 0x93, 0xa8, 0xb5, 0xd0, 0x18, 0x37, 0x6a, 0x81, 0x48, 0x25, 0x25, 0x61, + 0x98, 0x67, 0xf1, 0x2c, 0x8c, 0xaf, 0x64, 0xb4, 0x5d, 0x56, 0x61, 0xff, 0xcf, 0x16, 0x58, 0xff, + 0xd7, 0xf4, 0xdf, 0x01, 0x23, 0xd0, 0xc5, 0x36, 0x82, 0x6a, 0x17, 0x74, 0x1a, 0xbb, 0xc0, 0x83, + 0x4e, 0x21, 0x78, 0x34, 0xc7, 0xd4, 0xeb, 0xca, 0xe9, 0x52, 0x42, 0xa9, 0x91, 0x7d, 0xa4, 0x96, + 0x80, 0xc3, 0x4a, 0x58, 0xf5, 0x05, 0x34, 0xfa, 0xe2, 0x23, 0xbd, 0x2f, 0x7a, 0xd2, 0x23, 0x6f, + 0x3d, 0x2d, 0xd7, 0xd7, 0xc4, 0x7f, 0x37, 0xd3, 0xff, 0x36, 0xc0, 0xae, 0x9a, 0xea, 0x70, 0xbd, + 0xa9, 0x0e, 0xeb, 0xa6, 0x1a, 0x1d, 0x94, 0x4d, 0x35, 0x3a, 0x20, 0xcc, 0x4e, 0xca, 0xa6, 0x62, + 0x27, 0x54, 0xac, 0x07, 0x22, 0xce, 0x93, 0x83, 0x42, 0x55, 0xd5, 0x61, 0x15, 0x26, 0x26, 0x7e, + 0xbb, 0x40, 0xa1, 0x53, 0xed, 0x30, 0x8d, 0x88, 0xb7, 0xc7, 0x72, 0xe0, 0xa8, 0xe4, 0x2a, 0xe0, + 0xbe, 0x07, 0x36, 0xa3, 0xe4, 0xc9, 0x0c, 0xaf, 0xd5, 0x45, 0x8a, 0x99, 0xd2, 0x92, 0x51, 0xf5, + 0x9d, 0xa8, 0x09, 0x5c, 0x7e, 0x35, 0x7e, 0x08, 0xed, 0xf1, 0x22, 0x98, 0x65, 0xe5, 0xd6, 0x7d, + 0xbd, 0x31, 0xb0, 0x82, 0x25, 0x4a, 0x1d, 0xd3, 0x57, 0xfc, 0xa7, 0xe0, 0x54, 0xc2, 0xda, 0x1d, + 0xa3, 0xe9, 0x8e, 0x0b, 0xd6, 0x59, 0x14, 0x64, 0x65, 0xeb, 0xd2, 0x99, 0x82, 0x7d, 0x9a, 0xf3, + 0x28, 0x0b, 0xb2, 0xa2, 0x6c, 0xdd, 0x12, 0xfb, 0xf7, 0xb4, 0xfb, 0x64, 0xee, 0x2c, 0x49, 0x50, + 0xe8, 0x31, 0xa0, 0x80, 0x7c, 0x24, 0xbe, 0x42, 0x35, 0xc1, 0x4d, 0xa6, 0x80, 0xff, 0x1d, 0x38, + 0xc3, 0x10, 0x45, 0xc6, 0xf2, 0x10, 0xb7, 0x6d, 0xd6, 0x6f, 0xc6, 0x4f, 0x1e, 0x97, 0x1e, 0xd0, + 0xb9, 0x6e, 0x79, 0xf3, 0x5a, 0xcb, 0x3f, 0xe4, 0x09, 0x3f, 0x1a, 0x49, 0x9e, 0x9b, 0x4c, 0x23, + 0xff, 0x67, 0x03, 0x2c, 0x9a, 0x2d, 0x0d, 0xd3, 0xd6, 0xcb, 0xe6, 0xd2, 0x89, 0x88, 0x2f, 0x83, + 0x29, 0x8a, 0x32, 0xb8, 0x12, 0xcb, 0xa4, 0x4f, 0x16, 0x58, 0x2d, 0x70, 0x8d, 0x88, 0x6b, 0xf4, + 0x51, 0x59, 0xf6, 0x52, 0x83, 0x6b, 0x24, 0x66, 0x4a, 0xe9, 0xbe, 0x0d, 0x30, 0xce, 0x13, 0x14, + 0xc3, 0xe9, 0x32, 0x88, 0x64, 0xd1, 0xbb, 0xac, 0x21, 0xf1, 0xef, 0xab, 0xcf, 0xd4, 0x8d, 0x09, + 0x65, 0x6c, 0xff, 0xa4, 0xbd, 0xee, 0xb9, 0xff, 0x8b, 0x01, 0x9d, 0x47, 0x3c, 0x49, 0x82, 0x68, + 0xbe, 0x16, 0x85, 0xf1, 0xc2, 0x28, 0x5a, 0x6b, 0x51, 0xec, 0xc3, 0x6e, 0x79, 0x67, 0xed, 0x7d, + 0x95, 0x85, 0xad, 0x3a, 0x9d, 0x51, 0xab, 0x2a, 0xd6, 0xab, 0x7c, 0xc3, 0x9e, 0xae, 0xdf, 0xd9, + 0x56, 0xf0, 0x8d, 0xaa, 0xf4, 0xa1, 0xa7, 0xff, 0x7b, 0xc8, 0x2f, 0x79, 0x3d, 0x54, 0x1b, 0x22, + 0x7f, 0x1f, 0xda, 0x87, 0x71, 0x34, 0x0b, 0xe6, 0xee, 0x00, 0xac, 0x61, 0x9e, 0x2d, 0xa4, 0xc5, + 0xde, 0xfe, 0x6e, 0xa3, 0xf1, 0xf3, 0x6c, 0xa1, 0xee, 0x30, 0x79, 0xc3, 0xff, 0x02, 0xa0, 0x96, + 0xd1, 0x1f, 0x97, 0xba, 0x1a, 0x8f, 0xf1, 0x8a, 0x28, 0x93, 0x4a, 0x2b, 0x5d, 0xb6, 0x45, 0xe3, + 0x7f, 0x09, 0xce, 0x41, 0x1e, 0x84, 0xd3, 0xa3, 0x68, 0x16, 0xd3, 0xe8, 0x38, 0x47, 0x91, 0xd6, + 0xf5, 0x2a, 0x21, 0xa5, 0x9b, 0xa6, 0x48, 0xd5, 0x43, 0x1a, 0x5d, 0xb4, 0xe5, 0x7f, 0xbf, 0x7b, + 0xff, 0x06, 0x00, 0x00, 0xff, 0xff, 0xfe, 0xe9, 0xd1, 0x8f, 0x0d, 0x0e, 0x00, 0x00, } diff --git a/bolt/internal/internal.proto b/bolt/internal/internal.proto index 89504b776..f5fdef691 100644 --- a/bolt/internal/internal.proto +++ b/bolt/internal/internal.proto @@ -159,15 +159,22 @@ message User { } message Role { - string Organization = 1; // Organization is the ID of the organization that this user has a role in - string Name = 2; // Name is the name of the role of this user in the respective organization + string Organization = 1; // Organization is the ID of the organization that this user has a role in + string Name = 2; // Name is the name of the role of this user in the respective organization +} + +message Mapping { + string Provider = 1; // Provider is the provider that certifies and issues this user's authentication, e.g. GitHub + string Scheme = 2; // Scheme is the scheme used to perform this user's authentication, e.g. OAuth2 or LDAP + string ProviderOrganization = 3; // ProviderOrganization is the group or organizations that you are a part of in an auth provider + string ID = 4; // ID is the unique ID for the mapping + string Organization = 5; // Organization is the organization ID that resource belongs to } message Organization { - string ID = 1; // ID is the unique ID of the organization - string Name = 2; // Name is the organization's name - string DefaultRole = 3; // DefaultRole is the name of the role that is the default for any users added to the organization - bool Public = 4; // Public specifies that users must be explicitly added to the organization + string ID = 1; // ID is the unique ID of the organization + string Name = 2; // Name is the organization's name + string DefaultRole = 3; // DefaultRole is the name of the role that is the default for any users added to the organization } message Config { @@ -179,8 +186,8 @@ message AuthConfig { } message BuildInfo { - string Version = 1; // Version is a descriptive git SHA identifier - string Commit = 2; // Commit is an abbreviated SHA + string Version = 1; // Version is a descriptive git SHA identifier + string Commit = 2; // Commit is an abbreviated SHA } // The following is a vim modeline, it autoconfigures vim to have the diff --git a/bolt/mapping.go b/bolt/mapping.go new file mode 100644 index 000000000..52e643dfc --- /dev/null +++ b/bolt/mapping.go @@ -0,0 +1,128 @@ +package bolt + +import ( + "context" + "fmt" + + "github.com/boltdb/bolt" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/bolt/internal" +) + +// Ensure MappingsStore implements chronograf.MappingsStore. +var _ chronograf.MappingsStore = &MappingsStore{} + +var ( + // MappingsBucket is the bucket where organizations are stored. + MappingsBucket = []byte("MappingsV1") +) + +// MappingsStore uses bolt to store and retrieve Mappings +type MappingsStore struct { + client *Client +} + +// Migrate sets the default organization at runtime +func (s *MappingsStore) Migrate(ctx context.Context) error { + return nil +} + +// Add creates a new Mapping in the MappingsStore +func (s *MappingsStore) Add(ctx context.Context, o *chronograf.Mapping) (*chronograf.Mapping, error) { + err := s.client.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(MappingsBucket) + seq, err := b.NextSequence() + if err != nil { + return err + } + o.ID = fmt.Sprintf("%d", seq) + + v, err := internal.MarshalMapping(o) + if err != nil { + return err + } + + return b.Put([]byte(o.ID), v) + }) + + if err != nil { + return nil, err + } + + return o, nil +} + +// All returns all known organizations +func (s *MappingsStore) All(ctx context.Context) ([]chronograf.Mapping, error) { + var mappings []chronograf.Mapping + err := s.each(ctx, func(m *chronograf.Mapping) { + mappings = append(mappings, *m) + }) + + if err != nil { + return nil, err + } + + return mappings, nil +} + +// Delete the organization from MappingsStore +func (s *MappingsStore) Delete(ctx context.Context, o *chronograf.Mapping) error { + _, err := s.get(ctx, o.ID) + if err != nil { + return err + } + if err := s.client.db.Update(func(tx *bolt.Tx) error { + return tx.Bucket(MappingsBucket).Delete([]byte(o.ID)) + }); err != nil { + return err + } + return nil +} + +func (s *MappingsStore) get(ctx context.Context, id string) (*chronograf.Mapping, error) { + var o chronograf.Mapping + err := s.client.db.View(func(tx *bolt.Tx) error { + v := tx.Bucket(MappingsBucket).Get([]byte(id)) + if v == nil { + return chronograf.ErrMappingNotFound + } + return internal.UnmarshalMapping(v, &o) + }) + + if err != nil { + return nil, err + } + + return &o, nil +} + +func (s *MappingsStore) each(ctx context.Context, fn func(*chronograf.Mapping)) error { + return s.client.db.View(func(tx *bolt.Tx) error { + return tx.Bucket(MappingsBucket).ForEach(func(k, v []byte) error { + var m chronograf.Mapping + if err := internal.UnmarshalMapping(v, &m); err != nil { + return err + } + fn(&m) + return nil + }) + }) +} + +// Get returns a Mapping if the id exists. +func (s *MappingsStore) Get(ctx context.Context, id string) (*chronograf.Mapping, error) { + return s.get(ctx, id) +} + +// Update the organization in MappingsStore +func (s *MappingsStore) Update(ctx context.Context, o *chronograf.Mapping) error { + return s.client.db.Update(func(tx *bolt.Tx) error { + if v, err := internal.MarshalMapping(o); err != nil { + return err + } else if err := tx.Bucket(MappingsBucket).Put([]byte(o.ID), v); err != nil { + return err + } + return nil + }) +} diff --git a/bolt/mapping_test.go b/bolt/mapping_test.go new file mode 100644 index 000000000..745f5df90 --- /dev/null +++ b/bolt/mapping_test.go @@ -0,0 +1,483 @@ +package bolt_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/influxdata/chronograf" +) + +var mappingCmpOptions = cmp.Options{ + cmpopts.IgnoreFields(chronograf.Mapping{}, "ID"), + cmpopts.EquateEmpty(), +} + +func TestMappingStore_Add(t *testing.T) { + type fields struct { + mappings []*chronograf.Mapping + } + type args struct { + mapping *chronograf.Mapping + } + type wants struct { + mapping *chronograf.Mapping + err error + } + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "default with wildcards", + args: args{ + mapping: &chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + }, + wants: wants{ + mapping: &chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + }, + }, + { + name: "simple", + args: args{ + mapping: &chronograf.Mapping{ + Organization: "default", + Provider: "github", + Scheme: "oauth2", + ProviderOrganization: "idk", + }, + }, + wants: wants{ + mapping: &chronograf.Mapping{ + Organization: "default", + Provider: "github", + Scheme: "oauth2", + ProviderOrganization: "idk", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.MappingsStore + ctx := context.Background() + + for _, mapping := range tt.fields.mappings { + // YOLO database prepopulation + _, _ = s.Add(ctx, mapping) + } + + tt.args.mapping, err = s.Add(ctx, tt.args.mapping) + + if (err != nil) != (tt.wants.err != nil) { + t.Errorf("MappingsStore.Add() error = %v, want error %v", err, tt.wants.err) + return + } + + got, err := s.Get(ctx, tt.args.mapping.ID) + if err != nil { + t.Fatalf("failed to get mapping: %v", err) + return + } + if diff := cmp.Diff(got, tt.wants.mapping, mappingCmpOptions...); diff != "" { + t.Errorf("MappingStore.Add():\n-got/+want\ndiff %s", diff) + return + } + }) + } +} + +func TestMappingStore_All(t *testing.T) { + type fields struct { + mappings []*chronograf.Mapping + } + type args struct { + } + type wants struct { + mappings []chronograf.Mapping + err error + } + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "simple", + fields: fields{ + mappings: []*chronograf.Mapping{ + &chronograf.Mapping{ + Organization: "0", + Provider: "google", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + }, + wants: wants{ + mappings: []chronograf.Mapping{ + chronograf.Mapping{ + Organization: "0", + Provider: "google", + Scheme: "ldap", + ProviderOrganization: "*", + }, + chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.MappingsStore + ctx := context.Background() + + for _, mapping := range tt.fields.mappings { + // YOLO database prepopulation + _, _ = s.Add(ctx, mapping) + } + + got, err := s.All(ctx) + + if (err != nil) != (tt.wants.err != nil) { + t.Errorf("MappingsStore.All() error = %v, want error %v", err, tt.wants.err) + return + } + + if diff := cmp.Diff(got, tt.wants.mappings, mappingCmpOptions...); diff != "" { + t.Errorf("MappingStore.All():\n-got/+want\ndiff %s", diff) + return + } + }) + } +} + +func TestMappingStore_Delete(t *testing.T) { + type fields struct { + mappings []*chronograf.Mapping + } + type args struct { + mapping *chronograf.Mapping + } + type wants struct { + err error + } + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "simple", + fields: fields{ + mappings: []*chronograf.Mapping{ + &chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + &chronograf.Mapping{ + Organization: "0", + Provider: "google", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + }, + args: args{ + mapping: &chronograf.Mapping{ + ID: "1", + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + }, + wants: wants{ + err: nil, + }, + }, + { + name: "mapping not found", + fields: fields{ + mappings: []*chronograf.Mapping{ + &chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + &chronograf.Mapping{ + Organization: "0", + Provider: "google", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + }, + args: args{ + mapping: &chronograf.Mapping{ + ID: "0", + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + }, + wants: wants{ + err: chronograf.ErrMappingNotFound, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.MappingsStore + ctx := context.Background() + + for _, mapping := range tt.fields.mappings { + // YOLO database prepopulation + _, _ = s.Add(ctx, mapping) + } + + err = s.Delete(ctx, tt.args.mapping) + + if (err != nil) != (tt.wants.err != nil) { + t.Errorf("MappingsStore.Delete() error = %v, want error %v", err, tt.wants.err) + return + } + }) + } +} + +func TestMappingStore_Get(t *testing.T) { + type fields struct { + mappings []*chronograf.Mapping + } + type args struct { + mappingID string + } + type wants struct { + mapping *chronograf.Mapping + err error + } + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "simple", + fields: fields{ + mappings: []*chronograf.Mapping{ + &chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + &chronograf.Mapping{ + Organization: "0", + Provider: "google", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + }, + args: args{ + mappingID: "1", + }, + wants: wants{ + mapping: &chronograf.Mapping{ + ID: "1", + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + err: nil, + }, + }, + { + name: "mapping not found", + fields: fields{ + mappings: []*chronograf.Mapping{ + &chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + &chronograf.Mapping{ + Organization: "0", + Provider: "google", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + }, + args: args{ + mappingID: "0", + }, + wants: wants{ + err: chronograf.ErrMappingNotFound, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.MappingsStore + ctx := context.Background() + + for _, mapping := range tt.fields.mappings { + // YOLO database prepopulation + _, _ = s.Add(ctx, mapping) + } + + got, err := s.Get(ctx, tt.args.mappingID) + if (err != nil) != (tt.wants.err != nil) { + t.Errorf("MappingsStore.Get() error = %v, want error %v", err, tt.wants.err) + return + } + if diff := cmp.Diff(got, tt.wants.mapping, mappingCmpOptions...); diff != "" { + t.Errorf("MappingStore.Get():\n-got/+want\ndiff %s", diff) + return + } + }) + } +} + +func TestMappingStore_Update(t *testing.T) { + type fields struct { + mappings []*chronograf.Mapping + } + type args struct { + mapping *chronograf.Mapping + } + type wants struct { + mapping *chronograf.Mapping + err error + } + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "simple", + fields: fields{ + mappings: []*chronograf.Mapping{ + &chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + &chronograf.Mapping{ + Organization: "0", + Provider: "google", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + }, + args: args{ + mapping: &chronograf.Mapping{ + ID: "1", + Organization: "default", + Provider: "cool", + Scheme: "it", + ProviderOrganization: "works", + }, + }, + wants: wants{ + mapping: &chronograf.Mapping{ + ID: "1", + Organization: "default", + Provider: "cool", + Scheme: "it", + ProviderOrganization: "works", + }, + err: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.MappingsStore + ctx := context.Background() + + for _, mapping := range tt.fields.mappings { + // YOLO database prepopulation + _, _ = s.Add(ctx, mapping) + } + + err = s.Update(ctx, tt.args.mapping) + if (err != nil) != (tt.wants.err != nil) { + t.Errorf("MappingsStore.Update() error = %v, want error %v", err, tt.wants.err) + return + } + if diff := cmp.Diff(tt.args.mapping, tt.wants.mapping, mappingCmpOptions...); diff != "" { + t.Errorf("MappingStore.Update():\n-got/+want\ndiff %s", diff) + return + } + }) + } +} diff --git a/bolt/organizations.go b/bolt/organizations.go index 84ee78792..458fefafe 100644 --- a/bolt/organizations.go +++ b/bolt/organizations.go @@ -25,8 +25,6 @@ const ( DefaultOrganizationName string = "Default" // DefaultOrganizationRole is the DefaultRole for the Default organization DefaultOrganizationRole string = "member" - // DefaultOrganizationPublic is the Public setting for the Default organization. - DefaultOrganizationPublic bool = true ) // OrganizationsStore uses bolt to store and retrieve Organizations @@ -45,7 +43,14 @@ func (s *OrganizationsStore) CreateDefault(ctx context.Context) error { ID: string(DefaultOrganizationID), Name: DefaultOrganizationName, DefaultRole: DefaultOrganizationRole, - Public: DefaultOrganizationPublic, + } + + m := chronograf.Mapping{ + ID: string(DefaultOrganizationID), + Organization: string(DefaultOrganizationID), + Provider: chronograf.MappingWildcard, + Scheme: chronograf.MappingWildcard, + ProviderOrganization: chronograf.MappingWildcard, } return s.client.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(OrganizationsBucket) @@ -59,6 +64,17 @@ func (s *OrganizationsStore) CreateDefault(ctx context.Context) error { return err } + b = tx.Bucket(MappingsBucket) + v = b.Get(DefaultOrganizationID) + if v != nil { + return nil + } + if v, err := internal.MarshalMapping(&m); err != nil { + return err + } else if err := b.Put(DefaultOrganizationID, v); err != nil { + return err + } + return nil }) } @@ -189,6 +205,18 @@ func (s *OrganizationsStore) Delete(ctx context.Context, o *chronograf.Organizat } } + mappings, err := s.client.MappingsStore.All(ctx) + if err != nil { + return err + } + for _, mapping := range mappings { + if mapping.Organization == o.ID { + if err := s.client.MappingsStore.Delete(ctx, &mapping); err != nil { + return err + } + } + } + return nil } diff --git a/bolt/organizations_test.go b/bolt/organizations_test.go index eb30968d3..c6f5cfe66 100644 --- a/bolt/organizations_test.go +++ b/bolt/organizations_test.go @@ -170,12 +170,10 @@ func TestOrganizationsStore_All(t *testing.T) { { Name: "EE - Evil Empire", DefaultRole: roles.MemberRoleName, - Public: true, }, { Name: "The Good Place", DefaultRole: roles.EditorRoleName, - Public: true, }, }, }, @@ -183,17 +181,14 @@ func TestOrganizationsStore_All(t *testing.T) { { Name: "EE - Evil Empire", DefaultRole: roles.MemberRoleName, - Public: true, }, { Name: "The Good Place", DefaultRole: roles.EditorRoleName, - Public: true, }, { Name: bolt.DefaultOrganizationName, DefaultRole: bolt.DefaultOrganizationRole, - Public: bolt.DefaultOrganizationPublic, }, }, addFirst: true, @@ -316,52 +311,63 @@ func TestOrganizationsStore_Update(t *testing.T) { addFirst: true, }, { - name: "Update organization name, role, public", + name: "Update organization name, role", fields: fields{}, args: args{ ctx: context.Background(), initial: &chronograf.Organization{ Name: "The Good Place", DefaultRole: roles.ViewerRoleName, - Public: false, }, updates: &chronograf.Organization{ Name: "The Bad Place", - Public: true, DefaultRole: roles.AdminRoleName, }, }, want: &chronograf.Organization{ Name: "The Bad Place", - Public: true, DefaultRole: roles.AdminRoleName, }, addFirst: true, }, { - name: "Update organization name and public", + name: "Update organization name", fields: fields{}, args: args{ ctx: context.Background(), initial: &chronograf.Organization{ Name: "The Good Place", DefaultRole: roles.EditorRoleName, - Public: false, }, updates: &chronograf.Organization{ - Name: "The Bad Place", - Public: true, + Name: "The Bad Place", }, }, want: &chronograf.Organization{ Name: "The Bad Place", DefaultRole: roles.EditorRoleName, - Public: true, }, addFirst: true, }, { - name: "Update organization name - organization already exists", + name: "Update organization name", + fields: fields{}, + args: args{ + ctx: context.Background(), + initial: &chronograf.Organization{ + Name: "The Good Place", + }, + updates: &chronograf.Organization{ + Name: "The Bad Place", + }, + }, + want: &chronograf.Organization{ + Name: "The Bad Place", + }, + addFirst: true, + }, + { + name: "Update organization name - name already taken", fields: fields{ orgs: []chronograf.Organization{ { @@ -409,10 +415,6 @@ func TestOrganizationsStore_Update(t *testing.T) { tt.args.initial.DefaultRole = tt.args.updates.DefaultRole } - if tt.args.updates.Public != tt.args.initial.Public { - tt.args.initial.Public = tt.args.updates.Public - } - if err := s.Update(tt.args.ctx, tt.args.initial); (err != nil) != tt.wantErr { t.Errorf("%q. OrganizationsStore.Update() error = %v, wantErr %v", tt.name, err, tt.wantErr) } @@ -618,7 +620,6 @@ func TestOrganizationsStore_DefaultOrganization(t *testing.T) { ID: string(bolt.DefaultOrganizationID), Name: bolt.DefaultOrganizationName, DefaultRole: bolt.DefaultOrganizationRole, - Public: bolt.DefaultOrganizationPublic, }, wantErr: false, }, diff --git a/chronograf.go b/chronograf.go index fa29b560b..b242ed20a 100644 --- a/chronograf.go +++ b/chronograf.go @@ -30,6 +30,7 @@ const ( ErrInvalidLegendOrient = Error("Invalid orientation type. Valid orientation types are 'top', 'bottom', 'right', 'left'") ErrUserAlreadyExists = Error("user already exists") ErrOrganizationNotFound = Error("organization not found") + ErrMappingNotFound = Error("mapping not found") ErrOrganizationAlreadyExists = Error("organization already exists") ErrCannotDeleteDefaultOrganization = Error("cannot delete default organization") ErrConfigNotFound = Error("cannot find configuration") @@ -415,7 +416,7 @@ type User struct { Name string `json:"name"` Passwd string `json:"password,omitempty"` Permissions Permissions `json:"permissions,omitempty"` - Roles []Role `json:"roles,omitempty"` + Roles []Role `json:"roles"` Provider string `json:"provider,omitempty"` Scheme string `json:"scheme,omitempty"` SuperAdmin bool `json:"superAdmin,omitempty"` @@ -605,15 +606,52 @@ type LayoutsStore interface { Update(context.Context, Layout) error } +// MappingWildcard is the wildcard value for mappings +const MappingWildcard string = "*" + +// A Mapping is the structure that is used to determine a users +// role within an organization. The high level idea is to grant +// certain roles to certain users without them having to be given +// explicit role within the organization. +// +// One can think of a mapping like so: +// Provider:Scheme:Group -> Organization +// github:oauth2:influxdata -> Happy +// beyondcorp:ldap:influxdata -> TheBillHilliettas +// +// Any of Provider, Scheme, or Group may be provided as a wildcard * +// github:oauth2:* -> MyOrg +// *:*:* -> AllOrg +type Mapping struct { + ID string `json:"id"` + Organization string `json:"organizationId"` + Provider string `json:"provider"` + Scheme string `json:"scheme"` + ProviderOrganization string `json:"providerOrganization"` +} + +// MappingsStore is the storage and retrieval of Mappings +type MappingsStore interface { + // Add creates a new Mapping. + // The Created mapping is returned back to the user with the + // ID field populated. + Add(context.Context, *Mapping) (*Mapping, error) + // All lists all Mapping in the MappingsStore + All(context.Context) ([]Mapping, error) + // Delete removes an Mapping from the MappingsStore + Delete(context.Context, *Mapping) error + // Get retrieves an Mapping from the MappingsStore + Get(context.Context, string) (*Mapping, error) + // Update updates an Mapping in the MappingsStore + Update(context.Context, *Mapping) error +} + // Organization is a group of resources under a common name type Organization struct { ID string `json:"id"` Name string `json:"name"` // DefaultRole is the name of the role that is the default for any users added to the organization DefaultRole string `json:"defaultRole,omitempty"` - // Public specifies whether users must be explicitly added to the organization. - // It is currently only used by the default organization, but that may change in the future. - Public bool `json:"public"` } // OrganizationQuery represents the attributes that a organization may be retrieved by. diff --git a/circle.yml b/circle.yml index 207d9d5d5..59bb2c567 100644 --- a/circle.yml +++ b/circle.yml @@ -31,6 +31,7 @@ deployment: --upload - sudo chown -R ubuntu:ubuntu /home/ubuntu - cp build/linux/static_amd64/chronograf . + - cp build/linux/static_amd64/chronoctl . - docker build -t chronograf . - docker login -e $QUAY_EMAIL -u "$QUAY_USER" -p $QUAY_PASS quay.io - docker tag chronograf quay.io/influxdb/chronograf:${CIRCLE_SHA1:0:7} @@ -52,6 +53,7 @@ deployment: --bucket dl.influxdata.com/chronograf/releases - sudo chown -R ubuntu:ubuntu /home/ubuntu - cp build/linux/static_amd64/chronograf . + - cp build/linux/static_amd64/chronoctl . - docker build -t chronograf . - docker login -e $QUAY_EMAIL -u "$QUAY_USER" -p $QUAY_PASS quay.io - docker tag chronograf quay.io/influxdb/chronograf:${CIRCLE_SHA1:0:7} @@ -75,6 +77,7 @@ deployment: --bucket dl.influxdata.com/chronograf/releases - sudo chown -R ubuntu:ubuntu /home/ubuntu - cp build/linux/static_amd64/chronograf . + - cp build/linux/static_amd64/chronoctl . - docker build -t chronograf . - docker login -e $QUAY_EMAIL -u "$QUAY_USER" -p $QUAY_PASS quay.io - docker tag chronograf quay.io/influxdb/chronograf:${CIRCLE_SHA1:0:7} diff --git a/cmd/chronoctl/add.go b/cmd/chronoctl/add.go index 0a26c72c9..e6762575f 100644 --- a/cmd/chronoctl/add.go +++ b/cmd/chronoctl/add.go @@ -2,17 +2,18 @@ package main import ( "context" + "strings" "github.com/influxdata/chronograf" ) type AddCommand struct { - BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (e.g. './chronograf-v1.db')" env:"BOLT_PATH" default:"chronograf-v1.db"` - ID *uint64 `short:"i" long:"id" description:"Users ID. Must be id for existing user"` - Username string `short:"n" long:"name" description:"Users name. Must be Oauth-able email address or username"` - Provider string `short:"p" long:"provider" description:"Name of the Auth provider (e.g. google, github, auth0, or generic)"` - Scheme string `short:"s" long:"scheme" description:"Authentication scheme that matches auth provider (e.g. oauth or ldap)"` - //Organizations string `short:"o" long:"orgs" description:"A comma separated list of organizations that the user should be added to"` + BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (e.g. './chronograf-v1.db')" env:"BOLT_PATH" default:"chronograf-v1.db"` + ID *uint64 `short:"i" long:"id" description:"Users ID. Must be id for existing user"` + Username string `short:"n" long:"name" description:"Users name. Must be Oauth-able email address or username"` + Provider string `short:"p" long:"provider" description:"Name of the Auth provider (e.g. google, github, auth0, or generic)"` + Scheme string `short:"s" long:"scheme" description:"Authentication scheme that matches auth provider (e.g. oauth2)" default:"oauth2"` + Organizations string `short:"o" long:"orgs" description:"A comma separated list of organizations that the user should be added to" default:"default"` } var addCommand AddCommand @@ -41,9 +42,15 @@ func (l *AddCommand) Execute(args []string) error { return err } else if err == chronograf.ErrUserNotFound { user = &chronograf.User{ - Name: l.Username, - Provider: l.Provider, - Scheme: l.Scheme, + Name: l.Username, + Provider: l.Provider, + Scheme: l.Scheme, + Roles: []chronograf.Role{ + { + Name: "member", + Organization: "default", + }, + }, SuperAdmin: true, } @@ -53,13 +60,49 @@ func (l *AddCommand) Execute(args []string) error { } } else { user.SuperAdmin = true + if len(user.Roles) == 0 { + user.Roles = []chronograf.Role{ + { + Name: "member", + Organization: "default", + }, + } + } if err = c.UsersStore.Update(ctx, user); err != nil { return err } } // TODO(desa): Apply mapping to user and update their roles - // TODO(desa): Add a flag that allows the user to specify an organization to join + roles := []chronograf.Role{} +OrgLoop: + for _, org := range strings.Split(l.Organizations, ",") { + // Check to see is user is already a part of the organization + for _, r := range user.Roles { + if r.Organization == org { + continue OrgLoop + } + } + + orgQuery := chronograf.OrganizationQuery{ + ID: &org, + } + o, err := c.OrganizationsStore.Get(ctx, orgQuery) + if err != nil { + return err + } + + role := chronograf.Role{ + Organization: org, + Name: o.DefaultRole, + } + roles = append(roles, role) + } + + user.Roles = append(user.Roles, roles...) + if err = c.UsersStore.Update(ctx, user); err != nil { + return err + } w := NewTabWriter() WriteHeaders(w) diff --git a/cmd/chronoctl/main.go b/cmd/chronoctl/main.go index b3260fb21..3e8f180b9 100644 --- a/cmd/chronoctl/main.go +++ b/cmd/chronoctl/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "github.com/jessevdk/go-flags" @@ -18,8 +19,9 @@ func main() { if flagsErr, ok := err.(*flags.Error); ok && flagsErr.Type == flags.ErrHelp { os.Exit(0) } else { + fmt.Fprintln(os.Stdout) + parser.WriteHelp(os.Stdout) os.Exit(1) } } - } diff --git a/enterprise/enterprise.go b/enterprise/enterprise.go index d1ed03152..986417468 100644 --- a/enterprise/enterprise.go +++ b/enterprise/enterprise.go @@ -51,13 +51,13 @@ type Client struct { } // NewClientWithTimeSeries initializes a Client with a known set of TimeSeries. -func NewClientWithTimeSeries(lg chronograf.Logger, mu string, authorizer influx.Authorizer, tls bool, series ...chronograf.TimeSeries) (*Client, error) { +func NewClientWithTimeSeries(lg chronograf.Logger, mu string, authorizer influx.Authorizer, tls, insecure bool, series ...chronograf.TimeSeries) (*Client, error) { metaURL, err := parseMetaURL(mu, tls) if err != nil { return nil, err } - ctrl := NewMetaClient(metaURL, authorizer) + ctrl := NewMetaClient(metaURL, insecure, authorizer) c := &Client{ Ctrl: ctrl, UsersStore: &UserStore{ @@ -85,13 +85,13 @@ func NewClientWithTimeSeries(lg chronograf.Logger, mu string, authorizer influx. // varieties. TLS is used when the URL contains "https" or when the TLS // parameter is set. authorizer will add the correct `Authorization` headers // on the out-bound request. -func NewClientWithURL(mu string, authorizer influx.Authorizer, tls bool, lg chronograf.Logger) (*Client, error) { +func NewClientWithURL(mu string, authorizer influx.Authorizer, tls bool, insecure bool, lg chronograf.Logger) (*Client, error) { metaURL, err := parseMetaURL(mu, tls) if err != nil { return nil, err } - ctrl := NewMetaClient(metaURL, authorizer) + ctrl := NewMetaClient(metaURL, insecure, authorizer) return &Client{ Ctrl: ctrl, UsersStore: &UserStore{ diff --git a/enterprise/enterprise_test.go b/enterprise/enterprise_test.go index 033a51872..58437d35a 100644 --- a/enterprise/enterprise_test.go +++ b/enterprise/enterprise_test.go @@ -84,6 +84,7 @@ func Test_Enterprise_AdvancesDataNodes(t *testing.T) { Password: "thelake", }, false, + false, chronograf.TimeSeries(m1), chronograf.TimeSeries(m2)) if err != nil { @@ -114,23 +115,53 @@ func Test_Enterprise_NewClientWithURL(t *testing.T) { t.Parallel() urls := []struct { - url string - username string - password string - tls bool - shouldErr bool + name string + url string + username string + password string + tls bool + insecureSkipVerify bool + wantErr bool }{ - {"http://localhost:8086", "", "", false, false}, - {"https://localhost:8086", "", "", false, false}, - {"http://localhost:8086", "username", "password", false, false}, - - {"http://localhost:8086", "", "", true, false}, - {"https://localhost:8086", "", "", true, false}, - - {"localhost:8086", "", "", false, false}, - {"localhost:8086", "", "", true, false}, - - {":http", "", "", false, true}, + { + name: "no tls should have no error", + url: "http://localhost:8086", + }, + { + name: "tls sholuld have no error", + url: "https://localhost:8086", + }, + { + name: "no tls but with basic auth", + url: "http://localhost:8086", + username: "username", + password: "password", + }, + { + name: "tls request but url is not tls should not error", + url: "http://localhost:8086", + tls: true, + }, + { + name: "https with tls and with insecureSkipVerify should not error", + url: "https://localhost:8086", + tls: true, + insecureSkipVerify: true, + }, + { + name: "URL does not require http or https", + url: "localhost:8086", + }, + { + name: "URL with TLS request should not error", + url: "localhost:8086", + tls: true, + }, + { + name: "invalid URL causes error", + url: ":http", + wantErr: true, + }, } for _, testURL := range urls { @@ -141,10 +172,11 @@ func Test_Enterprise_NewClientWithURL(t *testing.T) { Password: testURL.password, }, testURL.tls, + testURL.insecureSkipVerify, log.New(log.DebugLevel)) - if err != nil && !testURL.shouldErr { + if err != nil && !testURL.wantErr { t.Errorf("Unexpected error creating Client with URL %s and TLS preference %t. err: %s", testURL.url, testURL.tls, err.Error()) - } else if err == nil && testURL.shouldErr { + } else if err == nil && testURL.wantErr { t.Errorf("Expected error creating Client with URL %s and TLS preference %t", testURL.url, testURL.tls) } } @@ -159,7 +191,7 @@ func Test_Enterprise_ComplainsIfNotOpened(t *testing.T) { Username: "docbrown", Password: "1.21 gigawatts", }, - false, chronograf.TimeSeries(m1)) + false, false, chronograf.TimeSeries(m1)) if err != nil { t.Error("Expected ErrUnitialized, but was this err:", err) } diff --git a/enterprise/meta.go b/enterprise/meta.go index 984c802c2..23629f438 100644 --- a/enterprise/meta.go +++ b/enterprise/meta.go @@ -3,6 +3,7 @@ package enterprise import ( "bytes" "context" + "crypto/tls" "encoding/json" "errors" "fmt" @@ -14,6 +15,14 @@ import ( "github.com/influxdata/chronograf/influx" ) +// Shared transports for all clients to prevent leaking connections +var ( + skipVerifyTransport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + defaultTransport = &http.Transport{} +) + type client interface { Do(URL *url.URL, path, method string, authorizer influx.Authorizer, params map[string]string, body io.Reader) (*http.Response, error) } @@ -26,10 +35,12 @@ type MetaClient struct { } // NewMetaClient represents a meta node in an Influx Enterprise cluster -func NewMetaClient(url *url.URL, authorizer influx.Authorizer) *MetaClient { +func NewMetaClient(url *url.URL, InsecureSkipVerify bool, authorizer influx.Authorizer) *MetaClient { return &MetaClient{ - URL: url, - client: &defaultClient{}, + URL: url, + client: &defaultClient{ + InsecureSkipVerify: InsecureSkipVerify, + }, authorizer: authorizer, } } @@ -399,7 +410,8 @@ func (m *MetaClient) Post(ctx context.Context, path string, action interface{}, } type defaultClient struct { - Leader string + Leader string + InsecureSkipVerify bool } // Do is a helper function to interface with Influx Enterprise's Meta API @@ -438,6 +450,12 @@ func (d *defaultClient) Do(URL *url.URL, path, method string, authorizer influx. CheckRedirect: d.AuthedCheckRedirect, } + if d.InsecureSkipVerify { + client.Transport = skipVerifyTransport + } else { + client.Transport = defaultTransport + } + res, err := client.Do(req) if err != nil { return nil, err diff --git a/enterprise/users.go b/enterprise/users.go index 03ad17a8d..d16f16084 100644 --- a/enterprise/users.go +++ b/enterprise/users.go @@ -37,7 +37,7 @@ func (c *UserStore) Delete(ctx context.Context, u *chronograf.User) error { return c.Ctrl.DeleteUser(ctx, u.Name) } -// Number of users in Influx +// Num of users in Influx func (c *UserStore) Num(ctx context.Context) (int, error) { all, err := c.All(ctx) if err != nil { diff --git a/etc/build.py b/etc/build.py index 5dc199454..e9d0bb53f 100755 --- a/etc/build.py +++ b/etc/build.py @@ -75,6 +75,7 @@ for f in CONFIGURATION_FILES: targets = { 'chronograf' : './cmd/chronograf', + 'chronoctl' : './cmd/chronoctl', } supported_builds = { diff --git a/integrations/server_test.go b/integrations/server_test.go index caed26275..cce8a8a51 100644 --- a/integrations/server_test.go +++ b/integrations/server_test.go @@ -26,6 +26,7 @@ import ( func TestServer(t *testing.T) { type fields struct { Organizations []chronograf.Organization + Mappings []chronograf.Mapping Users []chronograf.User Sources []chronograf.Source Servers []chronograf.Server @@ -352,8 +353,7 @@ func TestServer(t *testing.T) { }, "id": "default", "name": "Default", - "defaultRole": "member", - "public": true + "defaultRole": "member" }, { "links": { @@ -361,8 +361,7 @@ func TestServer(t *testing.T) { }, "id": "howdy", "name": "An Organization", - "defaultRole": "viewer", - "public": false + "defaultRole": "viewer" } ] }`, @@ -410,8 +409,7 @@ func TestServer(t *testing.T) { }, "id": "howdy", "name": "An Organization", - "defaultRole": "viewer", - "public": false + "defaultRole": "viewer" }`, }, }, @@ -2045,24 +2043,21 @@ func TestServer(t *testing.T) { "self": "/chronograf/v1/organizations/1/users/1" }, "organizations": [ - { - "id": "1", - "name": "Sweet", - "defaultRole": "viewer", - "public": false - }, - { - "id": "default", - "name": "Default", - "defaultRole": "member", - "public": true - } + { + "id": "1", + "name": "Sweet", + "defaultRole": "viewer" + }, + { + "id": "default", + "name": "Default", + "defaultRole": "member" + } ], "currentOrganization": { "id": "1", "name": "Sweet", - "defaultRole": "viewer", - "public": false + "defaultRole": "viewer" } }`, }, @@ -2124,6 +2119,502 @@ func TestServer(t *testing.T) { }`, }, }, + { + name: "GET /me", + subName: "New user hits me for the first time", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Mappings: []chronograf.Mapping{ + { + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "influxdata", + }, + { + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + { + ID: "2", + Organization: "2", + Provider: "github", + Scheme: "*", + ProviderOrganization: "*", + }, + { + ID: "3", + Organization: "3", + Provider: "auth0", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "Sweet", + DefaultRole: roles.ViewerRoleName, + }, + { + ID: "2", + Name: "What", + DefaultRole: roles.EditorRoleName, + }, + { + ID: "3", + Name: "Okay", + DefaultRole: roles.AdminRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{}, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/me", + principal: oauth2.Principal{ + Subject: "billietta", + Issuer: "github", + Group: "influxdata,idk,mimi", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "id": "2", + "name": "billietta", + "roles": [ + { + "name": "viewer", + "organization": "1" + }, + { + "name": "editor", + "organization": "2" + }, + { + "name": "member", + "organization": "default" + } + ], + "provider": "github", + "scheme": "oauth2", + "links": { + "self": "/chronograf/v1/organizations/default/users/2" + }, + "organizations": [ + { + "id": "1", + "name": "Sweet", + "defaultRole": "viewer" + }, + { + "id": "2", + "name": "What", + "defaultRole": "editor" + }, + { + "id": "default", + "name": "Default", + "defaultRole": "member" + } + ], + "currentOrganization": { + "id": "default", + "name": "Default", + "defaultRole": "member" + } +} +`, + }, + }, + { + name: "GET /mappings", + subName: "get all mappings", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Mappings: []chronograf.Mapping{ + { + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "influxdata", + }, + { + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + { + ID: "2", + Organization: "2", + Provider: "github", + Scheme: "*", + ProviderOrganization: "*", + }, + { + ID: "3", + Organization: "3", + Provider: "auth0", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "Sweet", + DefaultRole: roles.ViewerRoleName, + }, + { + ID: "2", + Name: "What", + DefaultRole: roles.EditorRoleName, + }, + { + ID: "3", + Name: "Okay", + DefaultRole: roles.AdminRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/mappings", + principal: oauth2.Principal{ + Subject: "billibob", + Issuer: "github", + Group: "influxdata,idk,mimi", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/mappings" + }, + "mappings": [ + { + "links": { + "self": "/chronograf/v1/mappings/1" + }, + "id": "1", + "organizationId": "1", + "provider": "*", + "scheme": "*", + "providerOrganization": "influxdata" + }, + { + "links": { + "self": "/chronograf/v1/mappings/2" + }, + "id": "2", + "organizationId": "1", + "provider": "*", + "scheme": "*", + "providerOrganization": "*" + }, + { + "links": { + "self": "/chronograf/v1/mappings/3" + }, + "id": "3", + "organizationId": "2", + "provider": "github", + "scheme": "*", + "providerOrganization": "*" + }, + { + "links": { + "self": "/chronograf/v1/mappings/4" + }, + "id": "4", + "organizationId": "3", + "provider": "auth0", + "scheme": "ldap", + "providerOrganization": "*" + }, + { + "links": { + "self": "/chronograf/v1/mappings/default" + }, + "id": "default", + "organizationId": "default", + "provider": "*", + "scheme": "*", + "providerOrganization": "*" + } + ] +} +`, + }, + }, + { + name: "GET /mappings", + subName: "get all mappings - user is not super admin", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Mappings: []chronograf.Mapping{ + { + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "influxdata", + }, + { + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + { + ID: "2", + Organization: "2", + Provider: "github", + Scheme: "*", + ProviderOrganization: "*", + }, + { + ID: "3", + Organization: "3", + Provider: "auth0", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "Sweet", + DefaultRole: roles.ViewerRoleName, + }, + { + ID: "2", + Name: "What", + DefaultRole: roles.EditorRoleName, + }, + { + ID: "3", + Name: "Okay", + DefaultRole: roles.AdminRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/mappings", + principal: oauth2.Principal{ + Subject: "billibob", + Issuer: "github", + Group: "influxdata,idk,mimi", + }, + }, + wants: wants{ + statusCode: 403, + body: ` +{ + "code": 403, + "message": "User is not authorized" +} +`, + }, + }, + { + name: "POST /mappings", + subName: "create new mapping", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Mappings: []chronograf.Mapping{}, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "Sweet", + DefaultRole: roles.ViewerRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "POST", + path: "/chronograf/v1/mappings", + payload: &chronograf.Mapping{ + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "influxdata", + }, + principal: oauth2.Principal{ + Subject: "billibob", + Issuer: "github", + Group: "influxdata,idk,mimi", + }, + }, + wants: wants{ + statusCode: 201, + body: ` +{ + "links": { + "self": "/chronograf/v1/mappings/1" + }, + "id": "1", + "organizationId": "1", + "provider": "*", + "scheme": "*", + "providerOrganization": "influxdata" +} +`, + }, + }, + { + name: "PUT /mappings", + subName: "update new mapping", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Mappings: []chronograf.Mapping{ + chronograf.Mapping{ + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "influxdata", + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "Sweet", + DefaultRole: roles.ViewerRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "PUT", + path: "/chronograf/v1/mappings/1", + payload: &chronograf.Mapping{ + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + principal: oauth2.Principal{ + Subject: "billibob", + Issuer: "github", + Group: "influxdata,idk,mimi", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/mappings/1" + }, + "id": "1", + "organizationId": "1", + "provider": "*", + "scheme": "*", + "providerOrganization": "*" +} +`, + }, + }, { name: "GET /", subName: "signed into default org", @@ -2325,6 +2816,16 @@ func TestServer(t *testing.T) { } } + // Populate Organizations + for i, mapping := range tt.fields.Mappings { + o, err := boltdb.MappingsStore.Add(ctx, &mapping) + if err != nil { + t.Fatalf("failed to add mapping: %v", err) + return + } + tt.fields.Mappings[i] = *o + } + // Populate Organizations for i, organization := range tt.fields.Organizations { o, err := boltdb.OrganizationsStore.Add(ctx, &organization) diff --git a/mocks/mapping.go b/mocks/mapping.go new file mode 100644 index 000000000..60463e3d8 --- /dev/null +++ b/mocks/mapping.go @@ -0,0 +1,35 @@ +package mocks + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +type MappingsStore struct { + AddF func(context.Context, *chronograf.Mapping) (*chronograf.Mapping, error) + AllF func(context.Context) ([]chronograf.Mapping, error) + DeleteF func(context.Context, *chronograf.Mapping) error + UpdateF func(context.Context, *chronograf.Mapping) error + GetF func(context.Context, string) (*chronograf.Mapping, error) +} + +func (s *MappingsStore) Add(ctx context.Context, m *chronograf.Mapping) (*chronograf.Mapping, error) { + return s.AddF(ctx, m) +} + +func (s *MappingsStore) All(ctx context.Context) ([]chronograf.Mapping, error) { + return s.AllF(ctx) +} + +func (s *MappingsStore) Delete(ctx context.Context, m *chronograf.Mapping) error { + return s.DeleteF(ctx, m) +} + +func (s *MappingsStore) Get(ctx context.Context, id string) (*chronograf.Mapping, error) { + return s.GetF(ctx, id) +} + +func (s *MappingsStore) Update(ctx context.Context, m *chronograf.Mapping) error { + return s.UpdateF(ctx, m) +} diff --git a/mocks/store.go b/mocks/store.go index ebc05ea49..8cb27b11b 100644 --- a/mocks/store.go +++ b/mocks/store.go @@ -9,6 +9,7 @@ import ( // Store is a server.DataStore type Store struct { SourcesStore chronograf.SourcesStore + MappingsStore chronograf.MappingsStore ServersStore chronograf.ServersStore LayoutsStore chronograf.LayoutsStore UsersStore chronograf.UsersStore @@ -36,6 +37,9 @@ func (s *Store) Users(ctx context.Context) chronograf.UsersStore { func (s *Store) Organizations(ctx context.Context) chronograf.OrganizationsStore { return s.OrganizationsStore } +func (s *Store) Mappings(ctx context.Context) chronograf.MappingsStore { + return s.MappingsStore +} func (s *Store) Dashboards(ctx context.Context) chronograf.DashboardsStore { return s.DashboardsStore diff --git a/noop/mappings.go b/noop/mappings.go new file mode 100644 index 000000000..e6f5a73bb --- /dev/null +++ b/noop/mappings.go @@ -0,0 +1,33 @@ +package noop + +import ( + "context" + "fmt" + + "github.com/influxdata/chronograf" +) + +// ensure MappingsStore implements chronograf.MappingsStore +var _ chronograf.MappingsStore = &MappingsStore{} + +type MappingsStore struct{} + +func (s *MappingsStore) All(context.Context) ([]chronograf.Mapping, error) { + return nil, fmt.Errorf("no mappings found") +} + +func (s *MappingsStore) Add(context.Context, *chronograf.Mapping) (*chronograf.Mapping, error) { + return nil, fmt.Errorf("failed to add mapping") +} + +func (s *MappingsStore) Delete(context.Context, *chronograf.Mapping) error { + return fmt.Errorf("failed to delete mapping") +} + +func (s *MappingsStore) Get(ctx context.Context, ID string) (*chronograf.Mapping, error) { + return nil, chronograf.ErrMappingNotFound +} + +func (s *MappingsStore) Update(context.Context, *chronograf.Mapping) error { + return fmt.Errorf("failed to update mapping") +} diff --git a/oauth2/auth0.go b/oauth2/auth0.go index 9c827b680..1d5c1f4a9 100644 --- a/oauth2/auth0.go +++ b/oauth2/auth0.go @@ -8,6 +8,8 @@ import ( "github.com/influxdata/chronograf" ) +var _ Provider = &Auth0{} + type Auth0 struct { Generic Organizations map[string]bool // the set of allowed organizations users may belong to @@ -41,6 +43,26 @@ func (a *Auth0) PrincipalID(provider *http.Client) (string, error) { return act.Email, nil } +func (a *Auth0) Group(provider *http.Client) (string, error) { + type Account struct { + Email string `json:"email"` + Organization string `json:"organization"` + } + + resp, err := provider.Get(a.Generic.APIURL) + if err != nil { + return "", err + } + + defer resp.Body.Close() + act := Account{} + if err = json.NewDecoder(resp.Body).Decode(&act); err != nil { + return "", err + } + + return act.Organization, nil +} + func NewAuth0(auth0Domain, clientID, clientSecret, redirectURL string, organizations []string, logger chronograf.Logger) (Auth0, error) { domain, err := url.Parse(auth0Domain) if err != nil { diff --git a/oauth2/generic.go b/oauth2/generic.go index 0172c70af..1606c8bf2 100644 --- a/oauth2/generic.go +++ b/oauth2/generic.go @@ -111,6 +111,31 @@ func (g *Generic) PrincipalID(provider *http.Client) (string, error) { return email, nil } +// Group returns the domain that a user belongs to in the +// the generic OAuth. +func (g *Generic) Group(provider *http.Client) (string, error) { + res := struct { + Email string `json:"email"` + }{} + + r, err := provider.Get(g.APIURL) + if err != nil { + return "", err + } + + defer r.Body.Close() + if err = json.NewDecoder(r.Body).Decode(&res); err != nil { + return "", err + } + + email := strings.Split(res.Email, "@") + if len(email) != 2 { + return "", fmt.Errorf("malformed email address, expected %q to contain @ symbol", res.Email) + } + + return email[1], nil +} + // UserEmail represents user's email address type UserEmail struct { Email *string `json:"email,omitempty"` diff --git a/oauth2/github.go b/oauth2/github.go index 0f3e0e932..d3a50c7c3 100644 --- a/oauth2/github.go +++ b/oauth2/github.go @@ -6,6 +6,7 @@ import ( "errors" "io" "net/http" + "strings" "github.com/google/go-github/github" "github.com/influxdata/chronograf" @@ -44,9 +45,9 @@ func (g *Github) Secret() string { // we are filtering by organizations. func (g *Github) Scopes() []string { scopes := []string{"user:email"} - if len(g.Orgs) > 0 { - scopes = append(scopes, "read:org") - } + // In order to access a users orgs, we need the "read:org" scope + // even if g.Orgs == 0 + scopes = append(scopes, "read:org") return scopes } @@ -84,6 +85,26 @@ func (g *Github) PrincipalID(provider *http.Client) (string, error) { return email, nil } +// Group returns a comma delimited string of Github organizations +// that a user belongs to in Github +func (g *Github) Group(provider *http.Client) (string, error) { + client := github.NewClient(provider) + orgs, err := getOrganizations(client, g.Logger) + if err != nil { + return "", err + } + + groups := []string{} + for _, org := range orgs { + if org.Login != nil { + groups = append(groups, *org.Login) + continue + } + } + + return strings.Join(groups, ","), nil +} + func randomString(length int) string { k := make([]byte, length) if _, err := io.ReadFull(rand.Reader, k); err != nil { diff --git a/oauth2/google.go b/oauth2/google.go index ee5ff3bb1..59a7ec56a 100644 --- a/oauth2/google.go +++ b/oauth2/google.go @@ -88,3 +88,19 @@ func (g *Google) PrincipalID(provider *http.Client) (string, error) { g.Logger.Error("Domain '", info.Hd, "' is not a member of required Google domain(s): ", g.Domains) return "", fmt.Errorf("Not in required domain") } + +// Group returns the string of domain a user belongs to in Google +func (g *Google) Group(provider *http.Client) (string, error) { + srv, err := goauth2.New(provider) + if err != nil { + g.Logger.Error("Unable to communicate with Google ", err.Error()) + return "", err + } + info, err := srv.Userinfo.Get().Do() + if err != nil { + g.Logger.Error("Unable to retrieve Google email ", err.Error()) + return "", err + } + + return info.Hd, nil +} diff --git a/oauth2/heroku.go b/oauth2/heroku.go index 831b095df..9c6f81416 100644 --- a/oauth2/heroku.go +++ b/oauth2/heroku.go @@ -88,6 +88,34 @@ func (h *Heroku) PrincipalID(provider *http.Client) (string, error) { return account.Email, nil } +// Group returns the Heroku organization that user belongs to. +func (h *Heroku) Group(provider *http.Client) (string, error) { + type DefaultOrg struct { + ID string `json:"id"` + Name string `json:"name"` + } + type Account struct { + Email string `json:"email"` + DefaultOrganization DefaultOrg `json:"default_organization"` + } + + resp, err := provider.Get(HerokuAccountRoute) + if err != nil { + h.Logger.Error("Unable to communicate with Heroku. err:", err) + return "", err + } + defer resp.Body.Close() + d := json.NewDecoder(resp.Body) + + var account Account + if err := d.Decode(&account); err != nil { + h.Logger.Error("Unable to decode response from Heroku. err:", err) + return "", err + } + + return account.DefaultOrganization.Name, nil +} + // Scopes for heroku is "identity" which grants access to user account // information. This will grant us access to the user's email address which is // used as the Principal's identifier. diff --git a/oauth2/jwt.go b/oauth2/jwt.go index 794f44a23..b45a623bf 100644 --- a/oauth2/jwt.go +++ b/oauth2/jwt.go @@ -31,9 +31,18 @@ var _ gojwt.Claims = &Claims{} // Claims extends jwt.StandardClaims' Valid to make sure claims has a subject. type Claims struct { gojwt.StandardClaims - // We were unable to find a standard claim at https://www.iana.org/assignments/jwt/jwt.xhtmldd + // We were unable to find a standard claim at https://www.iana.org/assignments/jwt/jwt.xhtml // that felt appropriate for Organization. As a result, we added a custom `org` field. Organization string `json:"org,omitempty"` + // We were unable to find a standard claim at https://www.iana.org/assignments/jwt/jwt.xhtml + // that felt appropriate for a users Group(s). As a result we added a custom `grp` field. + // Multiple groups may be specified by comma delimiting the various group. + // + // The singlular `grp` was chosen over the `grps` to keep consistent with the JWT naming + // convention (it is common for singlularly named values to actually be arrays, see `given_name`, + // `family_name`, and `middle_name` in the iana link provided above). I should add the discalimer + // I'm currently sick, so this thought process might be off. + Group string `json:"grp,omitempty"` } // Valid adds an empty subject test to the StandardClaims checks. @@ -99,6 +108,7 @@ func (j *JWT) ValidClaims(jwtToken Token, lifespan time.Duration, alg gojwt.Keyf Subject: claims.Subject, Issuer: claims.Issuer, Organization: claims.Organization, + Group: claims.Group, ExpiresAt: exp, IssuedAt: iat, }, nil @@ -117,6 +127,7 @@ func (j *JWT) Create(ctx context.Context, user Principal) (Token, error) { NotBefore: user.IssuedAt.Unix(), }, Organization: user.Organization, + Group: user.Group, } token := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims) // Sign and get the complete encoded token as a string using the secret diff --git a/oauth2/mux.go b/oauth2/mux.go index f9881cbe6..88e04b2e8 100644 --- a/oauth2/mux.go +++ b/oauth2/mux.go @@ -23,8 +23,8 @@ func NewAuthMux(p Provider, a Authenticator, t Tokenizer, basepath string, l chr Tokens: t, SuccessURL: path.Join(basepath, "/"), FailureURL: path.Join(basepath, "/login"), - Now: DefaultNowTime, - Logger: l, + Now: DefaultNowTime, + Logger: l, } } @@ -125,9 +125,17 @@ func (j *AuthMux) Callback() http.Handler { return } + group, err := j.Provider.Group(oauthClient) + if err != nil { + log.Error("Unable to get OAuth Group", err.Error()) + http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) + return + } + p := Principal{ Subject: id, Issuer: j.Provider.Name(), + Group: group, } ctx := r.Context() err = j.Auth.Authorize(ctx, w, p) diff --git a/oauth2/mux_test.go b/oauth2/mux_test.go index 7cc13ba34..a7dcecdb5 100644 --- a/oauth2/mux_test.go +++ b/oauth2/mux_test.go @@ -27,7 +27,11 @@ func setupMuxTest(selector func(*AuthMux) http.Handler) (*http.Client, *httptest now := func() time.Time { return testTime } - mp := &MockProvider{"biff@example.com", provider.URL} + mp := &MockProvider{ + Email: "biff@example.com", + ProviderURL: provider.URL, + Orgs: "", + } mt := &YesManTokenizer{} auth := &cookie{ Name: DefaultCookieName, diff --git a/oauth2/oauth2.go b/oauth2/oauth2.go index ef0c44b51..6ed19761d 100644 --- a/oauth2/oauth2.go +++ b/oauth2/oauth2.go @@ -33,6 +33,7 @@ type Principal struct { Subject string Issuer string Organization string + Group string ExpiresAt time.Time IssuedAt time.Time } @@ -53,6 +54,10 @@ type Provider interface { PrincipalID(provider *http.Client) (string, error) // Name is the name of the Provider Name() string + // Group is a comma delimited list of groups and organizations for a provider + // TODO: This will break if there are any group names that contain commas. + // I think this is okay, but I'm not 100% certain. + Group(provider *http.Client) (string, error) } // Mux is a collection of handlers responsible for servicing an Oauth2 interaction between a browser and a provider diff --git a/oauth2/oauth2_test.go b/oauth2/oauth2_test.go index 48e4929ce..f3cb1685e 100644 --- a/oauth2/oauth2_test.go +++ b/oauth2/oauth2_test.go @@ -16,6 +16,7 @@ var _ Provider = &MockProvider{} type MockProvider struct { Email string + Orgs string ProviderURL string } @@ -44,6 +45,10 @@ func (mp *MockProvider) PrincipalID(provider *http.Client) (string, error) { return mp.Email, nil } +func (mp *MockProvider) Group(provider *http.Client) (string, error) { + return mp.Orgs, nil +} + func (mp *MockProvider) Scopes() []string { return []string{} } diff --git a/server/mapping.go b/server/mapping.go new file mode 100644 index 000000000..b49f2ce9c --- /dev/null +++ b/server/mapping.go @@ -0,0 +1,250 @@ +package server + +import ( + "context" + + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/oauth2" +) + +func (s *Service) mapPrincipalToRoles(ctx context.Context, p oauth2.Principal) ([]chronograf.Role, error) { + mappings, err := s.Store.Mappings(ctx).All(ctx) + if err != nil { + return nil, err + } + roles := []chronograf.Role{} +MappingsLoop: + for _, mapping := range mappings { + if applyMapping(mapping, p) { + org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &mapping.Organization}) + if err != nil { + continue MappingsLoop + } + + for _, role := range roles { + if role.Organization == org.ID { + continue MappingsLoop + } + } + roles = append(roles, chronograf.Role{Organization: org.ID, Name: org.DefaultRole}) + } + } + + return roles, nil +} + +func applyMapping(m chronograf.Mapping, p oauth2.Principal) bool { + switch m.Provider { + case chronograf.MappingWildcard, p.Issuer: + default: + return false + } + + switch m.Scheme { + case chronograf.MappingWildcard, "oauth2": + default: + return false + } + + if m.ProviderOrganization == chronograf.MappingWildcard { + return true + } + + groups := strings.Split(p.Group, ",") + + return matchGroup(m.ProviderOrganization, groups) +} + +func matchGroup(match string, groups []string) bool { + for _, group := range groups { + if match == group { + return true + } + } + + return false +} + +type mappingsRequest chronograf.Mapping + +// Valid determines if a mapping request is valid +func (m *mappingsRequest) Valid() error { + if m.Provider == "" { + return fmt.Errorf("mapping must specify provider") + } + if m.Scheme == "" { + return fmt.Errorf("mapping must specify scheme") + } + if m.ProviderOrganization == "" { + return fmt.Errorf("mapping must specify group") + } + + return nil +} + +type mappingResponse struct { + Links selfLinks `json:"links"` + chronograf.Mapping +} + +func newMappingResponse(m chronograf.Mapping) *mappingResponse { + + return &mappingResponse{ + Links: selfLinks{ + Self: fmt.Sprintf("/chronograf/v1/mappings/%s", m.ID), + }, + Mapping: m, + } +} + +type mappingsResponse struct { + Links selfLinks `json:"links"` + Mappings []*mappingResponse `json:"mappings"` +} + +func newMappingsResponse(ms []chronograf.Mapping) *mappingsResponse { + mappings := []*mappingResponse{} + for _, m := range ms { + mappings = append(mappings, newMappingResponse(m)) + } + return &mappingsResponse{ + Links: selfLinks{ + Self: "/chronograf/v1/mappings", + }, + Mappings: mappings, + } +} + +// Mappings retrives all mappings +func (s *Service) Mappings(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + mappings, err := s.Store.Mappings(ctx).All(ctx) + if err != nil { + Error(w, http.StatusInternalServerError, "failed to retrieve mappings from database", s.Logger) + return + } + + fmt.Printf("mappings: %#v\n", mappings) + + res := newMappingsResponse(mappings) + + encodeJSON(w, http.StatusOK, res, s.Logger) +} + +// NewMapping adds a new mapping +func (s *Service) NewMapping(w http.ResponseWriter, r *http.Request) { + var req mappingsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, s.Logger) + return + } + + if err := req.Valid(); err != nil { + invalidData(w, err, s.Logger) + return + } + + ctx := r.Context() + + // validate that the organization exists + if !s.organizationExists(ctx, req.Organization) { + invalidData(w, fmt.Errorf("organization does not exist"), s.Logger) + return + } + + mapping := &chronograf.Mapping{ + Organization: req.Organization, + Scheme: req.Scheme, + Provider: req.Provider, + ProviderOrganization: req.ProviderOrganization, + } + + m, err := s.Store.Mappings(ctx).Add(ctx, mapping) + if err != nil { + Error(w, http.StatusInternalServerError, "failed to add mapping to database", s.Logger) + return + } + + cu := newMappingResponse(*m) + location(w, cu.Links.Self) + encodeJSON(w, http.StatusCreated, cu, s.Logger) +} + +// UpdateMapping updates a mapping +func (s *Service) UpdateMapping(w http.ResponseWriter, r *http.Request) { + var req mappingsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, s.Logger) + return + } + + if err := req.Valid(); err != nil { + invalidData(w, err, s.Logger) + return + } + + ctx := r.Context() + + // validate that the organization exists + if !s.organizationExists(ctx, req.Organization) { + invalidData(w, fmt.Errorf("organization does not exist"), s.Logger) + return + } + + mapping := &chronograf.Mapping{ + ID: req.ID, + Organization: req.Organization, + Scheme: req.Scheme, + Provider: req.Provider, + ProviderOrganization: req.ProviderOrganization, + } + + err := s.Store.Mappings(ctx).Update(ctx, mapping) + if err != nil { + Error(w, http.StatusInternalServerError, "failed to update mapping in database", s.Logger) + return + } + + cu := newMappingResponse(*mapping) + location(w, cu.Links.Self) + encodeJSON(w, http.StatusOK, cu, s.Logger) +} + +// RemoveMapping removes a mapping +func (s *Service) RemoveMapping(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := httprouter.GetParamFromContext(ctx, "id") + + m, err := s.Store.Mappings(ctx).Get(ctx, id) + if err == chronograf.ErrMappingNotFound { + Error(w, http.StatusNotFound, err.Error(), s.Logger) + return + } + + if err != nil { + Error(w, http.StatusInternalServerError, "failed to retrieve mapping from database", s.Logger) + return + } + + if err := s.Store.Mappings(ctx).Delete(ctx, m); err != nil { + Error(w, http.StatusInternalServerError, "failed to remove mapping from database", s.Logger) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (s *Service) organizationExists(ctx context.Context, orgID string) bool { + if _, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &orgID}); err != nil { + return false + } + + return true +} diff --git a/server/mapping_test.go b/server/mapping_test.go new file mode 100644 index 000000000..8697084f0 --- /dev/null +++ b/server/mapping_test.go @@ -0,0 +1,360 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "io/ioutil" + "net/http/httptest" + "testing" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/mocks" + "github.com/influxdata/chronograf/roles" +) + +func TestMappings_All(t *testing.T) { + type fields struct { + MappingsStore chronograf.MappingsStore + } + type args struct { + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "get all mappings", + fields: fields{ + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{ + { + Organization: "0", + Provider: chronograf.MappingWildcard, + Scheme: chronograf.MappingWildcard, + ProviderOrganization: chronograf.MappingWildcard, + }, + }, nil + }, + }, + }, + wants: wants{ + statusCode: 200, + contentType: "application/json", + body: `{"links":{"self":"/chronograf/v1/mappings"},"mappings":[{"links":{"self":"/chronograf/v1/mappings/"},"id":"","organizationId":"0","provider":"*","scheme":"*","providerOrganization":"*"}]}`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + MappingsStore: tt.fields.MappingsStore, + }, + Logger: log.New(log.DebugLevel), + } + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://any.url", nil) + s.Mappings(w, r) + + resp := w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wants.statusCode { + t.Errorf("%q. Mappings() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode) + } + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf("%q. Mappings() = %v, want %v", tt.name, content, tt.wants.contentType) + } + if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq { + t.Errorf("%q. Mappings() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body) + } + }) + } +} + +func TestMappings_Add(t *testing.T) { + type fields struct { + MappingsStore chronograf.MappingsStore + OrganizationsStore chronograf.OrganizationsStore + } + type args struct { + mapping *chronograf.Mapping + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "create new mapping", + fields: fields{ + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "0", + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + }, nil + }, + }, + MappingsStore: &mocks.MappingsStore{ + AddF: func(ctx context.Context, m *chronograf.Mapping) (*chronograf.Mapping, error) { + m.ID = "0" + return m, nil + }, + }, + }, + args: args{ + mapping: &chronograf.Mapping{ + Organization: "0", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + }, + wants: wants{ + statusCode: 201, + contentType: "application/json", + body: `{"links":{"self":"/chronograf/v1/mappings/0"},"id":"0","organizationId":"0","provider":"*","scheme":"*","providerOrganization":"*"}`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + MappingsStore: tt.fields.MappingsStore, + OrganizationsStore: tt.fields.OrganizationsStore, + }, + Logger: log.New(log.DebugLevel), + } + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://any.url", nil) + + buf, _ := json.Marshal(tt.args.mapping) + r.Body = ioutil.NopCloser(bytes.NewReader(buf)) + + s.NewMapping(w, r) + + resp := w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wants.statusCode { + t.Errorf("%q. Add() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode) + } + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf("%q. Add() = %v, want %v", tt.name, content, tt.wants.contentType) + } + if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq { + t.Errorf("%q. Add() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body) + } + }) + } +} + +func TestMappings_Update(t *testing.T) { + type fields struct { + MappingsStore chronograf.MappingsStore + OrganizationsStore chronograf.OrganizationsStore + } + type args struct { + mapping *chronograf.Mapping + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "update new mapping", + fields: fields{ + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "0", + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + }, nil + }, + }, + MappingsStore: &mocks.MappingsStore{ + UpdateF: func(ctx context.Context, m *chronograf.Mapping) error { + return nil + }, + }, + }, + args: args{ + mapping: &chronograf.Mapping{ + ID: "1", + Organization: "0", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + }, + wants: wants{ + statusCode: 200, + contentType: "application/json", + body: `{"links":{"self":"/chronograf/v1/mappings/1"},"id":"1","organizationId":"0","provider":"*","scheme":"*","providerOrganization":"*"}`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + MappingsStore: tt.fields.MappingsStore, + OrganizationsStore: tt.fields.OrganizationsStore, + }, + Logger: log.New(log.DebugLevel), + } + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://any.url", nil) + + buf, _ := json.Marshal(tt.args.mapping) + r.Body = ioutil.NopCloser(bytes.NewReader(buf)) + r = r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.args.mapping.ID, + }, + })) + + s.UpdateMapping(w, r) + + resp := w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wants.statusCode { + t.Errorf("%q. Add() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode) + } + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf("%q. Add() = %v, want %v", tt.name, content, tt.wants.contentType) + } + if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq { + t.Errorf("%q. Add() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body) + } + }) + } +} + +func TestMappings_Remove(t *testing.T) { + type fields struct { + MappingsStore chronograf.MappingsStore + } + type args struct { + id string + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "remove mapping", + fields: fields{ + MappingsStore: &mocks.MappingsStore{ + GetF: func(ctx context.Context, id string) (*chronograf.Mapping, error) { + return &chronograf.Mapping{ + ID: "1", + Organization: "0", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, nil + }, + DeleteF: func(ctx context.Context, m *chronograf.Mapping) error { + return nil + }, + }, + }, + args: args{}, + wants: wants{ + statusCode: 204, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + MappingsStore: tt.fields.MappingsStore, + }, + Logger: log.New(log.DebugLevel), + } + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://any.url", nil) + + r = r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.args.id, + }, + })) + + s.RemoveMapping(w, r) + + resp := w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wants.statusCode { + t.Errorf("%q. Remove() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode) + } + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf("%q. Remove() = %v, want %v", tt.name, content, tt.wants.contentType) + } + if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq { + t.Errorf("%q. Remove() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body) + } + }) + } +} diff --git a/server/me.go b/server/me.go index 1ec50d262..225cc2246 100644 --- a/server/me.go +++ b/server/me.go @@ -20,10 +20,22 @@ type meLinks struct { type meResponse struct { *chronograf.User Links meLinks `json:"links"` - Organizations []chronograf.Organization `json:"organizations,omitempty"` + Organizations []chronograf.Organization `json:"organizations"` CurrentOrganization *chronograf.Organization `json:"currentOrganization,omitempty"` } +type noAuthMeResponse struct { + Links meLinks `json:"links"` +} + +func newNoAuthMeResponse() noAuthMeResponse { + return noAuthMeResponse{ + Links: meLinks{ + Self: "/chronograf/v1/me", + }, + } +} + // If new user response is nil, return an empty meResponse because it // indicates authentication is not needed func newMeResponse(usr *chronograf.User, org string) meResponse { @@ -182,7 +194,7 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !s.UseAuth { // If there's no authentication, return an empty user - res := newMeResponse(nil, "") + res := newNoAuthMeResponse() encodeJSON(w, http.StatusOK, res, s.Logger) return } @@ -201,12 +213,13 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { ctx = context.WithValue(ctx, organizations.ContextKey, p.Organization) serverCtx := serverContext(ctx) + defaultOrg, err := s.Store.Organizations(serverCtx).DefaultOrganization(serverCtx) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + if p.Organization == "" { - defaultOrg, err := s.Store.Organizations(serverCtx).DefaultOrganization(serverCtx) - if err != nil { - unknownErrorWithMessage(w, err, s.Logger) - return - } p.Organization = defaultOrg.ID } @@ -220,35 +233,8 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { return } - defaultOrg, err := s.Store.Organizations(serverCtx).DefaultOrganization(serverCtx) - if err != nil { - unknownErrorWithMessage(w, err, s.Logger) - return - } - + // user exists if usr != nil { - - if defaultOrg.Public || usr.SuperAdmin == true { - // If the default organization is public, or the user is a super admin - // they will always have a role in the default organization - defaultOrgID := defaultOrg.ID - if !hasRoleInDefaultOrganization(usr, defaultOrgID) { - usr.Roles = append(usr.Roles, chronograf.Role{ - Organization: defaultOrgID, - Name: defaultOrg.DefaultRole, - }) - if err := s.Store.Users(serverCtx).Update(serverCtx, usr); err != nil { - unknownErrorWithMessage(w, err, s.Logger) - return - } - } - } - - // If the default org is private and the user has no roles, they should not have access - if !defaultOrg.Public && len(usr.Roles) == 0 { - Error(w, http.StatusForbidden, "This organization is private. To gain access, you must be explicitly added by an administrator.", s.Logger) - return - } currentOrg, err := s.Store.Organizations(serverCtx).Get(serverCtx, chronograf.OrganizationQuery{ID: &p.Organization}) if err == chronograf.ErrOrganizationNotFound { // The intent is to force a the user to go through another auth flow @@ -265,6 +251,7 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { unknownErrorWithMessage(w, err, s.Logger) return } + res := newMeResponse(usr, currentOrg.ID) res.Organizations = orgs res.CurrentOrganization = currentOrg @@ -272,13 +259,6 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { return } - // If users must be explicitly added to the default organization, respond with 403 - // forbidden - if !defaultOrg.Public { - Error(w, http.StatusForbidden, "This organization is private. To gain access, you must be explicitly added by an administrator.", s.Logger) - return - } - // Because we didnt find a user, making a new one user := &chronograf.User{ Name: p.Subject, @@ -287,17 +267,23 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { // support OAuth2. This hard-coding should be removed whenever we add // support for other authentication schemes. Scheme: scheme, - Roles: []chronograf.Role{ - { - Name: defaultOrg.DefaultRole, - // This is the ID of the default organization - Organization: defaultOrg.ID, - }, - }, // TODO(desa): this needs a better name SuperAdmin: s.newUsersAreSuperAdmin(), } + roles, err := s.mapPrincipalToRoles(serverCtx, p) + if err != nil { + Error(w, http.StatusInternalServerError, err.Error(), s.Logger) + return + } + + if len(roles) == 0 { + Error(w, http.StatusForbidden, "This Chronograf is private. To gain access, you must be explicitly added by an administrator.", s.Logger) + return + } + + user.Roles = roles + newUser, err := s.Store.Users(serverCtx).Add(serverCtx, user) if err != nil { msg := fmt.Errorf("error storing user %s: %v", user.Name, err) diff --git a/server/me_test.go b/server/me_test.go index 6733c44c3..6abf9e739 100644 --- a/server/me_test.go +++ b/server/me_test.go @@ -23,6 +23,7 @@ func TestService_Me(t *testing.T) { type fields struct { UsersStore chronograf.UsersStore OrganizationsStore chronograf.OrganizationsStore + MappingsStore chronograf.MappingsStore ConfigStore chronograf.ConfigStore Logger chronograf.Logger UseAuth bool @@ -56,13 +57,24 @@ func TestService_Me(t *testing.T) { }, }, }, + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{ + { + Organization: "0", + Provider: chronograf.MappingWildcard, + Scheme: chronograf.MappingWildcard, + ProviderOrganization: chronograf.MappingWildcard, + }, + }, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, - Public: false, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -72,13 +84,11 @@ func TestService_Me(t *testing.T) { ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, - Public: false, }, nil case "1": return &chronograf.Organization{ - ID: "1", - Name: "The Bad Place", - Public: false, + ID: "1", + Name: "The Bad Place", }, nil } return nil, nil @@ -108,12 +118,12 @@ func TestService_Me(t *testing.T) { Subject: "me", Issuer: "github", }, - wantStatus: http.StatusForbidden, + wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"code":403,"message":"This organization is private. To gain access, you must be explicitly added by an administrator."}`, + wantBody: `{"name":"me","roles":null,"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[],"currentOrganization":{"id":"0","name":"Default","defaultRole":"viewer"}}`, }, { - name: "Existing user - private default org and user is a super admin", + name: "Existing superadmin - not member of any organization", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest("GET", "http://example.com/foo", nil), @@ -121,13 +131,17 @@ func TestService_Me(t *testing.T) { fields: fields{ UseAuth: true, Logger: log.New(log.DebugLevel), + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{}, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, - Public: false, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -137,13 +151,11 @@ func TestService_Me(t *testing.T) { ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, - Public: true, }, nil case "1": return &chronograf.Organization{ - ID: "1", - Name: "The Bad Place", - Public: true, + ID: "1", + Name: "The Bad Place", }, nil } return nil, nil @@ -176,137 +188,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"me","roles":[{"name":"viewer","organization":"0"}],"provider":"github","scheme":"oauth2","superAdmin":true,"links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[{"id":"0","name":"Default","defaultRole":"viewer","public":true}],"currentOrganization":{"id":"0","name":"Default","defaultRole":"viewer","public":true}}`, - }, - { - name: "Existing user - private default org", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest("GET", "http://example.com/foo", nil), - }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - OrganizationsStore: &mocks.OrganizationsStore{ - DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { - return &chronograf.Organization{ - ID: "0", - Name: "Default", - DefaultRole: roles.ViewerRoleName, - Public: false, - }, nil - }, - GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { - switch *q.ID { - case "0": - return &chronograf.Organization{ - ID: "0", - Name: "Default", - DefaultRole: roles.ViewerRoleName, - Public: true, - }, nil - case "1": - return &chronograf.Organization{ - ID: "1", - Name: "The Bad Place", - Public: true, - }, nil - } - return nil, nil - }, - }, - UsersStore: &mocks.UsersStore{ - NumF: func(ctx context.Context) (int, error) { - // This function gets to verify that there is at least one first user - return 1, nil - }, - GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { - if q.Name == nil || q.Provider == nil || q.Scheme == nil { - return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") - } - return &chronograf.User{ - Name: "me", - Provider: "github", - Scheme: "oauth2", - }, nil - }, - UpdateF: func(ctx context.Context, u *chronograf.User) error { - return nil - }, - }, - }, - principal: oauth2.Principal{ - Subject: "me", - Issuer: "github", - }, - wantStatus: http.StatusForbidden, - wantContentType: "application/json", - wantBody: `{"code":403,"message":"This organization is private. To gain access, you must be explicitly added by an administrator."}`, - }, - { - name: "Existing user - default org public", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest("GET", "http://example.com/foo", nil), - }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - OrganizationsStore: &mocks.OrganizationsStore{ - DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { - return &chronograf.Organization{ - ID: "0", - Name: "Default", - DefaultRole: roles.ViewerRoleName, - Public: true, - }, nil - }, - GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { - switch *q.ID { - case "0": - return &chronograf.Organization{ - ID: "0", - Name: "Default", - DefaultRole: roles.ViewerRoleName, - Public: true, - }, nil - case "1": - return &chronograf.Organization{ - ID: "1", - Name: "The Bad Place", - Public: true, - }, nil - } - return nil, nil - }, - }, - UsersStore: &mocks.UsersStore{ - NumF: func(ctx context.Context) (int, error) { - // This function gets to verify that there is at least one first user - return 1, nil - }, - GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { - if q.Name == nil || q.Provider == nil || q.Scheme == nil { - return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") - } - return &chronograf.User{ - Name: "me", - Provider: "github", - Scheme: "oauth2", - }, nil - }, - UpdateF: func(ctx context.Context, u *chronograf.User) error { - return nil - }, - }, - }, - principal: oauth2.Principal{ - Subject: "me", - Issuer: "github", - }, - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"name":"me","roles":[{"name":"viewer","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[{"id":"0","name":"Default","defaultRole":"viewer","public":true}],"currentOrganization":{"id":"0","name":"Default","defaultRole":"viewer","public":true}}`, + wantBody: `{"name":"me","roles":null,"provider":"github","scheme":"oauth2","superAdmin":true,"links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[],"currentOrganization":{"id":"0","name":"Default","defaultRole":"viewer"}}`, }, { name: "Existing user - organization doesn't exist", @@ -317,13 +199,17 @@ func TestService_Me(t *testing.T) { fields: fields{ UseAuth: true, Logger: log.New(log.DebugLevel), + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{}, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, - Public: true, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -333,7 +219,6 @@ func TestService_Me(t *testing.T) { ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, - Public: true, }, nil } return nil, chronograf.ErrOrganizationNotFound @@ -365,7 +250,7 @@ func TestService_Me(t *testing.T) { wantBody: `{"code":403,"message":"user's current organization was not found"}`, }, { - name: "new user - default org is public", + name: "default mapping applies to new user", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest("GET", "http://example.com/foo", nil), @@ -380,13 +265,24 @@ func TestService_Me(t *testing.T) { }, }, }, + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{ + { + Organization: "0", + Provider: chronograf.MappingWildcard, + Scheme: chronograf.MappingWildcard, + ProviderOrganization: chronograf.MappingWildcard, + }, + }, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, - Public: true, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -394,7 +290,15 @@ func TestService_Me(t *testing.T) { ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, - Public: true, + }, nil + }, + AllF: func(ctx context.Context) ([]chronograf.Organization, error) { + return []chronograf.Organization{ + chronograf.Organization{ + ID: "0", + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + }, }, nil }, }, @@ -423,8 +327,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"secret","superAdmin":true,"roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","defaultRole":"viewer","public":true}],"currentOrganization":{"id":"0","name":"The Gnarly Default","defaultRole":"viewer","public":true}} -`, + wantBody: `{"name":"secret","superAdmin":true,"roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","defaultRole":"viewer"}}`, }, { name: "New user - New users not super admin, not first user", @@ -442,13 +345,24 @@ func TestService_Me(t *testing.T) { }, }, }, + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{ + { + Organization: "0", + Provider: chronograf.MappingWildcard, + Scheme: chronograf.MappingWildcard, + ProviderOrganization: chronograf.MappingWildcard, + }, + }, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, - Public: true, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -456,7 +370,15 @@ func TestService_Me(t *testing.T) { ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, - Public: true, + }, nil + }, + AllF: func(ctx context.Context) ([]chronograf.Organization, error) { + return []chronograf.Organization{ + chronograf.Organization{ + ID: "0", + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + }, }, nil }, }, @@ -485,8 +407,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"secret","roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}} -`, + wantBody: `{"name":"secret","roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","defaultRole":"viewer"}}`, }, { name: "New user - New users not super admin, first user", @@ -504,13 +425,24 @@ func TestService_Me(t *testing.T) { }, }, }, + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{ + { + Organization: "0", + Provider: chronograf.MappingWildcard, + Scheme: chronograf.MappingWildcard, + ProviderOrganization: chronograf.MappingWildcard, + }, + }, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, - Public: true, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -518,7 +450,15 @@ func TestService_Me(t *testing.T) { ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, - Public: true, + }, nil + }, + AllF: func(ctx context.Context) ([]chronograf.Organization, error) { + return []chronograf.Organization{ + chronograf.Organization{ + ID: "0", + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + }, }, nil }, }, @@ -547,8 +487,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"secret","superAdmin":true,"roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}} -`, + wantBody: `{"name":"secret","superAdmin":true,"roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","defaultRole":"viewer"}}`, }, { name: "Error adding user", @@ -565,18 +504,31 @@ func TestService_Me(t *testing.T) { }, }, }, + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{}, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: "0", - Public: true, + ID: "0", + Name: "The Bad Place", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: "0", - Name: "The Bad Place", - Public: true, + ID: "0", + Name: "The Bad Place", + }, nil + }, + AllF: func(ctx context.Context) ([]chronograf.Organization, error) { + return []chronograf.Organization{ + chronograf.Organization{ + ID: "0", + Name: "The Bad Place", + DefaultRole: roles.ViewerRoleName, + }, }, nil }, }, @@ -601,9 +553,9 @@ func TestService_Me(t *testing.T) { Subject: "secret", Issuer: "heroku", }, - wantStatus: http.StatusInternalServerError, + wantStatus: http.StatusForbidden, wantContentType: "application/json", - wantBody: `{"code":500,"message":"Unknown error: error storing user secret: Why Heavy?"}`, + wantBody: `{"code":403,"message":"This Chronograf is private. To gain access, you must be explicitly added by an administrator."}`, }, { name: "No Auth", @@ -624,8 +576,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/me"}} -`, + wantBody: `{"links":{"self":"/chronograf/v1/me"}}`, }, { name: "Empty Principal", @@ -659,13 +610,24 @@ func TestService_Me(t *testing.T) { fields: fields{ UseAuth: true, Logger: log.New(log.DebugLevel), + ConfigStore: mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{}, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ ID: "0", Name: "The Bad Place", DefaultRole: roles.MemberRoleName, - Public: false, }, nil }, }, @@ -694,7 +656,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusForbidden, wantContentType: "application/json", - wantBody: `{"code":403,"message":"This organization is private. To gain access, you must be explicitly added by an administrator."}`, + wantBody: `{"code":403,"message":"This Chronograf is private. To gain access, you must be explicitly added by an administrator."}`, }, } for _, tt := range tests { @@ -703,12 +665,14 @@ func TestService_Me(t *testing.T) { Store: &mocks.Store{ UsersStore: tt.fields.UsersStore, OrganizationsStore: tt.fields.OrganizationsStore, + MappingsStore: tt.fields.MappingsStore, ConfigStore: tt.fields.ConfigStore, }, Logger: tt.fields.Logger, UseAuth: tt.fields.UseAuth, } + fmt.Println(tt.name) s.Me(tt.args.w, tt.args.r) resp := tt.args.w.Result() @@ -792,7 +756,6 @@ func TestService_UpdateMe(t *testing.T) { ID: "0", Name: "Default", DefaultRole: roles.AdminRoleName, - Public: true, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -805,13 +768,11 @@ func TestService_UpdateMe(t *testing.T) { ID: "0", Name: "Default", DefaultRole: roles.AdminRoleName, - Public: true, }, nil case "1337": return &chronograf.Organization{ - ID: "1337", - Name: "The ShillBillThrilliettas", - Public: true, + ID: "1337", + Name: "The ShillBillThrilliettas", }, nil } return nil, nil @@ -824,7 +785,7 @@ func TestService_UpdateMe(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"},{"name":"admin","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/1337/users/0"},"organizations":[{"id":"0","name":"Default","defaultRole":"admin","public":true},{"id":"1337","name":"The ShillBillThrilliettas","public":true}],"currentOrganization":{"id":"1337","name":"The ShillBillThrilliettas","public":true}}`, + wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/1337/users/0"},"organizations":[{"id":"1337","name":"The ShillBillThrilliettas"}],"currentOrganization":{"id":"1337","name":"The ShillBillThrilliettas"}}`, }, { name: "Change the current User's organization", @@ -866,7 +827,6 @@ func TestService_UpdateMe(t *testing.T) { ID: "0", Name: "Default", DefaultRole: roles.EditorRoleName, - Public: true, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -876,16 +836,14 @@ func TestService_UpdateMe(t *testing.T) { switch *q.ID { case "1337": return &chronograf.Organization{ - ID: "1337", - Name: "The ThrillShilliettos", - Public: false, + ID: "1337", + Name: "The ThrillShilliettos", }, nil case "0": return &chronograf.Organization{ ID: "0", Name: "Default", DefaultRole: roles.EditorRoleName, - Public: true, }, nil } return nil, nil @@ -899,7 +857,7 @@ func TestService_UpdateMe(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"},{"name":"editor","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/1337/users/0"},"organizations":[{"id":"0","name":"Default","defaultRole":"editor","public":true},{"id":"1337","name":"The ThrillShilliettos","public":false}],"currentOrganization":{"id":"1337","name":"The ThrillShilliettos","public":false}}`, + wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/1337/users/0"},"organizations":[{"id":"1337","name":"The ThrillShilliettos"}],"currentOrganization":{"id":"1337","name":"The ThrillShilliettos"}}`, }, { name: "Unable to find requested user in valid organization", @@ -946,9 +904,8 @@ func TestService_UpdateMe(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: "1337", - Name: "The ShillBillThrilliettas", - Public: true, + ID: "1337", + Name: "The ShillBillThrilliettas", }, nil }, }, diff --git a/server/mux.go b/server/mux.go index 91523772e..cf8d1d83e 100644 --- a/server/mux.go +++ b/server/mux.go @@ -141,6 +141,13 @@ func NewMux(opts MuxOpts, service Service) http.Handler { router.PATCH("/chronograf/v1/organizations/:oid", EnsureSuperAdmin(service.UpdateOrganization)) router.DELETE("/chronograf/v1/organizations/:oid", EnsureSuperAdmin(service.RemoveOrganization)) + // Mappings + router.GET("/chronograf/v1/mappings", EnsureSuperAdmin(service.Mappings)) + router.POST("/chronograf/v1/mappings", EnsureSuperAdmin(service.NewMapping)) + + router.PUT("/chronograf/v1/mappings/:id", EnsureSuperAdmin(service.UpdateMapping)) + router.DELETE("/chronograf/v1/mappings/:id", EnsureSuperAdmin(service.RemoveMapping)) + // Sources router.GET("/chronograf/v1/sources", EnsureViewer(service.Sources)) router.POST("/chronograf/v1/sources", EnsureEditor(service.NewSource)) diff --git a/server/organizations.go b/server/organizations.go index a4debaa15..d9e223954 100644 --- a/server/organizations.go +++ b/server/organizations.go @@ -15,7 +15,6 @@ import ( type organizationRequest struct { Name string `json:"name"` DefaultRole string `json:"defaultRole"` - Public *bool `json:"public"` } func (r *organizationRequest) ValidCreate() error { @@ -27,7 +26,7 @@ func (r *organizationRequest) ValidCreate() error { } func (r *organizationRequest) ValidUpdate() error { - if r.Name == "" && r.DefaultRole == "" && r.Public == nil { + if r.Name == "" && r.DefaultRole == "" { return fmt.Errorf("No fields to update") } @@ -119,10 +118,6 @@ func (s *Service) NewOrganization(w http.ResponseWriter, r *http.Request) { DefaultRole: req.DefaultRole, } - if req.Public != nil { - org.Public = *req.Public - } - res, err := s.Store.Organizations(ctx).Add(ctx, org) if err != nil { Error(w, http.StatusBadRequest, err.Error(), s.Logger) @@ -207,10 +202,6 @@ func (s *Service) UpdateOrganization(w http.ResponseWriter, r *http.Request) { org.DefaultRole = req.DefaultRole } - if req.Public != nil { - org.Public = *req.Public - } - err = s.Store.Organizations(ctx).Update(ctx, org) if err != nil { Error(w, http.StatusBadRequest, err.Error(), s.Logger) diff --git a/server/organizations_test.go b/server/organizations_test.go index 846f8ab81..51dfd3b0f 100644 --- a/server/organizations_test.go +++ b/server/organizations_test.go @@ -52,9 +52,8 @@ func TestService_OrganizationID(t *testing.T) { switch *q.ID { case "1337": return &chronograf.Organization{ - ID: "1337", - Name: "The Good Place", - Public: false, + ID: "1337", + Name: "The Good Place", }, nil default: return nil, fmt.Errorf("Organization with ID %s not found", *q.ID) @@ -65,7 +64,38 @@ func TestService_OrganizationID(t *testing.T) { id: "1337", wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place","public":false}`, + wantBody: `{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place"}`, + }, + { + name: "Get Single Organization", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1337": + return &chronograf.Organization{ + ID: "1337", + Name: "The Good Place", + }, nil + default: + return nil, fmt.Errorf("Organization with ID %s not found", *q.ID) + } + }, + }, + }, + id: "1337", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"id":"1337","name":"The Good Place","links":{"self":"/chronograf/v1/organizations/1337"}}`, }, } @@ -124,7 +154,7 @@ func TestService_Organizations(t *testing.T) { wantBody string }{ { - name: "Get Single Organization", + name: "Get Organizations", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest( @@ -139,14 +169,12 @@ func TestService_Organizations(t *testing.T) { AllF: func(ctx context.Context) ([]chronograf.Organization, error) { return []chronograf.Organization{ chronograf.Organization{ - ID: "1337", - Name: "The Good Place", - Public: false, + ID: "1337", + Name: "The Good Place", }, chronograf.Organization{ - ID: "100", - Name: "The Bad Place", - Public: false, + ID: "100", + Name: "The Bad Place", }, }, nil }, @@ -154,7 +182,7 @@ func TestService_Organizations(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/organizations"},"organizations":[{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place","public":false},{"links":{"self":"/chronograf/v1/organizations/100"},"id":"100","name":"The Bad Place","public":false}]}`, + wantBody: `{"links":{"self":"/chronograf/v1/organizations"},"organizations":[{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place"},{"links":{"self":"/chronograf/v1/organizations/100"},"id":"100","name":"The Bad Place"}]}`, }, } @@ -195,7 +223,6 @@ func TestService_UpdateOrganization(t *testing.T) { w *httptest.ResponseRecorder r *http.Request org *organizationRequest - public bool setPtr bool } tests := []struct { @@ -231,7 +258,6 @@ func TestService_UpdateOrganization(t *testing.T) { ID: "1337", Name: "The Good Place", DefaultRole: roles.ViewerRoleName, - Public: false, }, nil }, }, @@ -239,41 +265,7 @@ func TestService_UpdateOrganization(t *testing.T) { id: "1337", wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"id":"1337","name":"The Bad Place","defaultRole":"viewer","links":{"self":"/chronograf/v1/organizations/1337"},"public":false}`, - }, - { - name: "Update Organization public", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "GET", - "http://any.url", // can be any valid URL as we are bypassing mux - nil, - ), - org: &organizationRequest{}, - public: false, - setPtr: true, - }, - fields: fields{ - Logger: log.New(log.DebugLevel), - OrganizationsStore: &mocks.OrganizationsStore{ - UpdateF: func(ctx context.Context, o *chronograf.Organization) error { - return nil - }, - GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { - return &chronograf.Organization{ - ID: "0", - Name: "The Good Place", - DefaultRole: roles.ViewerRoleName, - Public: true, - }, nil - }, - }, - }, - id: "0", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"id":"0","name":"The Good Place","defaultRole":"viewer","public":false,"links":{"self":"/chronograf/v1/organizations/0"}}`, + wantBody: `{"id":"1337","name":"The Bad Place","defaultRole":"viewer","links":{"self":"/chronograf/v1/organizations/1337"}}`, }, { name: "Update Organization - nothing to update", @@ -297,7 +289,6 @@ func TestService_UpdateOrganization(t *testing.T) { ID: "1337", Name: "The Good Place", DefaultRole: roles.ViewerRoleName, - Public: true, }, nil }, }, @@ -331,7 +322,6 @@ func TestService_UpdateOrganization(t *testing.T) { ID: "1337", Name: "The Good Place", DefaultRole: roles.MemberRoleName, - Public: false, }, nil }, }, @@ -339,7 +329,7 @@ func TestService_UpdateOrganization(t *testing.T) { id: "1337", wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place","defaultRole":"viewer","public":false}`, + wantBody: `{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place","defaultRole":"viewer"}`, }, { name: "Update Organization - invalid update", @@ -416,10 +406,6 @@ func TestService_UpdateOrganization(t *testing.T) { }, })) - if tt.args.setPtr { - tt.args.org.Public = &tt.args.public - } - buf, _ := json.Marshal(tt.args.org) tt.args.r.Body = ioutil.NopCloser(bytes.NewReader(buf)) s.UpdateOrganization(tt.args.w, tt.args.r) @@ -573,16 +559,54 @@ func TestService_NewOrganization(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ AddF: func(ctx context.Context, o *chronograf.Organization) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: "1337", - Name: "The Good Place", - Public: false, + ID: "1337", + Name: "The Good Place", }, nil }, }, }, wantStatus: http.StatusCreated, wantContentType: "application/json", - wantBody: `{"id":"1337","public":false,"name":"The Good Place","links":{"self":"/chronograf/v1/organizations/1337"}}`, + wantBody: `{"id":"1337","name":"The Good Place","links":{"self":"/chronograf/v1/organizations/1337"}}`, + }, + { + name: "Fail to create Organization - no org name", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + user: &chronograf.User{ + ID: 1, + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + }, + org: &organizationRequest{}, + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1, + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + AddF: func(ctx context.Context, o *chronograf.Organization) (*chronograf.Organization, error) { + return nil, nil + }, + }, + }, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"Name required on Chronograf Organization request body"}`, }, { name: "Create Organization - no user on context", diff --git a/server/server.go b/server/server.go index c469dd7d6..d93974ce5 100644 --- a/server/server.go +++ b/server/server.go @@ -484,6 +484,7 @@ func openService(ctx context.Context, buildInfo chronograf.BuildInfo, boltPath s OrganizationsStore: organizations, UsersStore: db.UsersStore, ConfigStore: db.ConfigStore, + MappingsStore: db.MappingsStore, }, Logger: logger, UseAuth: useAuth, diff --git a/server/service.go b/server/service.go index e1df8da8e..04c9a44fc 100644 --- a/server/service.go +++ b/server/service.go @@ -48,7 +48,8 @@ func (c *InfluxClient) New(src chronograf.Source, logger chronograf.Logger) (chr } if src.Type == chronograf.InfluxEnterprise && src.MetaURL != "" { tls := strings.Contains(src.MetaURL, "https") - return enterprise.NewClientWithTimeSeries(logger, src.MetaURL, influx.DefaultAuthorization(&src), tls, client) + insecure := src.InsecureSkipVerify + return enterprise.NewClientWithTimeSeries(logger, src.MetaURL, influx.DefaultAuthorization(&src), tls, insecure, client) } return client, nil } diff --git a/server/stores.go b/server/stores.go index 7f9d8ac52..9646a4b7d 100644 --- a/server/stores.go +++ b/server/stores.go @@ -88,6 +88,7 @@ type DataStore interface { Layouts(ctx context.Context) chronograf.LayoutsStore Users(ctx context.Context) chronograf.UsersStore Organizations(ctx context.Context) chronograf.OrganizationsStore + Mappings(ctx context.Context) chronograf.MappingsStore Dashboards(ctx context.Context) chronograf.DashboardsStore Config(ctx context.Context) chronograf.ConfigStore } @@ -102,6 +103,7 @@ type Store struct { LayoutsStore chronograf.LayoutsStore UsersStore chronograf.UsersStore DashboardsStore chronograf.DashboardsStore + MappingsStore chronograf.MappingsStore OrganizationsStore chronograf.OrganizationsStore ConfigStore chronograf.ConfigStore } @@ -191,3 +193,14 @@ func (s *Store) Config(ctx context.Context) chronograf.ConfigStore { } return &noop.ConfigStore{} } + +// Mappings returns the underlying MappingsStore. +func (s *Store) Mappings(ctx context.Context) chronograf.MappingsStore { + if isServer := hasServerContext(ctx); isServer { + return s.MappingsStore + } + if isSuperAdmin := hasSuperAdminContext(ctx); isSuperAdmin { + return s.MappingsStore + } + return &noop.MappingsStore{} +} diff --git a/server/swagger.json b/server/swagger.json index 5eb12617c..4843d55b0 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -3,7 +3,7 @@ "info": { "title": "Chronograf", "description": "API endpoints for Chronograf", - "version": "1.4.0.1" + "version": "1.4.1.3" }, "schemes": ["http"], "basePath": "/chronograf/v1", diff --git a/ui/package.json b/ui/package.json index 9ba4ad077..778647e70 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "chronograf-ui", - "version": "1.4.0-1", + "version": "1.4.1-3", "private": false, "license": "AGPL-3.0", "description": "", diff --git a/ui/src/CheckSources.js b/ui/src/CheckSources.js index d7aea0e60..9872576ba 100644 --- a/ui/src/CheckSources.js +++ b/ui/src/CheckSources.js @@ -36,13 +36,15 @@ class CheckSources extends Component { } async componentWillMount() { - const {auth: {isUsingAuth, me}} = this.props + const {router, auth: {isUsingAuth, me}} = this.props if (!isUsingAuth || isUserAuthorized(me.role, VIEWER_ROLE)) { await this.props.getSources() + this.setState({isFetching: false}) + } else { + router.push('/purgatory') + return } - - this.setState({isFetching: false}) } shouldComponentUpdate(nextProps) { @@ -66,7 +68,7 @@ class CheckSources extends Component { params, errorThrown, sources, - auth: {isUsingAuth, me, me: {organizations, currentOrganization}}, + auth: {isUsingAuth, me, me: {organizations = [], currentOrganization}}, notify, getSources, } = nextProps @@ -81,6 +83,14 @@ class CheckSources extends Component { return router.push('/') } + if (!isFetching && isUsingAuth && !organizations.length) { + notify( + 'error', + 'You have been removed from all organizations. Please contact your administrator.' + ) + return router.push('/purgatory') + } + if ( me.superAdmin && !organizations.find(o => o.id === currentOrganization.id) diff --git a/ui/src/admin/actions/chronograf.js b/ui/src/admin/actions/chronograf.js index 0ad0204b1..8f78e3192 100644 --- a/ui/src/admin/actions/chronograf.js +++ b/ui/src/admin/actions/chronograf.js @@ -10,6 +10,10 @@ import { createOrganization as createOrganizationAJAX, updateOrganization as updateOrganizationAJAX, deleteOrganization as deleteOrganizationAJAX, + getMappings as getMappingsAJAX, + createMapping as createMappingAJAX, + updateMapping as updateMappingAJAX, + deleteMapping as deleteMappingAJAX, } from 'src/admin/apis/chronograf' import {publishAutoDismissingNotification} from 'shared/dispatchers' @@ -94,6 +98,35 @@ export const removeOrganization = organization => ({ }, }) +export const loadMappings = ({mappings}) => ({ + type: 'CHRONOGRAF_LOAD_MAPPINGS', + payload: { + mappings, + }, +}) + +export const updateMapping = (staleMapping, updatedMapping) => ({ + type: 'CHRONOGRAF_UPDATE_MAPPING', + payload: { + staleMapping, + updatedMapping, + }, +}) + +export const addMapping = mapping => ({ + type: 'CHRONOGRAF_ADD_MAPPING', + payload: { + mapping, + }, +}) + +export const removeMapping = mapping => ({ + type: 'CHRONOGRAF_REMOVE_MAPPING', + payload: { + mapping, + }, +}) + // async actions (thunks) export const loadUsersAsync = url => async dispatch => { try { @@ -113,6 +146,62 @@ export const loadOrganizationsAsync = url => async dispatch => { } } +export const loadMappingsAsync = () => async dispatch => { + try { + const {data} = await getMappingsAJAX() + dispatch(loadMappings(data)) + } catch (error) { + dispatch(errorThrown(error)) + } +} + +export const createMappingAsync = (url, mapping) => async dispatch => { + const mappingWithTempId = {...mapping, _tempID: uuid.v4()} + dispatch(addMapping(mappingWithTempId)) + try { + const {data} = await createMappingAJAX(url, mapping) + dispatch(updateMapping(mappingWithTempId, data)) + } catch (error) { + const message = `${_.upperFirst( + _.toLower(error.data.message) + )}: Scheme: ${mapping.scheme} Provider: ${mapping.provider}` + dispatch(errorThrown(error, message)) + setTimeout( + () => dispatch(removeMapping(mappingWithTempId)), + REVERT_STATE_DELAY + ) + } +} + +export const deleteMappingAsync = mapping => async dispatch => { + dispatch(removeMapping(mapping)) + try { + await deleteMappingAJAX(mapping) + dispatch( + publishAutoDismissingNotification( + 'success', + `Mapping deleted: ${mapping.id} ${mapping.scheme}` + ) + ) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(addMapping(mapping)) + } +} + +export const updateMappingAsync = ( + staleMapping, + updatedMapping +) => async dispatch => { + dispatch(updateMapping(staleMapping, updatedMapping)) + try { + await updateMappingAJAX(updatedMapping) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(updateMapping(updatedMapping, staleMapping)) + } +} + export const createUserAsync = (url, user) => async dispatch => { // temp uuid is added to be able to disambiguate a created user that has the // same scheme, provider, and name as an existing user diff --git a/ui/src/admin/apis/chronograf.js b/ui/src/admin/apis/chronograf.js index 300884928..ed529b0e2 100644 --- a/ui/src/admin/apis/chronograf.js +++ b/ui/src/admin/apis/chronograf.js @@ -102,3 +102,54 @@ export const deleteOrganization = async organization => { throw error } } + +// Mappings +export const createMapping = async (url, mapping) => { + try { + return await AJAX({ + method: 'POST', + resource: 'mappings', + data: mapping, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const getMappings = async () => { + try { + return await AJAX({ + method: 'GET', + resource: 'mappings', + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const updateMapping = async mapping => { + try { + return await AJAX({ + method: 'PUT', + url: mapping.links.self, + data: mapping, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const deleteMapping = async mapping => { + try { + return await AJAX({ + method: 'DELETE', + url: mapping.links.self, + }) + } catch (error) { + console.error(error) + throw error + } +} diff --git a/ui/src/admin/components/chronograf/AdminTabs.js b/ui/src/admin/components/chronograf/AdminTabs.js index f52b02cb2..c9e4f8e92 100644 --- a/ui/src/admin/components/chronograf/AdminTabs.js +++ b/ui/src/admin/components/chronograf/AdminTabs.js @@ -9,23 +9,18 @@ import { import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'shared/components/Tabs' import OrganizationsPage from 'src/admin/containers/chronograf/OrganizationsPage' import UsersPage from 'src/admin/containers/chronograf/UsersPage' +import ProvidersPage from 'src/admin/containers/ProvidersPage' import AllUsersPage from 'src/admin/containers/chronograf/AllUsersPage' -const ORGANIZATIONS_TAB_NAME = 'Organizations' -const CURRENT_ORG_USERS_TAB_NAME = 'Current Org Users' +const ORGANIZATIONS_TAB_NAME = 'All Orgs' +const PROVIDERS_TAB_NAME = 'Org Mappings' +const CURRENT_ORG_USERS_TAB_NAME = 'Current Org' const ALL_USERS_TAB_NAME = 'All Users' const AdminTabs = ({ me: {currentOrganization: meCurrentOrganization, role: meRole, id: meID}, }) => { const tabs = [ - { - requiredRole: SUPERADMIN_ROLE, - type: ORGANIZATIONS_TAB_NAME, - component: ( - - ), - }, { requiredRole: ADMIN_ROLE, type: CURRENT_ORG_USERS_TAB_NAME, @@ -38,6 +33,18 @@ const AdminTabs = ({ type: ALL_USERS_TAB_NAME, component: , }, + { + requiredRole: SUPERADMIN_ROLE, + type: ORGANIZATIONS_TAB_NAME, + component: ( + + ), + }, + { + requiredRole: SUPERADMIN_ROLE, + type: PROVIDERS_TAB_NAME, + component: , + }, ].filter(t => isUserAuthorized(meRole, t.requiredRole)) return ( diff --git a/ui/src/admin/components/chronograf/AllUsersTableHeader.js b/ui/src/admin/components/chronograf/AllUsersTableHeader.js index 7c222af67..634df211b 100644 --- a/ui/src/admin/components/chronograf/AllUsersTableHeader.js +++ b/ui/src/admin/components/chronograf/AllUsersTableHeader.js @@ -19,7 +19,7 @@ const AllUsersTableHeader = ({ return (

- {numUsersString} in {numOrganizationsString} + {numUsersString} across {numOrganizationsString}

diff --git a/ui/src/admin/components/chronograf/OrganizationsTable.js b/ui/src/admin/components/chronograf/OrganizationsTable.js index 301093af0..848504357 100644 --- a/ui/src/admin/components/chronograf/OrganizationsTable.js +++ b/ui/src/admin/components/chronograf/OrganizationsTable.js @@ -4,9 +4,6 @@ import uuid from 'node-uuid' import OrganizationsTableRow from 'src/admin/components/chronograf/OrganizationsTableRow' import OrganizationsTableRowNew from 'src/admin/components/chronograf/OrganizationsTableRowNew' -import QuestionMarkTooltip from 'shared/components/QuestionMarkTooltip' - -import {PUBLIC_TOOLTIP} from 'src/admin/constants/index' class OrganizationsTable extends Component { constructor(props) { @@ -37,7 +34,6 @@ class OrganizationsTable extends Component { onDeleteOrg, onRenameOrg, onChooseDefaultRole, - onTogglePublic, currentOrganization, } = this.props const {isCreatingOrganization} = this.state @@ -71,15 +67,13 @@ class OrganizationsTable extends Component {
-
-
-
Name
-
- Public{' '} - +
+
+
Name
+
+ Default Role
-
Default Role
-
+
{isCreatingOrganization ? { - this.setState({isEditing: true}) + handleUpdateOrgName = newName => { + const {organization, onRename} = this.props + onRename(organization, newName) } - - handleConfirmRename = () => { - const {onRename, organization} = this.props - const {workingName} = this.state - - onRename(organization, workingName) - this.setState({workingName, isEditing: false}) - } - - handleCancelRename = () => { - const {organization} = this.props - - this.setState({ - workingName: organization.name, - isEditing: false, - }) - } - - handleInputChange = e => { - this.setState({workingName: e.target.value}) - } - - handleInputBlur = () => { - const {organization} = this.props - const {workingName} = this.state - - if (organization.name === workingName) { - this.handleCancelRename() - } else { - this.handleConfirmRename() - } - } - - handleKeyDown = e => { - if (e.key === 'Enter') { - this.handleInputBlur() - } else if (e.key === 'Escape') { - this.handleCancelRename() - } - } - - handleFocus = e => { - e.target.select() - } - handleDeleteClick = () => { this.setState({isDeleting: true}) } @@ -106,18 +59,13 @@ class OrganizationsTableRow extends Component { onDelete(organization) } - handleTogglePublic = () => { - const {organization, onTogglePublic} = this.props - onTogglePublic(organization) - } - handleChooseDefaultRole = role => { const {organization, onChooseDefaultRole} = this.props onChooseDefaultRole(organization, role.name) } render() { - const {workingName, isEditing, isDeleting} = this.state + const {isDeleting} = this.state const {organization, currentOrganization} = this.props const dropdownRolesItems = USER_ROLES.map(role => ({ @@ -126,12 +74,12 @@ class OrganizationsTableRow extends Component { })) const defaultRoleClassName = isDeleting - ? 'orgs-table--default-role editing' - : 'orgs-table--default-role' + ? 'fancytable--td orgs-table--default-role deleting' + : 'fancytable--td orgs-table--default-role' return ( -
-
+
+
{organization.id === currentOrganization.id ? }
- {isEditing - ? (this.inputRef = r)} - /> - :
- {workingName} - -
} - {organization.id === DEFAULT_ORG_ID - ?
- -
- :
} +
-
- (this.inputRef = r)} - /> -
+
+
+
+ (this.inputRef = r)} + /> +
+
{ + this.setState({isCreatingMap: true}) + } + + handleCancelCreateMap = () => { + this.setState({isCreatingMap: false}) + } + + handleCreateMap = newMap => { + this.props.onCreateMap(newMap) + this.setState({isCreatingMap: false}) + } + + render() { + const { + mappings = [], + organizations, + onUpdateMap, + onDeleteMap, + isLoading, + } = this.props + const {isCreatingMap} = this.state + + const tableTitle = + mappings.length === 1 ? '1 Map' : `${mappings.length} Maps` + + // define scheme options + const SCHEMES = [{text: '*'}, {text: 'oauth2'}] + + if (isLoading) { + return ( +
+
+
+
+
+ ) + } + + return ( +
+
+

+ {tableTitle} +

+ +
+ {mappings.length || isCreatingMap + ?
+
+
Scheme
+
+ Provider +
+
+ Provider Org +
+
+
+ Organization +
+
+
+
+ {mappings.map((mapping, i) => + + )} + {isCreatingMap + ? + : null} +
+ :
+
+

+ Looks like you have no mappings
+ New users will not be able to sign up automatically +

+ +
+
} +
+ ) + } +} + +const {arrayOf, bool, func, shape, string} = PropTypes + +ProvidersTable.propTypes = { + mappings: arrayOf( + shape({ + id: string, + scheme: string, + provider: string, + providerOrganization: string, + organizationId: string, + }) + ).isRequired, + organizations: arrayOf( + shape({ + id: string, // when optimistically created, organization will not have an id + name: string.isRequired, + }) + ).isRequired, + onCreateMap: func.isRequired, + onUpdateMap: func.isRequired, + onDeleteMap: func.isRequired, + isLoading: bool.isRequired, +} +export default ProvidersTable diff --git a/ui/src/admin/components/chronograf/ProvidersTableRow.js b/ui/src/admin/components/chronograf/ProvidersTableRow.js new file mode 100644 index 000000000..ba42f9a2b --- /dev/null +++ b/ui/src/admin/components/chronograf/ProvidersTableRow.js @@ -0,0 +1,150 @@ +import React, {Component, PropTypes} from 'react' + +import ConfirmButtons from 'shared/components/ConfirmButtons' +import Dropdown from 'shared/components/Dropdown' +import InputClickToEdit from 'shared/components/InputClickToEdit' + +import {DEFAULT_MAPPING_ID} from 'src/admin/constants/chronografAdmin' + +class ProvidersTableRow extends Component { + constructor(props) { + super(props) + + this.state = { + ...this.props.mapping, + isDeleting: false, + } + } + + handleDeleteClick = () => { + this.setState({isDeleting: true}) + } + + handleDismissDeleteConfirmation = () => { + this.setState({isDeleting: false}) + } + + handleDeleteMap = mapping => { + const {onDelete} = this.props + this.setState({isDeleting: false}) + onDelete(mapping) + } + + handleUpdateMapping = changes => { + const {onUpdate, mapping} = this.props + const newState = {...mapping, ...changes} + this.setState(newState) + onUpdate(mapping, newState) + } + + handleChangeProvider = provider => this.handleUpdateMapping({provider}) + + handleChangeProviderOrg = providerOrganization => + this.handleUpdateMapping({providerOrganization}) + + handleChooseOrganization = ({id: organizationId}) => + this.handleUpdateMapping({organizationId}) + + handleChooseScheme = ({text: scheme}) => this.handleUpdateMapping({scheme}) + + render() { + const { + scheme, + provider, + providerOrganization, + organizationId, + isDeleting, + } = this.state + const {organizations, mapping, schemes, rowIndex} = this.props + + const selectedOrg = organizations.find(o => o.id === organizationId) + const orgDropdownItems = organizations.map(role => ({ + ...role, + text: role.name, + })) + + const organizationIdClassName = isDeleting + ? 'fancytable--td provider--redirect deleting' + : 'fancytable--td provider--redirect' + + const isDefaultMapping = DEFAULT_MAPPING_ID === mapping.id + return ( +
+ + + +
+ +
+
+ +
+ {isDeleting + ? + : } +
+ ) + } +} + +const {arrayOf, func, number, shape, string} = PropTypes + +ProvidersTableRow.propTypes = { + mapping: shape({ + id: string, + scheme: string, + provider: string, + providerOrganization: string, + organizationId: string, + }), + organizations: arrayOf( + shape({ + id: string.isRequired, + name: string.isRequired, + }) + ), + schemes: arrayOf( + shape({ + text: string.isRequired, + }) + ), + rowIndex: number, + onDelete: func.isRequired, + onUpdate: func.isRequired, +} + +export default ProvidersTableRow diff --git a/ui/src/admin/components/chronograf/ProvidersTableRowNew.js b/ui/src/admin/components/chronograf/ProvidersTableRowNew.js new file mode 100644 index 000000000..d99f32d54 --- /dev/null +++ b/ui/src/admin/components/chronograf/ProvidersTableRowNew.js @@ -0,0 +1,116 @@ +import React, {Component, PropTypes} from 'react' + +import ConfirmButtons from 'shared/components/ConfirmButtons' +import Dropdown from 'shared/components/Dropdown' +import InputClickToEdit from 'shared/components/InputClickToEdit' + +class ProvidersTableRowNew extends Component { + constructor(props) { + super(props) + + this.state = { + scheme: '*', + provider: null, + providerOrganization: null, + organizationId: 'default', + } + } + + handleChooseScheme = scheme => { + this.setState({scheme: scheme.text}) + } + + handleChangeProvider = provider => { + this.setState({provider}) + } + + handleChangeProviderOrg = providerOrganization => { + this.setState({providerOrganization}) + } + + handleChooseOrganization = org => { + this.setState({organizationId: org.id}) + } + + handleSaveNewMapping = () => { + const {onCreate} = this.props + onCreate(this.state) + } + + render() { + const {scheme, provider, providerOrganization, organizationId} = this.state + + const {organizations, onCancel, schemes, rowIndex} = this.props + + const selectedOrg = organizations.find(o => o.id === organizationId) + + const dropdownItems = organizations.map(role => ({ + ...role, + text: role.name, + })) + + const preventCreate = !provider || !providerOrganization + + return ( +
+ + + +
+ +
+
+ +
+ +
+ ) + } +} + +const {arrayOf, func, number, shape, string} = PropTypes + +ProvidersTableRowNew.propTypes = { + organizations: arrayOf( + shape({ + id: string.isRequired, + name: string.isRequired, + }) + ).isRequired, + schemes: arrayOf( + shape({ + text: string.isRequired, + }) + ), + rowIndex: number, + onCreate: func.isRequired, + onCancel: func.isRequired, +} + +export default ProvidersTableRowNew diff --git a/ui/src/admin/constants/chronografAdmin.js b/ui/src/admin/constants/chronografAdmin.js index c3773e327..e26d0e121 100644 --- a/ui/src/admin/constants/chronografAdmin.js +++ b/ui/src/admin/constants/chronografAdmin.js @@ -13,3 +13,4 @@ export const USER_ROLES = [ ] export const DEFAULT_ORG_ID = 'default' +export const DEFAULT_MAPPING_ID = 'default' diff --git a/ui/src/admin/constants/index.js b/ui/src/admin/constants/index.js index 64cf2d487..b55fe2dab 100644 --- a/ui/src/admin/constants/index.js +++ b/ui/src/admin/constants/index.js @@ -46,6 +46,3 @@ export const NEW_DEFAULT_DATABASE = { isNew: true, retentionPolicies: [NEW_DEFAULT_RP], } - -export const PUBLIC_TOOLTIP = - 'If turned off, new users cannot
authenticate unless an Admin explicitly
adds them to the organization.' diff --git a/ui/src/admin/containers/ProvidersPage.js b/ui/src/admin/containers/ProvidersPage.js new file mode 100644 index 000000000..037eaf020 --- /dev/null +++ b/ui/src/admin/containers/ProvidersPage.js @@ -0,0 +1,101 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' + +import * as adminChronografActionCreators from 'src/admin/actions/chronograf' +import {publishAutoDismissingNotification} from 'shared/dispatchers' + +import ProvidersTable from 'src/admin/components/chronograf/ProvidersTable' + +class ProvidersPage extends Component { + constructor(props) { + super(props) + + this.state = {isLoading: true} + } + + async componentDidMount() { + const { + links, + actions: {loadOrganizationsAsync, loadMappingsAsync}, + } = this.props + + await Promise.all([ + loadOrganizationsAsync(links.organizations), + loadMappingsAsync(links.mappings), + ]) + + this.setState({isLoading: false}) + } + + handleCreateMap = mapping => { + this.props.actions.createMappingAsync(this.props.links.mappings, mapping) + } + + handleUpdateMap = (staleMap, updatedMap) => { + this.props.actions.updateMappingAsync(staleMap, updatedMap) + } + + handleDeleteMap = mapping => { + this.props.actions.deleteMappingAsync(mapping) + } + + render() { + const {organizations, mappings = []} = this.props + const {isLoading} = this.state + + return ( + + ) + } +} + +const {arrayOf, func, shape, string} = PropTypes + +ProvidersPage.propTypes = { + links: shape({ + organizations: string.isRequired, + }), + organizations: arrayOf( + shape({ + id: string.isRequired, + name: string.isRequired, + }) + ), + mappings: arrayOf( + shape({ + id: string, + scheme: string, + provider: string, + providerOrganization: string, + organizationId: string, + }) + ), + actions: shape({ + loadOrganizationsAsync: func.isRequired, + }), + notify: func.isRequired, +} + +const mapStateToProps = ({ + links, + adminChronograf: {organizations, mappings}, +}) => ({ + links, + organizations, + mappings, +}) + +const mapDispatchToProps = dispatch => ({ + actions: bindActionCreators(adminChronografActionCreators, dispatch), + notify: bindActionCreators(publishAutoDismissingNotification, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(ProvidersPage) diff --git a/ui/src/admin/containers/chronograf/OrganizationsPage.js b/ui/src/admin/containers/chronograf/OrganizationsPage.js index e9a6b3da3..17bb253c7 100644 --- a/ui/src/admin/containers/chronograf/OrganizationsPage.js +++ b/ui/src/admin/containers/chronograf/OrganizationsPage.js @@ -36,14 +36,6 @@ class OrganizationsPage extends Component { getMe({shouldResetMe: false}) } - handleTogglePublic = organization => { - const {actionsAdmin: {updateOrganizationAsync}} = this.props - updateOrganizationAsync(organization, { - ...organization, - public: !organization.public, - }) - } - handleChooseDefaultRole = (organization, defaultRole) => { const {actionsAdmin: {updateOrganizationAsync}} = this.props updateOrganizationAsync(organization, {...organization, defaultRole}) @@ -65,7 +57,6 @@ class OrganizationsPage extends Component { onCreateOrg={this.handleCreateOrganization} onDeleteOrg={this.handleDeleteOrganization} onRenameOrg={this.handleRenameOrganization} - onTogglePublic={this.handleTogglePublic} onChooseDefaultRole={this.handleChooseDefaultRole} me={me} /> diff --git a/ui/src/admin/reducers/chronograf.js b/ui/src/admin/reducers/chronograf.js index 8f465cb45..01d744f4a 100644 --- a/ui/src/admin/reducers/chronograf.js +++ b/ui/src/admin/reducers/chronograf.js @@ -3,6 +3,7 @@ import {isSameUser} from 'shared/reducers/helpers/auth' const initialState = { users: [], organizations: [], + mappings: [], authConfig: { superAdminNewUsers: false, }, @@ -58,7 +59,10 @@ const adminChronograf = (state = initialState, action) => { case 'CHRONOGRAF_ADD_ORGANIZATION': { const {organization} = action.payload - return {...state, organizations: [organization, ...state.organizations]} + return { + ...state, + organizations: [organization, ...state.organizations], + } } case 'CHRONOGRAF_RENAME_ORGANIZATION': { @@ -94,9 +98,57 @@ const adminChronograf = (state = initialState, action) => { ), } } + + case 'CHRONOGRAF_LOAD_MAPPINGS': { + const {mappings} = action.payload + return { + ...state, + mappings, + } + } + + case 'CHRONOGRAF_UPDATE_MAPPING': { + const {staleMapping, updatedMapping} = action.payload + return { + ...state, + mappings: state.mappings.map(m => + replaceMapping(m, staleMapping, updatedMapping) + ), + } + } + + case 'CHRONOGRAF_ADD_MAPPING': { + const {mapping} = action.payload + return { + ...state, + mappings: [...state.mappings, mapping], + } + } + + case 'CHRONOGRAF_REMOVE_MAPPING': { + const {mapping} = action.payload + return { + ...state, + mappings: state.mappings.filter( + m => + mapping._tempID + ? m._tempID !== mapping._tempID + : m.id !== mapping.id + ), + } + } } return state } +function replaceMapping(m, staleMapping, updatedMapping) { + if (staleMapping._tempID && m._tempID === staleMapping._tempID) { + return {...updatedMapping} + } else if (m.id === staleMapping.id) { + return {...updatedMapping} + } + return m +} + export default adminChronograf diff --git a/ui/src/dashboards/components/CellEditorOverlay.js b/ui/src/dashboards/components/CellEditorOverlay.js index 8432c8f31..5b82c428d 100644 --- a/ui/src/dashboards/components/CellEditorOverlay.js +++ b/ui/src/dashboards/components/CellEditorOverlay.js @@ -28,9 +28,10 @@ import { DEFAULT_VALUE_MIN, DEFAULT_VALUE_MAX, GAUGE_COLORS, - SINGLE_STAT_TEXT, - SINGLE_STAT_BG, - validateColors, + validateGaugeColors, + validateSingleStatColors, + getSingleStatType, + stringifyColorValues, } from 'src/dashboards/constants/gaugeColors' class CellEditorOverlay extends Component { @@ -49,7 +50,8 @@ class CellEditorOverlay extends Component { source, })) ) - const colorsTypeContainsText = _.some(colors, {type: SINGLE_STAT_TEXT}) + + const singleStatType = getSingleStatType(colors) this.state = { cellWorkingName: name, @@ -58,8 +60,9 @@ class CellEditorOverlay extends Component { activeQueryIndex: 0, isDisplayOptionsTabActive: false, axes, - colorSingleStatText: colorsTypeContainsText, - colors: validateColors(colors, type, colorsTypeContainsText), + singleStatType, + gaugeColors: validateGaugeColors(colors), + singleStatColors: validateSingleStatColors(colors, singleStatType), } } @@ -80,27 +83,21 @@ class CellEditorOverlay extends Component { this.overlayRef.focus() } - handleAddThreshold = () => { - const {colors, cellWorkingType} = this.state - const sortedColors = _.sortBy(colors, color => Number(color.value)) + handleAddGaugeThreshold = () => { + const {gaugeColors} = this.state + const sortedColors = _.sortBy(gaugeColors, color => color.value) if (sortedColors.length <= MAX_THRESHOLDS) { const randomColor = _.random(0, GAUGE_COLORS.length - 1) - const maxValue = - cellWorkingType === 'gauge' - ? Number(sortedColors[sortedColors.length - 1].value) - : DEFAULT_VALUE_MAX - const minValue = - cellWorkingType === 'gauge' - ? Number(sortedColors[0].value) - : DEFAULT_VALUE_MIN + const maxValue = sortedColors[sortedColors.length - 1].value + const minValue = sortedColors[0].value - const colorsValues = _.mapValues(colors, 'value') + const colorsValues = _.mapValues(gaugeColors, 'value') let randomValue do { - randomValue = `${_.round(_.random(minValue, maxValue, true), 2)}` + randomValue = _.round(_.random(minValue, maxValue, true), 2) } while (_.includes(colorsValues, randomValue)) const newThreshold = { @@ -111,68 +108,134 @@ class CellEditorOverlay extends Component { name: GAUGE_COLORS[randomColor].name, } - this.setState({colors: [...colors, newThreshold]}) + this.setState({gaugeColors: [...gaugeColors, newThreshold]}) } } + handleAddSingleStatThreshold = () => { + const {singleStatColors, singleStatType} = this.state + + const randomColor = _.random(0, GAUGE_COLORS.length - 1) + + const maxValue = DEFAULT_VALUE_MIN + const minValue = DEFAULT_VALUE_MAX + + let randomValue = _.round(_.random(minValue, maxValue, true), 2) + + if (singleStatColors.length > 0) { + const colorsValues = _.mapValues(singleStatColors, 'value') + do { + randomValue = _.round(_.random(minValue, maxValue, true), 2) + } while (_.includes(colorsValues, randomValue)) + } + + const newThreshold = { + type: singleStatType, + id: uuid.v4(), + value: randomValue, + hex: GAUGE_COLORS[randomColor].hex, + name: GAUGE_COLORS[randomColor].name, + } + + this.setState({singleStatColors: [...singleStatColors, newThreshold]}) + } + handleDeleteThreshold = threshold => () => { - const {colors} = this.state + const {cellWorkingType} = this.state - const newColors = colors.filter(color => color.id !== threshold.id) + if (cellWorkingType === 'gauge') { + const gaugeColors = this.state.gaugeColors.filter( + color => color.id !== threshold.id + ) - this.setState({colors: newColors}) + this.setState({gaugeColors}) + } + + if (cellWorkingType === 'single-stat') { + const singleStatColors = this.state.singleStatColors.filter( + color => color.id !== threshold.id + ) + + this.setState({singleStatColors}) + } } handleChooseColor = threshold => chosenColor => { - const {colors} = this.state + const {cellWorkingType} = this.state - const newColors = colors.map( - color => - color.id === threshold.id - ? {...color, hex: chosenColor.hex, name: chosenColor.name} - : color - ) + if (cellWorkingType === 'gauge') { + const gaugeColors = this.state.gaugeColors.map( + color => + color.id === threshold.id + ? {...color, hex: chosenColor.hex, name: chosenColor.name} + : color + ) - this.setState({colors: newColors}) + this.setState({gaugeColors}) + } + + if (cellWorkingType === 'single-stat') { + const singleStatColors = this.state.singleStatColors.map( + color => + color.id === threshold.id + ? {...color, hex: chosenColor.hex, name: chosenColor.name} + : color + ) + + this.setState({singleStatColors}) + } } - handleUpdateColorValue = (threshold, newValue) => { - const {colors} = this.state - const newColors = colors.map( - color => (color.id === threshold.id ? {...color, value: newValue} : color) - ) - this.setState({colors: newColors}) + handleUpdateColorValue = (threshold, value) => { + const {cellWorkingType} = this.state + + if (cellWorkingType === 'gauge') { + const gaugeColors = this.state.gaugeColors.map( + color => (color.id === threshold.id ? {...color, value} : color) + ) + + this.setState({gaugeColors}) + } + + if (cellWorkingType === 'single-stat') { + const singleStatColors = this.state.singleStatColors.map( + color => (color.id === threshold.id ? {...color, value} : color) + ) + + this.setState({singleStatColors}) + } } - handleValidateColorValue = (threshold, e) => { - const {colors, cellWorkingType} = this.state - const sortedColors = _.sortBy(colors, color => Number(color.value)) - const thresholdValue = Number(threshold.value) - const targetValueNumber = Number(e.target.value) + handleValidateColorValue = (threshold, targetValue) => { + const {gaugeColors, singleStatColors, cellWorkingType} = this.state + const thresholdValue = threshold.value let allowedToUpdate = false if (cellWorkingType === 'single-stat') { // If type is single-stat then value only has to be unique - return !sortedColors.some(color => color.value === e.target.value) + const sortedColors = _.sortBy(singleStatColors, color => color.value) + return !sortedColors.some(color => color.value === targetValue) } - const minValue = Number(sortedColors[0].value) - const maxValue = Number(sortedColors[sortedColors.length - 1].value) + const sortedColors = _.sortBy(gaugeColors, color => color.value) + + const minValue = sortedColors[0].value + const maxValue = sortedColors[sortedColors.length - 1].value // If lowest value, make sure it is less than the next threshold if (thresholdValue === minValue) { - const nextValue = Number(sortedColors[1].value) - allowedToUpdate = targetValueNumber < nextValue + const nextValue = sortedColors[1].value + allowedToUpdate = targetValue < nextValue } // If highest value, make sure it is greater than the previous threshold if (thresholdValue === maxValue) { - const previousValue = Number(sortedColors[sortedColors.length - 2].value) - allowedToUpdate = previousValue < targetValueNumber + const previousValue = sortedColors[sortedColors.length - 2].value + allowedToUpdate = previousValue < targetValue } // If not min or max, make sure new value is greater than min, less than max, and unique if (thresholdValue !== minValue && thresholdValue !== maxValue) { - const greaterThanMin = targetValueNumber > minValue - const lessThanMax = targetValueNumber < maxValue + const greaterThanMin = targetValue > minValue + const lessThanMax = targetValue < maxValue const colorsWithoutMinOrMax = sortedColors.slice( 1, @@ -180,7 +243,7 @@ class CellEditorOverlay extends Component { ) const isUnique = !colorsWithoutMinOrMax.some( - color => color.value === e.target.value + color => color.value === targetValue ) allowedToUpdate = greaterThanMin && lessThanMax && isUnique @@ -189,16 +252,15 @@ class CellEditorOverlay extends Component { return allowedToUpdate } - handleToggleSingleStatText = () => { - const {colors, colorSingleStatText} = this.state - const formattedColors = colors.map(color => ({ + handleToggleSingleStatType = type => () => { + const singleStatColors = this.state.singleStatColors.map(color => ({ ...color, - type: colorSingleStatText ? SINGLE_STAT_BG : SINGLE_STAT_TEXT, + type, })) this.setState({ - colorSingleStatText: !colorSingleStatText, - colors: formattedColors, + singleStatType: type, + singleStatColors, }) } @@ -298,7 +360,8 @@ class CellEditorOverlay extends Component { cellWorkingType: type, cellWorkingName: name, axes, - colors, + gaugeColors, + singleStatColors, } = this.state const {cell} = this.props @@ -314,6 +377,13 @@ class CellEditorOverlay extends Component { } }) + let colors = [] + if (type === 'gauge') { + colors = stringifyColorValues(gaugeColors) + } else if (type === 'single-stat' || type === 'line-plus-single-stat') { + colors = stringifyColorValues(singleStatColors) + } + this.props.onSave({ ...cell, name, @@ -324,14 +394,8 @@ class CellEditorOverlay extends Component { }) } - handleSelectGraphType = graphType => () => { - const {colors, colorSingleStatText} = this.state - const validatedColors = validateColors( - colors, - graphType, - colorSingleStatText - ) - this.setState({cellWorkingType: graphType, colors: validatedColors}) + handleSelectGraphType = cellWorkingType => () => { + this.setState({cellWorkingType}) } handleClickDisplayOptionsTab = isDisplayOptionsTabActive => () => { @@ -474,13 +538,14 @@ class CellEditorOverlay extends Component { const { axes, - colors, + gaugeColors, + singleStatColors, activeQueryIndex, cellWorkingName, cellWorkingType, isDisplayOptionsTabActive, queriesWorkingDraft, - colorSingleStatText, + singleStatType, } = this.state const queryActions = { @@ -492,6 +557,9 @@ class CellEditorOverlay extends Component { (!!query.measurement && !!query.database && !!query.fields.length) || !!query.rawText + const visualizationColors = + cellWorkingType === 'gauge' ? gaugeColors : singleStatColors + return (
{ const { - colors, + gaugeColors, + singleStatColors, onSetBase, onSetScale, onSetLabel, @@ -43,13 +44,14 @@ class DisplayOptions extends Component { onSetPrefixSuffix, onSetYAxisBoundMin, onSetYAxisBoundMax, - onAddThreshold, + onAddGaugeThreshold, + onAddSingleStatThreshold, onDeleteThreshold, onChooseColor, onValidateColorValue, onUpdateColorValue, - colorSingleStatText, - onToggleSingleStatText, + singleStatType, + onToggleSingleStatType, onSetSuffix, } = this.props const {axes, axes: {y: {suffix}}} = this.state @@ -58,27 +60,27 @@ class DisplayOptions extends Component { case 'gauge': return ( ) case 'single-stat': return ( ) default: @@ -111,10 +113,11 @@ class DisplayOptions extends Component { ) } } -const {arrayOf, bool, func, shape, string} = PropTypes +const {arrayOf, func, number, shape, string} = PropTypes DisplayOptions.propTypes = { - onAddThreshold: func.isRequired, + onAddGaugeThreshold: func.isRequired, + onAddSingleStatThreshold: func.isRequired, onDeleteThreshold: func.isRequired, onChooseColor: func.isRequired, onValidateColorValue: func.isRequired, @@ -129,18 +132,27 @@ DisplayOptions.propTypes = { onSetLabel: func.isRequired, onSetBase: func.isRequired, axes: shape({}).isRequired, - colors: arrayOf( + gaugeColors: arrayOf( shape({ type: string.isRequired, hex: string.isRequired, id: string.isRequired, name: string.isRequired, - value: string.isRequired, + value: number.isRequired, + }).isRequired + ), + singleStatColors: arrayOf( + shape({ + type: string.isRequired, + hex: string.isRequired, + id: string.isRequired, + name: string.isRequired, + value: number.isRequired, }).isRequired ), queryConfigs: arrayOf(shape()).isRequired, - colorSingleStatText: bool.isRequired, - onToggleSingleStatText: func.isRequired, + singleStatType: string.isRequired, + onToggleSingleStatType: func.isRequired, } export default DisplayOptions diff --git a/ui/src/dashboards/components/GaugeOptions.js b/ui/src/dashboards/components/GaugeOptions.js index 8a70c37bf..4c42cc8e0 100644 --- a/ui/src/dashboards/components/GaugeOptions.js +++ b/ui/src/dashboards/components/GaugeOptions.js @@ -19,7 +19,7 @@ const GaugeOptions = ({ }) => { const disableMaxColor = colors.length > MIN_THRESHOLDS const disableAddThreshold = colors.length > MAX_THRESHOLDS - const sortedColors = _.sortBy(colors, color => Number(color.value)) + const sortedColors = _.sortBy(colors, color => color.value) return ( { + const {hex, name} = color + return {hex, name} +} const SingleStatOptions = ({ suffix, onSetSuffix, @@ -15,12 +26,12 @@ const SingleStatOptions = ({ onChooseColor, onValidateColorValue, onUpdateColorValue, - colorSingleStatText, - onToggleSingleStatText, + singleStatType, + onToggleSingleStatType, }) => { const disableAddThreshold = colors.length > MAX_THRESHOLDS - const sortedColors = _.sortBy(colors, color => Number(color.value)) + const sortedColors = _.sortBy(colors, color => color.value) return ( Add Threshold - {sortedColors.map(color => - + {sortedColors.map( + color => + color.id === SINGLE_STAT_BASE + ?
+
Base Color
+ +
+ : )}
@@ -54,14 +76,18 @@ const SingleStatOptions = ({
  • Background
  • Text
  • @@ -83,7 +109,7 @@ const SingleStatOptions = ({ ) } -const {arrayOf, bool, func, shape, string} = PropTypes +const {arrayOf, func, number, shape, string} = PropTypes SingleStatOptions.defaultProps = { colors: [], @@ -96,7 +122,7 @@ SingleStatOptions.propTypes = { hex: string.isRequired, id: string.isRequired, name: string.isRequired, - value: string.isRequired, + value: number.isRequired, }).isRequired ), onAddThreshold: func.isRequired, @@ -104,8 +130,8 @@ SingleStatOptions.propTypes = { onChooseColor: func.isRequired, onValidateColorValue: func.isRequired, onUpdateColorValue: func.isRequired, - colorSingleStatText: bool.isRequired, - onToggleSingleStatText: func.isRequired, + singleStatType: string.isRequired, + onToggleSingleStatType: func.isRequired, onSetSuffix: func.isRequired, suffix: string.isRequired, } diff --git a/ui/src/dashboards/components/Threshold.js b/ui/src/dashboards/components/Threshold.js index d01d76c4c..df274f6da 100644 --- a/ui/src/dashboards/components/Threshold.js +++ b/ui/src/dashboards/components/Threshold.js @@ -16,14 +16,15 @@ class Threshold extends Component { handleChangeWorkingValue = e => { const {threshold, onValidateColorValue, onUpdateColorValue} = this.props + const targetValue = Number(e.target.value) - const valid = onValidateColorValue(threshold, e) + const valid = onValidateColorValue(threshold, targetValue) if (valid) { - onUpdateColorValue(threshold, e.target.value) + onUpdateColorValue(threshold, targetValue) } - this.setState({valid, workingValue: e.target.value}) + this.setState({valid, workingValue: targetValue}) } handleBlur = () => { @@ -98,7 +99,7 @@ class Threshold extends Component { } } -const {bool, func, shape, string} = PropTypes +const {bool, func, number, shape, string} = PropTypes Threshold.propTypes = { visualizationType: string.isRequired, @@ -107,7 +108,7 @@ Threshold.propTypes = { hex: string.isRequired, id: string.isRequired, name: string.isRequired, - value: string.isRequired, + value: number.isRequired, }).isRequired, disableMaxColor: bool, onChooseColor: func.isRequired, diff --git a/ui/src/dashboards/components/Visualization.js b/ui/src/dashboards/components/Visualization.js index 2d6764d70..51d345a21 100644 --- a/ui/src/dashboards/components/Visualization.js +++ b/ui/src/dashboards/components/Visualization.js @@ -3,6 +3,8 @@ import RefreshingGraph from 'shared/components/RefreshingGraph' import buildQueries from 'utils/buildQueriesForGraphs' import VisualizationName from 'src/dashboards/components/VisualizationName' +import {stringifyColorValues} from 'src/dashboards/constants/gaugeColors' + const DashVisualization = ( { axes, @@ -23,7 +25,7 @@ const DashVisualization = (
    { - if (type === 'single-stat') { - // Single stat colors should all have type of 'text' or 'background' - const colorType = colorSingleStatText ? SINGLE_STAT_TEXT : SINGLE_STAT_BG - return colors ? colors.map(color => ({...color, type: colorType})) : null - } +export const DEFAULT_SINGLESTAT_COLORS = [ + { + type: SINGLE_STAT_TEXT, + hex: GAUGE_COLORS[11].hex, + id: SINGLE_STAT_BASE, + name: GAUGE_COLORS[11].name, + value: 0, + }, +] + +export const validateSingleStatColors = (colors, type) => { if (!colors || colors.length === 0) { - return DEFAULT_COLORS - } - if (type === 'gauge') { - // Gauge colors should have a type of min, any number of thresholds, and a max - const formatttedColors = _.sortBy(colors, color => - Number(color.value) - ).map(c => ({ - ...c, - type: COLOR_TYPE_THRESHOLD, - })) - formatttedColors[0].type = COLOR_TYPE_MIN - formatttedColors[formatttedColors.length - 1].type = COLOR_TYPE_MAX - return formatttedColors + return DEFAULT_SINGLESTAT_COLORS } - return colors.length >= MIN_THRESHOLDS ? colors : DEFAULT_COLORS + let containsBaseColor = false + + const formattedColors = colors.map(color => { + if (color.id === SINGLE_STAT_BASE) { + // Check for existance of base color + containsBaseColor = true + return {...color, value: Number(color.value), type} + } + // Single stat colors should all have type of 'text' or 'background' + return {...color, value: Number(color.value), type} + }) + + const formattedColorsWithBase = [ + ...formattedColors, + DEFAULT_SINGLESTAT_COLORS[0], + ] + + return containsBaseColor ? formattedColors : formattedColorsWithBase +} + +export const getSingleStatType = colors => { + const type = _.get(colors, ['0', 'type'], false) + + if (type) { + if (_.includes([SINGLE_STAT_TEXT, SINGLE_STAT_BG], type)) { + return type + } + } + + return SINGLE_STAT_TEXT +} + +export const validateGaugeColors = colors => { + if (!colors || colors.length < MIN_THRESHOLDS) { + return DEFAULT_GAUGE_COLORS + } + + // Gauge colors should have a type of min, any number of thresholds, and a max + const formattedColors = _.sortBy(colors, color => + Number(color.value) + ).map(color => ({ + ...color, + value: Number(color.value), + type: COLOR_TYPE_THRESHOLD, + })) + + formattedColors[0].type = COLOR_TYPE_MIN + formattedColors[formattedColors.length - 1].type = COLOR_TYPE_MAX + + return formattedColors +} + +export const stringifyColorValues = colors => { + return colors.map(color => ({...color, value: `${color.value}`})) } diff --git a/ui/src/data_explorer/components/VisHeader.js b/ui/src/data_explorer/components/VisHeader.js index 2d3325456..478f0a38b 100644 --- a/ui/src/data_explorer/components/VisHeader.js +++ b/ui/src/data_explorer/components/VisHeader.js @@ -5,10 +5,15 @@ import _ from 'lodash' import {fetchTimeSeriesAsync} from 'shared/actions/timeSeries' import {resultsToCSV} from 'src/shared/parsing/resultsToCSV.js' import download from 'src/external/download.js' +import {TEMPLATES} from 'src/data_explorer/constants' const getCSV = (query, errorThrown) => async () => { try { - const {results} = await fetchTimeSeriesAsync({source: query.host, query}) + const {results} = await fetchTimeSeriesAsync({ + source: query.host, + query, + tempVars: TEMPLATES, + }) const {flag, name, CSVString} = resultsToCSV(results) if (flag === 'no_data') { errorThrown('no data', 'There are no data to download.') diff --git a/ui/src/index.js b/ui/src/index.js index 3370a1ce5..bd5160a30 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -74,12 +74,19 @@ window.addEventListener('keyup', event => { const history = syncHistoryWithStore(browserHistory, store) const Root = React.createClass({ + getInitialState() { + return { + ready: false, + } + }, + async componentWillMount() { this.flushErrorsQueue() try { await this.getLinks() - this.checkAuth() + await this.checkAuth() + this.setState({ready: true}) } catch (error) { dispatch(errorThrown(error)) } @@ -115,48 +122,66 @@ const Root = React.createClass({ }, render() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + return !this.state.ready // eslint-disable-line no-negated-condition + ?
    + : + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - ) + + + }, }) diff --git a/ui/src/kapacitor/components/LogItemHTTPError.js b/ui/src/kapacitor/components/LogItemHTTPError.js index fa0e79694..c2f1d7407 100644 --- a/ui/src/kapacitor/components/LogItemHTTPError.js +++ b/ui/src/kapacitor/components/LogItemHTTPError.js @@ -10,7 +10,7 @@ const LogItemHTTPError = ({logItem}) =>
    HTTP Server
    -
    +
    ERROR: {logItem.msg}
    diff --git a/ui/src/kapacitor/components/LogItemInfluxDBDebug.js b/ui/src/kapacitor/components/LogItemInfluxDBDebug.js index b6be11705..bbbd292df 100644 --- a/ui/src/kapacitor/components/LogItemInfluxDBDebug.js +++ b/ui/src/kapacitor/components/LogItemInfluxDBDebug.js @@ -10,7 +10,7 @@ const LogItemInfluxDBDebug = ({logItem}) =>
    InfluxDB
    -
    +
    DEBUG: {logItem.msg}
    diff --git a/ui/src/kapacitor/components/LogItemKapacitorDebug.js b/ui/src/kapacitor/components/LogItemKapacitorDebug.js index 9b99d5129..3f694dd4e 100644 --- a/ui/src/kapacitor/components/LogItemKapacitorDebug.js +++ b/ui/src/kapacitor/components/LogItemKapacitorDebug.js @@ -10,7 +10,7 @@ const LogItemKapacitorDebug = ({logItem}) =>
    Kapacitor
    -
    +
    DEBUG: {logItem.msg}
    diff --git a/ui/src/kapacitor/components/LogItemKapacitorError.js b/ui/src/kapacitor/components/LogItemKapacitorError.js index 1d4ca573d..83fa42b81 100644 --- a/ui/src/kapacitor/components/LogItemKapacitorError.js +++ b/ui/src/kapacitor/components/LogItemKapacitorError.js @@ -10,7 +10,7 @@ const LogItemKapacitorError = ({logItem}) =>
    Kapacitor
    -
    +
    ERROR: {logItem.msg}
    diff --git a/ui/src/kapacitor/components/LogItemKapacitorPoint.js b/ui/src/kapacitor/components/LogItemKapacitorPoint.js index 6a7639330..898f656bb 100644 --- a/ui/src/kapacitor/components/LogItemKapacitorPoint.js +++ b/ui/src/kapacitor/components/LogItemKapacitorPoint.js @@ -1,18 +1,26 @@ import React, {PropTypes} from 'react' -const renderKeysAndValues = object => { +const renderKeysAndValues = (object, name) => { if (!object) { return -- } - const objKeys = Object.keys(object) - const objValues = Object.values(object) - const objElements = objKeys.map((objKey, i) => -
    - {objKey}: {objValues[i]} + const sortedObjKeys = Object.keys(object).sort() + + return ( +
    +

    + {`${sortedObjKeys.length} ${name}`} +

    +
    + {sortedObjKeys.map(objKey => +
    + {objKey}: {object[objKey]} +
    + )} +
    ) - return objElements } const LogItemKapacitorPoint = ({logItem}) =>
    @@ -24,15 +32,9 @@ const LogItemKapacitorPoint = ({logItem}) =>
    Kapacitor Point
    -
    -
    - TAGS
    - {renderKeysAndValues(logItem.tag)} -
    -
    - FIELDS
    - {renderKeysAndValues(logItem.field)} -
    +
    + {renderKeysAndValues(logItem.tag, 'Tags')} + {renderKeysAndValues(logItem.field, 'Fields')}
    diff --git a/ui/src/kapacitor/components/LogsTable.js b/ui/src/kapacitor/components/LogsTable.js index ad7945c1b..41fee0de5 100644 --- a/ui/src/kapacitor/components/LogsTable.js +++ b/ui/src/kapacitor/components/LogsTable.js @@ -1,24 +1,23 @@ import React, {PropTypes} from 'react' -import InfiniteScroll from 'shared/components/InfiniteScroll' import LogsTableRow from 'src/kapacitor/components/LogsTableRow' +import FancyScrollbar from 'src/shared/components/FancyScrollbar' + +const numLogsToRender = 200 const LogsTable = ({logs}) => -
    +
    -

    Logs

    -
    -
    - {logs.length - ? - - )} - /> - :
    } + {`${numLogsToRender} Most Recent Logs`}
    + + {logs + .slice(0, numLogsToRender) + .map(log => )} +
    const {arrayOf, shape, string} = PropTypes diff --git a/ui/src/kapacitor/components/LogsTableRow.js b/ui/src/kapacitor/components/LogsTableRow.js index 32b658c67..83c12bcec 100644 --- a/ui/src/kapacitor/components/LogsTableRow.js +++ b/ui/src/kapacitor/components/LogsTableRow.js @@ -8,31 +8,31 @@ import LogItemKapacitorError from 'src/kapacitor/components/LogItemKapacitorErro import LogItemKapacitorDebug from 'src/kapacitor/components/LogItemKapacitorDebug' import LogItemInfluxDBDebug from 'src/kapacitor/components/LogItemInfluxDBDebug' -const LogsTableRow = ({logItem, index}) => { +const LogsTableRow = ({logItem}) => { if (logItem.service === 'sessions') { - return + return } if (logItem.service === 'http' && logItem.msg === 'http request') { - return + return } if (logItem.service === 'kapacitor' && logItem.msg === 'point') { - return + return } if (logItem.service === 'httpd_server_errors' && logItem.lvl === 'error') { - return + return } if (logItem.service === 'kapacitor' && logItem.lvl === 'error') { - return + return } if (logItem.service === 'kapacitor' && logItem.lvl === 'debug') { - return + return } if (logItem.service === 'influxdb' && logItem.lvl === 'debug') { - return + return } return ( -
    +
    @@ -43,7 +43,7 @@ const LogsTableRow = ({logItem, index}) => {
    {logItem.service || '--'}
    -
    +
    {logItem.msg || '--'}
    @@ -53,7 +53,7 @@ const LogsTableRow = ({logItem, index}) => { ) } -const {number, shape, string} = PropTypes +const {shape, string} = PropTypes LogsTableRow.propTypes = { logItem: shape({ @@ -62,7 +62,6 @@ LogsTableRow.propTypes = { lvl: string.isRequired, msg: string.isRequired, }).isRequired, - index: number, } export default LogsTableRow diff --git a/ui/src/kapacitor/components/Tickscript.js b/ui/src/kapacitor/components/Tickscript.js index 8cec26829..65320d056 100644 --- a/ui/src/kapacitor/components/Tickscript.js +++ b/ui/src/kapacitor/components/Tickscript.js @@ -34,7 +34,10 @@ const Tickscript = ({ isNewTickscript={isNewTickscript} />
    -
    +
    s.y) + const withValues = legend.series.filter(s => !_.isNil(s.y)) const sorted = _.sortBy( withValues, ({y, label}) => (sortType === 'numeric' ? y : label) @@ -158,6 +158,7 @@ class DygraphLegend extends Component {
    Z
    ) + const renderSortNum = (