diff --git a/CHANGELOG.md b/CHANGELOG.md index 452baf1e6..b386532af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Bug Fixes 1. [#882](https://github.com/influxdata/chronograf/pull/882): Fix y-axis graph padding 2. [#907](https://github.com/influxdata/chronograf/pull/907): Fix react-router warning + 3. [#926](https://github.com/influxdata/chronograf/pull/926): Fix Kapacitor RuleGraph display ### Features 1. [#873](https://github.com/influxdata/chronograf/pull/873): Add [TLS](https://github.com/influxdata/chronograf/blob/master/docs/tls.md) support @@ -10,13 +11,14 @@ 3. [#891](https://github.com/influxdata/chronograf/issues/891): Make dashboard visualizations draggable 4. [#892](https://github.com/influxdata/chronograf/issues/891): Make dashboard visualizations resizable 5. [#893](https://github.com/influxdata/chronograf/issues/893): Persist dashboard visualization position - 6. [#922](https://github.com/influxdata/chronograf/issues/922): Additional OAuth2 support for Heroku and Google + 6. [#922](https://github.com/influxdata/chronograf/issues/922): Additional OAuth2 support for [Heroku](https://github.com/influxdata/chronograf/blob/master/docs/auth.md#heroku) and [Google](https://github.com/influxdata/chronograf/blob/master/docs/auth.md#google) ### UI Improvements 1. [#905](https://github.com/influxdata/chronograf/pull/905): Make scroll bar thumb element bigger 2. [#917](https://github.com/influxdata/chronograf/pull/917): Simplify side navigation 3. [#920](https://github.com/influxdata/chronograf/pull/920): Display stacked and step plot graphs 4. [#851](https://github.com/influxdata/chronograf/pull/851): Add configuration for Influx Enterprise Meta nodes + 5. [#916](https://github.com/influxdata/chronograf/pull/916): Dynamically scale font size based on resolution ## v1.2.0-beta3 [2017-02-15] diff --git a/Makefile b/Makefile index 737690874..a43b18e1c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: assets dep clean test gotest gotestrace jstest run run-dev ctags +.PHONY: assets dep clean test gotest gotestrace jstest run run-dev ctags continuous VERSION ?= $(shell git describe --always --tags) COMMIT ?= $(shell git rev-parse --short=8 HEAD) @@ -20,7 +20,7 @@ build: assets ${BINARY} dev: dep dev-assets ${BINARY} -${BINARY}: $(SOURCES) .bindata +${BINARY}: $(SOURCES) .bindata .jsdep .godep go build -o ${BINARY} ${LDFLAGS} ./cmd/chronograf/main.go docker-${BINARY}: $(SOURCES) @@ -94,7 +94,7 @@ run: ${BINARY} ./chronograf run-dev: ${BINARY} - ./chronograf -d + ./chronograf -d --log-level=debug clean: if [ -f ${BINARY} ] ; then rm ${BINARY} ; fi @@ -103,5 +103,8 @@ clean: rm -f dist/dist_gen.go canned/bin_gen.go server/swagger_gen.go @rm -f .godep .jsdep .jssrc .dev-jssrc .bindata +continuous: + while true; do if fswatch -r --one-event .; then echo "#-> Starting build: `date`"; make dev; pkill chronograf; ./chronograf -d --log-level=debug & echo "#-> Build complete."; fi; sleep 0.5; done + ctags: ctags -R --languages="Go" --exclude=.git --exclude=ui . diff --git a/bolt/internal/internal.go b/bolt/internal/internal.go index b7820f8a8..b2ac3da5d 100644 --- a/bolt/internal/internal.go +++ b/bolt/internal/internal.go @@ -280,21 +280,35 @@ func UnmarshalAlertRule(data []byte, r *ScopedAlert) error { } // MarshalUser encodes a user to binary protobuf format. +// We are ignoring the password for now. func MarshalUser(u *chronograf.User) ([]byte, error) { - return proto.Marshal(&User{ - ID: uint64(u.ID), - Email: u.Email, + return MarshalUserPB(&User{ + Name: u.Name, }) } +// MarshalUserPB encodes a user to binary protobuf format. +// We are ignoring the password for now. +func MarshalUserPB(u *User) ([]byte, error) { + return proto.Marshal(u) +} + // UnmarshalUser decodes a user from binary protobuf data. +// We are ignoring the password for now. func UnmarshalUser(data []byte, u *chronograf.User) error { var pb User - if err := proto.Unmarshal(data, &pb); err != nil { + if err := UnmarshalUserPB(data, &pb); err != nil { + return err + } + u.Name = pb.Name + return nil +} + +// UnmarshalUser decodes a user from binary protobuf data. +// We are ignoring the password for now. +func UnmarshalUserPB(data []byte, u *User) error { + if err := proto.Unmarshal(data, u); err != nil { return err } - - u.ID = chronograf.UserID(pb.ID) - u.Email = pb.Email return nil } diff --git a/bolt/internal/internal.pb.go b/bolt/internal/internal.pb.go index 40a592a23..5912e1236 100644 --- a/bolt/internal/internal.pb.go +++ b/bolt/internal/internal.pb.go @@ -199,8 +199,8 @@ func (*AlertRule) ProtoMessage() {} func (*AlertRule) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{8} } type User struct { - ID uint64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` - Email string `protobuf:"bytes,2,opt,name=Email,proto3" json:"Email,omitempty"` + ID uint64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` } func (m *User) Reset() { *m = User{} } @@ -224,47 +224,46 @@ func init() { func init() { proto.RegisterFile("internal.proto", fileDescriptorInternal) } var fileDescriptorInternal = []byte{ - // 662 bytes of a gzipped FileDescriptorProto + // 653 bytes of a gzipped FileDescriptorProto 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x02, 0xff, 0xbc, 0x54, 0xd1, 0x6e, 0xd3, 0x4a, - 0x10, 0xd5, 0xc6, 0x76, 0x12, 0x4f, 0x7b, 0x7b, 0xaf, 0x56, 0xd5, 0xbd, 0xab, 0xfb, 0x14, 0x59, - 0x20, 0x05, 0x09, 0xfa, 0x40, 0xbf, 0xa0, 0xad, 0x11, 0x0a, 0xb4, 0xa5, 0x6c, 0x5a, 0x78, 0x02, - 0x69, 0x9b, 0x4e, 0x1a, 0x0b, 0x27, 0x36, 0x6b, 0x9b, 0xd4, 0xbf, 0x80, 0xf8, 0x02, 0x1e, 0xf8, - 0x08, 0x7e, 0x85, 0x1f, 0x42, 0xb3, 0xbb, 0x76, 0x5c, 0x51, 0xa1, 0x3e, 0xf1, 0x36, 0x67, 0x66, - 0x73, 0x66, 0xe6, 0x9c, 0x89, 0x61, 0x27, 0x59, 0x95, 0xa8, 0x57, 0x2a, 0xdd, 0xcb, 0x75, 0x56, - 0x66, 0x7c, 0xd8, 0xe0, 0xe8, 0x73, 0x0f, 0xfa, 0xd3, 0xac, 0xd2, 0x33, 0xe4, 0x3b, 0xd0, 0x9b, - 0xc4, 0x82, 0x8d, 0xd8, 0xd8, 0x93, 0xbd, 0x49, 0xcc, 0x39, 0xf8, 0xa7, 0x6a, 0x89, 0xa2, 0x37, - 0x62, 0xe3, 0x50, 0x9a, 0x98, 0x72, 0xe7, 0x75, 0x8e, 0xc2, 0xb3, 0x39, 0x8a, 0xf9, 0xff, 0x30, - 0xbc, 0x28, 0x88, 0x6d, 0x89, 0xc2, 0x37, 0xf9, 0x16, 0x53, 0xed, 0x4c, 0x15, 0xc5, 0x3a, 0xd3, - 0x57, 0x22, 0xb0, 0xb5, 0x06, 0xf3, 0x7f, 0xc0, 0xbb, 0x90, 0xc7, 0xa2, 0x6f, 0xd2, 0x14, 0x72, - 0x01, 0x83, 0x18, 0xe7, 0xaa, 0x4a, 0x4b, 0x31, 0x18, 0xb1, 0xf1, 0x50, 0x36, 0x90, 0x78, 0xce, - 0x31, 0xc5, 0x6b, 0xad, 0xe6, 0x62, 0x68, 0x79, 0x1a, 0xcc, 0xf7, 0x80, 0x4f, 0x56, 0x05, 0xce, - 0x2a, 0x8d, 0xd3, 0x0f, 0x49, 0xfe, 0x06, 0x75, 0x32, 0xaf, 0x45, 0x68, 0x08, 0xee, 0xa8, 0x50, - 0x97, 0x13, 0x2c, 0x15, 0xf5, 0x06, 0x43, 0xd5, 0xc0, 0xe8, 0x3d, 0x84, 0xb1, 0x2a, 0x16, 0x97, - 0x99, 0xd2, 0x57, 0xf7, 0x92, 0xe3, 0x09, 0x04, 0x33, 0x4c, 0xd3, 0x42, 0x78, 0x23, 0x6f, 0xbc, - 0xf5, 0xf4, 0xbf, 0xbd, 0x56, 0xe7, 0x96, 0xe7, 0x08, 0xd3, 0x54, 0xda, 0x57, 0xd1, 0x57, 0x06, - 0x7f, 0xdd, 0x2a, 0xf0, 0x6d, 0x60, 0x37, 0xa6, 0x47, 0x20, 0xd9, 0x0d, 0xa1, 0xda, 0xf0, 0x07, - 0x92, 0xd5, 0x84, 0xd6, 0x46, 0xe8, 0x40, 0xb2, 0x35, 0xa1, 0x85, 0x91, 0x37, 0x90, 0x6c, 0xc1, - 0x1f, 0xc1, 0xe0, 0x63, 0x85, 0x3a, 0xc1, 0x42, 0x04, 0xa6, 0xf5, 0xdf, 0x9b, 0xd6, 0xaf, 0x2b, - 0xd4, 0xb5, 0x6c, 0xea, 0x34, 0xb7, 0xb1, 0xc6, 0xea, 0x6c, 0x62, 0xca, 0x95, 0x64, 0xe3, 0xc0, - 0xe6, 0x28, 0x8e, 0xbe, 0x30, 0xe8, 0x4f, 0x51, 0x7f, 0x42, 0x7d, 0xaf, 0xd5, 0xbb, 0xae, 0x7b, - 0xbf, 0x71, 0xdd, 0xbf, 0xdb, 0xf5, 0x60, 0xe3, 0xfa, 0x2e, 0x04, 0x53, 0x3d, 0x9b, 0xc4, 0x66, - 0x42, 0x4f, 0x5a, 0x10, 0x7d, 0x63, 0xd0, 0x3f, 0x56, 0x75, 0x56, 0x95, 0x9d, 0x71, 0x42, 0x33, - 0xce, 0x08, 0xb6, 0x0e, 0xf2, 0x3c, 0x4d, 0x66, 0xaa, 0x4c, 0xb2, 0x95, 0x9b, 0xaa, 0x9b, 0xa2, - 0x17, 0x27, 0xa8, 0x8a, 0x4a, 0xe3, 0x12, 0x57, 0xa5, 0x9b, 0xaf, 0x9b, 0xe2, 0x0f, 0x20, 0x38, - 0x32, 0xce, 0xf9, 0x46, 0xbe, 0x9d, 0x8d, 0x7c, 0xd6, 0x30, 0x53, 0xa4, 0x45, 0x0e, 0xaa, 0x32, - 0x9b, 0xa7, 0xd9, 0xda, 0x4c, 0x3c, 0x94, 0x2d, 0x8e, 0x7e, 0x30, 0xf0, 0xff, 0x94, 0x87, 0xdb, - 0xc0, 0x12, 0x67, 0x20, 0x4b, 0x5a, 0x47, 0x07, 0x1d, 0x47, 0x05, 0x0c, 0x6a, 0xad, 0x56, 0xd7, - 0x58, 0x88, 0xe1, 0xc8, 0x1b, 0x7b, 0xb2, 0x81, 0xa6, 0x92, 0xaa, 0x4b, 0x4c, 0x0b, 0x11, 0x8e, - 0x3c, 0x3a, 0x77, 0x07, 0xdb, 0x2b, 0x80, 0xce, 0x15, 0x7c, 0x67, 0x10, 0x98, 0xe6, 0xf4, 0xbb, - 0xa3, 0x6c, 0xb9, 0x54, 0xab, 0x2b, 0x27, 0x7d, 0x03, 0xc9, 0x8f, 0xf8, 0xd0, 0xc9, 0xde, 0x8b, - 0x0f, 0x09, 0xcb, 0x33, 0x27, 0x72, 0x4f, 0x9e, 0x91, 0x6a, 0xcf, 0x75, 0x56, 0xe5, 0x87, 0xb5, - 0x95, 0x37, 0x94, 0x2d, 0xe6, 0xff, 0x42, 0xff, 0xed, 0x02, 0xb5, 0xdb, 0x39, 0x94, 0x0e, 0xd1, - 0x11, 0x1c, 0xd3, 0x54, 0x6e, 0x4b, 0x0b, 0xf8, 0x43, 0x08, 0x24, 0x6d, 0x61, 0x56, 0xbd, 0x25, - 0x90, 0x49, 0x4b, 0x5b, 0x8d, 0xf6, 0xdd, 0x33, 0x62, 0xb9, 0xc8, 0x73, 0xd4, 0xee, 0x76, 0x2d, - 0x30, 0xdc, 0xd9, 0x1a, 0xb5, 0x19, 0xd9, 0x93, 0x16, 0x44, 0xef, 0x20, 0x3c, 0x48, 0x51, 0x97, - 0xb2, 0x4a, 0xf1, 0x97, 0x13, 0xe3, 0xe0, 0xbf, 0x98, 0xbe, 0x3a, 0x6d, 0x2e, 0x9e, 0xe2, 0xcd, - 0x9d, 0x7a, 0x9d, 0x3b, 0xa5, 0x85, 0x5e, 0xaa, 0x5c, 0x4d, 0x62, 0x63, 0xac, 0x27, 0x1d, 0x8a, - 0x1e, 0x83, 0x4f, 0xff, 0x87, 0x0e, 0xb3, 0x6f, 0x98, 0x77, 0x21, 0x78, 0xb6, 0x54, 0x49, 0xea, - 0xa8, 0x2d, 0xb8, 0xec, 0x9b, 0xef, 0xf2, 0xfe, 0xcf, 0x00, 0x00, 0x00, 0xff, 0xff, 0xbd, 0x59, - 0x67, 0x12, 0xa9, 0x05, 0x00, 0x00, + 0x10, 0xd5, 0xc6, 0x76, 0x12, 0x4f, 0x7b, 0x7b, 0xaf, 0x56, 0x57, 0xb0, 0xe2, 0xc9, 0xb2, 0x40, + 0x0a, 0x48, 0xf4, 0x81, 0x7e, 0x41, 0x5b, 0x4b, 0x28, 0xd0, 0x96, 0xb2, 0x69, 0xe1, 0x09, 0xa4, + 0x6d, 0x3a, 0x69, 0x2c, 0x1c, 0xdb, 0xac, 0x6d, 0x52, 0xff, 0x02, 0xe2, 0x0b, 0x78, 0xe0, 0x23, + 0xf8, 0x15, 0x7e, 0x08, 0xcd, 0x7a, 0xed, 0xb8, 0xa2, 0xa0, 0x3e, 0xf1, 0x36, 0x67, 0x66, 0x73, + 0x66, 0xe6, 0x9c, 0x71, 0x60, 0x27, 0x4e, 0x4b, 0xd4, 0xa9, 0x4a, 0x76, 0x73, 0x9d, 0x95, 0x19, + 0x1f, 0xb7, 0x38, 0xfc, 0x3c, 0x80, 0xe1, 0x2c, 0xab, 0xf4, 0x1c, 0xf9, 0x0e, 0x0c, 0xa6, 0x91, + 0x60, 0x01, 0x9b, 0x38, 0x72, 0x30, 0x8d, 0x38, 0x07, 0xf7, 0x44, 0xad, 0x50, 0x0c, 0x02, 0x36, + 0xf1, 0xa5, 0x89, 0x29, 0x77, 0x56, 0xe7, 0x28, 0x9c, 0x26, 0x47, 0x31, 0x7f, 0x00, 0xe3, 0xf3, + 0x82, 0xd8, 0x56, 0x28, 0x5c, 0x93, 0xef, 0x30, 0xd5, 0x4e, 0x55, 0x51, 0xac, 0x33, 0x7d, 0x29, + 0xbc, 0xa6, 0xd6, 0x62, 0xfe, 0x1f, 0x38, 0xe7, 0xf2, 0x48, 0x0c, 0x4d, 0x9a, 0x42, 0x2e, 0x60, + 0x14, 0xe1, 0x42, 0x55, 0x49, 0x29, 0x46, 0x01, 0x9b, 0x8c, 0x65, 0x0b, 0x89, 0xe7, 0x0c, 0x13, + 0xbc, 0xd2, 0x6a, 0x21, 0xc6, 0x0d, 0x4f, 0x8b, 0xf9, 0x2e, 0xf0, 0x69, 0x5a, 0xe0, 0xbc, 0xd2, + 0x38, 0xfb, 0x10, 0xe7, 0x6f, 0x50, 0xc7, 0x8b, 0x5a, 0xf8, 0x86, 0xe0, 0x96, 0x0a, 0x75, 0x39, + 0xc6, 0x52, 0x51, 0x6f, 0x30, 0x54, 0x2d, 0x0c, 0xdf, 0x83, 0x1f, 0xa9, 0x62, 0x79, 0x91, 0x29, + 0x7d, 0x79, 0x27, 0x39, 0x9e, 0x82, 0x37, 0xc7, 0x24, 0x29, 0x84, 0x13, 0x38, 0x93, 0xad, 0x67, + 0xf7, 0x77, 0x3b, 0x9d, 0x3b, 0x9e, 0x43, 0x4c, 0x12, 0xd9, 0xbc, 0x0a, 0xbf, 0x32, 0xf8, 0xe7, + 0x46, 0x81, 0x6f, 0x03, 0xbb, 0x36, 0x3d, 0x3c, 0xc9, 0xae, 0x09, 0xd5, 0x86, 0xdf, 0x93, 0xac, + 0x26, 0xb4, 0x36, 0x42, 0x7b, 0x92, 0xad, 0x09, 0x2d, 0x8d, 0xbc, 0x9e, 0x64, 0x4b, 0xfe, 0x18, + 0x46, 0x1f, 0x2b, 0xd4, 0x31, 0x16, 0xc2, 0x33, 0xad, 0xff, 0xdd, 0xb4, 0x7e, 0x5d, 0xa1, 0xae, + 0x65, 0x5b, 0xa7, 0xb9, 0x8d, 0x35, 0x8d, 0xce, 0x26, 0xa6, 0x5c, 0x49, 0x36, 0x8e, 0x9a, 0x1c, + 0xc5, 0xe1, 0x17, 0x06, 0xc3, 0x19, 0xea, 0x4f, 0xa8, 0xef, 0xb4, 0x7a, 0xdf, 0x75, 0xe7, 0x0f, + 0xae, 0xbb, 0xb7, 0xbb, 0xee, 0x6d, 0x5c, 0xff, 0x1f, 0xbc, 0x99, 0x9e, 0x4f, 0x23, 0x33, 0xa1, + 0x23, 0x1b, 0x10, 0x7e, 0x63, 0x30, 0x3c, 0x52, 0x75, 0x56, 0x95, 0xbd, 0x71, 0x7c, 0x33, 0x4e, + 0x00, 0x5b, 0xfb, 0x79, 0x9e, 0xc4, 0x73, 0x55, 0xc6, 0x59, 0x6a, 0xa7, 0xea, 0xa7, 0xe8, 0xc5, + 0x31, 0xaa, 0xa2, 0xd2, 0xb8, 0xc2, 0xb4, 0xb4, 0xf3, 0xf5, 0x53, 0xfc, 0x21, 0x78, 0x87, 0xc6, + 0x39, 0xd7, 0xc8, 0xb7, 0xb3, 0x91, 0xaf, 0x31, 0xcc, 0x14, 0x69, 0x91, 0xfd, 0xaa, 0xcc, 0x16, + 0x49, 0xb6, 0x36, 0x13, 0x8f, 0x65, 0x87, 0xc3, 0x1f, 0x0c, 0xdc, 0xbf, 0xe5, 0xe1, 0x36, 0xb0, + 0xd8, 0x1a, 0xc8, 0xe2, 0xce, 0xd1, 0x51, 0xcf, 0x51, 0x01, 0xa3, 0x5a, 0xab, 0xf4, 0x0a, 0x0b, + 0x31, 0x0e, 0x9c, 0x89, 0x23, 0x5b, 0x68, 0x2a, 0x89, 0xba, 0xc0, 0xa4, 0x10, 0x7e, 0xe0, 0xd0, + 0xb9, 0x5b, 0xd8, 0x5d, 0x01, 0xf4, 0xae, 0xe0, 0x3b, 0x03, 0xcf, 0x34, 0xa7, 0xdf, 0x1d, 0x66, + 0xab, 0x95, 0x4a, 0x2f, 0xad, 0xf4, 0x2d, 0x24, 0x3f, 0xa2, 0x03, 0x2b, 0xfb, 0x20, 0x3a, 0x20, + 0x2c, 0x4f, 0xad, 0xc8, 0x03, 0x79, 0x4a, 0xaa, 0x3d, 0xd7, 0x59, 0x95, 0x1f, 0xd4, 0x8d, 0xbc, + 0xbe, 0xec, 0x30, 0xbf, 0x07, 0xc3, 0xb7, 0x4b, 0xd4, 0x76, 0x67, 0x5f, 0x5a, 0x44, 0x47, 0x70, + 0x44, 0x53, 0xd9, 0x2d, 0x1b, 0xc0, 0x1f, 0x81, 0x27, 0x69, 0x0b, 0xb3, 0xea, 0x0d, 0x81, 0x4c, + 0x5a, 0x36, 0xd5, 0x70, 0xcf, 0x3e, 0x23, 0x96, 0xf3, 0x3c, 0x47, 0x6d, 0x6f, 0xb7, 0x01, 0x86, + 0x3b, 0x5b, 0xa3, 0x36, 0x23, 0x3b, 0xb2, 0x01, 0xe1, 0x3b, 0xf0, 0xf7, 0x13, 0xd4, 0xa5, 0xac, + 0x12, 0xfc, 0xe5, 0xc4, 0x38, 0xb8, 0x2f, 0x66, 0xaf, 0x4e, 0xda, 0x8b, 0xa7, 0x78, 0x73, 0xa7, + 0x4e, 0xef, 0x4e, 0x69, 0xa1, 0x97, 0x2a, 0x57, 0xd3, 0xc8, 0x18, 0xeb, 0x48, 0x8b, 0xc2, 0x27, + 0xe0, 0xd2, 0xf7, 0xd0, 0x63, 0x76, 0x7f, 0xf7, 0x2d, 0x5d, 0x0c, 0xcd, 0xbf, 0xf2, 0xde, 0xcf, + 0x00, 0x00, 0x00, 0xff, 0xff, 0xfa, 0x57, 0xfe, 0xff, 0xa7, 0x05, 0x00, 0x00, } diff --git a/bolt/internal/internal.proto b/bolt/internal/internal.proto index f84bae193..62023a4d4 100644 --- a/bolt/internal/internal.proto +++ b/bolt/internal/internal.proto @@ -83,6 +83,6 @@ message AlertRule { } message User { - uint64 ID = 1; // ID is the unique ID of this user - string Email = 2; // Email byte representation of the user + uint64 ID = 1; // ID is the unique ID of this user + string Name = 2; // Name is the user's login name } diff --git a/bolt/sources.go b/bolt/sources.go index 0f548a823..46ced92b8 100644 --- a/bolt/sources.go +++ b/bolt/sources.go @@ -202,23 +202,23 @@ func (s *SourcesStore) setRandomDefault(ctx context.Context, src chronograf.Sour return err } else if target.Default { // Locate another source to be the new default - if srcs, err := s.all(ctx, tx); err != nil { + srcs, err := s.all(ctx, tx) + if err != nil { return err - } else { - var other *chronograf.Source - for idx, _ := range srcs { - other = &srcs[idx] - // avoid selecting the source we're about to delete as the new default - if other.ID != target.ID { - break - } + } + var other *chronograf.Source + for idx := range srcs { + other = &srcs[idx] + // avoid selecting the source we're about to delete as the new default + if other.ID != target.ID { + break } + } - // set the other to be the default - other.Default = true - if err := s.update(ctx, *other, tx); err != nil { - return err - } + // set the other to be the default + other.Default = true + if err := s.update(ctx, *other, tx); err != nil { + return err } } return nil diff --git a/bolt/users.go b/bolt/users.go index b2376c7b9..6df80d32c 100644 --- a/bolt/users.go +++ b/bolt/users.go @@ -11,31 +11,36 @@ import ( // Ensure UsersStore implements chronograf.UsersStore. var _ chronograf.UsersStore = &UsersStore{} -var UsersBucket = []byte("Users") +// UsersBucket is used to store users local to chronograf +var UsersBucket = []byte("UsersV1") +// UsersStore uses bolt to store and retrieve users type UsersStore struct { client *Client } -// FindByEmail searches the UsersStore for all users owned with the email -func (s *UsersStore) FindByEmail(ctx context.Context, email string) (*chronograf.User, error) { - var user chronograf.User +// get searches the UsersStore for user with name and returns the bolt representation +func (s *UsersStore) get(ctx context.Context, name string) (*internal.User, error) { + found := false + var user internal.User err := s.client.db.View(func(tx *bolt.Tx) error { err := tx.Bucket(UsersBucket).ForEach(func(k, v []byte) error { var u chronograf.User if err := internal.UnmarshalUser(v, &u); err != nil { return err - } else if u.Email != email { + } else if u.Name != name { return nil } - user.Email = u.Email - user.ID = u.ID + found = true + if err := internal.UnmarshalUserPB(v, &user); err != nil { + return err + } return nil }) if err != nil { return err } - if user.ID == 0 { + if found == false { return chronograf.ErrUserNotFound } return nil @@ -47,7 +52,18 @@ func (s *UsersStore) FindByEmail(ctx context.Context, email string) (*chronograf return &user, nil } -// Create a new Users in the UsersStore. +// Get searches the UsersStore for user with name +func (s *UsersStore) Get(ctx context.Context, name string) (*chronograf.User, error) { + u, err := s.get(ctx, name) + if err != nil { + return nil, err + } + return &chronograf.User{ + Name: u.Name, + }, nil +} + +// Add a new Users in the UsersStore. func (s *UsersStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { if err := s.client.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(UsersBucket) @@ -55,11 +71,9 @@ func (s *UsersStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.U if err != nil { return err } - u.ID = chronograf.UserID(seq) - if v, err := internal.MarshalUser(u); err != nil { return err - } else if err := b.Put(itob(int(u.ID)), v); err != nil { + } else if err := b.Put(u64tob(seq), v); err != nil { return err } return nil @@ -71,9 +85,13 @@ func (s *UsersStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.U } // Delete the users from the UsersStore -func (s *UsersStore) Delete(ctx context.Context, u *chronograf.User) error { +func (s *UsersStore) Delete(ctx context.Context, user *chronograf.User) error { + u, err := s.get(ctx, user.Name) + if err != nil { + return err + } if err := s.client.db.Update(func(tx *bolt.Tx) error { - if err := tx.Bucket(UsersBucket).Delete(itob(int(u.ID))); err != nil { + if err := tx.Bucket(UsersBucket).Delete(u64tob(u.ID)); err != nil { return err } return nil @@ -84,13 +102,39 @@ func (s *UsersStore) Delete(ctx context.Context, u *chronograf.User) error { return nil } -// Get retrieves a user by id. -func (s *UsersStore) Get(ctx context.Context, id chronograf.UserID) (*chronograf.User, error) { - var u chronograf.User +// Update a user +func (s *UsersStore) Update(ctx context.Context, usr *chronograf.User) error { + u, err := s.get(ctx, usr.Name) + if err != nil { + return err + } + if err := s.client.db.Update(func(tx *bolt.Tx) error { + u.Name = usr.Name + if v, err := internal.MarshalUserPB(u); err != nil { + return err + } else if err := tx.Bucket(UsersBucket).Put(u64tob(u.ID), v); err != nil { + return err + } + return nil + }); err != nil { + return err + } + + return nil +} + +// All returns all users +func (s *UsersStore) All(ctx context.Context) ([]chronograf.User, error) { + var users []chronograf.User if err := s.client.db.View(func(tx *bolt.Tx) error { - if v := tx.Bucket(UsersBucket).Get(itob(int(id))); v == nil { - return chronograf.ErrUserNotFound - } else if err := internal.UnmarshalUser(v, &u); err != nil { + if err := tx.Bucket(UsersBucket).ForEach(func(k, v []byte) error { + var user chronograf.User + if err := internal.UnmarshalUser(v, &user); err != nil { + return err + } + users = append(users, user) + return nil + }); err != nil { return err } return nil @@ -98,32 +142,5 @@ func (s *UsersStore) Get(ctx context.Context, id chronograf.UserID) (*chronograf return nil, err } - return &u, nil -} - -// Update a user -func (s *UsersStore) Update(ctx context.Context, usr *chronograf.User) error { - if err := s.client.db.Update(func(tx *bolt.Tx) error { - // Retrieve an existing user with the same ID. - var u chronograf.User - b := tx.Bucket(UsersBucket) - if v := b.Get(itob(int(usr.ID))); v == nil { - return chronograf.ErrUserNotFound - } else if err := internal.UnmarshalUser(v, &u); err != nil { - return err - } - - u.Email = usr.Email - - if v, err := internal.MarshalUser(&u); err != nil { - return err - } else if err := b.Put(itob(int(u.ID)), v); err != nil { - return err - } - return nil - }); err != nil { - return err - } - - return nil + return users, nil } diff --git a/bolt/users_test.go b/bolt/users_test.go new file mode 100644 index 000000000..28c623bdc --- /dev/null +++ b/bolt/users_test.go @@ -0,0 +1,257 @@ +package bolt_test + +import ( + "context" + "reflect" + "testing" + + "github.com/influxdata/chronograf" +) + +func TestUsersStore_Get(t *testing.T) { + type args struct { + ctx context.Context + name string + } + tests := []struct { + name string + args args + want *chronograf.User + wantErr bool + }{ + { + name: "User not found", + args: args{ + ctx: context.Background(), + name: "unknown", + }, + wantErr: true, + }, + } + for _, tt := range tests { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + if err := client.Open(); err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.UsersStore + got, err := s.Get(tt.args.ctx, tt.args.name) + if (err != nil) != tt.wantErr { + t.Errorf("%q. UsersStore.Get() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. UsersStore.Get() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestUsersStore_Add(t *testing.T) { + type args struct { + ctx context.Context + u *chronograf.User + } + tests := []struct { + name string + args args + want *chronograf.User + wantErr bool + }{ + { + name: "Add new user", + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + }, + }, + want: &chronograf.User{ + Name: "docbrown", + }, + }, + } + for _, tt := range tests { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + if err := client.Open(); err != nil { + t.Fatal(err) + } + defer client.Close() + s := client.UsersStore + got, err := s.Add(tt.args.ctx, tt.args.u) + if (err != nil) != tt.wantErr { + t.Errorf("%q. UsersStore.Add() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. UsersStore.Add() = %v, want %v", tt.name, got, tt.want) + } + + got, _ = s.Get(tt.args.ctx, got.Name) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. UsersStore.Add() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestUsersStore_Delete(t *testing.T) { + type args struct { + ctx context.Context + user *chronograf.User + } + tests := []struct { + name string + args args + addFirst bool + wantErr bool + }{ + { + name: "No such user", + args: args{ + ctx: context.Background(), + user: &chronograf.User{ + Name: "noone", + }, + }, + wantErr: true, + }, + { + name: "Delete new user", + args: args{ + ctx: context.Background(), + user: &chronograf.User{ + Name: "noone", + }, + }, + addFirst: true, + }, + } + for _, tt := range tests { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + if err := client.Open(); err != nil { + t.Fatal(err) + } + defer client.Close() + s := client.UsersStore + + if tt.addFirst { + s.Add(tt.args.ctx, tt.args.user) + } + if err := s.Delete(tt.args.ctx, tt.args.user); (err != nil) != tt.wantErr { + t.Errorf("%q. UsersStore.Delete() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + } +} + +func TestUsersStore_Update(t *testing.T) { + type args struct { + ctx context.Context + usr *chronograf.User + } + tests := []struct { + name string + args args + addFirst bool + wantErr bool + }{ + { + name: "No such user", + args: args{ + ctx: context.Background(), + usr: &chronograf.User{ + Name: "noone", + }, + }, + wantErr: true, + }, + { + name: "Update new user", + args: args{ + ctx: context.Background(), + usr: &chronograf.User{ + Name: "noone", + }, + }, + addFirst: true, + }, + } + for _, tt := range tests { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + if err := client.Open(); err != nil { + t.Fatal(err) + } + defer client.Close() + s := client.UsersStore + + if tt.addFirst { + s.Add(tt.args.ctx, tt.args.usr) + } + + if err := s.Update(tt.args.ctx, tt.args.usr); (err != nil) != tt.wantErr { + t.Errorf("%q. UsersStore.Update() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + } +} + +func TestUsersStore_All(t *testing.T) { + tests := []struct { + name string + ctx context.Context + want []chronograf.User + addFirst bool + wantErr bool + }{ + { + name: "No users", + }, + { + name: "Update new user", + want: []chronograf.User{ + { + Name: "howdy", + }, + { + Name: "doody", + }, + }, + addFirst: true, + }, + } + for _, tt := range tests { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + if err := client.Open(); err != nil { + t.Fatal(err) + } + defer client.Close() + s := client.UsersStore + + if tt.addFirst { + for _, u := range tt.want { + s.Add(tt.ctx, &u) + } + } + got, err := s.All(tt.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("%q. UsersStore.All() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. UsersStore.All() = %v, want %v", tt.name, got, tt.want) + } + } +} diff --git a/bolt/util.go b/bolt/util.go index 0ee028cad..660aad01a 100644 --- a/bolt/util.go +++ b/bolt/util.go @@ -10,3 +10,10 @@ func itob(v int) []byte { binary.BigEndian.PutUint64(b, uint64(v)) return b } + +// u64tob returns an 8-byte big endian representation of v. +func u64tob(v uint64) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, v) + return b +} diff --git a/canned/apache.json b/canned/apache.json index 33f458bdf..336c0973a 100644 --- a/canned/apache.json +++ b/canned/apache.json @@ -14,6 +14,7 @@ "queries": [ { "query": "SELECT non_negative_derivative(max(\"BytesPerSec\")) AS \"bytes_per_sec\" FROM apache", + "label": "bytes/s", "groupbys": [ "\"server\"" ], @@ -31,6 +32,7 @@ "queries": [ { "query": "SELECT non_negative_derivative(max(\"ReqPerSec\")) AS \"req_per_sec\" FROM apache", + "label": "requests/s", "groupbys": [ "\"server\"" ], @@ -48,6 +50,7 @@ "queries": [ { "query": "SELECT non_negative_derivative(max(\"TotalAccesses\")) AS \"tot_access\" FROM apache", + "label": "accesses/s", "groupbys": [ "\"server\"" ], diff --git a/canned/consul.json b/canned/consul.json index cc41b1b2f..0f7b68381 100644 --- a/canned/consul.json +++ b/canned/consul.json @@ -14,6 +14,7 @@ "queries": [ { "query": "SELECT count(\"check_id\") as \"Number Critical\" FROM consul_health_checks", + "label": "count", "groupbys": [ "\"service_name\"" ], @@ -33,6 +34,7 @@ "queries": [ { "query": "SELECT count(\"check_id\") as \"Number Warning\" FROM consul_health_checks", + "label": "count", "groupbys": [ "\"service_name\"" ], diff --git a/canned/cpu.json b/canned/cpu.json index 0043919de..931f0dd4f 100644 --- a/canned/cpu.json +++ b/canned/cpu.json @@ -14,6 +14,7 @@ "queries": [ { "query": "SELECT mean(\"usage_user\") AS \"usage_user\" FROM \"cpu\"", + "label": "% CPU time", "groupbys": [], "wheres": [] } diff --git a/canned/disk.json b/canned/disk.json index 844398355..bfb43b85a 100644 --- a/canned/disk.json +++ b/canned/disk.json @@ -14,6 +14,7 @@ "queries": [ { "query": "SELECT mean(\"used_percent\") AS \"used_percent\" FROM disk", + "label": "% used", "groupbys": [ "\"path\"" ], diff --git a/canned/docker.json b/canned/docker.json index b0192d65e..ff0f7b5d3 100644 --- a/canned/docker.json +++ b/canned/docker.json @@ -10,10 +10,11 @@ "w": 4, "h": 4, "i": "4c79cefb-5152-410c-9b88-74f9bff7ef22", - "name": "Docker - Container CPU", + "name": "Docker - Container CPU %", "queries": [ { "query": "SELECT mean(\"usage_percent\") AS \"usage_percent\" FROM \"docker_container_cpu\"", + "label": "% CPU time", "groupbys": [ "\"container_name\"" ] @@ -27,10 +28,11 @@ "w": 4, "h": 4, "i": "4c79cefb-5152-410c-9b88-74f9bff7ef00", - "name": "Docker - Container Memory", + "name": "Docker - Container Memory (MB)", "queries": [ { - "query": "SELECT mean(\"usage\") AS \"usage\" FROM \"docker_container_mem\"", + "query": "SELECT mean(\"usage\") / 1048576 AS \"usage\" FROM \"docker_container_mem\"", + "label": "MB", "groupbys": [ "\"container_name\"" ] @@ -48,6 +50,7 @@ "queries": [ { "query": "SELECT max(\"n_containers\") AS \"max_n_containers\" FROM \"docker\"", + "label": "count", "groupbys": [ "\"host\"" ] @@ -82,6 +85,7 @@ "queries": [ { "query": "SELECT max(\"n_containers_running\") AS \"max_n_containers_running\" FROM \"docker\"", + "label": "count", "groupbys": [ "\"host\"" ] diff --git a/canned/influxdb_httpd.json b/canned/influxdb_httpd.json index 78253b385..954fcad2f 100644 --- a/canned/influxdb_httpd.json +++ b/canned/influxdb_httpd.json @@ -13,7 +13,8 @@ "name": "InfluxDB - Write HTTP Requests", "queries": [ { - "query": "SELECT non_negative_derivative(max(\"writeReq\"), 1s) AS \"http_requests\" FROM \"influxdb_httpd\"", + "query": "SELECT non_negative_derivative(max(\"writeReq\")) AS \"http_requests\" FROM \"influxdb_httpd\"", + "label": "count/s", "groupbys": [], "wheres": [] } @@ -28,13 +29,15 @@ "name": "InfluxDB - Query Requests", "queries": [ { - "query": "SELECT non_negative_derivative(max(\"queryReq\"), 1s) AS \"query_requests\" FROM \"influxdb_httpd\"", + "query": "SELECT non_negative_derivative(max(\"queryReq\")) AS \"query_requests\" FROM \"influxdb_httpd\"", + "label": "count/s", "groupbys": [], "wheres": [] } ] }, { + "type": "line-stepplot", "x": 0, "y": 0, "w": 4, @@ -43,7 +46,8 @@ "name": "InfluxDB - Client Failures", "queries": [ { - "query": "SELECT non_negative_derivative(max(\"clientError\"), 1s) AS \"client_errors\" FROM \"influxdb_httpd\"", + "query": "SELECT non_negative_derivative(max(\"clientError\")) AS \"client_errors\" FROM \"influxdb_httpd\"", + "label": "count/s", "groupbys": [], "wheres": [] }, diff --git a/canned/influxdb_write.json b/canned/influxdb_write.json index 8d8a81813..06e3f6cef 100644 --- a/canned/influxdb_write.json +++ b/canned/influxdb_write.json @@ -13,7 +13,8 @@ "name": "InfluxDB - Write Points", "queries": [ { - "query": "SELECT non_negative_derivative(max(\"pointReq\"), 1s) AS \"points_written\" FROM \"influxdb_write\"", + "query": "SELECT non_negative_derivative(max(\"pointReq\")) AS \"points_written\" FROM \"influxdb_write\"", + "label": "points/s", "groupbys": [], "wheres": [] } @@ -28,12 +29,13 @@ "name": "InfluxDB - Write Errors", "queries": [ { - "query": "SELECT non_negative_derivative(max(\"writeError\"), 1s) AS \"shard_write_error\" FROM \"influxdb_write\"", + "query": "SELECT non_negative_derivative(max(\"writeError\")) AS \"shard_write_error\" FROM \"influxdb_write\"", + "label": "errors/s", "groupbys": [], "wheres": [] }, { - "query": "SELECT non_negative_derivative(max(\"serveError\"), 1s) AS \"http_error\" FROM \"influxdb_httpd\"", + "query": "SELECT non_negative_derivative(max(\"serveError\")) AS \"http_error\" FROM \"influxdb_httpd\"", "groupbys": [], "wheres": [] } diff --git a/canned/mem.json b/canned/mem.json index 657c0618e..bab5e9df9 100644 --- a/canned/mem.json +++ b/canned/mem.json @@ -10,10 +10,11 @@ "w": 4, "h": 4, "i": "e6e5063c-43d5-409b-a0ab-68da51ed3f28", - "name": "System - Memory Bytes Used", + "name": "System - Memory Gigabytes Used", "queries": [ { - "query": "SELECT mean(\"used\") AS \"used\", mean(\"available\") AS \"available\" FROM \"mem\"", + "query": "SELECT mean(\"used\") / 1073741824 AS \"used\", mean(\"available\") / 1073741824 AS \"available\" FROM \"mem\"", + "label": "GB", "groupbys": [], "wheres": [] } diff --git a/canned/memcached.json b/canned/memcached.json index 89a5e0abe..d9ca0aa8a 100644 --- a/canned/memcached.json +++ b/canned/memcached.json @@ -14,6 +14,7 @@ "queries": [ { "query": "SELECT max(\"curr_connections\") AS \"current_connections\" FROM memcached", + "label": "count", "groupbys": [], "wheres": [] } @@ -28,7 +29,8 @@ "name": "Memcached - Get Hits/Second", "queries": [ { - "query": "SELECT non_negative_derivative(max(\"get_hits\"), 1s) AS \"get_hits\" FROM memcached", + "query": "SELECT non_negative_derivative(max(\"get_hits\")) AS \"get_hits\" FROM memcached", + "label": "hits/s", "groupbys": [], "wheres": [] } @@ -43,7 +45,8 @@ "name": "Memcached - Get Misses/Second", "queries": [ { - "query": "SELECT non_negative_derivative(max(\"get_misses\"), 1s) AS \"get_misses\" FROM memcached", + "query": "SELECT non_negative_derivative(max(\"get_misses\")) AS \"get_misses\" FROM memcached", + "label": "misses/s", "groupbys": [], "wheres": [] } @@ -58,7 +61,8 @@ "name": "Memcached - Delete Hits/Second", "queries": [ { - "query": "SELECT non_negative_derivative(max(\"delete_hits\"), 1s) AS \"delete_hits\" FROM memcached", + "query": "SELECT non_negative_derivative(max(\"delete_hits\")) AS \"delete_hits\" FROM memcached", + "label": "deletes/s", "groupbys": [], "wheres": [] } @@ -73,7 +77,8 @@ "name": "Memcached - Delete Misses/Second", "queries": [ { - "query": "SELECT non_negative_derivative(max(\"delete_misses\"), 1s) AS \"delete_misses\" FROM memcached", + "query": "SELECT non_negative_derivative(max(\"delete_misses\")) AS \"delete_misses\" FROM memcached", + "label": "delete misses/s", "groupbys": [], "wheres": [] } @@ -88,7 +93,8 @@ "name": "Memcached - Incr Hits/Second", "queries": [ { - "query": "SELECT non_negative_derivative(max(\"incr_hits\"), 1s) AS \"incr_hits\" FROM memcached", + "query": "SELECT non_negative_derivative(max(\"incr_hits\")) AS \"incr_hits\" FROM memcached", + "label": "incr hits/s", "groupbys": [], "wheres": [] } @@ -103,7 +109,8 @@ "name": "Memcached - Incr Misses/Second", "queries": [ { - "query": "SELECT non_negative_derivative(max(\"incr_misses\"), 1s) AS \"incr_misses\" FROM memcached", + "query": "SELECT non_negative_derivative(max(\"incr_misses\")) AS \"incr_misses\" FROM memcached", + "label": "incr misses/s", "groupbys": [], "wheres": [] } @@ -119,6 +126,7 @@ "queries": [ { "query": "SELECT max(\"curr_items\") AS \"current_items\" FROM memcached", + "label": "count", "groupbys": [], "wheres": [] } @@ -134,6 +142,7 @@ "queries": [ { "query": "SELECT max(\"total_items\") AS \"total_items\" FROM memcached", + "label": "count", "groupbys": [], "wheres": [] } @@ -149,6 +158,7 @@ "queries": [ { "query": "SELECT max(\"bytes\") AS \"bytes\" FROM memcached", + "label": "bytes", "groupbys": [], "wheres": [] } @@ -163,7 +173,8 @@ "name": "Memcached - Bytes Read/Sec", "queries": [ { - "query": "SELECT non_negative_derivative(max(\"bytes_read\"), 1s) AS \"bytes_read\" FROM memcached", + "query": "SELECT non_negative_derivative(max(\"bytes_read\")) AS \"bytes_read\" FROM memcached", + "label": "bytes/s", "groupbys": [], "wheres": [] } @@ -178,7 +189,8 @@ "name": "Memcached - Bytes Written/Sec", "queries": [ { - "query": "SELECT non_negative_derivative(max(\"bytes_written\"), 1s) AS \"bytes_written\" FROM memcached", + "query": "SELECT non_negative_derivative(max(\"bytes_written\")) AS \"bytes_written\" FROM memcached", + "label": "bytes/s", "groupbys": [], "wheres": [] } @@ -194,6 +206,7 @@ "queries": [ { "query": "SELECT non_negative_derivative(max(\"evictions\"), 10s) AS \"evictions\" FROM memcached", + "label": "evictions / 10s", "groupbys": [], "wheres": [] } diff --git a/canned/mongodb.json b/canned/mongodb.json index e9e777b9b..fb02e4b41 100644 --- a/canned/mongodb.json +++ b/canned/mongodb.json @@ -14,6 +14,7 @@ "queries": [ { "query": "SELECT mean(queries_per_sec) AS queries_per_second, mean(getmores_per_sec) AS getmores_per_second FROM mongodb", + "label": "reads/s", "groupbys": [], "wheres": [] } @@ -29,6 +30,7 @@ "queries": [ { "query": "SELECT mean(inserts_per_sec) AS inserts_per_second, mean(updates_per_sec) AS updates_per_second, mean(deletes_per_sec) AS deletes_per_second FROM mongodb", + "label": "writes/s", "groupbys": [], "wheres": [] } @@ -44,6 +46,7 @@ "queries": [ { "query": "SELECT mean(open_connections) AS open_connections FROM mongodb", + "label": "count", "groupbys": [], "wheres": [] } @@ -59,6 +62,7 @@ "queries": [ { "query": "SELECT max(queued_reads) AS queued_reads, max(queued_writes) as queued_writes FROM mongodb", + "label": "count", "groupbys": [], "wheres": [] } @@ -74,6 +78,7 @@ "queries": [ { "query": "SELECT mean(net_in_bytes) AS net_in_bytes, mean(net_out_bytes) as net_out_bytes FROM mongodb", + "label": "bytes/s", "groupbys": [], "wheres": [] } @@ -89,6 +94,7 @@ "queries": [ { "query": "SELECT mean(page_faults_per_sec) AS page_faults_per_second FROM mongodb", + "label": "faults/s", "groupbys": [], "wheres": [] } @@ -104,6 +110,7 @@ "queries": [ { "query": "SELECT mean(vsize_megabytes) AS virtual_memory_megabytes, mean(resident_megabytes) as resident_memory_megabytes FROM mongodb", + "label": "MB", "groupbys": [], "wheres": [] } diff --git a/chronograf.go b/chronograf.go index c3ecef02c..55c078cf0 100644 --- a/chronograf.go +++ b/chronograf.go @@ -15,6 +15,8 @@ const ( ErrUserNotFound = Error("user not found") ErrLayoutInvalid = Error("layout is invalid") ErrAlertNotFound = Error("alert not found") + ErrAuthentication = Error("user not authenticated") + ErrUninitialized = Error("client uninitialized. Call Open() method") ) // Error is a domain error encountered while processing chronograf requests @@ -49,6 +51,33 @@ type TimeSeries interface { Query(context.Context, Query) (Response, error) // Connect will connect to the time series using the information in `Source`. Connect(context.Context, *Source) error + // UsersStore represents the user accounts within the TimeSeries database + Users(context.Context) UsersStore + // Allowances returns all valid names permissions in this database + Allowances(context.Context) Allowances + // Roles represents the roles associated with this TimesSeriesDatabase + Roles(context.Context) (RolesStore, error) +} + +// Role is a restricted set of permissions assigned to a set of users. +type Role struct { + Name string `json:"name"` + Permissions Permissions `json:"permissions,omitempty"` + Users []User `json:"users,omitempty"` +} + +// RolesStore is the Storage and retrieval of authentication information +type RolesStore interface { + // All lists all roles from the RolesStore + All(context.Context) ([]Role, error) + // Create a new Role in the RolesStore + Add(context.Context, *Role) (*Role, error) + // Delete the Role from the RolesStore + Delete(context.Context, *Role) error + // Get retrieves a role if name exists. + Get(ctx context.Context, name string) (*Role, error) + // Update the roles' users or permissions + Update(context.Context, *Role) error } // Range represents an upper and lower bound for data @@ -217,27 +246,49 @@ type ID interface { Generate() (string, error) } -// UserID is a unique ID for a source user. -type UserID int +const ( + // AllScope grants permission for all databases. + AllScope Scope = "all" + // DBScope grants permissions for a specific database + DBScope Scope = "database" +) + +// Permission is a specific allowance for User or Role bound to a +// scope of the data source +type Permission struct { + Scope Scope `json:"scope"` + Name string `json:"name,omitempty"` + Allowed Allowances `json:"allowed"` +} + +// Permissions represent the entire set of permissions a User or Role may have +type Permissions []Permission + +// Allowances defines what actions a user can have on a scoped permission +type Allowances []string + +// Scope defines the location of access of a permission +type Scope string // User represents an authenticated user. type User struct { - ID UserID `json:"id"` - Email string `json:"email"` + Name string `json:"name"` + Passwd string `json:"password"` + Permissions Permissions `json:"permissions,omitempty"` } // UsersStore is the Storage and retrieval of authentication information type UsersStore interface { + // All lists all users from the UsersStore + All(context.Context) ([]User, error) // Create a new User in the UsersStore Add(context.Context, *User) (*User, error) // Delete the User from the UsersStore Delete(context.Context, *User) error - // Get retrieves a user if `ID` exists. - Get(ctx context.Context, ID UserID) (*User, error) + // Get retrieves a user if name exists. + Get(ctx context.Context, name string) (*User, error) // Update the user's permissions or roles Update(context.Context, *User) error - // FindByEmail will retrieve a user by email address. - FindByEmail(ctx context.Context, Email string) (*User, error) } // DashboardID is the dashboard ID diff --git a/enterprise/enterprise.go b/enterprise/enterprise.go new file mode 100644 index 000000000..0df064057 --- /dev/null +++ b/enterprise/enterprise.go @@ -0,0 +1,198 @@ +package enterprise + +import ( + "container/ring" + "net/url" + "strings" + + "context" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/influx" +) + +var _ chronograf.TimeSeries = &Client{} + +// Ctrl represents administrative controls over an Influx Enterprise cluster +type Ctrl interface { + ShowCluster(ctx context.Context) (*Cluster, error) + + Users(ctx context.Context, name *string) (*Users, error) + User(ctx context.Context, name string) (*User, error) + CreateUser(ctx context.Context, name, passwd string) error + DeleteUser(ctx context.Context, name string) error + ChangePassword(ctx context.Context, name, passwd string) error + SetUserPerms(ctx context.Context, name string, perms Permissions) error + + Roles(ctx context.Context, name *string) (*Roles, error) + Role(ctx context.Context, name string) (*Role, error) + CreateRole(ctx context.Context, name string) error + DeleteRole(ctx context.Context, name string) error + SetRolePerms(ctx context.Context, name string, perms Permissions) error + SetRoleUsers(ctx context.Context, name string, users []string) error +} + +// Client is a device for retrieving time series data from an Influx Enterprise +// cluster. It is configured using the addresses of one or more meta node URLs. +// Data node URLs are retrieved automatically from the meta nodes and queries +// are appropriately load balanced across the cluster. +type Client struct { + Ctrl + UsersStore chronograf.UsersStore + RolesStore chronograf.RolesStore + Logger chronograf.Logger + + dataNodes *ring.Ring + opened bool +} + +// NewClientWithTimeSeries initializes a Client with a known set of TimeSeries. +func NewClientWithTimeSeries(lg chronograf.Logger, mu, username, password string, tls bool, series ...chronograf.TimeSeries) (*Client, error) { + metaURL, err := parseMetaURL(mu, tls) + if err != nil { + return nil, err + } + metaURL.User = url.UserPassword(username, password) + ctrl := NewMetaClient(metaURL) + c := &Client{ + Ctrl: ctrl, + UsersStore: &UserStore{ + Ctrl: ctrl, + Logger: lg, + }, + RolesStore: &RolesStore{ + Ctrl: ctrl, + Logger: lg, + }, + } + + c.dataNodes = ring.New(len(series)) + + for _, s := range series { + c.dataNodes.Value = s + c.dataNodes = c.dataNodes.Next() + } + + return c, nil +} + +// NewClientWithURL initializes an Enterprise client with a URL to a Meta Node. +// Acceptable URLs include host:port combinations as well as scheme://host:port +// varieties. TLS is used when the URL contains "https" or when the TLS +// parameter is set. The latter option is provided for host:port combinations +// Username and Password are used for Basic Auth +func NewClientWithURL(mu, username, password string, tls bool, lg chronograf.Logger) (*Client, error) { + metaURL, err := parseMetaURL(mu, tls) + if err != nil { + return nil, err + } + metaURL.User = url.UserPassword(username, password) + ctrl := NewMetaClient(metaURL) + return &Client{ + Ctrl: ctrl, + UsersStore: &UserStore{ + Ctrl: ctrl, + Logger: lg, + }, + RolesStore: &RolesStore{ + Ctrl: ctrl, + Logger: lg, + }, + Logger: lg, + }, nil +} + +// Connect prepares a Client to process queries. It must be called prior to calling Query +func (c *Client) Connect(ctx context.Context, src *chronograf.Source) error { + c.opened = true + // return early if we already have dataNodes + if c.dataNodes != nil { + return nil + } + cluster, err := c.Ctrl.ShowCluster(ctx) + if err != nil { + return err + } + + c.dataNodes = ring.New(len(cluster.DataNodes)) + for _, dn := range cluster.DataNodes { + cl, err := influx.NewClient(dn.HTTPAddr, c.Logger) + if err != nil { + continue + } else { + c.dataNodes.Value = cl + c.dataNodes = c.dataNodes.Next() + } + } + return nil +} + +// Query retrieves timeseries information pertaining to a specified query. It +// can be cancelled by using a provided context. +func (c *Client) Query(ctx context.Context, q chronograf.Query) (chronograf.Response, error) { + if !c.opened { + return nil, chronograf.ErrUninitialized + } + return c.nextDataNode().Query(ctx, q) +} + +// Users is the interface to the users within Influx Enterprise +func (c *Client) Users(context.Context) chronograf.UsersStore { + return c.UsersStore +} + +// Roles provide a grouping of permissions given to a grouping of users +func (c *Client) Roles(ctx context.Context) (chronograf.RolesStore, error) { + return c.RolesStore, nil +} + +// Allowances returns all Influx Enterprise permission strings +func (c *Client) Allowances(context.Context) chronograf.Allowances { + return chronograf.Allowances{ + "NoPermissions", + "ViewAdmin", + "ViewChronograf", + "CreateDatabase", + "CreateUserAndRole", + "AddRemoveNode", + "DropDatabase", + "DropData", + "ReadData", + "WriteData", + "Rebalance", + "ManageShard", + "ManageContinuousQuery", + "ManageQuery", + "ManageSubscription", + "Monitor", + "CopyShard", + "KapacitorAPI", + "KapacitorConfigAPI", + } +} + +// nextDataNode retrieves the next available data node +func (c *Client) nextDataNode() chronograf.TimeSeries { + c.dataNodes = c.dataNodes.Next() + return c.dataNodes.Value.(chronograf.TimeSeries) +} + +// parseMetaURL constructs a url from either a host:port combination or a +// scheme://host:port combo. The optional TLS parameter takes precedence over +// any TLS preference found in the provided URL +func parseMetaURL(mu string, tls bool) (metaURL *url.URL, err error) { + if strings.Contains(mu, "http") { + metaURL, err = url.Parse(mu) + } else { + metaURL = &url.URL{ + Scheme: "http", + Host: mu, + } + } + + if tls { + metaURL.Scheme = "https" + } + + return +} diff --git a/enterprise/enterprise_test.go b/enterprise/enterprise_test.go new file mode 100644 index 000000000..f26560ddb --- /dev/null +++ b/enterprise/enterprise_test.go @@ -0,0 +1,185 @@ +package enterprise_test + +import ( + "context" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/enterprise" + "github.com/influxdata/chronograf/log" +) + +func Test_Enterprise_FetchesDataNodes(t *testing.T) { + t.Parallel() + showClustersCalled := false + ctrl := &mockCtrl{ + showCluster: func(ctx context.Context) (*enterprise.Cluster, error) { + showClustersCalled = true + return &enterprise.Cluster{}, nil + }, + } + + cl := &enterprise.Client{ + Ctrl: ctrl, + } + + bg := context.Background() + err := cl.Connect(bg, &chronograf.Source{}) + + if err != nil { + t.Fatal("Unexpected error while creating enterprise client. err:", err) + } + + if showClustersCalled != true { + t.Fatal("Expected request to meta node but none was issued") + } +} + +func Test_Enterprise_IssuesQueries(t *testing.T) { + t.Parallel() + + called := false + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + called = true + if r.URL.Path != "/query" { + t.Fatal("Expected request to '/query' but was", r.URL.Path) + } + rw.Write([]byte(`{}`)) + })) + defer ts.Close() + + cl := &enterprise.Client{ + Ctrl: NewMockControlClient(ts.URL), + Logger: log.New(log.DebugLevel), + } + + err := cl.Connect(context.Background(), &chronograf.Source{}) + if err != nil { + t.Fatal("Unexpected error initializing client: err:", err) + } + + _, err = cl.Query(context.Background(), chronograf.Query{Command: "show shards", DB: "_internal", RP: "autogen"}) + + if err != nil { + t.Fatal("Unexpected error while querying data node: err:", err) + } + + if called == false { + t.Fatal("Expected request to data node but none was received") + } +} + +func Test_Enterprise_AdvancesDataNodes(t *testing.T) { + m1 := NewMockTimeSeries("http://host-1.example.com:8086") + m2 := NewMockTimeSeries("http://host-2.example.com:8086") + cl, err := enterprise.NewClientWithTimeSeries(log.New(log.DebugLevel), "http://meta.example.com:8091", "marty", "thelake", false, chronograf.TimeSeries(m1), chronograf.TimeSeries(m2)) + if err != nil { + t.Error("Unexpected error while initializing client: err:", err) + } + + err = cl.Connect(context.Background(), &chronograf.Source{}) + if err != nil { + t.Error("Unexpected error while initializing client: err:", err) + } + + _, err = cl.Query(context.Background(), chronograf.Query{Command: "show shards", DB: "_internal", RP: "autogen"}) + if err != nil { + t.Fatal("Unexpected error while issuing query: err:", err) + } + + _, err = cl.Query(context.Background(), chronograf.Query{Command: "show shards", DB: "_internal", RP: "autogen"}) + if err != nil { + t.Fatal("Unexpected error while issuing query: err:", err) + } + + if m1.QueryCtr != 1 || m2.QueryCtr != 1 { + t.Fatalf("Expected m1.Query to be called once but was %d. Expected m2.Query to be called once but was %d\n", m1.QueryCtr, m2.QueryCtr) + } +} + +func Test_Enterprise_NewClientWithURL(t *testing.T) { + t.Parallel() + + urls := []struct { + url string + username string + password string + tls bool + shouldErr 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}, + } + + for _, testURL := range urls { + _, err := enterprise.NewClientWithURL(testURL.url, testURL.username, testURL.password, testURL.tls, log.New(log.DebugLevel)) + if err != nil && !testURL.shouldErr { + 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 { + t.Errorf("Expected error creating Client with URL %s and TLS preference %t", testURL.url, testURL.tls) + } + } +} + +func Test_Enterprise_ComplainsIfNotOpened(t *testing.T) { + m1 := NewMockTimeSeries("http://host-1.example.com:8086") + cl, err := enterprise.NewClientWithTimeSeries(log.New(log.DebugLevel), "http://meta.example.com:8091", "docbrown", "1.21 gigawatts", false, chronograf.TimeSeries(m1)) + if err != nil { + t.Error("Expected ErrUnitialized, but was this err:", err) + } + _, err = cl.Query(context.Background(), chronograf.Query{Command: "show shards", DB: "_internal", RP: "autogen"}) + if err != chronograf.ErrUninitialized { + t.Error("Expected ErrUnitialized, but was this err:", err) + } +} + +func TestClient_Allowances(t *testing.T) { + tests := []struct { + name string + + want chronograf.Allowances + }{ + { + name: "All possible enterprise permissions", + want: chronograf.Allowances{ + "NoPermissions", + "ViewAdmin", + "ViewChronograf", + "CreateDatabase", + "CreateUserAndRole", + "AddRemoveNode", + "DropDatabase", + "DropData", + "ReadData", + "WriteData", + "Rebalance", + "ManageShard", + "ManageContinuousQuery", + "ManageQuery", + "ManageSubscription", + "Monitor", + "CopyShard", + "KapacitorAPI", + "KapacitorConfigAPI", + }, + }, + } + for _, tt := range tests { + c := &enterprise.Client{} + if got := c.Allowances(context.Background()); !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. Client.Allowances() = %v, want %v", tt.name, got, tt.want) + } + } +} diff --git a/enterprise/meta.go b/enterprise/meta.go new file mode 100644 index 000000000..1a1274369 --- /dev/null +++ b/enterprise/meta.go @@ -0,0 +1,367 @@ +package enterprise + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/influxdata/chronograf" +) + +// MetaClient represents a Meta node in an Influx Enterprise cluster +type MetaClient struct { + URL *url.URL + client interface { + Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) + } +} + +// NewMetaClient represents a meta node in an Influx Enterprise cluster +func NewMetaClient(url *url.URL) *MetaClient { + return &MetaClient{ + URL: url, + client: &defaultClient{}, + } +} + +// ShowCluster returns the cluster configuration (not health) +func (m *MetaClient) ShowCluster(ctx context.Context) (*Cluster, error) { + res, err := m.Do(ctx, "GET", "/show-cluster", nil, nil) + if err != nil { + return nil, err + } + + defer res.Body.Close() + dec := json.NewDecoder(res.Body) + out := &Cluster{} + err = dec.Decode(out) + if err != nil { + return nil, err + } + return out, nil +} + +// Users gets all the users. If name is not nil it filters for a single user +func (m *MetaClient) Users(ctx context.Context, name *string) (*Users, error) { + params := map[string]string{} + if name != nil { + params["name"] = *name + } + res, err := m.Do(ctx, "GET", "/user", params, nil) + if err != nil { + return nil, err + } + + defer res.Body.Close() + dec := json.NewDecoder(res.Body) + users := &Users{} + err = dec.Decode(users) + if err != nil { + return nil, err + } + return users, nil +} + +// User returns a single Influx Enterprise user +func (m *MetaClient) User(ctx context.Context, name string) (*User, error) { + users, err := m.Users(ctx, &name) + if err != nil { + return nil, err + } + + for _, user := range users.Users { + return &user, nil + } + return nil, fmt.Errorf("No user found") +} + +// CreateUser adds a user to Influx Enterprise +func (m *MetaClient) CreateUser(ctx context.Context, name, passwd string) error { + return m.CreateUpdateUser(ctx, "create", name, passwd) +} + +// ChangePassword updates a user's password in Influx Enterprise +func (m *MetaClient) ChangePassword(ctx context.Context, name, passwd string) error { + return m.CreateUpdateUser(ctx, "change-password", name, passwd) +} + +// CreateUpdateUser is a helper function to POST to the /user Influx Enterprise endpoint +func (m *MetaClient) CreateUpdateUser(ctx context.Context, action, name, passwd string) error { + a := &UserAction{ + Action: action, + User: &User{ + Name: name, + Password: passwd, + }, + } + return m.Post(ctx, "/user", a, nil) +} + +// DeleteUser removes a user from Influx Enterprise +func (m *MetaClient) DeleteUser(ctx context.Context, name string) error { + a := &UserAction{ + Action: "delete", + User: &User{ + Name: name, + }, + } + + return m.Post(ctx, "/user", a, nil) +} + +// RemoveAllUserPerms revokes all permissions for a user in Influx Enterprise +func (m *MetaClient) RemoveAllUserPerms(ctx context.Context, name string) error { + user, err := m.User(ctx, name) + if err != nil { + return err + } + + // No permissions to remove + if len(user.Permissions) == 0 { + return nil + } + + a := &UserAction{ + Action: "remove-permissions", + User: user, + } + return m.Post(ctx, "/user", a, nil) +} + +// SetUserPerms removes all permissions and then adds the requested perms +func (m *MetaClient) SetUserPerms(ctx context.Context, name string, perms Permissions) error { + err := m.RemoveAllUserPerms(ctx, name) + if err != nil { + return err + } + + // No permissions to add, so, user is in the right state + if len(perms) == 0 { + return nil + } + + a := &UserAction{ + Action: "add-permissions", + User: &User{ + Name: name, + Permissions: perms, + }, + } + return m.Post(ctx, "/user", a, nil) +} + +// Roles gets all the roles. If name is not nil it filters for a single role +func (m *MetaClient) Roles(ctx context.Context, name *string) (*Roles, error) { + params := map[string]string{} + if name != nil { + params["name"] = *name + } + res, err := m.Do(ctx, "GET", "/role", params, nil) + if err != nil { + return nil, err + } + + defer res.Body.Close() + dec := json.NewDecoder(res.Body) + roles := &Roles{} + err = dec.Decode(roles) + if err != nil { + return nil, err + } + return roles, nil +} + +// Role returns a single named role +func (m *MetaClient) Role(ctx context.Context, name string) (*Role, error) { + roles, err := m.Roles(ctx, &name) + if err != nil { + return nil, err + } + for _, role := range roles.Roles { + return &role, nil + } + return nil, fmt.Errorf("No role found") +} + +// CreateRole adds a role to Influx Enterprise +func (m *MetaClient) CreateRole(ctx context.Context, name string) error { + a := &RoleAction{ + Action: "create", + Role: &Role{ + Name: name, + }, + } + return m.Post(ctx, "/role", a, nil) +} + +// DeleteRole removes a role from Influx Enterprise +func (m *MetaClient) DeleteRole(ctx context.Context, name string) error { + a := &RoleAction{ + Action: "delete", + Role: &Role{ + Name: name, + }, + } + return m.Post(ctx, "/role", a, nil) +} + +// RemoveAllRolePerms removes all permissions from a role +func (m *MetaClient) RemoveAllRolePerms(ctx context.Context, name string) error { + role, err := m.Role(ctx, name) + if err != nil { + return err + } + + // No permissions to remove + if len(role.Permissions) == 0 { + return nil + } + + a := &RoleAction{ + Action: "remove-permissions", + Role: role, + } + return m.Post(ctx, "/role", a, nil) +} + +// SetRolePerms removes all permissions and then adds the requested perms to role +func (m *MetaClient) SetRolePerms(ctx context.Context, name string, perms Permissions) error { + err := m.RemoveAllRolePerms(ctx, name) + if err != nil { + return err + } + + // No permissions to add, so, role is in the right state + if len(perms) == 0 { + return nil + } + + a := &RoleAction{ + Action: "add-permissions", + Role: &Role{ + Name: name, + Permissions: perms, + }, + } + return m.Post(ctx, "/role", a, nil) +} + +// RemoveAllRoleUsers removes all users from a role +func (m *MetaClient) RemoveAllRoleUsers(ctx context.Context, name string) error { + role, err := m.Role(ctx, name) + if err != nil { + return err + } + + // No users to remove + if len(role.Users) == 0 { + return nil + } + + a := &RoleAction{ + Action: "remove-users", + Role: role, + } + return m.Post(ctx, "/role", a, nil) +} + +// SetRoleUsers removes all users and then adds the requested users to role +func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []string) error { + err := m.RemoveAllRoleUsers(ctx, name) + if err != nil { + return err + } + + // No permissions to add, so, role is in the right state + if len(users) == 0 { + return nil + } + + a := &RoleAction{ + Action: "add-users", + Role: &Role{ + Name: name, + Users: users, + }, + } + return m.Post(ctx, "/role", a, nil) +} + +// Post is a helper function to POST to Influx Enterprise +func (m *MetaClient) Post(ctx context.Context, path string, action interface{}, params map[string]string) error { + b, err := json.Marshal(action) + if err != nil { + return err + } + body := bytes.NewReader(b) + _, err = m.Do(ctx, "POST", path, params, body) + if err != nil { + return err + } + return nil +} + +type defaultClient struct{} + +// Do is a helper function to interface with Influx Enterprise's Meta API +func (d *defaultClient) Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) { + p := url.Values{} + for k, v := range params { + p.Add(k, v) + } + + URL.Path = path + URL.RawQuery = p.Encode() + + req, err := http.NewRequest(method, URL.String(), body) + if err != nil { + return nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + if res.StatusCode != http.StatusOK { + defer res.Body.Close() + dec := json.NewDecoder(res.Body) + out := &Error{} + err = dec.Decode(out) + if err != nil { + return nil, err + } + return nil, errors.New(out.Error) + } + + return res, nil + +} + +// Do is a cancelable function to interface with Influx Enterprise's Meta API +func (m *MetaClient) Do(ctx context.Context, method, path string, params map[string]string, body io.Reader) (*http.Response, error) { + type result struct { + Response *http.Response + Err error + } + resps := make(chan (result)) + go func() { + resp, err := m.client.Do(m.URL, path, method, params, body) + resps <- result{resp, err} + }() + + select { + case resp := <-resps: + return resp.Response, resp.Err + case <-ctx.Done(): + return nil, chronograf.ErrUpstreamTimeout + } +} diff --git a/enterprise/meta_test.go b/enterprise/meta_test.go new file mode 100644 index 000000000..b860f2192 --- /dev/null +++ b/enterprise/meta_test.go @@ -0,0 +1,1307 @@ +package enterprise + +import ( + "bytes" + "context" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "reflect" + "testing" +) + +func TestMetaClient_ShowCluster(t *testing.T) { + type fields struct { + URL *url.URL + client interface { + Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) + } + } + tests := []struct { + name string + fields fields + want *Cluster + wantErr bool + }{ + { + name: "Successful Show Cluster", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{"data":[{"id":2,"version":"1.1.0-c1.1.0","tcpAddr":"data-1.twinpinesmall.net:8088","httpAddr":"data-1.twinpinesmall.net:8086","httpScheme":"https","status":"joined"}],"meta":[{"id":1,"addr":"meta-0.twinpinesmall.net:8091","httpScheme":"http","tcpAddr":"meta-0.twinpinesmall.net:8089","version":"1.1.0-c1.1.0"}]}`), + nil, + nil, + ), + }, + want: &Cluster{ + DataNodes: []DataNode{ + { + ID: 2, + TCPAddr: "data-1.twinpinesmall.net:8088", + HTTPAddr: "data-1.twinpinesmall.net:8086", + HTTPScheme: "https", + Status: "joined", + }, + }, + MetaNodes: []Node{ + { + ID: 1, + Addr: "meta-0.twinpinesmall.net:8091", + HTTPScheme: "http", + TCPAddr: "meta-0.twinpinesmall.net:8089", + }, + }, + }, + }, + { + name: "Failed Show Cluster", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusBadGateway, + nil, + nil, + fmt.Errorf("Time circuits on. Flux Capacitor... fluxxing."), + ), + }, + wantErr: true, + }, + { + name: "Bad JSON from Show Cluster", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{data}`), + nil, + nil, + ), + }, + wantErr: true, + }, + } + for _, tt := range tests { + m := &MetaClient{ + URL: tt.fields.URL, + client: tt.fields.client, + } + got, err := m.ShowCluster(context.Background()) + if (err != nil) != tt.wantErr { + t.Errorf("%q. MetaClient.ShowCluster() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. MetaClient.ShowCluster() = %v, want %v", tt.name, got, tt.want) + } + if tt.wantErr { + continue + } + reqs := tt.fields.client.(*MockClient).Requests + if len(reqs) != 1 { + t.Errorf("%q. MetaClient.ShowCluster() expected 1 but got %d", tt.name, len(reqs)) + continue + } + req := reqs[0] + if req.Method != "GET" { + t.Errorf("%q. MetaClient.ShowCluster() expected GET method", tt.name) + } + if req.URL.Path != "/show-cluster" { + t.Errorf("%q. MetaClient.ShowCluster() expected /show-cluster path but got %s", tt.name, req.URL.Path) + } + } +} + +func TestMetaClient_Users(t *testing.T) { + type fields struct { + URL *url.URL + client interface { + Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) + } + } + type args struct { + ctx context.Context + name *string + } + tests := []struct { + name string + fields fields + args args + want *Users + wantErr bool + }{ + { + name: "Successful Show users", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{"users":[{"name":"admin","hash":"1234","permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: nil, + }, + want: &Users{ + Users: []User{ + { + Name: "admin", + Permissions: map[string][]string{ + "": []string{ + "ViewAdmin", "ViewChronograf", + }, + }, + }, + }, + }, + }, + { + name: "Successful Show users single user", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{"users":[{"name":"admin","hash":"1234","permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: &[]string{"admin"}[0], + }, + want: &Users{ + Users: []User{ + { + Name: "admin", + Permissions: map[string][]string{ + "": []string{ + "ViewAdmin", "ViewChronograf", + }, + }, + }, + }, + }, + }, + { + name: "Failure Show users", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{"users":[{"name":"admin","hash":"1234","permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), + nil, + fmt.Errorf("Time circuits on. Flux Capacitor... fluxxing."), + ), + }, + args: args{ + ctx: context.Background(), + name: nil, + }, + wantErr: true, + }, + { + name: "Bad JSON from Show users", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{foo}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: nil, + }, + wantErr: true, + }, + } + for _, tt := range tests { + m := &MetaClient{ + URL: tt.fields.URL, + client: tt.fields.client, + } + got, err := m.Users(tt.args.ctx, tt.args.name) + if (err != nil) != tt.wantErr { + t.Errorf("%q. MetaClient.Users() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. MetaClient.Users() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestMetaClient_User(t *testing.T) { + type fields struct { + URL *url.URL + client interface { + Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) + } + } + type args struct { + ctx context.Context + name string + } + tests := []struct { + name string + fields fields + args args + want *User + wantErr bool + }{ + { + name: "Successful Show users", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{"users":[{"name":"admin","hash":"1234","permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: "admin", + }, + want: &User{ + Name: "admin", + Permissions: map[string][]string{ + "": []string{ + "ViewAdmin", "ViewChronograf", + }, + }, + }, + }, + { + name: "No such user", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusNotFound, + []byte(`{"error":"user not found"}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: "unknown", + }, + wantErr: true, + }, + { + name: "Bad JSON", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusNotFound, + []byte(`{BAD}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + }, + wantErr: true, + }, + } + for _, tt := range tests { + m := &MetaClient{ + URL: tt.fields.URL, + client: tt.fields.client, + } + got, err := m.User(tt.args.ctx, tt.args.name) + if (err != nil) != tt.wantErr { + t.Errorf("%q. MetaClient.User() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. MetaClient.User() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestMetaClient_CreateUser(t *testing.T) { + type fields struct { + URL *url.URL + client interface { + Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) + } + } + type args struct { + ctx context.Context + name string + passwd string + } + tests := []struct { + name string + fields fields + args args + want string + wantErr bool + }{ + { + name: "Successful Create User", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + nil, + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: "admin", + passwd: "hunter2", + }, + want: `{"action":"create","user":{"name":"admin","password":"hunter2"}}`, + }, + } + for _, tt := range tests { + m := &MetaClient{ + URL: tt.fields.URL, + client: tt.fields.client, + } + if err := m.CreateUser(tt.args.ctx, tt.args.name, tt.args.passwd); (err != nil) != tt.wantErr { + t.Errorf("%q. MetaClient.CreateUser() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + if tt.wantErr { + continue + } + reqs := tt.fields.client.(*MockClient).Requests + if len(reqs) != 1 { + t.Errorf("%q. MetaClient.CreateUser() expected 1 but got %d", tt.name, len(reqs)) + continue + } + req := reqs[0] + if req.Method != "POST" { + t.Errorf("%q. MetaClient.CreateUser() expected POST method", tt.name) + } + if req.URL.Path != "/user" { + t.Errorf("%q. MetaClient.CreateUser() expected /user path but got %s", tt.name, req.URL.Path) + } + got, _ := ioutil.ReadAll(req.Body) + if string(got) != tt.want { + t.Errorf("%q. MetaClient.CreateUser() = %v, want %v", tt.name, string(got), tt.want) + } + } +} + +func TestMetaClient_ChangePassword(t *testing.T) { + type fields struct { + URL *url.URL + client interface { + Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) + } + } + type args struct { + ctx context.Context + name string + passwd string + } + tests := []struct { + name string + fields fields + args args + want string + wantErr bool + }{ + { + name: "Successful Change Password", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + nil, + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: "admin", + passwd: "hunter2", + }, + want: `{"action":"change-password","user":{"name":"admin","password":"hunter2"}}`, + }, + } + for _, tt := range tests { + m := &MetaClient{ + URL: tt.fields.URL, + client: tt.fields.client, + } + if err := m.ChangePassword(tt.args.ctx, tt.args.name, tt.args.passwd); (err != nil) != tt.wantErr { + t.Errorf("%q. MetaClient.ChangePassword() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + + if tt.wantErr { + continue + } + reqs := tt.fields.client.(*MockClient).Requests + if len(reqs) != 1 { + t.Errorf("%q. MetaClient.ChangePassword() expected 1 but got %d", tt.name, len(reqs)) + continue + } + req := reqs[0] + if req.Method != "POST" { + t.Errorf("%q. MetaClient.ChangePassword() expected POST method", tt.name) + } + if req.URL.Path != "/user" { + t.Errorf("%q. MetaClient.ChangePassword() expected /user path but got %s", tt.name, req.URL.Path) + } + got, _ := ioutil.ReadAll(req.Body) + if string(got) != tt.want { + t.Errorf("%q. MetaClient.ChangePassword() = %v, want %v", tt.name, string(got), tt.want) + } + } +} + +func TestMetaClient_DeleteUser(t *testing.T) { + type fields struct { + URL *url.URL + client interface { + Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) + } + } + type args struct { + ctx context.Context + name string + } + tests := []struct { + name string + fields fields + args args + want string + wantErr bool + }{ + { + name: "Successful delete User", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + nil, + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: "admin", + }, + want: `{"action":"delete","user":{"name":"admin"}}`, + }, + } + for _, tt := range tests { + m := &MetaClient{ + URL: tt.fields.URL, + client: tt.fields.client, + } + if err := m.DeleteUser(tt.args.ctx, tt.args.name); (err != nil) != tt.wantErr { + t.Errorf("%q. MetaClient.DeleteUser() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + if tt.wantErr { + continue + } + reqs := tt.fields.client.(*MockClient).Requests + if len(reqs) != 1 { + t.Errorf("%q. MetaClient.DeleteUser() expected 1 but got %d", tt.name, len(reqs)) + continue + } + req := reqs[0] + if req.Method != "POST" { + t.Errorf("%q. MetaClient.DeleteUser() expected POST method", tt.name) + } + if req.URL.Path != "/user" { + t.Errorf("%q. MetaClient.DeleteUser() expected /user path but got %s", tt.name, req.URL.Path) + } + got, _ := ioutil.ReadAll(req.Body) + if string(got) != tt.want { + t.Errorf("%q. MetaClient.DeleteUser() = %v, want %v", tt.name, string(got), tt.want) + } + } +} + +func TestMetaClient_SetUserPerms(t *testing.T) { + type fields struct { + URL *url.URL + client interface { + Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) + } + } + type args struct { + ctx context.Context + name string + perms Permissions + } + tests := []struct { + name string + fields fields + args args + wantRm string + wantAdd string + wantErr bool + }{ + { + name: "Successful set permissions User", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{"users":[{"name":"admin","hash":"1234","permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: "admin", + }, + wantRm: `{"action":"remove-permissions","user":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]}}}`, + }, + { + name: "Successful set permissions User", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{"users":[{"name":"admin","hash":"1234","permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: "admin", + perms: Permissions{ + "telegraf": []string{ + "ReadData", + }, + }, + }, + wantRm: `{"action":"remove-permissions","user":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]}}}`, + wantAdd: `{"action":"add-permissions","user":{"name":"admin","permissions":{"telegraf":["ReadData"]}}}`, + }, + } + for _, tt := range tests { + m := &MetaClient{ + URL: tt.fields.URL, + client: tt.fields.client, + } + if err := m.SetUserPerms(tt.args.ctx, tt.args.name, tt.args.perms); (err != nil) != tt.wantErr { + t.Errorf("%q. MetaClient.SetUserPerms() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + if tt.wantErr { + continue + } + reqs := tt.fields.client.(*MockClient).Requests + if len(reqs) < 2 { + t.Errorf("%q. MetaClient.SetUserPerms() expected 2 but got %d", tt.name, len(reqs)) + continue + } + + usr := reqs[0] + if usr.Method != "GET" { + t.Errorf("%q. MetaClient.SetUserPerms() expected GET method", tt.name) + } + if usr.URL.Path != "/user" { + t.Errorf("%q. MetaClient.SetUserPerms() expected /user path but got %s", tt.name, usr.URL.Path) + } + + prm := reqs[1] + if prm.Method != "POST" { + t.Errorf("%q. MetaClient.SetUserPerms() expected GET method", tt.name) + } + if prm.URL.Path != "/user" { + t.Errorf("%q. MetaClient.SetUserPerms() expected /user path but got %s", tt.name, prm.URL.Path) + } + + got, _ := ioutil.ReadAll(prm.Body) + if string(got) != tt.wantRm { + t.Errorf("%q. MetaClient.SetUserPerms() = %v, want %v", tt.name, string(got), tt.wantRm) + } + if tt.wantAdd != "" { + prm := reqs[2] + if prm.Method != "POST" { + t.Errorf("%q. MetaClient.SetUserPerms() expected GET method", tt.name) + } + if prm.URL.Path != "/user" { + t.Errorf("%q. MetaClient.SetUserPerms() expected /user path but got %s", tt.name, prm.URL.Path) + } + + got, _ := ioutil.ReadAll(prm.Body) + if string(got) != tt.wantAdd { + t.Errorf("%q. MetaClient.SetUserPerms() = %v, want %v", tt.name, string(got), tt.wantAdd) + } + } + } +} + +func TestMetaClient_Roles(t *testing.T) { + type fields struct { + URL *url.URL + client interface { + Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) + } + } + type args struct { + ctx context.Context + name *string + } + tests := []struct { + name string + fields fields + args args + want *Roles + wantErr bool + }{ + { + name: "Successful Show role", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{"roles":[{"name":"admin","users":["marty"],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: nil, + }, + want: &Roles{ + Roles: []Role{ + { + Name: "admin", + Permissions: map[string][]string{ + "": []string{ + "ViewAdmin", "ViewChronograf", + }, + }, + Users: []string{"marty"}, + }, + }, + }, + }, + { + name: "Successful Show role single role", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{"roles":[{"name":"admin","users":["marty"],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: &[]string{"admin"}[0], + }, + want: &Roles{ + Roles: []Role{ + { + Name: "admin", + Permissions: map[string][]string{ + "": []string{ + "ViewAdmin", "ViewChronograf", + }, + }, + Users: []string{"marty"}, + }, + }, + }, + }, + } + for _, tt := range tests { + m := &MetaClient{ + URL: tt.fields.URL, + client: tt.fields.client, + } + got, err := m.Roles(tt.args.ctx, tt.args.name) + if (err != nil) != tt.wantErr { + t.Errorf("%q. MetaClient.Roles() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. MetaClient.Roles() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestMetaClient_Role(t *testing.T) { + type fields struct { + URL *url.URL + client interface { + Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) + } + } + type args struct { + ctx context.Context + name string + } + tests := []struct { + name string + fields fields + args args + want *Role + wantErr bool + }{ + { + name: "Successful Show role", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{"roles":[{"name":"admin","users":["marty"],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: "admin", + }, + want: &Role{ + Name: "admin", + Permissions: map[string][]string{ + "": []string{ + "ViewAdmin", "ViewChronograf", + }, + }, + Users: []string{"marty"}, + }, + }, + { + name: "No such role", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusNotFound, + []byte(`{"error":"user not found"}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: "unknown", + }, + wantErr: true, + }, + } + for _, tt := range tests { + m := &MetaClient{ + URL: tt.fields.URL, + client: tt.fields.client, + } + got, err := m.Role(tt.args.ctx, tt.args.name) + if (err != nil) != tt.wantErr { + t.Errorf("%q. MetaClient.Role() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. MetaClient.Role() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestMetaClient_CreateRole(t *testing.T) { + type fields struct { + URL *url.URL + client interface { + Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) + } + } + type args struct { + ctx context.Context + name string + } + tests := []struct { + name string + fields fields + args args + want string + wantErr bool + }{ + { + name: "Successful Create Role", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + nil, + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: "admin", + }, + want: `{"action":"create","role":{"name":"admin"}}`, + }, + } + for _, tt := range tests { + m := &MetaClient{ + URL: tt.fields.URL, + client: tt.fields.client, + } + if err := m.CreateRole(tt.args.ctx, tt.args.name); (err != nil) != tt.wantErr { + t.Errorf("%q. MetaClient.CreateRole() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + reqs := tt.fields.client.(*MockClient).Requests + if len(reqs) != 1 { + t.Errorf("%q. MetaClient.CreateRole() expected 1 but got %d", tt.name, len(reqs)) + continue + } + req := reqs[0] + if req.Method != "POST" { + t.Errorf("%q. MetaClient.CreateRole() expected POST method", tt.name) + } + if req.URL.Path != "/role" { + t.Errorf("%q. MetaClient.CreateRole() expected /role path but got %s", tt.name, req.URL.Path) + } + got, _ := ioutil.ReadAll(req.Body) + if string(got) != tt.want { + t.Errorf("%q. MetaClient.CreateRole() = %v, want %v", tt.name, string(got), tt.want) + } + } +} + +func TestMetaClient_DeleteRole(t *testing.T) { + type fields struct { + URL *url.URL + client interface { + Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) + } + } + type args struct { + ctx context.Context + name string + } + tests := []struct { + name string + fields fields + args args + want string + wantErr bool + }{ + { + name: "Successful delete role", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + nil, + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: "admin", + }, + want: `{"action":"delete","role":{"name":"admin"}}`, + }, + } + for _, tt := range tests { + m := &MetaClient{ + URL: tt.fields.URL, + client: tt.fields.client, + } + if err := m.DeleteRole(tt.args.ctx, tt.args.name); (err != nil) != tt.wantErr { + t.Errorf("%q. MetaClient.DeleteRole() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + if tt.wantErr { + continue + } + reqs := tt.fields.client.(*MockClient).Requests + if len(reqs) != 1 { + t.Errorf("%q. MetaClient.DeleteRole() expected 1 but got %d", tt.name, len(reqs)) + continue + } + req := reqs[0] + if req.Method != "POST" { + t.Errorf("%q. MetaClient.DeleDeleteRoleteUser() expected POST method", tt.name) + } + if req.URL.Path != "/role" { + t.Errorf("%q. MetaClient.DeleteRole() expected /role path but got %s", tt.name, req.URL.Path) + } + got, _ := ioutil.ReadAll(req.Body) + if string(got) != tt.want { + t.Errorf("%q. MetaClient.DeleteRole() = %v, want %v", tt.name, string(got), tt.want) + } + } +} + +func TestMetaClient_SetRolePerms(t *testing.T) { + type fields struct { + URL *url.URL + client interface { + Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) + } + } + type args struct { + ctx context.Context + name string + perms Permissions + } + tests := []struct { + name string + fields fields + args args + wantRm string + wantAdd string + wantErr bool + }{ + { + name: "Successful set permissions role", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{"roles":[{"name":"admin","users":["marty"],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: "admin", + }, + wantRm: `{"action":"remove-permissions","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`, + }, + { + name: "Successful set single permissions role", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{"roles":[{"name":"admin","users":["marty"],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: "admin", + perms: Permissions{ + "telegraf": []string{ + "ReadData", + }, + }, + }, + wantRm: `{"action":"remove-permissions","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`, + wantAdd: `{"action":"add-permissions","role":{"name":"admin","permissions":{"telegraf":["ReadData"]}}}`, + }, + } + for _, tt := range tests { + m := &MetaClient{ + URL: tt.fields.URL, + client: tt.fields.client, + } + if err := m.SetRolePerms(tt.args.ctx, tt.args.name, tt.args.perms); (err != nil) != tt.wantErr { + t.Errorf("%q. MetaClient.SetRolePerms() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + if tt.wantErr { + continue + } + reqs := tt.fields.client.(*MockClient).Requests + if len(reqs) < 2 { + t.Errorf("%q. MetaClient.SetRolePerms() expected 2 but got %d", tt.name, len(reqs)) + continue + } + + usr := reqs[0] + if usr.Method != "GET" { + t.Errorf("%q. MetaClient.SetRolePerms() expected GET method", tt.name) + } + if usr.URL.Path != "/role" { + t.Errorf("%q. MetaClient.SetRolePerms() expected /user path but got %s", tt.name, usr.URL.Path) + } + + prm := reqs[1] + if prm.Method != "POST" { + t.Errorf("%q. MetaClient.SetRolePerms() expected GET method", tt.name) + } + if prm.URL.Path != "/role" { + t.Errorf("%q. MetaClient.SetRolePerms() expected /role path but got %s", tt.name, prm.URL.Path) + } + + got, _ := ioutil.ReadAll(prm.Body) + if string(got) != tt.wantRm { + t.Errorf("%q. MetaClient.SetRolePerms() = %v, want %v", tt.name, string(got), tt.wantRm) + } + if tt.wantAdd != "" { + prm := reqs[2] + if prm.Method != "POST" { + t.Errorf("%q. MetaClient.SetRolePerms() expected GET method", tt.name) + } + if prm.URL.Path != "/role" { + t.Errorf("%q. MetaClient.SetRolePerms() expected /role path but got %s", tt.name, prm.URL.Path) + } + + got, _ := ioutil.ReadAll(prm.Body) + if string(got) != tt.wantAdd { + t.Errorf("%q. MetaClient.SetRolePerms() = %v, want %v", tt.name, string(got), tt.wantAdd) + } + } + } +} + +func TestMetaClient_SetRoleUsers(t *testing.T) { + type fields struct { + URL *url.URL + client interface { + Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) + } + } + type args struct { + ctx context.Context + name string + users []string + } + tests := []struct { + name string + fields fields + args args + wantRm string + wantAdd string + wantErr bool + }{ + { + name: "Successful set users role", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{"roles":[{"name":"admin","users":["marty"],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: "admin", + }, + wantRm: `{"action":"remove-users","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`, + }, + { + name: "Successful set single user role", + fields: fields{ + URL: &url.URL{ + Host: "twinpinesmall.net:8091", + Scheme: "https", + }, + client: NewMockClient( + http.StatusOK, + []byte(`{"roles":[{"name":"admin","users":["marty"],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), + nil, + nil, + ), + }, + args: args{ + ctx: context.Background(), + name: "admin", + users: []string{"marty"}, + }, + wantRm: `{"action":"remove-users","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`, + wantAdd: `{"action":"add-users","role":{"name":"admin","users":["marty"]}}`, + }, + } + for _, tt := range tests { + m := &MetaClient{ + URL: tt.fields.URL, + client: tt.fields.client, + } + if err := m.SetRoleUsers(tt.args.ctx, tt.args.name, tt.args.users); (err != nil) != tt.wantErr { + t.Errorf("%q. MetaClient.SetRoleUsers() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + + if tt.wantErr { + continue + } + reqs := tt.fields.client.(*MockClient).Requests + if len(reqs) < 2 { + t.Errorf("%q. MetaClient.SetRoleUsers() expected 2 but got %d", tt.name, len(reqs)) + continue + } + + usr := reqs[0] + if usr.Method != "GET" { + t.Errorf("%q. MetaClient.SetRoleUsers() expected GET method", tt.name) + } + if usr.URL.Path != "/role" { + t.Errorf("%q. MetaClient.SetRoleUsers() expected /user path but got %s", tt.name, usr.URL.Path) + } + + prm := reqs[1] + if prm.Method != "POST" { + t.Errorf("%q. MetaClient.SetRoleUsers() expected GET method", tt.name) + } + if prm.URL.Path != "/role" { + t.Errorf("%q. MetaClient.SetRoleUsers() expected /role path but got %s", tt.name, prm.URL.Path) + } + + got, _ := ioutil.ReadAll(prm.Body) + if string(got) != tt.wantRm { + t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wantRm) + } + if tt.wantAdd != "" { + prm := reqs[2] + if prm.Method != "POST" { + t.Errorf("%q. MetaClient.SetRoleUsers() expected GET method", tt.name) + } + if prm.URL.Path != "/role" { + t.Errorf("%q. MetaClient.SetRoleUsers() expected /role path but got %s", tt.name, prm.URL.Path) + } + + got, _ := ioutil.ReadAll(prm.Body) + if string(got) != tt.wantAdd { + t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wantAdd) + } + } + } +} + +type MockClient struct { + Code int // HTTP Status code + Body []byte + HeaderMap http.Header + Err error + + Requests []*http.Request +} + +func NewMockClient(code int, body []byte, headers http.Header, err error) *MockClient { + return &MockClient{ + Code: code, + Body: body, + HeaderMap: headers, + Err: err, + Requests: make([]*http.Request, 0), + } +} + +func (c *MockClient) Do(URL *url.URL, path, method string, params map[string]string, body io.Reader) (*http.Response, error) { + if c == nil { + return nil, fmt.Errorf("NIL MockClient") + } + if URL == nil { + return nil, fmt.Errorf("NIL url") + } + if c.Err != nil { + return nil, c.Err + } + + // Record the request in the mock client + p := url.Values{} + for k, v := range params { + p.Add(k, v) + } + + URL.Path = path + URL.RawQuery = p.Encode() + + req, err := http.NewRequest(method, URL.String(), body) + if err != nil { + return nil, err + } + c.Requests = append(c.Requests, req) + + return &http.Response{ + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + StatusCode: c.Code, + Status: http.StatusText(c.Code), + Header: c.HeaderMap, + Body: ioutil.NopCloser(bytes.NewReader(c.Body)), + }, nil +} diff --git a/enterprise/mocks_test.go b/enterprise/mocks_test.go new file mode 100644 index 000000000..6a88d5d0d --- /dev/null +++ b/enterprise/mocks_test.go @@ -0,0 +1,126 @@ +package enterprise_test + +import ( + "context" + "encoding/json" + "net/url" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/enterprise" +) + +type ControlClient struct { + Cluster *enterprise.Cluster + ShowClustersCalled bool +} + +func NewMockControlClient(addr string) *ControlClient { + _, err := url.Parse(addr) + if err != nil { + panic(err) + } + + return &ControlClient{ + Cluster: &enterprise.Cluster{ + DataNodes: []enterprise.DataNode{ + enterprise.DataNode{ + HTTPAddr: addr, + }, + }, + }, + } +} + +func (cc *ControlClient) ShowCluster(context.Context) (*enterprise.Cluster, error) { + cc.ShowClustersCalled = true + return cc.Cluster, nil +} + +func (cc *ControlClient) User(ctx context.Context, name string) (*enterprise.User, error) { + return nil, nil +} + +func (cc *ControlClient) CreateUser(ctx context.Context, name, passwd string) error { + return nil +} + +func (cc *ControlClient) DeleteUser(ctx context.Context, name string) error { + return nil +} + +func (cc *ControlClient) ChangePassword(ctx context.Context, name, passwd string) error { + return nil +} + +func (cc *ControlClient) Users(ctx context.Context, name *string) (*enterprise.Users, error) { + return nil, nil +} + +func (cc *ControlClient) SetUserPerms(ctx context.Context, name string, perms enterprise.Permissions) error { + return nil +} + +func (cc *ControlClient) CreateRole(ctx context.Context, name string) error { + return nil +} + +func (cc *ControlClient) Role(ctx context.Context, name string) (*enterprise.Role, error) { + return nil, nil +} + +func (ccm *ControlClient) Roles(ctx context.Context, name *string) (*enterprise.Roles, error) { + return nil, nil +} + +func (cc *ControlClient) DeleteRole(ctx context.Context, name string) error { + return nil +} + +func (cc *ControlClient) SetRolePerms(ctx context.Context, name string, perms enterprise.Permissions) error { + return nil +} + +func (cc *ControlClient) SetRoleUsers(ctx context.Context, name string, users []string) error { + return nil +} + +type TimeSeries struct { + URLs []string + Response Response + + QueryCtr int +} + +type Response struct{} + +func (r *Response) MarshalJSON() ([]byte, error) { + return json.Marshal(r) +} + +func (ts *TimeSeries) Query(ctx context.Context, q chronograf.Query) (chronograf.Response, error) { + ts.QueryCtr++ + return &Response{}, nil +} + +func (ts *TimeSeries) Connect(ctx context.Context, src *chronograf.Source) error { + return nil +} + +func (ts *TimeSeries) Users(ctx context.Context) chronograf.UsersStore { + return nil +} + +func (ts *TimeSeries) Roles(ctx context.Context) (chronograf.RolesStore, error) { + return nil, nil +} + +func (ts *TimeSeries) Allowances(ctx context.Context) chronograf.Allowances { + return chronograf.Allowances{} +} + +func NewMockTimeSeries(urls ...string) *TimeSeries { + return &TimeSeries{ + URLs: urls, + Response: Response{}, + } +} diff --git a/enterprise/roles.go b/enterprise/roles.go new file mode 100644 index 000000000..e95d34e8c --- /dev/null +++ b/enterprise/roles.go @@ -0,0 +1,105 @@ +package enterprise + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +// RolesStore uses a control client operate on Influx Enterprise roles. Roles are +// groups of permissions applied to groups of users +type RolesStore struct { + Ctrl + Logger chronograf.Logger +} + +// Add creates a new Role in Influx Enterprise +// This must be done in three smaller steps: creating, setting permissions, setting users. +func (c *RolesStore) Add(ctx context.Context, u *chronograf.Role) (*chronograf.Role, error) { + if err := c.Ctrl.CreateRole(ctx, u.Name); err != nil { + return nil, err + } + if err := c.Ctrl.SetRolePerms(ctx, u.Name, ToEnterprise(u.Permissions)); err != nil { + return nil, err + } + + users := make([]string, len(u.Users)) + for i, u := range u.Users { + users[i] = u.Name + } + if err := c.Ctrl.SetRoleUsers(ctx, u.Name, users); err != nil { + return nil, err + } + return u, nil +} + +// Delete the Role from Influx Enterprise +func (c *RolesStore) Delete(ctx context.Context, u *chronograf.Role) error { + return c.Ctrl.DeleteRole(ctx, u.Name) +} + +// Get retrieves a Role if name exists. +func (c *RolesStore) Get(ctx context.Context, name string) (*chronograf.Role, error) { + role, err := c.Ctrl.Role(ctx, name) + if err != nil { + return nil, err + } + + // Hydrate all the users to gather their permissions and their roles. + users := make([]chronograf.User, len(role.Users)) + for i, u := range role.Users { + user, err := c.Ctrl.User(ctx, u) + if err != nil { + return nil, err + } + users[i] = chronograf.User{ + Name: user.Name, + Permissions: ToChronograf(user.Permissions), + } + } + return &chronograf.Role{ + Name: role.Name, + Permissions: ToChronograf(role.Permissions), + Users: users, + }, nil +} + +// Update the Role's permissions and roles +func (c *RolesStore) Update(ctx context.Context, u *chronograf.Role) error { + perms := ToEnterprise(u.Permissions) + if err := c.Ctrl.SetRolePerms(ctx, u.Name, perms); err != nil { + return err + } + + users := make([]string, len(u.Users)) + for i, u := range u.Users { + users[i] = u.Name + } + return c.Ctrl.SetRoleUsers(ctx, u.Name, users) +} + +// All is all Roles in influx +func (c *RolesStore) All(ctx context.Context) ([]chronograf.Role, error) { + all, err := c.Ctrl.Roles(ctx, nil) + if err != nil { + return nil, err + } + + res := make([]chronograf.Role, len(all.Roles)) + for i, role := range all.Roles { + + users := make([]chronograf.User, len(role.Users)) + for i, user := range role.Users { + users[i] = chronograf.User{ + Name: user, + } + } + + res[i] = chronograf.Role{ + Name: role.Name, + Permissions: ToChronograf(role.Permissions), + Users: users, + } + } + return res, nil +} diff --git a/enterprise/types.go b/enterprise/types.go new file mode 100644 index 000000000..d3c241ca2 --- /dev/null +++ b/enterprise/types.go @@ -0,0 +1,71 @@ +package enterprise + +// Cluster is a collection of data nodes and non-data nodes within a +// Plutonium cluster. +type Cluster struct { + DataNodes []DataNode `json:"data"` + MetaNodes []Node `json:"meta"` +} + +// DataNode represents a data node in an Influx Enterprise Cluster +type DataNode struct { + ID uint64 `json:"id"` // Meta store ID. + TCPAddr string `json:"tcpAddr"` // RPC addr, e.g., host:8088. + HTTPAddr string `json:"httpAddr"` // Client addr, e.g., host:8086. + HTTPScheme string `json:"httpScheme"` // "http" or "https" for HTTP addr. + Status string `json:"status,omitempty"` // The cluster status of the node. +} + +// Node represent any meta or data node in an Influx Enterprise cluster +type Node struct { + ID uint64 `json:"id"` + Addr string `json:"addr"` + HTTPScheme string `json:"httpScheme"` + TCPAddr string `json:"tcpAddr"` +} + +// Permissions maps resources to a set of permissions. +// Specifically, it maps a database to a set of permissions +type Permissions map[string][]string + +// User represents an enterprise user. +type User struct { + Name string `json:"name"` + Password string `json:"password,omitempty"` + Permissions Permissions `json:"permissions,omitempty"` +} + +// Users represents a set of enterprise users. +type Users struct { + Users []User `json:"users,omitempty"` +} + +// UserAction represents and action to be taken with a user. +type UserAction struct { + Action string `json:"action"` + User *User `json:"user"` +} + +// Role is a restricted set of permissions assigned to a set of users. +type Role struct { + Name string `json:"name"` + NewName string `json:"newName,omitempty"` + Permissions Permissions `json:"permissions,omitempty"` + Users []string `json:"users,omitempty"` +} + +// Roles is a set of roles +type Roles struct { + Roles []Role `json:"roles,omitempty"` +} + +// RoleAction represents an action to be taken with a role. +type RoleAction struct { + Action string `json:"action"` + Role *Role `json:"role"` +} + +// Error is JSON error message return by Influx Enterprise's meta API. +type Error struct { + Error string `json:"error"` +} diff --git a/enterprise/users.go b/enterprise/users.go new file mode 100644 index 000000000..c1e940567 --- /dev/null +++ b/enterprise/users.go @@ -0,0 +1,106 @@ +package enterprise + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +// UserStore uses a control client operate on Influx Enterprise users +type UserStore struct { + Ctrl + Logger chronograf.Logger +} + +// Add creates a new User in Influx Enterprise +func (c *UserStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + if err := c.Ctrl.CreateUser(ctx, u.Name, u.Passwd); err != nil { + return nil, err + } + perms := ToEnterprise(u.Permissions) + if err := c.Ctrl.SetUserPerms(ctx, u.Name, perms); err != nil { + return nil, err + } + return u, nil +} + +// Delete the User from Influx Enterprise +func (c *UserStore) Delete(ctx context.Context, u *chronograf.User) error { + return c.Ctrl.DeleteUser(ctx, u.Name) +} + +// Get retrieves a user if name exists. +func (c *UserStore) Get(ctx context.Context, name string) (*chronograf.User, error) { + u, err := c.Ctrl.User(ctx, name) + if err != nil { + return nil, err + } + return &chronograf.User{ + Name: u.Name, + Permissions: ToChronograf(u.Permissions), + }, nil +} + +// Update the user's permissions or roles +func (c *UserStore) Update(ctx context.Context, u *chronograf.User) error { + // Only allow one type of change at a time. If it is a password + // change then do it and return without any changes to permissions + if u.Passwd != "" { + return c.Ctrl.ChangePassword(ctx, u.Name, u.Passwd) + } + perms := ToEnterprise(u.Permissions) + return c.Ctrl.SetUserPerms(ctx, u.Name, perms) +} + +// All is all users in influx +func (c *UserStore) All(ctx context.Context) ([]chronograf.User, error) { + all, err := c.Ctrl.Users(ctx, nil) + if err != nil { + return nil, err + } + + res := make([]chronograf.User, len(all.Users)) + for i, user := range all.Users { + res[i] = chronograf.User{ + Name: user.Name, + Permissions: ToChronograf(user.Permissions), + } + } + return res, nil +} + +// ToEnterprise converts chronograf permission shape to enterprise +func ToEnterprise(perms chronograf.Permissions) Permissions { + res := Permissions{} + for _, perm := range perms { + if perm.Scope == chronograf.AllScope { + // Enterprise uses empty string as the key for all databases + res[""] = perm.Allowed + } else { + res[perm.Name] = perm.Allowed + } + } + return res +} + +// ToChronograf converts enterprise permissions shape to chronograf shape +func ToChronograf(perms Permissions) chronograf.Permissions { + res := chronograf.Permissions{} + for db, perm := range perms { + // Enterprise uses empty string as the key for all databases + if db == "" { + res = append(res, chronograf.Permission{ + Scope: chronograf.AllScope, + Allowed: perm, + }) + } else { + res = append(res, chronograf.Permission{ + Scope: chronograf.DBScope, + Name: db, + Allowed: perm, + }) + + } + } + return res +} diff --git a/enterprise/users_test.go b/enterprise/users_test.go new file mode 100644 index 000000000..2a64eb55a --- /dev/null +++ b/enterprise/users_test.go @@ -0,0 +1,554 @@ +package enterprise_test + +import ( + "context" + "fmt" + "reflect" + "testing" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/enterprise" +) + +func TestClient_Add(t *testing.T) { + type fields struct { + Ctrl *mockCtrl + Logger chronograf.Logger + } + type args struct { + ctx context.Context + u *chronograf.User + } + tests := []struct { + name string + fields fields + args args + want *chronograf.User + wantErr bool + }{ + { + name: "Successful Create User", + fields: fields{ + Ctrl: &mockCtrl{ + createUser: func(ctx context.Context, name, passwd string) error { + return nil + }, + setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error { + return nil + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "marty", + Passwd: "johnny be good", + }, + }, + want: &chronograf.User{ + Name: "marty", + Passwd: "johnny be good", + }, + }, + { + name: "Failure to Create User", + fields: fields{ + Ctrl: &mockCtrl{ + createUser: func(ctx context.Context, name, passwd string) error { + return fmt.Errorf("1.21 Gigawatts! Tom, how could I have been so careless?") + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "marty", + Passwd: "johnny be good", + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + c := &enterprise.UserStore{ + Ctrl: tt.fields.Ctrl, + Logger: tt.fields.Logger, + } + got, err := c.Add(tt.args.ctx, tt.args.u) + if (err != nil) != tt.wantErr { + t.Errorf("%q. Client.Add() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. Client.Add() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestClient_Delete(t *testing.T) { + type fields struct { + Ctrl *mockCtrl + Logger chronograf.Logger + } + type args struct { + ctx context.Context + u *chronograf.User + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + name: "Successful Delete User", + fields: fields{ + Ctrl: &mockCtrl{ + deleteUser: func(ctx context.Context, name string) error { + return nil + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "marty", + Passwd: "johnny be good", + }, + }, + }, + { + name: "Failure to Delete User", + fields: fields{ + Ctrl: &mockCtrl{ + deleteUser: func(ctx context.Context, name string) error { + return fmt.Errorf("1.21 Gigawatts! Tom, how could I have been so careless?") + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "marty", + Passwd: "johnny be good", + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + c := &enterprise.UserStore{ + Ctrl: tt.fields.Ctrl, + Logger: tt.fields.Logger, + } + if err := c.Delete(tt.args.ctx, tt.args.u); (err != nil) != tt.wantErr { + t.Errorf("%q. Client.Delete() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + } +} + +func TestClient_Get(t *testing.T) { + type fields struct { + Ctrl *mockCtrl + Logger chronograf.Logger + } + type args struct { + ctx context.Context + name string + } + tests := []struct { + name string + fields fields + args args + want *chronograf.User + wantErr bool + }{ + { + name: "Successful Get User", + fields: fields{ + Ctrl: &mockCtrl{ + user: func(ctx context.Context, name string) (*enterprise.User, error) { + return &enterprise.User{ + Name: "marty", + Password: "johnny be good", + Permissions: map[string][]string{ + "": { + "ViewChronograf", + "ReadData", + "WriteData", + }, + }, + }, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + name: "marty", + }, + want: &chronograf.User{ + Name: "marty", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"ViewChronograf", "ReadData", "WriteData"}, + }, + }, + }, + }, + { + name: "Failure to get User", + fields: fields{ + Ctrl: &mockCtrl{ + user: func(ctx context.Context, name string) (*enterprise.User, error) { + return nil, fmt.Errorf("1.21 Gigawatts! Tom, how could I have been so careless?") + }, + }, + }, + args: args{ + ctx: context.Background(), + name: "marty", + }, + wantErr: true, + }, + } + for _, tt := range tests { + c := &enterprise.UserStore{ + Ctrl: tt.fields.Ctrl, + Logger: tt.fields.Logger, + } + got, err := c.Get(tt.args.ctx, tt.args.name) + if (err != nil) != tt.wantErr { + t.Errorf("%q. Client.Get() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. Client.Get() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestClient_Update(t *testing.T) { + type fields struct { + Ctrl *mockCtrl + Logger chronograf.Logger + } + type args struct { + ctx context.Context + u *chronograf.User + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + name: "Successful Change Password", + fields: fields{ + Ctrl: &mockCtrl{ + changePassword: func(ctx context.Context, name, passwd string) error { + return nil + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "marty", + Passwd: "johnny be good", + }, + }, + }, + { + name: "Failure to Change Password", + fields: fields{ + Ctrl: &mockCtrl{ + changePassword: func(ctx context.Context, name, passwd string) error { + return fmt.Errorf("Ronald Reagan, the actor?! Ha Then who’s Vice President Jerry Lewis? I suppose Jane Wyman is First Lady") + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "marty", + Passwd: "johnny be good", + }, + }, + wantErr: true, + }, + { + name: "Success setting permissions User", + fields: fields{ + Ctrl: &mockCtrl{ + setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error { + return nil + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "marty", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"ViewChronograf", "KapacitorAPI"}, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "Failure setting permissions User", + fields: fields{ + Ctrl: &mockCtrl{ + setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error { + return fmt.Errorf("They found me, I don't know how, but they found me.") + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "marty", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"ViewChronograf", "KapacitorAPI"}, + }, + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + c := &enterprise.UserStore{ + Ctrl: tt.fields.Ctrl, + Logger: tt.fields.Logger, + } + if err := c.Update(tt.args.ctx, tt.args.u); (err != nil) != tt.wantErr { + t.Errorf("%q. Client.Update() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + } +} + +func TestClient_All(t *testing.T) { + type fields struct { + Ctrl *mockCtrl + Logger chronograf.Logger + } + type args struct { + ctx context.Context + } + tests := []struct { + name string + fields fields + args args + want []chronograf.User + wantErr bool + }{ + { + name: "Successful Get User", + fields: fields{ + Ctrl: &mockCtrl{ + users: func(ctx context.Context, name *string) (*enterprise.Users, error) { + return &enterprise.Users{ + Users: []enterprise.User{ + { + Name: "marty", + Password: "johnny be good", + Permissions: map[string][]string{ + "": { + "ViewChronograf", + "ReadData", + "WriteData", + }, + }, + }, + }, + }, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + }, + want: []chronograf.User{ + { + Name: "marty", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"ViewChronograf", "ReadData", "WriteData"}, + }, + }, + }, + }, + }, + { + name: "Failure to get User", + fields: fields{ + Ctrl: &mockCtrl{ + users: func(ctx context.Context, name *string) (*enterprise.Users, error) { + return nil, fmt.Errorf("1.21 Gigawatts! Tom, how could I have been so careless?") + }, + }, + }, + args: args{ + ctx: context.Background(), + }, + wantErr: true, + }, + } + for _, tt := range tests { + c := &enterprise.UserStore{ + Ctrl: tt.fields.Ctrl, + Logger: tt.fields.Logger, + } + got, err := c.All(tt.args.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("%q. Client.All() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. Client.All() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func Test_ToEnterprise(t *testing.T) { + tests := []struct { + name string + perms chronograf.Permissions + want enterprise.Permissions + }{ + { + name: "All Scopes", + want: enterprise.Permissions{"": []string{"ViewChronograf", "KapacitorAPI"}}, + perms: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"ViewChronograf", "KapacitorAPI"}, + }, + }, + }, + { + name: "DB Scope", + want: enterprise.Permissions{"telegraf": []string{"ReadData", "WriteData"}}, + perms: chronograf.Permissions{ + { + Scope: chronograf.DBScope, + Name: "telegraf", + Allowed: chronograf.Allowances{"ReadData", "WriteData"}, + }, + }, + }, + } + for _, tt := range tests { + if got := enterprise.ToEnterprise(tt.perms); !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. ToEnterprise() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func Test_ToChronograf(t *testing.T) { + tests := []struct { + name string + perms enterprise.Permissions + want chronograf.Permissions + }{ + { + name: "All Scopes", + perms: enterprise.Permissions{"": []string{"ViewChronograf", "KapacitorAPI"}}, + want: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"ViewChronograf", "KapacitorAPI"}, + }, + }, + }, + { + name: "DB Scope", + perms: enterprise.Permissions{"telegraf": []string{"ReadData", "WriteData"}}, + want: chronograf.Permissions{ + { + Scope: chronograf.DBScope, + Name: "telegraf", + Allowed: chronograf.Allowances{"ReadData", "WriteData"}, + }, + }, + }, + } + for _, tt := range tests { + if got := enterprise.ToChronograf(tt.perms); !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. toChronograf() = %v, want %v", tt.name, got, tt.want) + } + } +} + +type mockCtrl struct { + showCluster func(ctx context.Context) (*enterprise.Cluster, error) + user func(ctx context.Context, name string) (*enterprise.User, error) + createUser func(ctx context.Context, name, passwd string) error + deleteUser func(ctx context.Context, name string) error + changePassword func(ctx context.Context, name, passwd string) error + users func(ctx context.Context, name *string) (*enterprise.Users, error) + setUserPerms func(ctx context.Context, name string, perms enterprise.Permissions) error + + roles func(ctx context.Context, name *string) (*enterprise.Roles, error) + role func(ctx context.Context, name string) (*enterprise.Role, error) + createRole func(ctx context.Context, name string) error + deleteRole func(ctx context.Context, name string) error + setRolePerms func(ctx context.Context, name string, perms enterprise.Permissions) error + setRoleUsers func(ctx context.Context, name string, users []string) error +} + +func (m *mockCtrl) ShowCluster(ctx context.Context) (*enterprise.Cluster, error) { + return m.showCluster(ctx) +} +func (m *mockCtrl) User(ctx context.Context, name string) (*enterprise.User, error) { + return m.user(ctx, name) +} +func (m *mockCtrl) CreateUser(ctx context.Context, name, passwd string) error { + return m.createUser(ctx, name, passwd) +} +func (m *mockCtrl) DeleteUser(ctx context.Context, name string) error { + return m.deleteUser(ctx, name) +} +func (m *mockCtrl) ChangePassword(ctx context.Context, name, passwd string) error { + return m.changePassword(ctx, name, passwd) +} +func (m *mockCtrl) Users(ctx context.Context, name *string) (*enterprise.Users, error) { + return m.users(ctx, name) +} +func (m *mockCtrl) SetUserPerms(ctx context.Context, name string, perms enterprise.Permissions) error { + return m.setUserPerms(ctx, name, perms) +} + +func (m *mockCtrl) Roles(ctx context.Context, name *string) (*enterprise.Roles, error) { + return m.roles(ctx, name) +} + +func (m *mockCtrl) Role(ctx context.Context, name string) (*enterprise.Role, error) { + return m.role(ctx, name) +} + +func (m *mockCtrl) CreateRole(ctx context.Context, name string) error { + return m.createRole(ctx, name) +} + +func (m *mockCtrl) DeleteRole(ctx context.Context, name string) error { + return m.deleteRole(ctx, name) +} + +func (m *mockCtrl) SetRolePerms(ctx context.Context, name string, perms enterprise.Permissions) error { + return m.setRolePerms(ctx, name, perms) +} + +func (m *mockCtrl) SetRoleUsers(ctx context.Context, name string, users []string) error { + return m.setRoleUsers(ctx, name, users) +} diff --git a/influx/influx.go b/influx/influx.go index bd19e3632..60ef9f786 100644 --- a/influx/influx.go +++ b/influx/influx.go @@ -11,6 +11,8 @@ import ( "github.com/influxdata/chronograf" ) +var _ chronograf.TimeSeries = &Client{} + // Client is a device for retrieving time series data from an InfluxDB instance type Client struct { URL *url.URL @@ -35,11 +37,14 @@ func NewClient(host string, lg chronograf.Logger) (*Client, error) { }, nil } +// Response is a partial JSON decoded InfluxQL response used +// to check for some errors type Response struct { Results json.RawMessage Err string `json:"error,omitempty"` } +// MarshalJSON returns the raw results bytes from the response func (r Response) MarshalJSON() ([]byte, error) { return r.Results, nil } @@ -148,6 +153,7 @@ func (c *Client) Query(ctx context.Context, q chronograf.Query) (chronograf.Resp } } +// Connect caches the URL for the data source func (c *Client) Connect(ctx context.Context, src *chronograf.Source) error { u, err := url.Parse(src.URL) if err != nil { @@ -161,3 +167,13 @@ func (c *Client) Connect(ctx context.Context, src *chronograf.Source) error { c.URL = u return nil } + +// Users transforms InfluxDB into a user store +func (c *Client) Users(ctx context.Context) chronograf.UsersStore { + return c +} + +// Roles aren't support in OSS +func (c *Client) Roles(ctx context.Context) (chronograf.RolesStore, error) { + return nil, fmt.Errorf("Roles not support in open-source InfluxDB. Roles are support in Influx Enterprise") +} diff --git a/influx/influx_test.go b/influx/influx_test.go index 8fabd3b79..6fa4a859f 100644 --- a/influx/influx_test.go +++ b/influx/influx_test.go @@ -1,6 +1,7 @@ package influx_test import ( + "context" "net/http" "net/http/httptest" "testing" @@ -9,7 +10,6 @@ import ( "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/influx" "github.com/influxdata/chronograf/log" - "golang.org/x/net/context" ) func Test_Influx_MakesRequestsToQueryEndpoint(t *testing.T) { @@ -204,3 +204,11 @@ func Test_Influx_ReportsInfluxErrs(t *testing.T) { t.Fatal("Expected an error but received none") } } + +func TestClient_Roles(t *testing.T) { + c := &influx.Client{} + _, err := c.Roles(context.Background()) + if err == nil { + t.Errorf("Client.Roles() want error") + } +} diff --git a/influx/permissions.go b/influx/permissions.go new file mode 100644 index 000000000..1a8570280 --- /dev/null +++ b/influx/permissions.go @@ -0,0 +1,200 @@ +package influx + +import ( + "context" + "fmt" + + "github.com/influxdata/chronograf" +) + +var ( + // AllowAll means a user gets both read and write permissions + AllowAll = chronograf.Allowances{"WRITE", "READ"} + // AllowRead means a user is only able to read the database. + AllowRead = chronograf.Allowances{"READ"} + // AllowWrite means a user is able to only write to the database + AllowWrite = chronograf.Allowances{"WRITE"} + // NoPrivileges occasionally shows up as a response for a users grants. + NoPrivileges = "NO PRIVILEGES" + // AllPrivileges means that a user has both read and write perms + AllPrivileges = "ALL PRIVILEGES" + // All means a user has both read and write perms. Alternative to AllPrivileges + All = "ALL" + // Read means a user can read a database + Read = "READ" + // Write means a user can write to a database + Write = "WRITE" +) + +// Allowances return just READ and WRITE for OSS Influx +func (c *Client) Allowances(context.Context) chronograf.Allowances { + return chronograf.Allowances{"READ", "WRITE"} +} + +// showResults is used to deserialize InfluxQL SHOW commands +type showResults []struct { + Series []struct { + Values [][]interface{} `json:"values"` + } `json:"series"` +} + +// Users converts SHOW USERS to chronograf Users +func (r *showResults) Users() []chronograf.User { + res := []chronograf.User{} + for _, u := range *r { + for _, s := range u.Series { + for _, v := range s.Values { + if name, ok := v[0].(string); !ok { + continue + } else if admin, ok := v[1].(bool); !ok { + continue + } else { + c := chronograf.User{ + Name: name, + Permissions: chronograf.Permissions{}, + } + if admin { + c.Permissions = adminPerms() + } + res = append(res, c) + } + } + } + } + return res +} + +// Permissions converts SHOW GRANTS to chronograf.Permissions +func (r *showResults) Permissions() chronograf.Permissions { + res := []chronograf.Permission{} + for _, u := range *r { + for _, s := range u.Series { + for _, v := range s.Values { + if db, ok := v[0].(string); !ok { + continue + } else if priv, ok := v[1].(string); !ok { + continue + } else { + c := chronograf.Permission{ + Name: db, + Scope: chronograf.DBScope, + } + switch priv { + case AllPrivileges, All: + c.Allowed = AllowAll + case Read: + c.Allowed = AllowRead + case Write: + c.Allowed = AllowWrite + default: + // sometimes influx reports back NO PRIVILEGES + continue + } + res = append(res, c) + } + } + } + } + return res +} + +func adminPerms() chronograf.Permissions { + return []chronograf.Permission{ + { + Scope: chronograf.AllScope, + Allowed: AllowAll, + }, + } +} + +// ToInfluxQL converts the permission into InfluxQL +func ToInfluxQL(action, preposition, username string, perm chronograf.Permission) string { + if perm.Scope == chronograf.AllScope { + return fmt.Sprintf(`%s ALL PRIVILEGES %s "%s"`, action, preposition, username) + } else if len(perm.Allowed) == 0 { + // All privileges are to be removed for this user on this database + return fmt.Sprintf(`%s ALL PRIVILEGES ON "%s" %s "%s"`, action, perm.Name, preposition, username) + } + priv := ToPriv(perm.Allowed) + if priv == NoPrivileges { + return "" + } + return fmt.Sprintf(`%s %s ON "%s" %s "%s"`, action, priv, perm.Name, preposition, username) +} + +// ToRevoke converts the permission into InfluxQL revokes +func ToRevoke(username string, perm chronograf.Permission) string { + return ToInfluxQL("REVOKE", "FROM", username, perm) +} + +// ToGrant converts the permission into InfluxQL grants +func ToGrant(username string, perm chronograf.Permission) string { + if len(perm.Allowed) == 0 { + return "" + } + return ToInfluxQL("GRANT", "TO", username, perm) +} + +// ToPriv converts chronograf allowances to InfluxQL +func ToPriv(a chronograf.Allowances) string { + if len(a) == 0 { + return NoPrivileges + } + hasWrite := false + hasRead := false + for _, aa := range a { + if aa == Read { + hasRead = true + } else if aa == Write { + hasWrite = true + } else if aa == All { + hasRead, hasWrite = true, true + } + } + + if hasWrite && hasRead { + return All + } else if hasWrite { + return Write + } else if hasRead { + return Read + } + return NoPrivileges +} + +// Difference compares two permission sets and returns a set to be revoked and a set to be added +func Difference(wants chronograf.Permissions, haves chronograf.Permissions) (revoke chronograf.Permissions, add chronograf.Permissions) { + for _, want := range wants { + found := false + for _, got := range haves { + if want.Scope != got.Scope || want.Name != got.Name { + continue + } + found = true + if len(want.Allowed) == 0 { + revoke = append(revoke, want) + } else { + add = append(add, want) + } + break + } + if !found { + add = append(add, want) + } + } + + for _, got := range haves { + found := false + for _, want := range wants { + if want.Scope != got.Scope || want.Name != got.Name { + continue + } + found = true + break + } + if !found { + revoke = append(revoke, got) + } + } + return +} diff --git a/influx/permissions_test.go b/influx/permissions_test.go new file mode 100644 index 000000000..956e706a8 --- /dev/null +++ b/influx/permissions_test.go @@ -0,0 +1,422 @@ +package influx + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/influxdata/chronograf" +) + +func TestDifference(t *testing.T) { + t.Parallel() + type args struct { + wants chronograf.Permissions + haves chronograf.Permissions + } + tests := []struct { + name string + args args + wantRevoke chronograf.Permissions + wantAdd chronograf.Permissions + }{ + { + name: "add write to permissions", + args: args{ + wants: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "tensorflowdb", + Allowed: []string{"READ", "WRITE"}, + }, + }, + haves: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "tensorflowdb", + Allowed: []string{"READ"}, + }, + }, + }, + wantRevoke: nil, + wantAdd: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "tensorflowdb", + Allowed: []string{"READ", "WRITE"}, + }, + }, + }, + { + name: "revoke write to permissions", + args: args{ + wants: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "tensorflowdb", + Allowed: []string{"READ"}, + }, + }, + haves: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "tensorflowdb", + Allowed: []string{"READ", "WRITE"}, + }, + }, + }, + wantRevoke: nil, + wantAdd: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "tensorflowdb", + Allowed: []string{"READ"}, + }, + }, + }, + { + name: "revoke all permissions", + args: args{ + wants: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "tensorflowdb", + Allowed: []string{}, + }, + }, + haves: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "tensorflowdb", + Allowed: []string{"READ", "WRITE"}, + }, + }, + }, + wantRevoke: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "tensorflowdb", + Allowed: []string{}, + }, + }, + wantAdd: nil, + }, + { + name: "add permissions different db", + args: args{ + wants: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "new", + Allowed: []string{"READ"}, + }, + }, + haves: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "old", + Allowed: []string{"READ", "WRITE"}, + }, + }, + }, + wantRevoke: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "old", + Allowed: []string{"READ", "WRITE"}, + }, + }, + wantAdd: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "new", + Allowed: []string{"READ"}, + }, + }, + }, + } + for _, tt := range tests { + gotRevoke, gotAdd := Difference(tt.args.wants, tt.args.haves) + if !reflect.DeepEqual(gotRevoke, tt.wantRevoke) { + t.Errorf("%q. Difference() gotRevoke = %v, want %v", tt.name, gotRevoke, tt.wantRevoke) + } + if !reflect.DeepEqual(gotAdd, tt.wantAdd) { + t.Errorf("%q. Difference() gotAdd = %v, want %v", tt.name, gotAdd, tt.wantAdd) + } + } +} + +func TestToPriv(t *testing.T) { + t.Parallel() + type args struct { + a chronograf.Allowances + } + tests := []struct { + name string + args args + want string + }{ + { + name: "no privs", + args: args{ + a: chronograf.Allowances{}, + }, + want: NoPrivileges, + }, + { + name: "read and write privs", + args: args{ + a: chronograf.Allowances{"READ", "WRITE"}, + }, + want: All, + }, + { + name: "write privs", + args: args{ + a: chronograf.Allowances{"WRITE"}, + }, + want: Write, + }, + { + name: "read privs", + args: args{ + a: chronograf.Allowances{"READ"}, + }, + want: Read, + }, + { + name: "all privs", + args: args{ + a: chronograf.Allowances{"ALL"}, + }, + want: All, + }, + { + name: "bad privs", + args: args{ + a: chronograf.Allowances{"BAD"}, + }, + want: NoPrivileges, + }, + } + for _, tt := range tests { + if got := ToPriv(tt.args.a); got != tt.want { + t.Errorf("%q. ToPriv() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestToGrant(t *testing.T) { + t.Parallel() + type args struct { + username string + perm chronograf.Permission + } + tests := []struct { + name string + args args + want string + }{ + { + name: "grant all for all dbs", + args: args{ + username: "biff", + perm: chronograf.Permission{ + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"ALL"}, + }, + }, + want: `GRANT ALL PRIVILEGES TO "biff"`, + }, + { + name: "grant all for one db", + args: args{ + username: "biff", + perm: chronograf.Permission{ + Scope: chronograf.DBScope, + Name: "gray_sports_almanac", + Allowed: chronograf.Allowances{"ALL"}, + }, + }, + want: `GRANT ALL ON "gray_sports_almanac" TO "biff"`, + }, + { + name: "bad allowance", + args: args{ + username: "biff", + perm: chronograf.Permission{ + Scope: chronograf.DBScope, + Name: "gray_sports_almanac", + Allowed: chronograf.Allowances{"bad"}, + }, + }, + want: "", + }, + } + for _, tt := range tests { + if got := ToGrant(tt.args.username, tt.args.perm); got != tt.want { + t.Errorf("%q. ToGrant() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestToRevoke(t *testing.T) { + t.Parallel() + type args struct { + username string + perm chronograf.Permission + } + tests := []struct { + name string + args args + want string + }{ + { + name: "revoke all for all dbs", + args: args{ + username: "biff", + perm: chronograf.Permission{ + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"ALL"}, + }, + }, + want: `REVOKE ALL PRIVILEGES FROM "biff"`, + }, + { + name: "revoke all for one db", + args: args{ + username: "biff", + perm: chronograf.Permission{ + Scope: chronograf.DBScope, + Name: "pleasure_paradice", + Allowed: chronograf.Allowances{}, + }, + }, + want: `REVOKE ALL PRIVILEGES ON "pleasure_paradice" FROM "biff"`, + }, + } + for _, tt := range tests { + if got := ToRevoke(tt.args.username, tt.args.perm); got != tt.want { + t.Errorf("%q. ToRevoke() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func Test_showResults_Users(t *testing.T) { + t.Parallel() + tests := []struct { + name string + octets []byte + want []chronograf.User + }{ + { + name: "admin and non-admin", + octets: []byte(`[{"series":[{"columns":["user","admin"],"values":[["admin",true],["reader",false]]}]}]`), + want: []chronograf.User{ + { + Name: "admin", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"WRITE", "READ"}, + }, + }, + }, + { + Name: "reader", + Permissions: chronograf.Permissions{}, + }, + }, + }, + { + name: "bad JSON", + octets: []byte(`[{"series":[{"columns":["user","admin"],"values":[[1,true],["reader","false"]]}]}]`), + want: []chronograf.User{}, + }, + } + + for _, tt := range tests { + r := &showResults{} + json.Unmarshal(tt.octets, r) + if got := r.Users(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. showResults.Users() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func Test_showResults_Permissions(t *testing.T) { + t.Parallel() + tests := []struct { + name string + octets []byte + want chronograf.Permissions + }{ + { + name: "write for one db", + octets: []byte(`[{"series":[{"columns":["database","privilege"],"values":[["tensorflowdb","WRITE"]]}]}]`), + want: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "tensorflowdb", + Allowed: []string{"WRITE"}, + }, + }, + }, + { + name: "all for one db", + octets: []byte(`[{"series":[{"columns":["database","privilege"],"values":[["tensorflowdb","ALL PRIVILEGES"]]}]}]`), + want: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "tensorflowdb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + }, + { + name: "read for one db", + octets: []byte(`[{"series":[{"columns":["database","privilege"],"values":[["tensorflowdb","READ"]]}]}]`), + want: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "tensorflowdb", + Allowed: []string{"READ"}, + }, + }, + }, + { + name: "other all for one db", + octets: []byte(`[{"series":[{"columns":["database","privilege"],"values":[["tensorflowdb","ALL"]]}]}]`), + want: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "tensorflowdb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + }, + { + name: "other all for one db", + octets: []byte(`[{"series":[{"columns":["database","privilege"],"values":[["tensorflowdb","NO PRIVILEGES"]]}]}]`), + want: chronograf.Permissions{}, + }, + { + name: "bad JSON", + octets: []byte(`[{"series":[{"columns":["database","privilege"],"values":[[1,"WRITE"]]}]}]`), + want: chronograf.Permissions{}, + }, + { + name: "bad JSON", + octets: []byte(`[{"series":[{"columns":["database","privilege"],"values":[["tensorflowdb",1]]}]}]`), + want: chronograf.Permissions{}, + }, + } + + for _, tt := range tests { + r := &showResults{} + json.Unmarshal(tt.octets, r) + if got := r.Permissions(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. showResults.Users() = %v, want %v", tt.name, got, tt.want) + } + } +} diff --git a/influx/users.go b/influx/users.go new file mode 100644 index 000000000..bca83fec3 --- /dev/null +++ b/influx/users.go @@ -0,0 +1,212 @@ +package influx + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/influxdata/chronograf" +) + +// Add a new User in InfluxDB +func (c *Client) Add(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + _, err := c.Query(ctx, chronograf.Query{ + Command: fmt.Sprintf(`CREATE USER "%s" WITH PASSWORD '%s'`, u.Name, u.Passwd), + }) + if err != nil { + return nil, err + } + + return u, nil +} + +// Delete the User from InfluxDB +func (c *Client) Delete(ctx context.Context, u *chronograf.User) error { + res, err := c.Query(ctx, chronograf.Query{ + Command: fmt.Sprintf(`DROP USER "%s"`, u.Name), + }) + if err != nil { + return err + } + // The DROP USER statement puts the error within the results itself + // So, we have to crack open the results to see what happens + octets, err := res.MarshalJSON() + if err != nil { + return err + } + + results := make([]struct{ Error string }, 0) + if err := json.Unmarshal(octets, &results); err != nil { + return err + } + + // At last, we can check if there are any error strings + for _, r := range results { + if r.Error != "" { + return fmt.Errorf(r.Error) + } + } + return nil +} + +// Get retrieves a user if name exists. +func (c *Client) Get(ctx context.Context, name string) (*chronograf.User, error) { + users, err := c.showUsers(ctx) + if err != nil { + return nil, err + } + + for _, user := range users { + if user.Name == name { + perms, err := c.userPermissions(ctx, user.Name) + if err != nil { + return nil, err + } + user.Permissions = append(user.Permissions, perms...) + return &user, nil + } + } + + return nil, fmt.Errorf("user not found") +} + +// Update the user's permissions or roles +func (c *Client) Update(ctx context.Context, u *chronograf.User) error { + // Only allow one type of change at a time. If it is a password + // change then do it and return without any changes to permissions + if u.Passwd != "" { + return c.updatePassword(ctx, u.Name, u.Passwd) + } + + user, err := c.Get(ctx, u.Name) + if err != nil { + return err + } + + revoke, add := Difference(u.Permissions, user.Permissions) + for _, a := range add { + if err := c.grantPermission(ctx, u.Name, a); err != nil { + return err + } + } + + for _, r := range revoke { + if err := c.revokePermission(ctx, u.Name, r); err != nil { + return err + } + } + return nil +} + +// All users in influx +func (c *Client) All(ctx context.Context) ([]chronograf.User, error) { + users, err := c.showUsers(ctx) + if err != nil { + return nil, err + } + + // For all users we need to look up permissions to add to the user. + for i, user := range users { + perms, err := c.userPermissions(ctx, user.Name) + if err != nil { + return nil, err + } + + user.Permissions = append(user.Permissions, perms...) + users[i] = user + } + return users, nil +} + +// showUsers runs SHOW USERS InfluxQL command and returns chronograf users. +func (c *Client) showUsers(ctx context.Context) ([]chronograf.User, error) { + res, err := c.Query(ctx, chronograf.Query{ + Command: `SHOW USERS`, + }) + if err != nil { + return nil, err + } + octets, err := res.MarshalJSON() + if err != nil { + return nil, err + } + + results := showResults{} + if err := json.Unmarshal(octets, &results); err != nil { + return nil, err + } + + return results.Users(), nil +} + +func (c *Client) grantPermission(ctx context.Context, username string, perm chronograf.Permission) error { + query := ToGrant(username, perm) + if query == "" { + return nil + } + + _, err := c.Query(ctx, chronograf.Query{ + Command: query, + }) + return err +} + +func (c *Client) revokePermission(ctx context.Context, username string, perm chronograf.Permission) error { + query := ToRevoke(username, perm) + if query == "" { + return nil + } + + _, err := c.Query(ctx, chronograf.Query{ + Command: query, + }) + return err +} + +func (c *Client) userPermissions(ctx context.Context, name string) (chronograf.Permissions, error) { + res, err := c.Query(ctx, chronograf.Query{ + Command: fmt.Sprintf(`SHOW GRANTS FOR "%s"`, name), + }) + if err != nil { + return nil, err + } + + octets, err := res.MarshalJSON() + if err != nil { + return nil, err + } + + results := showResults{} + if err := json.Unmarshal(octets, &results); err != nil { + return nil, err + } + return results.Permissions(), nil +} + +func (c *Client) updatePassword(ctx context.Context, name, passwd string) error { + res, err := c.Query(ctx, chronograf.Query{ + Command: fmt.Sprintf(`SET PASSWORD for "%s" = '%s'`, name, passwd), + }) + if err != nil { + return err + } + // The SET PASSWORD statements puts the error within the results itself + // So, we have to crack open the results to see what happens + octets, err := res.MarshalJSON() + if err != nil { + return err + } + + results := make([]struct{ Error string }, 0) + if err := json.Unmarshal(octets, &results); err != nil { + return err + } + + // At last, we can check if there are any error strings + for _, r := range results { + if r.Error != "" { + return fmt.Errorf(r.Error) + } + } + return nil +} diff --git a/influx/users_test.go b/influx/users_test.go new file mode 100644 index 000000000..f486e13a9 --- /dev/null +++ b/influx/users_test.go @@ -0,0 +1,949 @@ +package influx + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/log" +) + +func TestClient_userPermissions(t *testing.T) { + t.Parallel() + type args struct { + ctx context.Context + name string + } + tests := []struct { + name string + showGrants []byte + status int + args args + want chronograf.Permissions + wantErr bool + }{ + { + name: "Check all grants", + showGrants: []byte(`{"results":[{"series":[{"columns":["database","privilege"],"values":[["mydb","ALL PRIVILEGES"]]}]}]}`), + status: http.StatusOK, + args: args{ + ctx: context.Background(), + name: "docbrown", + }, + want: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "mydb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + }, + { + name: "Permission Denied", + status: http.StatusUnauthorized, + args: args{ + ctx: context.Background(), + name: "docbrown", + }, + wantErr: true, + }, + { + name: "bad JSON", + showGrants: []byte(`{"results":[{"series":"adffdadf"}]}`), + status: http.StatusOK, + args: args{ + ctx: context.Background(), + name: "docbrown", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if path := r.URL.Path; path != "/query" { + t.Error("Expected the path to contain `/query` but was", path) + } + rw.WriteHeader(tt.status) + rw.Write(tt.showGrants) + })) + u, _ := url.Parse(ts.URL) + c := &Client{ + URL: u, + Logger: log.New(log.DebugLevel), + } + defer ts.Close() + + got, err := c.userPermissions(tt.args.ctx, tt.args.name) + if (err != nil) != tt.wantErr { + t.Errorf("%q. Client.userPermissions() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. Client.userPermissions() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestClient_Add(t *testing.T) { + t.Parallel() + type args struct { + ctx context.Context + u *chronograf.User + } + tests := []struct { + name string + args args + status int + want *chronograf.User + wantQuery string + wantErr bool + }{ + { + name: "Create User", + status: http.StatusOK, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + Passwd: "Dont Need Roads", + }, + }, + wantQuery: `CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`, + want: &chronograf.User{ + Name: "docbrown", + Passwd: "Dont Need Roads", + }, + }, + { + name: "Permission Denied", + status: http.StatusUnauthorized, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + Passwd: "Dont Need Roads", + }, + }, + wantQuery: `CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`, + wantErr: true, + }, + } + for _, tt := range tests { + query := "" + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if path := r.URL.Path; path != "/query" { + t.Error("Expected the path to contain `/query` but was", path) + } + query = r.URL.Query().Get("q") + rw.WriteHeader(tt.status) + rw.Write([]byte(`{"results":[{}]}`)) + })) + u, _ := url.Parse(ts.URL) + c := &Client{ + URL: u, + Logger: log.New(log.DebugLevel), + } + defer ts.Close() + got, err := c.Add(tt.args.ctx, tt.args.u) + if (err != nil) != tt.wantErr { + t.Errorf("%q. Client.Add() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if tt.wantQuery != query { + t.Errorf("%q. Client.Add() query = %v, want %v", tt.name, query, tt.wantQuery) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. Client.Add() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestClient_Delete(t *testing.T) { + type args struct { + ctx context.Context + u *chronograf.User + } + tests := []struct { + name string + status int + dropUser []byte + args args + wantErr bool + }{ + { + name: "Drop User", + dropUser: []byte(`{"results":[{"series":[{"columns":["database","privilege"],"values":[["mydb","ALL PRIVILEGES"]]}]}]}`), + status: http.StatusOK, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + }, + }, + }, + { + name: "No such user", + dropUser: []byte(`{"results":[{"error":"user not found"}]}`), + status: http.StatusOK, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + }, + }, + wantErr: true, + }, + { + name: "Bad InfluxQL", + dropUser: []byte(`{"error":"error parsing query: found doody, expected ; at line 1, char 17"}`), + status: http.StatusBadRequest, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + }, + }, + wantErr: true, + }, + { + name: "Bad JSON", + dropUser: []byte(`{"results":[{"error":breakhere}]}`), + status: http.StatusOK, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if path := r.URL.Path; path != "/query" { + t.Error("Expected the path to contain `/query` but was", path) + } + rw.WriteHeader(tt.status) + rw.Write(tt.dropUser) + })) + u, _ := url.Parse(ts.URL) + c := &Client{ + URL: u, + Logger: log.New(log.DebugLevel), + } + defer ts.Close() + + if err := c.Delete(tt.args.ctx, tt.args.u); (err != nil) != tt.wantErr { + t.Errorf("%q. Client.Delete() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + } +} + +func TestClient_Get(t *testing.T) { + type args struct { + ctx context.Context + name string + } + tests := []struct { + name string + args args + statusUsers int + showUsers []byte + statusGrants int + showGrants []byte + want *chronograf.User + wantErr bool + }{ + { + name: "Get User", + statusUsers: http.StatusOK, + showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`), + statusGrants: http.StatusOK, + showGrants: []byte(`{"results":[{"series":[{"columns":["database","privilege"],"values":[["mydb","ALL PRIVILEGES"]]}]}]}`), + args: args{ + ctx: context.Background(), + name: "docbrown", + }, + want: &chronograf.User{ + Name: "docbrown", + Permissions: chronograf.Permissions{ + chronograf.Permission{ + Scope: "all", + Allowed: []string{"WRITE", "READ"}, + }, + chronograf.Permission{ + Scope: "database", + Name: "mydb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + }, + }, + { + name: "Fail show users", + statusUsers: http.StatusBadRequest, + showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`), + args: args{ + ctx: context.Background(), + name: "docbrown", + }, + wantErr: true, + }, + { + name: "Fail show grants", + statusUsers: http.StatusOK, + showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`), + statusGrants: http.StatusBadRequest, + showGrants: []byte(`{"results":[{"series":[{"columns":["database","privilege"],"values":[["mydb","ALL PRIVILEGES"]]}]}]}`), + args: args{ + ctx: context.Background(), + name: "docbrown", + }, + wantErr: true, + }, + { + name: "Fail no such user", + statusUsers: http.StatusOK, + showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true]]}]}]}`), + args: args{ + ctx: context.Background(), + name: "docbrown", + }, + wantErr: true, + }, + } + for _, tt := range tests { + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if path := r.URL.Path; path != "/query" { + t.Error("Expected the path to contain `/query` but was", path) + } + query := r.URL.Query().Get("q") + if strings.Contains(query, "GRANTS") { + rw.WriteHeader(tt.statusGrants) + rw.Write(tt.showGrants) + } else if strings.Contains(query, "USERS") { + rw.WriteHeader(tt.statusUsers) + rw.Write(tt.showUsers) + } + })) + u, _ := url.Parse(ts.URL) + c := &Client{ + URL: u, + Logger: log.New(log.DebugLevel), + } + defer ts.Close() + got, err := c.Get(tt.args.ctx, tt.args.name) + if (err != nil) != tt.wantErr { + t.Errorf("%q. Client.Get() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. Client.Get() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestClient_grantPermission(t *testing.T) { + type args struct { + ctx context.Context + username string + perm chronograf.Permission + } + tests := []struct { + name string + args args + status int + results []byte + wantQuery string + wantErr bool + }{ + { + name: "simple grants", + status: http.StatusOK, + results: []byte(`{"results":[]}`), + args: args{ + ctx: context.Background(), + username: "docbrown", + perm: chronograf.Permission{ + Scope: "database", + Name: "mydb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + wantQuery: `GRANT ALL ON "mydb" TO "docbrown"`, + }, + { + name: "bad grants", + status: http.StatusOK, + results: []byte(`{"results":[]}`), + args: args{ + ctx: context.Background(), + username: "docbrown", + perm: chronograf.Permission{ + Scope: "database", + Name: "mydb", + Allowed: []string{"howdy"}, + }, + }, + wantQuery: ``, + }, + { + name: "no grants", + status: http.StatusOK, + results: []byte(`{"results":[]}`), + args: args{ + ctx: context.Background(), + username: "docbrown", + perm: chronograf.Permission{ + Scope: "database", + Name: "mydb", + Allowed: []string{}, + }, + }, + wantQuery: ``, + }, + } + for _, tt := range tests { + query := "" + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if path := r.URL.Path; path != "/query" { + t.Error("Expected the path to contain `/query` but was", path) + } + query = r.URL.Query().Get("q") + rw.WriteHeader(tt.status) + rw.Write(tt.results) + })) + u, _ := url.Parse(ts.URL) + c := &Client{ + URL: u, + Logger: log.New(log.DebugLevel), + } + defer ts.Close() + if err := c.grantPermission(tt.args.ctx, tt.args.username, tt.args.perm); (err != nil) != tt.wantErr { + t.Errorf("%q. Client.grantPermission() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + if query != tt.wantQuery { + t.Errorf("%q. Client.grantPermission() = %v, want %v", tt.name, query, tt.wantQuery) + } + } +} + +func TestClient_revokePermission(t *testing.T) { + type args struct { + ctx context.Context + username string + perm chronograf.Permission + } + tests := []struct { + name string + args args + status int + results []byte + wantQuery string + wantErr bool + }{ + { + name: "simple revoke", + status: http.StatusOK, + results: []byte(`{"results":[]}`), + args: args{ + ctx: context.Background(), + username: "docbrown", + perm: chronograf.Permission{ + Scope: "database", + Name: "mydb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + wantQuery: `REVOKE ALL ON "mydb" FROM "docbrown"`, + }, + { + name: "bad revoke", + status: http.StatusOK, + results: []byte(`{"results":[]}`), + args: args{ + ctx: context.Background(), + username: "docbrown", + perm: chronograf.Permission{ + Scope: "database", + Name: "mydb", + Allowed: []string{"howdy"}, + }, + }, + wantQuery: ``, + }, + { + name: "no permissions", + status: http.StatusOK, + results: []byte(`{"results":[]}`), + args: args{ + ctx: context.Background(), + username: "docbrown", + perm: chronograf.Permission{ + Scope: "database", + Name: "mydb", + Allowed: []string{}, + }, + }, + wantQuery: `REVOKE ALL PRIVILEGES ON "mydb" FROM "docbrown"`, + }, + } + for _, tt := range tests { + query := "" + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if path := r.URL.Path; path != "/query" { + t.Error("Expected the path to contain `/query` but was", path) + } + query = r.URL.Query().Get("q") + rw.WriteHeader(tt.status) + rw.Write(tt.results) + })) + u, _ := url.Parse(ts.URL) + c := &Client{ + URL: u, + Logger: log.New(log.DebugLevel), + } + defer ts.Close() + if err := c.revokePermission(tt.args.ctx, tt.args.username, tt.args.perm); (err != nil) != tt.wantErr { + t.Errorf("%q. Client.revokePermission() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + if query != tt.wantQuery { + t.Errorf("%q. Client.revokePermission() = %v, want %v", tt.name, query, tt.wantQuery) + } + } +} + +func TestClient_All(t *testing.T) { + type args struct { + ctx context.Context + } + tests := []struct { + name string + args args + statusUsers int + showUsers []byte + statusGrants int + showGrants []byte + want []chronograf.User + wantErr bool + }{ + { + name: "All Users", + statusUsers: http.StatusOK, + showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`), + statusGrants: http.StatusOK, + showGrants: []byte(`{"results":[{"series":[{"columns":["database","privilege"],"values":[["mydb","ALL PRIVILEGES"]]}]}]}`), + args: args{ + ctx: context.Background(), + }, + want: []chronograf.User{ + { + Name: "admin", + Permissions: chronograf.Permissions{ + chronograf.Permission{ + Scope: "all", + Allowed: []string{"WRITE", "READ"}, + }, + chronograf.Permission{ + Scope: "database", + Name: "mydb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + }, + { + Name: "docbrown", + Permissions: chronograf.Permissions{ + chronograf.Permission{ + Scope: "all", + Allowed: []string{"WRITE", "READ"}, + }, + chronograf.Permission{ + Scope: "database", + Name: "mydb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + }, + { + Name: "reader", + Permissions: chronograf.Permissions{ + chronograf.Permission{ + Scope: "database", + Name: "mydb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + }, + }, + }, + { + name: "Unauthorized", + statusUsers: http.StatusUnauthorized, + showUsers: []byte(`{}`), + args: args{ + ctx: context.Background(), + }, + wantErr: true, + }, + { + name: "Permission error", + statusUsers: http.StatusOK, + showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`), + statusGrants: http.StatusBadRequest, + showGrants: []byte(`{}`), + args: args{ + ctx: context.Background(), + }, + wantErr: true, + }, + } + for _, tt := range tests { + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if path := r.URL.Path; path != "/query" { + t.Error("Expected the path to contain `/query` but was", path) + } + query := r.URL.Query().Get("q") + if strings.Contains(query, "GRANTS") { + rw.WriteHeader(tt.statusGrants) + rw.Write(tt.showGrants) + } else if strings.Contains(query, "USERS") { + rw.WriteHeader(tt.statusUsers) + rw.Write(tt.showUsers) + } + })) + u, _ := url.Parse(ts.URL) + c := &Client{ + URL: u, + Logger: log.New(log.DebugLevel), + } + defer ts.Close() + got, err := c.All(tt.args.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("%q. Client.All() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. Client.All() = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestClient_Update(t *testing.T) { + type args struct { + ctx context.Context + u *chronograf.User + } + tests := []struct { + name string + statusUsers int + showUsers []byte + statusGrants int + showGrants []byte + statusRevoke int + revoke []byte + statusGrant int + grant []byte + statusPassword int + password []byte + args args + want []string + wantErr bool + }{ + { + name: "Change Password", + statusPassword: http.StatusOK, + password: []byte(`{"results":[]}`), + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + Passwd: "hunter2", + }, + }, + want: []string{ + `SET PASSWORD for "docbrown" = 'hunter2'`, + }, + }, + { + name: "Grant all permissions", + statusUsers: http.StatusOK, + showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`), + statusGrants: http.StatusOK, + showGrants: []byte(`{"results":[{"series":[{"columns":["database","privilege"],"values":[["mydb","ALL PRIVILEGES"]]}]}]}`), + statusRevoke: http.StatusOK, + revoke: []byte(`{"results":[]}`), + statusGrant: http.StatusOK, + grant: []byte(`{"results":[]}`), + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + Permissions: chronograf.Permissions{ + { + Scope: "all", + Allowed: []string{"WRITE", "READ"}, + }, + { + Scope: "database", + Name: "mydb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + }, + }, + want: []string{ + `SHOW USERS`, + `SHOW GRANTS FOR "docbrown"`, + `GRANT ALL PRIVILEGES TO "docbrown"`, + `GRANT ALL ON "mydb" TO "docbrown"`, + }, + }, + { + name: "Revoke all permissions", + statusUsers: http.StatusOK, + showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`), + statusGrants: http.StatusOK, + showGrants: []byte(`{"results":[{"series":[{"columns":["database","privilege"],"values":[["mydb","ALL PRIVILEGES"]]}]}]}`), + statusRevoke: http.StatusOK, + revoke: []byte(`{"results":[]}`), + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + }, + }, + want: []string{ + `SHOW USERS`, + `SHOW GRANTS FOR "docbrown"`, + `REVOKE ALL PRIVILEGES FROM "docbrown"`, + `REVOKE ALL ON "mydb" FROM "docbrown"`, + }, + }, + { + name: "Grant all permissions", + statusUsers: http.StatusOK, + showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`), + statusGrants: http.StatusOK, + showGrants: []byte(`{"results":[{"series":[{"columns":["database","privilege"],"values":[["mydb","ALL PRIVILEGES"]]}]}]}`), + statusRevoke: http.StatusOK, + revoke: []byte(`{"results":[]}`), + statusGrant: http.StatusOK, + grant: []byte(`{"results":[]}`), + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + Permissions: chronograf.Permissions{ + { + Scope: "all", + Allowed: []string{"WRITE", "READ"}, + }, + { + Scope: "database", + Name: "mydb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + }, + }, + want: []string{ + `SHOW USERS`, + `SHOW GRANTS FOR "docbrown"`, + `GRANT ALL PRIVILEGES TO "docbrown"`, + `GRANT ALL ON "mydb" TO "docbrown"`, + }, + }, + { + name: "Revoke some add some", + statusUsers: http.StatusOK, + showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`), + statusGrants: http.StatusOK, + showGrants: []byte(`{"results":[{"series":[{"columns":["database","privilege"],"values":[["mydb","ALL PRIVILEGES"]]}]}]}`), + statusRevoke: http.StatusOK, + revoke: []byte(`{"results":[]}`), + statusGrant: http.StatusOK, + grant: []byte(`{"results":[]}`), + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + Permissions: chronograf.Permissions{ + { + Scope: "all", + Allowed: []string{}, + }, + { + Scope: "database", + Name: "mydb", + Allowed: []string{"WRITE"}, + }, + { + Scope: "database", + Name: "newdb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + }, + }, + want: []string{ + `SHOW USERS`, + `SHOW GRANTS FOR "docbrown"`, + `GRANT WRITE ON "mydb" TO "docbrown"`, + `GRANT ALL ON "newdb" TO "docbrown"`, + `REVOKE ALL PRIVILEGES FROM "docbrown"`, + }, + }, + { + name: "Fail users", + statusUsers: http.StatusBadRequest, + showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`), + statusGrants: http.StatusOK, + showGrants: []byte(`{"results":[{"series":[{"columns":["database","privilege"],"values":[["mydb","ALL PRIVILEGES"]]}]}]}`), + statusRevoke: http.StatusOK, + revoke: []byte(`{"results":[]}`), + statusGrant: http.StatusOK, + grant: []byte(`{"results":[]}`), + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + }, + }, + wantErr: true, + want: []string{ + `SHOW USERS`, + }, + }, + { + name: "fail grants", + statusUsers: http.StatusOK, + showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`), + statusGrants: http.StatusOK, + showGrants: []byte(`{"results":[{"series":[{"columns":["database","privilege"],"values":[["mydb","ALL PRIVILEGES"]]}]}]}`), + statusRevoke: http.StatusOK, + revoke: []byte(`{"results":[]}`), + statusGrant: http.StatusBadRequest, + grant: []byte(`{"results":[]}`), + wantErr: true, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + Permissions: chronograf.Permissions{ + { + Scope: "all", + Allowed: []string{}, + }, + { + Scope: "database", + Name: "mydb", + Allowed: []string{"WRITE"}, + }, + { + Scope: "database", + Name: "newdb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + }, + }, + want: []string{ + `SHOW USERS`, + `SHOW GRANTS FOR "docbrown"`, + `GRANT WRITE ON "mydb" TO "docbrown"`, + }, + }, + { + name: "fail revoke", + statusUsers: http.StatusOK, + showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`), + statusGrants: http.StatusOK, + showGrants: []byte(`{"results":[{"series":[{"columns":["database","privilege"],"values":[["mydb","ALL PRIVILEGES"]]}]}]}`), + statusRevoke: http.StatusBadRequest, + revoke: []byte(`{"results":[]}`), + statusGrant: http.StatusOK, + grant: []byte(`{"results":[]}`), + wantErr: true, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + Name: "docbrown", + Permissions: chronograf.Permissions{ + { + Scope: "all", + Allowed: []string{}, + }, + { + Scope: "database", + Name: "mydb", + Allowed: []string{"WRITE"}, + }, + { + Scope: "database", + Name: "newdb", + Allowed: []string{"WRITE", "READ"}, + }, + }, + }, + }, + want: []string{ + `SHOW USERS`, + `SHOW GRANTS FOR "docbrown"`, + `GRANT WRITE ON "mydb" TO "docbrown"`, + `GRANT ALL ON "newdb" TO "docbrown"`, + `REVOKE ALL PRIVILEGES FROM "docbrown"`, + }, + }, + } + for _, tt := range tests { + queries := []string{} + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if path := r.URL.Path; path != "/query" { + t.Error("Expected the path to contain `/query` but was", path) + } + query := r.URL.Query().Get("q") + if strings.Contains(query, "GRANTS") { + rw.WriteHeader(tt.statusGrants) + rw.Write(tt.showGrants) + } else if strings.Contains(query, "USERS") { + rw.WriteHeader(tt.statusUsers) + rw.Write(tt.showUsers) + } else if strings.Contains(query, "REVOKE") { + rw.WriteHeader(tt.statusRevoke) + rw.Write(tt.revoke) + } else if strings.Contains(query, "GRANT") { + rw.WriteHeader(tt.statusGrant) + rw.Write(tt.grant) + } else if strings.Contains(query, "PASSWORD") { + rw.WriteHeader(tt.statusPassword) + rw.Write(tt.password) + } + queries = append(queries, query) + })) + u, _ := url.Parse(ts.URL) + c := &Client{ + URL: u, + Logger: log.New(log.DebugLevel), + } + defer ts.Close() + if err := c.Update(tt.args.ctx, tt.args.u); (err != nil) != tt.wantErr { + t.Errorf("%q. Client.Update() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + if !reflect.DeepEqual(queries, tt.want) { + t.Errorf("%q. Client.Update() = %v, want %v", tt.name, queries, tt.want) + } + } +} + +/* + + + + */ diff --git a/mocks/roles.go b/mocks/roles.go new file mode 100644 index 000000000..db09f8a4a --- /dev/null +++ b/mocks/roles.go @@ -0,0 +1,43 @@ +package mocks + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +var _ chronograf.RolesStore = &RolesStore{} + +// RolesStore mock allows all functions to be set for testing +type RolesStore struct { + AllF func(context.Context) ([]chronograf.Role, error) + AddF func(context.Context, *chronograf.Role) (*chronograf.Role, error) + DeleteF func(context.Context, *chronograf.Role) error + GetF func(ctx context.Context, name string) (*chronograf.Role, error) + UpdateF func(context.Context, *chronograf.Role) error +} + +// All lists all Roles from the RolesStore +func (s *RolesStore) All(ctx context.Context) ([]chronograf.Role, error) { + return s.AllF(ctx) +} + +// Add a new Role in the RolesStore +func (s *RolesStore) Add(ctx context.Context, u *chronograf.Role) (*chronograf.Role, error) { + return s.AddF(ctx, u) +} + +// Delete the Role from the RolesStore +func (s *RolesStore) Delete(ctx context.Context, u *chronograf.Role) error { + return s.DeleteF(ctx, u) +} + +// Get retrieves a Role if name exists. +func (s *RolesStore) Get(ctx context.Context, name string) (*chronograf.Role, error) { + return s.GetF(ctx, name) +} + +// Update the Role's permissions or users +func (s *RolesStore) Update(ctx context.Context, u *chronograf.Role) error { + return s.UpdateF(ctx, u) +} diff --git a/mocks/sources.go b/mocks/sources.go new file mode 100644 index 000000000..16fac4ffa --- /dev/null +++ b/mocks/sources.go @@ -0,0 +1,43 @@ +package mocks + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +var _ chronograf.SourcesStore = &SourcesStore{} + +// SourcesStore mock allows all functions to be set for testing +type SourcesStore struct { + AllF func(context.Context) ([]chronograf.Source, error) + AddF func(context.Context, chronograf.Source) (chronograf.Source, error) + DeleteF func(context.Context, chronograf.Source) error + GetF func(ctx context.Context, ID int) (chronograf.Source, error) + UpdateF func(context.Context, chronograf.Source) error +} + +// All returns all sources in the store +func (s *SourcesStore) All(ctx context.Context) ([]chronograf.Source, error) { + return s.AllF(ctx) +} + +// Add creates a new source in the SourcesStore and returns Source with ID +func (s *SourcesStore) Add(ctx context.Context, src chronograf.Source) (chronograf.Source, error) { + return s.AddF(ctx, src) +} + +// Delete the Source from the store +func (s *SourcesStore) Delete(ctx context.Context, src chronograf.Source) error { + return s.DeleteF(ctx, src) +} + +// Get retrieves Source if `ID` exists +func (s *SourcesStore) Get(ctx context.Context, ID int) (chronograf.Source, error) { + return s.GetF(ctx, ID) +} + +// Update the Source in the store. +func (s *SourcesStore) Update(ctx context.Context, src chronograf.Source) error { + return s.UpdateF(ctx, src) +} diff --git a/mocks/timeseries.go b/mocks/timeseries.go new file mode 100644 index 000000000..064ccc16b --- /dev/null +++ b/mocks/timeseries.go @@ -0,0 +1,53 @@ +package mocks + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +var _ chronograf.TimeSeries = &TimeSeries{} + +// TimeSeries is a mockable chronograf time series by overriding the functions. +type TimeSeries struct { + // Query retrieves time series data from the database. + QueryF func(context.Context, chronograf.Query) (chronograf.Response, error) + // Connect will connect to the time series using the information in `Source`. + ConnectF func(context.Context, *chronograf.Source) error + // UsersStore represents the user accounts within the TimeSeries database + UsersF func(context.Context) chronograf.UsersStore + // Allowances returns all valid names permissions in this database + AllowancesF func(context.Context) chronograf.Allowances + // RolesF represents the roles. Roles group permissions and Users + RolesF func(context.Context) (chronograf.RolesStore, error) +} + +// New implements TimeSeriesClient +func (t *TimeSeries) New(chronograf.Source, chronograf.Logger) (chronograf.TimeSeries, error) { + return t, nil +} + +// Query retrieves time series data from the database. +func (t *TimeSeries) Query(ctx context.Context, query chronograf.Query) (chronograf.Response, error) { + return t.QueryF(ctx, query) +} + +// Connect will connect to the time series using the information in `Source`. +func (t *TimeSeries) Connect(ctx context.Context, src *chronograf.Source) error { + return t.ConnectF(ctx, src) +} + +// Users represents the user accounts within the TimeSeries database +func (t *TimeSeries) Users(ctx context.Context) chronograf.UsersStore { + return t.UsersF(ctx) +} + +// Roles represents the roles. Roles group permissions and Users +func (t *TimeSeries) Roles(ctx context.Context) (chronograf.RolesStore, error) { + return t.RolesF(ctx) +} + +// Allowances returns all valid names permissions in this database +func (t *TimeSeries) Allowances(ctx context.Context) chronograf.Allowances { + return t.AllowancesF(ctx) +} diff --git a/mocks/users.go b/mocks/users.go new file mode 100644 index 000000000..78071307f --- /dev/null +++ b/mocks/users.go @@ -0,0 +1,43 @@ +package mocks + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +var _ chronograf.UsersStore = &UsersStore{} + +// UsersStore mock allows all functions to be set for testing +type UsersStore struct { + AllF func(context.Context) ([]chronograf.User, error) + AddF func(context.Context, *chronograf.User) (*chronograf.User, error) + DeleteF func(context.Context, *chronograf.User) error + GetF func(ctx context.Context, name string) (*chronograf.User, error) + UpdateF func(context.Context, *chronograf.User) error +} + +// All lists all users from the UsersStore +func (s *UsersStore) All(ctx context.Context) ([]chronograf.User, error) { + return s.AllF(ctx) +} + +// Add a new User in the UsersStore +func (s *UsersStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return s.AddF(ctx, u) +} + +// Delete the User from the UsersStore +func (s *UsersStore) Delete(ctx context.Context, u *chronograf.User) error { + return s.DeleteF(ctx, u) +} + +// Get retrieves a user if name exists. +func (s *UsersStore) Get(ctx context.Context, name string) (*chronograf.User, error) { + return s.GetF(ctx, name) +} + +// Update the user's permissions or roles +func (s *UsersStore) Update(ctx context.Context, u *chronograf.User) error { + return s.UpdateF(ctx, u) +} diff --git a/oauth2/oauth2.go b/oauth2/oauth2.go index 9b1f1ebe5..393359f20 100644 --- a/oauth2/oauth2.go +++ b/oauth2/oauth2.go @@ -9,18 +9,21 @@ import ( "golang.org/x/oauth2" ) -/* Constants */ -const ( +type principalKey string + +func (p principalKey) String() string { + return string(p) +} + +var ( // PrincipalKey is used to pass principal // via context.Context to request-scoped // functions. - PrincipalKey string = "principal" -) - -var ( - /* Errors */ + PrincipalKey = principalKey("principal") + // ErrAuthentication means that oauth2 exchange failed ErrAuthentication = errors.New("user not authenticated") - ErrOrgMembership = errors.New("Not a member of the required organization") + // ErrOrgMembership means that the user is not in the OAuth2 filtered group + ErrOrgMembership = errors.New("Not a member of the required organization") ) /* Types */ diff --git a/server/admin.go b/server/admin.go new file mode 100644 index 000000000..7bb91ae7a --- /dev/null +++ b/server/admin.go @@ -0,0 +1,529 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" +) + +func validPermissions(perms *chronograf.Permissions) error { + if perms == nil { + return nil + } + for _, perm := range *perms { + if perm.Scope != chronograf.AllScope && perm.Scope != chronograf.DBScope { + return fmt.Errorf("Invalid permission scope") + } + if perm.Scope == chronograf.DBScope && perm.Name == "" { + return fmt.Errorf("Database scoped permission requires a name") + } + } + return nil +} + +type sourceUserRequest struct { + Username string `json:"name,omitempty"` // Username for new account + Password string `json:"password,omitempty"` // Password for new account + Permissions chronograf.Permissions `json:"permissions,omitempty"` // Optional permissions +} + +func (r *sourceUserRequest) ValidCreate() error { + if r.Username == "" { + return fmt.Errorf("Username required") + } + if r.Password == "" { + return fmt.Errorf("Password required") + } + return validPermissions(&r.Permissions) +} + +func (r *sourceUserRequest) ValidUpdate() error { + if r.Password == "" && len(r.Permissions) == 0 { + return fmt.Errorf("No fields to update") + } + return validPermissions(&r.Permissions) +} + +type sourceUser struct { + Username string `json:"name,omitempty"` // Username for new account + Permissions chronograf.Permissions `json:"permissions,omitempty"` // Account's permissions + Links selfLinks `json:"links"` // Links are URI locations related to user +} + +type selfLinks struct { + Self string `json:"self"` // Self link mapping to this resource +} + +func newSelfLinks(id int, parent, resource string) selfLinks { + httpAPISrcs := "/chronograf/v1/sources" + u := &url.URL{Path: resource} + encodedResource := u.String() + return selfLinks{ + Self: fmt.Sprintf("%s/%d/%s/%s", httpAPISrcs, id, parent, encodedResource), + } +} + +// NewSourceUser adds user to source +func (h *Service) NewSourceUser(w http.ResponseWriter, r *http.Request) { + var req sourceUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, h.Logger) + return + } + if err := req.ValidCreate(); err != nil { + invalidData(w, err, h.Logger) + return + } + + ctx := r.Context() + + srcID, store, err := h.sourceUsersStore(ctx, w, r) + if err != nil { + return + } + + user := &chronograf.User{ + Name: req.Username, + Passwd: req.Password, + } + + res, err := store.Add(ctx, user) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.Logger) + return + } + + su := sourceUser{ + Username: res.Name, + Permissions: req.Permissions, + Links: newSelfLinks(srcID, "users", res.Name), + } + w.Header().Add("Location", su.Links.Self) + encodeJSON(w, http.StatusCreated, su, h.Logger) +} + +type sourceUsers struct { + Users []sourceUser `json:"users"` +} + +// SourceUsers retrieves all users from source. +func (h *Service) SourceUsers(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + srcID, store, err := h.sourceUsersStore(ctx, w, r) + if err != nil { + return + } + + users, err := store.All(ctx) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.Logger) + return + } + + su := []sourceUser{} + for _, u := range users { + su = append(su, sourceUser{ + Username: u.Name, + Permissions: u.Permissions, + Links: newSelfLinks(srcID, "users", u.Name), + }) + } + + res := sourceUsers{ + Users: su, + } + + encodeJSON(w, http.StatusOK, res, h.Logger) +} + +// SourceUserID retrieves a user with ID from store. +func (h *Service) SourceUserID(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid := httprouter.GetParamFromContext(ctx, "uid") + + srcID, store, err := h.sourceUsersStore(ctx, w, r) + if err != nil { + return + } + + u, err := store.Get(ctx, uid) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.Logger) + return + } + + res := sourceUser{ + Username: u.Name, + Permissions: u.Permissions, + Links: newSelfLinks(srcID, "users", u.Name), + } + encodeJSON(w, http.StatusOK, res, h.Logger) +} + +// RemoveSourceUser removes the user from the InfluxDB source +func (h *Service) RemoveSourceUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid := httprouter.GetParamFromContext(ctx, "uid") + + _, store, err := h.sourceUsersStore(ctx, w, r) + if err != nil { + return + } + + if err := store.Delete(ctx, &chronograf.User{Name: uid}); err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.Logger) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// UpdateSourceUser changes the password or permissions of a source user +func (h *Service) UpdateSourceUser(w http.ResponseWriter, r *http.Request) { + var req sourceUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, h.Logger) + return + } + if err := req.ValidUpdate(); err != nil { + invalidData(w, err, h.Logger) + return + } + + ctx := r.Context() + uid := httprouter.GetParamFromContext(ctx, "uid") + srcID, store, err := h.sourceUsersStore(ctx, w, r) + if err != nil { + return + } + + user := &chronograf.User{ + Name: uid, + Passwd: req.Password, + Permissions: req.Permissions, + } + + if err := store.Update(ctx, user); err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.Logger) + return + } + + su := sourceUser{ + Username: user.Name, + Permissions: user.Permissions, + Links: newSelfLinks(srcID, "users", user.Name), + } + w.Header().Add("Location", su.Links.Self) + encodeJSON(w, http.StatusOK, su, h.Logger) +} + +func (h *Service) sourcesSeries(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.TimeSeries, error) { + srcID, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + return 0, nil, err + } + + src, err := h.SourcesStore.Get(ctx, srcID) + if err != nil { + notFound(w, srcID, h.Logger) + return 0, nil, err + } + + ts, err := h.TimeSeries(src) + if err != nil { + msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err) + Error(w, http.StatusBadRequest, msg, h.Logger) + return 0, nil, err + } + + if err = ts.Connect(ctx, &src); err != nil { + msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err) + Error(w, http.StatusBadRequest, msg, h.Logger) + return 0, nil, err + } + return srcID, ts, nil +} + +func (h *Service) sourceUsersStore(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.UsersStore, error) { + srcID, ts, err := h.sourcesSeries(ctx, w, r) + if err != nil { + return 0, nil, err + } + + store := ts.Users(ctx) + return srcID, store, nil +} + +// hasRoles checks if the influx source has roles or not +func (h *Service) hasRoles(ctx context.Context, ts chronograf.TimeSeries) (chronograf.RolesStore, bool) { + store, err := ts.Roles(ctx) + if err != nil { + return nil, false + } + return store, true +} + +// Permissions returns all possible permissions for this source. +func (h *Service) Permissions(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + srcID, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + return + } + + src, err := h.SourcesStore.Get(ctx, srcID) + if err != nil { + notFound(w, srcID, h.Logger) + return + } + + ts, err := h.TimeSeries(src) + if err != nil { + msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err) + Error(w, http.StatusBadRequest, msg, h.Logger) + return + } + + if err = ts.Connect(ctx, &src); err != nil { + msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err) + Error(w, http.StatusBadRequest, msg, h.Logger) + return + } + + perms := ts.Allowances(ctx) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.Logger) + return + } + httpAPISrcs := "/chronograf/v1/sources" + res := struct { + Permissions chronograf.Allowances `json:"permissions"` + Links map[string]string `json:"links"` // Links are URI locations related to user + }{ + Permissions: perms, + Links: map[string]string{ + "self": fmt.Sprintf("%s/%d/permissions", httpAPISrcs, srcID), + "source": fmt.Sprintf("%s/%d", httpAPISrcs, srcID), + }, + } + encodeJSON(w, http.StatusOK, res, h.Logger) +} + +type sourceRoleRequest struct { + chronograf.Role +} + +func (r *sourceRoleRequest) ValidCreate() error { + if r.Name == "" || len(r.Name) > 254 { + return fmt.Errorf("Name is required for a role") + } + for _, user := range r.Users { + if user.Name == "" { + return fmt.Errorf("Username required") + } + } + return validPermissions(&r.Permissions) +} + +func (r *sourceRoleRequest) ValidUpdate() error { + if len(r.Name) > 254 { + return fmt.Errorf("Username too long; must be less than 254 characters") + } + for _, user := range r.Users { + if user.Name == "" { + return fmt.Errorf("Username required") + } + } + return validPermissions(&r.Permissions) +} + +type roleResponse struct { + Users []sourceUser `json:"users"` + Name string `json:"name"` + Permissions chronograf.Permissions `json:"permissions"` + Links selfLinks `json:"links"` +} + +func newRoleResponse(srcID int, res *chronograf.Role) roleResponse { + su := make([]sourceUser, len(res.Users)) + for i := range res.Users { + name := res.Users[i].Name + su[i] = sourceUser{ + Username: name, + Links: newSelfLinks(srcID, "users", name), + } + } + + if res.Permissions == nil { + res.Permissions = make(chronograf.Permissions, 0) + } + return roleResponse{ + Name: res.Name, + Permissions: res.Permissions, + Users: su, + Links: newSelfLinks(srcID, "roles", res.Name), + } +} + +// NewRole adds role to source +func (h *Service) NewRole(w http.ResponseWriter, r *http.Request) { + var req sourceRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, h.Logger) + return + } + + if err := req.ValidCreate(); err != nil { + invalidData(w, err, h.Logger) + return + } + + ctx := r.Context() + srcID, ts, err := h.sourcesSeries(ctx, w, r) + if err != nil { + return + } + + roles, ok := h.hasRoles(ctx, ts) + if !ok { + Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger) + return + } + + res, err := roles.Add(ctx, &req.Role) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.Logger) + return + } + + rr := newRoleResponse(srcID, res) + w.Header().Add("Location", rr.Links.Self) + encodeJSON(w, http.StatusCreated, rr, h.Logger) +} + +// UpdateRole changes the permissions or users of a role +func (h *Service) UpdateRole(w http.ResponseWriter, r *http.Request) { + var req sourceRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, h.Logger) + return + } + if err := req.ValidUpdate(); err != nil { + invalidData(w, err, h.Logger) + return + } + + ctx := r.Context() + srcID, ts, err := h.sourcesSeries(ctx, w, r) + if err != nil { + return + } + + roles, ok := h.hasRoles(ctx, ts) + if !ok { + Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger) + return + } + + rid := httprouter.GetParamFromContext(ctx, "rid") + req.Name = rid + + if err := roles.Update(ctx, &req.Role); err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.Logger) + return + } + + role, err := roles.Get(ctx, req.Name) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.Logger) + return + } + rr := newRoleResponse(srcID, role) + w.Header().Add("Location", rr.Links.Self) + encodeJSON(w, http.StatusOK, rr, h.Logger) +} + +// RoleID retrieves a role with ID from store. +func (h *Service) RoleID(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + srcID, ts, err := h.sourcesSeries(ctx, w, r) + if err != nil { + return + } + + roles, ok := h.hasRoles(ctx, ts) + if !ok { + Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger) + return + } + + rid := httprouter.GetParamFromContext(ctx, "rid") + role, err := roles.Get(ctx, rid) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.Logger) + return + } + rr := newRoleResponse(srcID, role) + encodeJSON(w, http.StatusOK, rr, h.Logger) +} + +// Roles retrieves all roles from the store +func (h *Service) Roles(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + srcID, ts, err := h.sourcesSeries(ctx, w, r) + if err != nil { + return + } + + store, ok := h.hasRoles(ctx, ts) + if !ok { + Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger) + return + } + + roles, err := store.All(ctx) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.Logger) + return + } + + rr := make([]roleResponse, len(roles)) + for i, role := range roles { + rr[i] = newRoleResponse(srcID, &role) + } + + res := struct { + Roles []roleResponse `json:"roles"` + }{rr} + encodeJSON(w, http.StatusOK, res, h.Logger) +} + +// RemoveRole removes role from data source. +func (h *Service) RemoveRole(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + srcID, ts, err := h.sourcesSeries(ctx, w, r) + if err != nil { + return + } + + roles, ok := h.hasRoles(ctx, ts) + if !ok { + Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger) + return + } + + rid := httprouter.GetParamFromContext(ctx, "rid") + if err := roles.Delete(ctx, &chronograf.Role{Name: rid}); err != nil { + Error(w, http.StatusBadRequest, err.Error(), h.Logger) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/admin_test.go b/server/admin_test.go new file mode 100644 index 000000000..3474fd39d --- /dev/null +++ b/server/admin_test.go @@ -0,0 +1,1471 @@ +package server_test + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/mocks" + "github.com/influxdata/chronograf/server" +) + +func TestService_NewSourceUser(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries server.TimeSeriesClient + Logger chronograf.Logger + UseAuth bool + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "New user for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + } + }, + }, + }, + ID: "1", + wantStatus: http.StatusCreated, + wantContentType: "application/json", + wantBody: `{"name":"marty","links":{"self":"/chronograf/v1/sources/1/users/marty"}} +`, + }, + { + name: "Error adding user", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return nil, fmt.Errorf("Weight Has Nothing to Do With It") + }, + } + }, + }, + }, + ID: "1", + wantStatus: http.StatusBadRequest, + wantContentType: "application/json", + wantBody: `{"code":400,"message":"Weight Has Nothing to Do With It"}`, + }, + { + name: "Failure connecting to user store", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return fmt.Errorf("Biff just happens to be my supervisor") + }, + }, + }, + ID: "1", + wantStatus: http.StatusBadRequest, + wantContentType: "application/json", + wantBody: `{"code":400,"message":"Unable to connect to source 1: Biff just happens to be my supervisor"}`, + }, + { + name: "Failure getting source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{}, fmt.Errorf("No McFly ever amounted to anything in the history of Hill Valley") + }, + }, + }, + ID: "1", + wantStatus: http.StatusNotFound, + wantContentType: "application/json", + wantBody: `{"code":404,"message":"ID 1 not found"}`, + }, + { + name: "Bad ID", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + }, + ID: "BAD", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"Error converting ID BAD"}`, + }, + { + name: "Bad name", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + }, + ID: "BAD", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"Username required"}`, + }, + { + name: "Bad JSON", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{password}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + }, + ID: "BAD", + wantStatus: http.StatusBadRequest, + wantContentType: "application/json", + wantBody: `{"code":400,"message":"Unparsable JSON"}`, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + + h.NewSourceUser(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. NewSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. NewSourceUser() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. NewSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_SourceUsers(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries server.TimeSeriesClient + Logger chronograf.Logger + UseAuth bool + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "All users for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://server.local/chronograf/v1/sources/1", + nil), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + AllF: func(ctx context.Context) ([]chronograf.User, error) { + return []chronograf.User{ + { + Name: "strickland", + Passwd: "discipline", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"READ"}, + }, + }, + }, + }, nil + }, + } + }, + }, + }, + ID: "1", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"users":[{"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"links":{"self":"/chronograf/v1/sources/1/users/strickland"}}]} +`, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + + h.SourceUsers(tt.args.w, tt.args.r) + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. SourceUsers() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. SourceUsers() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. SourceUsers() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_SourceUserID(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries server.TimeSeriesClient + Logger chronograf.Logger + UseAuth bool + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + UID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Single user for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://server.local/chronograf/v1/sources/1", + nil), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + GetF: func(ctx context.Context, uid string) (*chronograf.User, error) { + return &chronograf.User{ + Name: "strickland", + Passwd: "discipline", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"READ"}, + }, + }, + }, nil + }, + } + }, + }, + }, + ID: "1", + UID: "strickland", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"links":{"self":"/chronograf/v1/sources/1/users/strickland"}} +`, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + + h.SourceUserID(tt.args.w, tt.args.r) + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. SourceUserID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. SourceUserID() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. SourceUserID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_RemoveSourceUser(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries server.TimeSeriesClient + Logger chronograf.Logger + UseAuth bool + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + UID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Delete user for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://server.local/chronograf/v1/sources/1", + nil), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + DeleteF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + } + }, + }, + }, + ID: "1", + UID: "strickland", + wantStatus: http.StatusNoContent, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + h.RemoveSourceUser(tt.args.w, tt.args.r) + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. RemoveSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. RemoveSourceUser() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. RemoveSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_UpdateSourceUser(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries server.TimeSeriesClient + Logger chronograf.Logger + UseAuth bool + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + UID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Update user password for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + } + }, + }, + }, + ID: "1", + UID: "marty", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"name":"marty","links":{"self":"/chronograf/v1/sources/1/users/marty"}} +`, + }, + { + name: "Invalid update JSON", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + }, + ID: "1", + UID: "marty", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"No fields to update"}`, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + { + Key: "uid", + Value: tt.UID, + }, + })) + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + h.UpdateSourceUser(tt.args.w, tt.args.r) + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. UpdateSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. UpdateSourceUser() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. UpdateSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_Permissions(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries server.TimeSeriesClient + Logger chronograf.Logger + UseAuth bool + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "New user for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + AllowancesF: func(ctx context.Context) chronograf.Allowances { + return chronograf.Allowances{"READ", "WRITE"} + }, + }, + }, + ID: "1", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"permissions":["READ","WRITE"],"links":{"self":"/chronograf/v1/sources/1/permissions","source":"/chronograf/v1/sources/1"}} +`, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + h.Permissions(tt.args.w, tt.args.r) + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. Permissions() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. Permissions() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. Permissions() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_NewSourceRole(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries server.TimeSeriesClient + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Bad JSON", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1/roles", + ioutil.NopCloser( + bytes.NewReader([]byte(`{BAD}`)))), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + }, + wantStatus: http.StatusBadRequest, + wantContentType: "application/json", + wantBody: `{"code":400,"message":"Unparsable JSON"}`, + }, + { + name: "Invalid request", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1/roles", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": ""}`)))), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + }, + ID: "1", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"Name is required for a role"}`, + }, + { + name: "Invalid source ID", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1/roles", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "newrole"}`)))), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + }, + ID: "BADROLE", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"Error converting ID BADROLE"}`, + }, + { + name: "Source doesn't support roles", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1/roles", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "role"}`)))), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, fmt.Errorf("roles not supported") + }, + }, + }, + ID: "1", + wantStatus: http.StatusNotFound, + wantContentType: "application/json", + wantBody: `{"code":404,"message":"Source 1 does not have role capability"}`, + }, + { + name: "Unable to add role to server", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1/roles", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "role"}`)))), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return &mocks.RolesStore{ + AddF: func(ctx context.Context, u *chronograf.Role) (*chronograf.Role, error) { + return nil, fmt.Errorf("server had and issue") + }, + }, nil + }, + }, + }, + ID: "1", + wantStatus: http.StatusBadRequest, + wantContentType: "application/json", + wantBody: `{"code":400,"message":"server had and issue"}`, + }, + { + name: "New role for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1/roles", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "biffsgang","users": [{"name": "match"},{"name": "skinhead"},{"name": "3-d"}]}`)))), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return &mocks.RolesStore{ + AddF: func(ctx context.Context, u *chronograf.Role) (*chronograf.Role, error) { + return u, nil + }, + }, nil + }, + }, + }, + ID: "1", + wantStatus: http.StatusCreated, + wantContentType: "application/json", + wantBody: `{"users":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/3-d"}}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}} +`, + }, + } + for _, tt := range tests { + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + } + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + + h.NewRole(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. NewRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. NewRole() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. NewRole() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_UpdateRole(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries server.TimeSeriesClient + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + RoleID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Update role for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1/roles", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "biffsgang","users": [{"name": "match"},{"name": "skinhead"},{"name": "3-d"}]}`)))), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return &mocks.RolesStore{ + UpdateF: func(ctx context.Context, u *chronograf.Role) error { + return nil + }, + GetF: func(ctx context.Context, name string) (*chronograf.Role, error) { + return &chronograf.Role{ + Name: "biffsgang", + Users: []chronograf.User{ + { + Name: "match", + }, + { + Name: "skinhead", + }, + { + Name: "3-d", + }, + }, + }, nil + }, + }, nil + }, + }, + }, + ID: "1", + RoleID: "biffsgang", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"users":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/3-d"}}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}} +`, + }, + } + for _, tt := range tests { + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + } + + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + { + Key: "rid", + Value: tt.RoleID, + }, + })) + + h.UpdateRole(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. NewRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. NewRole() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. NewRole() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_RoleID(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries server.TimeSeriesClient + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + RoleID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Get role for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://server.local/chronograf/v1/sources/1/roles/biffsgang", + nil), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return &mocks.RolesStore{ + GetF: func(ctx context.Context, name string) (*chronograf.Role, error) { + return &chronograf.Role{ + Name: "biffsgang", + Permissions: chronograf.Permissions{ + { + Name: "grays_sports_almanac", + Scope: "DBScope", + Allowed: chronograf.Allowances{ + "ReadData", + }, + }, + }, + Users: []chronograf.User{ + { + Name: "match", + }, + { + Name: "skinhead", + }, + { + Name: "3-d", + }, + }, + }, nil + }, + }, nil + }, + }, + }, + ID: "1", + RoleID: "biffsgang", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"users":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/3-d"}}],"name":"biffsgang","permissions":[{"scope":"DBScope","name":"grays_sports_almanac","allowed":["ReadData"]}],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}} +`, + }, + } + for _, tt := range tests { + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + } + + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + { + Key: "rid", + Value: tt.RoleID, + }, + })) + + h.RoleID(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. RoleID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. RoleID() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. RoleID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_RemoveRole(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries server.TimeSeriesClient + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + RoleID string + wantStatus int + }{ + { + name: "remove role for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://server.local/chronograf/v1/sources/1/roles/biffsgang", + nil), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + Name: "muh source", + Username: "name", + Password: "hunter2", + URL: "http://localhost:8086", + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return &mocks.RolesStore{ + DeleteF: func(context.Context, *chronograf.Role) error { + return nil + }, + }, nil + }, + }, + }, + ID: "1", + RoleID: "biffsgang", + wantStatus: http.StatusNoContent, + }, + } + for _, tt := range tests { + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + } + + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + { + Key: "rid", + Value: tt.RoleID, + }, + })) + + h.RemoveRole(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. RemoveRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + } +} + +func TestService_Roles(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries server.TimeSeriesClient + Logger chronograf.Logger + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + ID string + RoleID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Get roles for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://server.local/chronograf/v1/sources/1/roles", + nil), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 1, + }, nil + }, + }, + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return &mocks.RolesStore{ + AllF: func(ctx context.Context) ([]chronograf.Role, error) { + return []chronograf.Role{ + chronograf.Role{ + Name: "biffsgang", + Permissions: chronograf.Permissions{ + { + Name: "grays_sports_almanac", + Scope: "DBScope", + Allowed: chronograf.Allowances{ + "ReadData", + }, + }, + }, + Users: []chronograf.User{ + { + Name: "match", + }, + { + Name: "skinhead", + }, + { + Name: "3-d", + }, + }, + }, + }, nil + }, + }, nil + }, + }, + }, + ID: "1", + RoleID: "biffsgang", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"roles":[{"users":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/3-d"}}],"name":"biffsgang","permissions":[{"scope":"DBScope","name":"grays_sports_almanac","allowed":["ReadData"]}],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}]} +`, + }, + } + for _, tt := range tests { + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + } + + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + { + Key: "rid", + Value: tt.RoleID, + }, + })) + + h.Roles(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. RoleID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. RoleID() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. RoleID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} diff --git a/server/influx.go b/server/influx.go new file mode 100644 index 000000000..82d665420 --- /dev/null +++ b/server/influx.go @@ -0,0 +1,77 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/influxdata/chronograf" +) + +// ValidInfluxRequest checks if queries specify a command. +func ValidInfluxRequest(p chronograf.Query) error { + if p.Command == "" { + return fmt.Errorf("query field required") + } + return nil +} + +type postInfluxResponse struct { + Results interface{} `json:"results"` // results from influx +} + +// Influx proxies requests to infludb. +func (h *Service) Influx(w http.ResponseWriter, r *http.Request) { + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) + return + } + + var req chronograf.Query + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, h.Logger) + return + } + if err = ValidInfluxRequest(req); err != nil { + invalidData(w, err, h.Logger) + return + } + + ctx := r.Context() + src, err := h.SourcesStore.Get(ctx, id) + if err != nil { + notFound(w, id, h.Logger) + return + } + + ts, err := h.TimeSeries(src) + if err != nil { + msg := fmt.Sprintf("Unable to connect to source %d: %v", id, err) + Error(w, http.StatusBadRequest, msg, h.Logger) + return + } + + if err = ts.Connect(ctx, &src); err != nil { + msg := fmt.Sprintf("Unable to connect to source %d: %v", id, err) + Error(w, http.StatusBadRequest, msg, h.Logger) + return + } + + response, err := ts.Query(ctx, req) + if err != nil { + if err == chronograf.ErrUpstreamTimeout { + msg := "Timeout waiting for Influx response" + Error(w, http.StatusRequestTimeout, msg, h.Logger) + return + } + // TODO: Here I want to return the error code from influx. + Error(w, http.StatusBadRequest, err.Error(), h.Logger) + return + } + + res := postInfluxResponse{ + Results: response, + } + encodeJSON(w, http.StatusOK, res, h.Logger) +} diff --git a/server/kapacitors.go b/server/kapacitors.go index 798a8c08b..33b172b8f 100644 --- a/server/kapacitors.go +++ b/server/kapacitors.go @@ -445,10 +445,12 @@ func (h *Service) KapacitorRulesPut(w http.ResponseWriter, r *http.Request) { encodeJSON(w, http.StatusOK, res, h.Logger) } +// KapacitorStatus is the current state of a running task type KapacitorStatus struct { Status string `json:"status"` } +// Valid check if the kapacitor status is enabled or disabled func (k *KapacitorStatus) Valid() error { if k.Status == "enabled" || k.Status == "disabled" { return nil diff --git a/server/mux.go b/server/mux.go index cac95963b..1738ba255 100644 --- a/server/mux.go +++ b/server/mux.go @@ -63,8 +63,27 @@ func NewMux(opts MuxOpts, service Service) http.Handler { router.PATCH("/chronograf/v1/sources/:id", service.UpdateSource) router.DELETE("/chronograf/v1/sources/:id", service.RemoveSource) - // Source Proxy - router.POST("/chronograf/v1/sources/:id/proxy", service.Proxy) + // Source Proxy to Influx + router.POST("/chronograf/v1/sources/:id/proxy", service.Influx) + + // All possible permissions for users in this source + router.GET("/chronograf/v1/sources/:id/permissions", service.Permissions) + + // Users associated with the data source + router.GET("/chronograf/v1/sources/:id/users", service.SourceUsers) + router.POST("/chronograf/v1/sources/:id/users", service.NewSourceUser) + + router.GET("/chronograf/v1/sources/:id/users/:uid", service.SourceUserID) + router.DELETE("/chronograf/v1/sources/:id/users/:uid", service.RemoveSourceUser) + router.PATCH("/chronograf/v1/sources/:id/users/:uid", service.UpdateSourceUser) + + // Roles associated with the data source + router.GET("/chronograf/v1/sources/:id/roles", service.Roles) + router.POST("/chronograf/v1/sources/:id/roles", service.NewRole) + + router.GET("/chronograf/v1/sources/:id/roles/:rid", service.RoleID) + router.DELETE("/chronograf/v1/sources/:id/roles/:rid", service.RemoveRole) + router.PATCH("/chronograf/v1/sources/:id/roles/:rid", service.UpdateRole) // Kapacitor router.GET("/chronograf/v1/sources/:id/kapacitors", service.Kapacitors) @@ -102,11 +121,6 @@ func NewMux(opts MuxOpts, service Service) http.Handler { // Users router.GET("/chronograf/v1/me", service.Me) - router.POST("/chronograf/v1/users", service.NewUser) - - router.GET("/chronograf/v1/users/:id", service.UserID) - router.PATCH("/chronograf/v1/users/:id", service.UpdateUser) - router.DELETE("/chronograf/v1/users/:id", service.RemoveUser) // Dashboards router.GET("/chronograf/v1/dashboards", service.Dashboards) diff --git a/server/proxy.go b/server/proxy.go index 10537db63..cf1cd155d 100644 --- a/server/proxy.go +++ b/server/proxy.go @@ -2,76 +2,12 @@ package server import ( "encoding/base64" - "encoding/json" "fmt" "net/http" "net/http/httputil" "net/url" - - "github.com/influxdata/chronograf" ) -// ValidProxyRequest checks if queries specify a command. -func ValidProxyRequest(p chronograf.Query) error { - if p.Command == "" { - return fmt.Errorf("query field required") - } - return nil -} - -type postProxyResponse struct { - Results interface{} `json:"results"` // results from influx -} - -// Proxy proxies requests to infludb. -func (h *Service) Proxy(w http.ResponseWriter, r *http.Request) { - id, err := paramID("id", r) - if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) - return - } - - var req chronograf.Query - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { - invalidJSON(w, h.Logger) - return - } - if err = ValidProxyRequest(req); err != nil { - invalidData(w, err, h.Logger) - return - } - - ctx := r.Context() - src, err := h.SourcesStore.Get(ctx, id) - if err != nil { - notFound(w, id, h.Logger) - return - } - - if err = h.TimeSeries.Connect(ctx, &src); err != nil { - msg := fmt.Sprintf("Unable to connect to source %d", id) - Error(w, http.StatusBadRequest, msg, h.Logger) - return - } - - response, err := h.TimeSeries.Query(ctx, req) - if err != nil { - if err == chronograf.ErrUpstreamTimeout { - msg := "Timeout waiting for Influx response" - Error(w, http.StatusRequestTimeout, msg, h.Logger) - return - } - // TODO: Here I want to return the error code from influx. - Error(w, http.StatusBadRequest, err.Error(), h.Logger) - return - } - - res := postProxyResponse{ - Results: response, - } - encodeJSON(w, http.StatusOK, res, h.Logger) -} - // KapacitorProxy proxies requests to kapacitor using the path query parameter. func (h *Service) KapacitorProxy(w http.ResponseWriter, r *http.Request) { srcID, err := paramID("id", r) diff --git a/server/routes.go b/server/routes.go index 4a8d89987..879766744 100644 --- a/server/routes.go +++ b/server/routes.go @@ -32,7 +32,6 @@ type getRoutesResponse struct { Layouts string `json:"layouts"` // Location of the layouts endpoint Mappings string `json:"mappings"` // Location of the application mappings endpoint Sources string `json:"sources"` // Location of the sources endpoint - Users string `json:"users"` // Location of the users endpoint Me string `json:"me"` // Location of the me endpoint Dashboards string `json:"dashboards"` // Location of the dashboards endpoint Auth []AuthRoute `json:"auth"` // Location of all auth routes. @@ -43,7 +42,6 @@ func AllRoutes(authRoutes []AuthRoute, logger chronograf.Logger) http.HandlerFun routes := getRoutesResponse{ Sources: "/chronograf/v1/sources", Layouts: "/chronograf/v1/layouts", - Users: "/chronograf/v1/users", Me: "/chronograf/v1/me", Mappings: "/chronograf/v1/mappings", Dashboards: "/chronograf/v1/dashboards", @@ -59,33 +57,3 @@ func AllRoutes(authRoutes []AuthRoute, logger chronograf.Logger) http.HandlerFun return }) } - -func NewGithubRoute() AuthRoute { - return AuthRoute{ - Name: "github", - Label: "GitHub", - Login: "/oauth/github/login", - Logout: "/oauth/github/logout", - Callback: "/oauth/github/callback", - } -} - -func NewGoogleRoute() AuthRoute { - return AuthRoute{ - Name: "google", - Label: "Google", - Login: "/oauth/google/login", - Logout: "/oauth/google/logout", - Callback: "/oauth/google/callback", - } -} - -func NewHerokuRoute() AuthRoute { - return AuthRoute{ - Name: "heroku", - Label: "Heroku", - Login: "/oauth/heroku/login", - Logout: "/oauth/heroku/logout", - Callback: "/oauth/heroku/callback", - } -} diff --git a/server/server.go b/server/server.go index 4f5101483..f9a12cd97 100644 --- a/server/server.go +++ b/server/server.go @@ -12,7 +12,6 @@ import ( "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/bolt" "github.com/influxdata/chronograf/canned" - "github.com/influxdata/chronograf/influx" "github.com/influxdata/chronograf/layouts" clog "github.com/influxdata/chronograf/log" "github.com/influxdata/chronograf/oauth2" @@ -267,17 +266,15 @@ func openService(boltPath, cannedPath string, logger chronograf.Logger, useAuth } return Service{ - SourcesStore: db.SourcesStore, - ServersStore: db.ServersStore, - UsersStore: db.UsersStore, - TimeSeries: &influx.Client{ - Logger: logger, - }, - LayoutStore: layouts, - DashboardsStore: db.DashboardsStore, - AlertRulesStore: db.AlertsStore, - Logger: logger, - UseAuth: useAuth, + TimeSeriesClient: &InfluxClient{}, + SourcesStore: db.SourcesStore, + ServersStore: db.ServersStore, + UsersStore: db.UsersStore, + LayoutStore: layouts, + DashboardsStore: db.DashboardsStore, + AlertRulesStore: db.AlertsStore, + Logger: logger, + UseAuth: useAuth, } } diff --git a/server/service.go b/server/service.go index 1831484ec..8dfa115a4 100644 --- a/server/service.go +++ b/server/service.go @@ -1,18 +1,30 @@ package server -import "github.com/influxdata/chronograf" +import ( + "context" + "strings" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/enterprise" + "github.com/influxdata/chronograf/influx" +) // Service handles REST calls to the persistence type Service struct { - SourcesStore chronograf.SourcesStore - ServersStore chronograf.ServersStore - LayoutStore chronograf.LayoutStore - AlertRulesStore chronograf.AlertRulesStore - UsersStore chronograf.UsersStore - DashboardsStore chronograf.DashboardsStore - TimeSeries chronograf.TimeSeries - Logger chronograf.Logger - UseAuth bool + SourcesStore chronograf.SourcesStore + ServersStore chronograf.ServersStore + LayoutStore chronograf.LayoutStore + AlertRulesStore chronograf.AlertRulesStore + UsersStore chronograf.UsersStore + DashboardsStore chronograf.DashboardsStore + TimeSeriesClient TimeSeriesClient + Logger chronograf.Logger + UseAuth bool +} + +// TimeSeriesClient returns the correct client for a time series database. +type TimeSeriesClient interface { + New(chronograf.Source, chronograf.Logger) (chronograf.TimeSeries, error) } // ErrorMessage is the error response format for all service errors @@ -20,3 +32,29 @@ type ErrorMessage struct { Code int `json:"code"` Message string `json:"message"` } + +// TimeSeries returns a new client connected to a time series database +func (s *Service) TimeSeries(src chronograf.Source) (chronograf.TimeSeries, error) { + return s.TimeSeriesClient.New(src, s.Logger) +} + +// InfluxClient returns a new client to connect to OSS or Enterprise +type InfluxClient struct{} + +// New creates a client to connect to OSS or enterprise +func (c *InfluxClient) New(src chronograf.Source, logger chronograf.Logger) (chronograf.TimeSeries, error) { + if src.Type == "influx-enterprise" && src.MetaURL != "" { + dataNode := &influx.Client{ + Logger: logger, + } + if err := dataNode.Connect(context.TODO(), &src); err != nil { + return nil, err + } + + tls := strings.Contains(src.MetaURL, "https") + return enterprise.NewClientWithTimeSeries(logger, src.MetaURL, src.Username, src.Password, tls, dataNode) + } + return &influx.Client{ + Logger: logger, + }, nil +} diff --git a/server/sources.go b/server/sources.go index 2e04a049f..c53c8f1df 100644 --- a/server/sources.go +++ b/server/sources.go @@ -11,9 +11,12 @@ import ( ) type sourceLinks struct { - Self string `json:"self"` // Self link mapping to this resource - Kapacitors string `json:"kapacitors"` // URL for kapacitors endpoint - Proxy string `json:"proxy"` // URL for proxy endpoint + Self string `json:"self"` // Self link mapping to this resource + Kapacitors string `json:"kapacitors"` // URL for kapacitors endpoint + Proxy string `json:"proxy"` // URL for proxy endpoint + Permissions string `json:"permissions"` // URL for all allowed permissions for this source + Users string `json:"users"` // URL for all users associated with this source + Roles string `json:"roles,omitempty"` // URL for all users associated with this source } type sourceResponse struct { @@ -31,14 +34,21 @@ func newSourceResponse(src chronograf.Source) sourceResponse { src.Password = "" httpAPISrcs := "/chronograf/v1/sources" - return sourceResponse{ + res := sourceResponse{ Source: src, Links: sourceLinks{ - Self: fmt.Sprintf("%s/%d", httpAPISrcs, src.ID), - Proxy: fmt.Sprintf("%s/%d/proxy", httpAPISrcs, src.ID), - Kapacitors: fmt.Sprintf("%s/%d/kapacitors", httpAPISrcs, src.ID), + Self: fmt.Sprintf("%s/%d", httpAPISrcs, src.ID), + Proxy: fmt.Sprintf("%s/%d/proxy", httpAPISrcs, src.ID), + Kapacitors: fmt.Sprintf("%s/%d/kapacitors", httpAPISrcs, src.ID), + Permissions: fmt.Sprintf("%s/%d/permissions", httpAPISrcs, src.ID), + Users: fmt.Sprintf("%s/%d/users", httpAPISrcs, src.ID), }, } + + if src.Type == "influx-enterprise" { + res.Links.Roles = fmt.Sprintf("%s/%d/roles", httpAPISrcs, src.ID) + } + return res } // NewSource adds a new valid source to the store diff --git a/server/sources_test.go b/server/sources_test.go index 290fa0a89..729d9a504 100644 --- a/server/sources_test.go +++ b/server/sources_test.go @@ -25,9 +25,11 @@ func Test_newSourceResponse(t *testing.T) { Telegraf: "telegraf", }, Links: sourceLinks{ - Self: "/chronograf/v1/sources/1", - Proxy: "/chronograf/v1/sources/1/proxy", - Kapacitors: "/chronograf/v1/sources/1/kapacitors", + Self: "/chronograf/v1/sources/1", + Proxy: "/chronograf/v1/sources/1/proxy", + Kapacitors: "/chronograf/v1/sources/1/kapacitors", + Users: "/chronograf/v1/sources/1/users", + Permissions: "/chronograf/v1/sources/1/permissions", }, }, }, @@ -43,9 +45,11 @@ func Test_newSourceResponse(t *testing.T) { Telegraf: "howdy", }, Links: sourceLinks{ - Self: "/chronograf/v1/sources/1", - Proxy: "/chronograf/v1/sources/1/proxy", - Kapacitors: "/chronograf/v1/sources/1/kapacitors", + Self: "/chronograf/v1/sources/1", + Proxy: "/chronograf/v1/sources/1/proxy", + Kapacitors: "/chronograf/v1/sources/1/kapacitors", + Users: "/chronograf/v1/sources/1/users", + Permissions: "/chronograf/v1/sources/1/permissions", }, }, }, diff --git a/server/swagger.json b/server/swagger.json index c4e826b15..7d7664a3a 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -272,13 +272,95 @@ } } }, - "/users": { + "/sources/{id}/permissions": { + "get": { + "tags": [ + "sources", + "users" + ], + "summary": "Retrieve possible permissions for this data source", + "parameters": [ + { + "name": "id", + "in": "path", + "type": "string", + "description": "ID of the data source", + "required": true + } + ], + "responses": { + "200": { + "description": "Listing of all possible permissions", + "schema": { + "$ref": "#/definitions/AllPermissions" + } + }, + "404": { + "description": "Data source id does not exist.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "default": { + "description": "A processing or an unexpected error.", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, + "/sources/{id}/users": { + "get": { + "tags": [ + "sources", + "users" + ], + "summary": "Retrieve all data sources users", + "parameters": [ + { + "name": "id", + "in": "path", + "type": "string", + "description": "ID of the data source", + "required": true + } + ], + "responses": { + "200": { + "description": "Listing of all users", + "schema": { + "$ref": "#/definitions/Users" + } + }, + "404": { + "description": "Data source id does not exist.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "default": { + "description": "A processing or an unexpected error.", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + }, "post": { "tags": [ + "sources", "users" ], "summary": "Create new user for this data source", "parameters": [ + { + "name": "id", + "in": "path", + "type": "string", + "description": "ID of the data source", + "required": true + }, { "name": "user", "in": "body", @@ -302,6 +384,12 @@ "$ref": "#/definitions/User" } }, + "404": { + "description": "Data source id does not exist.", + "schema": { + "$ref": "#/definitions/Error" + } + }, "default": { "description": "A processing or an unexpected error.", "schema": { @@ -311,12 +399,20 @@ } } }, - "/users/{user_id}": { + "/sources/{id}/users/{user_id}": { "get": { "tags": [ + "sources", "users" ], "parameters": [ + { + "name": "id", + "in": "path", + "type": "string", + "description": "ID of the data source", + "required": true + }, { "name": "user_id", "in": "path", @@ -326,7 +422,7 @@ } ], "summary": "Returns information about a specific user", - "description": "Specific User.\n", + "description": "Specific User within a data source", "responses": { "200": { "description": "Information relating to the user", @@ -335,7 +431,7 @@ } }, "404": { - "description": "Unknown user", + "description": "Unknown user or unknown source", "schema": { "$ref": "#/definitions/Error" } @@ -350,10 +446,18 @@ }, "patch": { "tags": [ + "sources", "users" ], "summary": "Update user configuration", "parameters": [ + { + "name": "id", + "in": "path", + "type": "string", + "description": "ID of the data source", + "required": true + }, { "name": "user_id", "in": "path", @@ -379,7 +483,7 @@ } }, "404": { - "description": "Happens when trying to access a non-existent user.", + "description": "Happens when trying to access a non-existent user or source.", "schema": { "$ref": "#/definitions/Error" } @@ -394,9 +498,17 @@ }, "delete": { "tags": [ + "sources", "users" ], "parameters": [ + { + "name": "id", + "in": "path", + "type": "string", + "description": "ID of the data source", + "required": true + }, { "name": "user_id", "in": "path", @@ -405,13 +517,245 @@ "required": true } ], - "summary": "This specific user will be removed from the data store", + "summary": "This specific user will be removed from the data source", "responses": { "204": { "description": "User has been removed" }, "404": { - "description": "Unknown user id", + "description": "Unknown user id or data source", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "default": { + "description": "Unexpected internal service error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, + "/sources/{id}/roles": { + "get": { + "tags": [ + "sources", + "users", + "roles" + ], + "summary": "Retrieve all data sources roles. Available only in Influx Enterprise", + "parameters": [ + { + "name": "id", + "in": "path", + "type": "string", + "description": "ID of the data source", + "required": true + } + ], + "responses": { + "200": { + "description": "Listing of all roles", + "schema": { + "$ref": "#/definitions/Roles" + } + }, + "404": { + "description": "Data source id does not exist.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "default": { + "description": "A processing or an unexpected error.", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + }, + "post": { + "tags": [ + "sources", + "users", + "roles" + ], + "summary": "Create new role for this data source", + "parameters": [ + { + "name": "id", + "in": "path", + "type": "string", + "description": "ID of the data source", + "required": true + }, + { + "name": "roleuser", + "in": "body", + "description": "Configuration options for new role", + "schema": { + "$ref": "#/definitions/Role" + } + } + ], + "responses": { + "201": { + "description": "Successfully created new role", + "headers": { + "Location": { + "type": "string", + "format": "url", + "description": "Location of the newly created role resource." + } + }, + "schema": { + "$ref": "#/definitions/Role" + } + }, + "404": { + "description": "Data source id does not exist.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "default": { + "description": "A processing or an unexpected error.", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, + "/sources/{id}/roles/{role_id}": { + "get": { + "tags": [ + "sources", + "users", + "roles" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "type": "string", + "description": "ID of the data source", + "required": true + }, + { + "name": "role_id", + "in": "path", + "type": "string", + "description": "ID of the specific role", + "required": true + } + ], + "summary": "Returns information about a specific role", + "description": "Specific role within a data source", + "responses": { + "200": { + "description": "Information relating to the role", + "schema": { + "$ref": "#/definitions/Role" + } + }, + "404": { + "description": "Unknown role or unknown source", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "default": { + "description": "Unexpected internal service error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + }, + "patch": { + "tags": [ + "sources", + "users", + "roles" + ], + "summary": "Update role configuration", + "parameters": [ + { + "name": "id", + "in": "path", + "type": "string", + "description": "ID of the data source", + "required": true + }, + { + "name": "role_id", + "in": "path", + "type": "string", + "description": "ID of the specific role", + "required": true + }, + { + "name": "config", + "in": "body", + "description": "role configuration", + "schema": { + "$ref": "#/definitions/Role" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Roles's configuration was changed", + "schema": { + "$ref": "#/definitions/Role" + } + }, + "404": { + "description": "Happens when trying to access a non-existent role or source.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "default": { + "description": "A processing or an unexpected error.", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + }, + "delete": { + "tags": [ + "sources", + "users", + "roles" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "type": "string", + "description": "ID of the data source", + "required": true + }, + { + "name": "role_id", + "in": "path", + "type": "string", + "description": "ID of the specific role", + "required": true + } + ], + "summary": "This specific role will be removed from the data source", + "responses": { + "204": { + "description": "Role has been removed" + }, + "404": { + "description": "Unknown role id or data source", "schema": { "$ref": "#/definitions/Error" } @@ -1478,7 +1822,7 @@ }, "put": { "tags": [ - "layouts" + "dashboards" ], "summary": "Replace dashboard information.", "parameters": [ @@ -1588,6 +1932,16 @@ "name", "url" ], + "example": { + "id": "4", + "name": "kapa", + "url": "http://localhost:9092", + "links": { + "proxy": "/chronograf/v1/sources/4/kapacitors/4/proxy", + "self": "/chronograf/v1/sources/4/kapacitors/4", + "rules": "/chronograf/v1/sources/4/kapacitors/4/rules" + } + }, "properties": { "id": { "type": "string", @@ -1657,6 +2011,26 @@ }, "QueryConfig": { "type": "object", + "example": { + "id": "ce72917d-1ecb-45ea-a6cb-4c122deb93c7", + "database": "telegraf", + "measurement": "cpu", + "retentionPolicy": "autogen", + "fields": [ + { + "field": "usage_system", + "funcs": [ + "max" + ] + } + ], + "tags": {}, + "groupBy": { + "time": "10m", + "tags": [] + }, + "areTagsAccepted": true + }, "properties": { "id": { "type": "string" @@ -1778,6 +2152,55 @@ }, "Rule": { "type": "object", + "example": { + "id": "chronograf-v1-b2b065ea-79bd-4e4f-8c0d-d0ef68477d38", + "query": { + "id": "ce72917d-1ecb-45ea-a6cb-4c122deb93c7", + "database": "telegraf", + "measurement": "cpu", + "retentionPolicy": "autogen", + "fields": [ + { + "field": "usage_system", + "funcs": [ + "max" + ] + } + ], + "tags": {}, + "groupBy": { + "time": "10m", + "tags": [] + }, + "areTagsAccepted": true + }, + "every": "30s", + "alerts": [ + "alerta" + ], + "alertNodes": [ + { + "name": "alerta", + "args": [], + "properties": [] + } + ], + "message": "too much spam", + "details": "muh body", + "trigger": "threshold", + "values": { + "operator": "greater than", + "value": "10" + }, + "name": "Untitled Rule", + "tickscript": "var db = 'telegraf'\n\nvar rp = 'autogen'\n\nvar measurement = 'cpu'\n\nvar groupBy = []\n\nvar whereFilter = lambda: TRUE\n\nvar period = 10m\n\nvar every = 30s\n\nvar name = 'Untitled Rule'\n\nvar idVar = name + ':{{.Group}}'\n\nvar message = 'too much spam'\n\nvar idTag = 'alertID'\n\nvar levelTag = 'level'\n\nvar messageField = 'message'\n\nvar durationField = 'duration'\n\nvar outputDB = 'chronograf'\n\nvar outputRP = 'autogen'\n\nvar outputMeasurement = 'alerts'\n\nvar triggerType = 'threshold'\n\nvar details = 'muh body'\n\nvar crit = 10\n\nvar data = stream\n |from()\n .database(db)\n .retentionPolicy(rp)\n .measurement(measurement)\n .groupBy(groupBy)\n .where(whereFilter)\n |window()\n .period(period)\n .every(every)\n .align()\n |max('usage_system')\n .as('value')\n\nvar trigger = data\n |alert()\n .crit(lambda: \"value\" > crit)\n .stateChangesOnly()\n .message(message)\n .id(idVar)\n .idTag(idTag)\n .levelTag(levelTag)\n .messageField(messageField)\n .durationField(durationField)\n .details(details)\n .alerta()\n\ntrigger\n |influxDBOut()\n .create()\n .database(outputDB)\n .retentionPolicy(outputRP)\n .measurement(outputMeasurement)\n .tag('alertName', name)\n .tag('triggerType', triggerType)\n\ntrigger\n |httpOut('output')\n", + "status": "enabled", + "links": { + "self": "/chronograf/v1/sources/5/kapacitors/5/rules/chronograf-v1-b2b065ea-79bd-4e4f-8c0d-d0ef68477d38", + "kapacitor": "/chronograf/v1/sources/5/kapacitors/5/proxy?path=%2Fkapacitor%2Fv1%2Ftasks%2Fchronograf-v1-b2b065ea-79bd-4e4f-8c0d-d0ef68477d38", + "output": "/chronograf/v1/sources/5/kapacitors/5/proxy?path=%2Fkapacitor%2Fv1%2Ftasks%2Fchronograf-v1-b2b065ea-79bd-4e4f-8c0d-d0ef68477d38%2Foutput" + } + }, "required": [ "query", "every", @@ -1789,7 +2212,6 @@ "description": "ID for this rule; the ID is shared with kapacitor" }, "query": { - "description": "Query config structure is historical from chronograf 1.0", "$ref": "#/definitions/QueryConfig" }, "name": { @@ -1946,6 +2368,21 @@ }, "Source": { "type": "object", + "example": { + "id": "4", + "name": "Influx 1", + "url": "http://localhost:8086", + "default": false, + "telegraf": "telegraf", + "links": { + "self": "/chronograf/v1/sources/4", + "kapacitors": "/chronograf/v1/sources/4/kapacitors", + "proxy": "/chronograf/v1/sources/4/proxy", + "permissions": "/chronograf/v1/sources/4/permissions", + "users": "/chronograf/v1/sources/4/users", + "roles": "/chronograf/v1/sources/4/roles" + } + }, "required": [ "name", "url" @@ -2016,6 +2453,21 @@ "type": "string", "description": "URL location of the kapacitors endpoint for this source", "format": "url" + }, + "users": { + "type": "string", + "description": "URL location of the users endpoint for this source", + "format": "url" + }, + "permissions": { + "type": "string", + "description": "URL location of the permissions endpoint for this source", + "format": "url" + }, + "roles": { + "type": "string", + "description": "Optional path to the roles endpoint IFF it is supported on this source", + "format": "url" } } } @@ -2023,6 +2475,12 @@ }, "Proxy": { "type": "object", + "example": { + "query": "select * from cpu where time > now() - 10m", + "db": "telegraf", + "rp": "autogen", + "format": "raw" + }, "required": [ "query" ], @@ -2047,6 +2505,50 @@ }, "ProxyResponse": { "type": "object", + "example": { + "results": [ + { + "statement_id": 0, + "series": [ + { + "name": "cpu", + "columns": [ + "time", + "cpu", + "host", + "usage_guest", + "usage_guest_nice", + "usage_idle", + "usage_iowait", + "usage_irq", + "usage_nice", + "usage_softirq", + "usage_steal", + "usage_system", + "usage_user" + ], + "values": [ + [ + 1487785510000, + "cpu-total", + "ChristohersMBP2.lan", + 0, + 0, + 76.6916354556804, + 0, + 0, + 0, + 0, + 0, + 4.781523096129837, + 18.526841448189764 + ] + ] + } + ] + } + ] + }, "properties": { "results": { "description": "results from influx", @@ -2054,6 +2556,48 @@ } } }, + "Roles": { + "type": "object", + "properties": { + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/Role" + } + } + } + }, + "Role": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Unique name of the role", + "maxLength": 254, + "minLength": 1 + }, + "users": { + "$ref": "#/definitions/Users" + }, + "permissions": { + "$ref": "#/definitions/Permissions" + }, + "links": { + "type": "object", + "description": "URL relations of this role", + "properties": { + "self": { + "type": "string", + "format": "url", + "description": "URI of resource." + } + } + } + } + }, "Users": { "type": "object", "properties": { @@ -2063,21 +2607,179 @@ "$ref": "#/definitions/User" } } + }, + "example": { + "users": [ + { + "name": "docbrown", + "permissions": [ + { + "scope": "all", + "allowed": [ + "ViewChronograf", + "ReadData" + ] + }, + { + "scope": "database", + "name": "telegraf", + "allowed": [ + "ViewChronograf", + "ReadData" + ] + } + ], + "links": { + "self": "/chronograf/v1/source/1/users/docbrown" + } + } + ] } }, "User": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique name of the user", + "maxLength": 254, + "minLength": 1 + }, + "password": { + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/Permissions" + }, + "links": { + "type": "object", + "description": "URL relations of this user", + "properties": { + "self": { + "type": "string", + "format": "url", + "description": "URI of resource." + } + } + } + }, + "example": { + "name": "docbrown", + "permissions": [ + { + "scope": "all", + "allowed": [ + "ViewChronograf", + "ReadData" + ] + }, + { + "scope": "database", + "name": "telegraf", + "allowed": [ + "ViewChronograf", + "ReadData" + ] + } + ], + "links": { + "self": "/chronograf/v1/source/1/users/docbrown" + } + } + }, + "Permissions": { + "description": "Permissions represent the entire set of permissions a User or Role may have", + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + }, + "Permission": { + "description": "Permission is a specific allowance for User or Role bound to a scope of the data source", "type": "object", "required": [ - "email" + "scope", + "allowed" ], "properties": { - "email": { + "scope": { "type": "string", - "maxLength": 254 + "description": "Describes if the permission is for all databases or restricted to one database", + "enum": [ + "all", + "database" + ] }, - "link": { - "$ref": "#/definitions/Link" + "name": { + "type": "string", + "description": "If the scope is database this identifies the name of the database" + }, + "allowed": { + "$ref": "#/definitions/Allowances" } + }, + "example": { + "scope": "database", + "name": "telegraf", + "allowed": [ + "READ", + "WRITE" + ] + } + }, + "AllPermissions": { + "description": "All possible permissions for this particular datasource. Used as a static list", + "type": "object", + "properties": { + "permissions": { + "$ref": "#/definitions/Allowances" + }, + "links": { + "type": "object", + "properties": { + "self": { + "description": "Relative link back to the permissions endpoint", + "type": "string", + "format": "uri" + }, + "source": { + "description": "Relative link to host with these permissiosn", + "type": "string", + "format": "uri" + } + } + } + } + }, + "Allowances": { + "description": "Allowances defines what actions a user can have on a scoped permission", + "type": "array", + "items": { + "type": "string", + "description": "OSS InfluxDB is READ and WRITE. Enterprise is all others", + "enum": [ + "READ", + "WRITE", + "NoPermissions", + "ViewAdmin", + "ViewChronograf", + "CreateDatabase", + "CreateUserAndRole", + "AddRemoveNode", + "DropDatabase", + "DropData", + "ReadData", + "WriteData", + "Rebalance", + "ManageShard", + "ManageContinuousQuery", + "ManageQuery", + "ManageSubscription", + "Monitor", + "CopyShard", + "KapacitorAPI", + "KapacitorConfigAPI" + ] } }, "Layouts": { @@ -2124,6 +2826,41 @@ "link": { "$ref": "#/definitions/Link" } + }, + "example": { + "id": "0e980b97-c162-487b-a815-3f955df62430", + "app": "docker", + "measurement": "docker_container_net", + "autoflow": true, + "cells": [ + { + "x": 0, + "y": 0, + "w": 4, + "h": 4, + "i": "4c79cefb-5152-410c-9b88-74f9bff7ef23", + "name": "Docker - Container Network", + "queries": [ + { + "query": "SELECT derivative(mean(\"tx_bytes\"), 10s) AS \"net_tx_bytes\" FROM \"docker_container_net\"", + "groupbys": [ + "\"container_name\"" + ] + }, + { + "query": "SELECT derivative(mean(\"rx_bytes\"), 10s) AS \"net_rx_bytes\" FROM \"docker_container_net\"", + "groupbys": [ + "\"container_name\"" + ] + } + ], + "type": "" + } + ], + "link": { + "href": "/chronograf/v1/layouts/0e980b97-c162-487b-a815-3f955df62430", + "rel": "self" + } } }, "Mappings": { @@ -2155,6 +2892,10 @@ "description": "The application name which will be assigned to the corresponding measurement", "type": "string" } + }, + "example": { + "measurement": "riak", + "name": "riak" } }, "Cell": { @@ -2204,6 +2945,20 @@ "type": "string", "format": "uuid4" } + }, + "example": { + "x": 5, + "y": 5, + "w": 4, + "h": 4, + "name": "usage_user", + "queries": [ + { + "query": "SELECT mean(\"usage_user\") AS \"usage_user\" FROM \"cpu\"", + "label": "%" + } + ], + "type": "line" } }, "LayoutQuery": { @@ -2253,6 +3008,16 @@ "type": "string" } } + }, + "example": { + "label": "# warnings", + "query": "SELECT count(\"check_id\") as \"Number Warning\" FROM consul_health_checks", + "wheres": [ + "\"status\" = 'warning'" + ], + "groupbys": [ + "\"service_name\"" + ] } }, "Dashboards": { @@ -2345,16 +3110,48 @@ } } } + }, + "example": { + "id": 4, + "cells": [ + { + "x": 5, + "y": 5, + "w": 4, + "h": 4, + "name": "usage_user", + "queries": [ + { + "query": "SELECT mean(\"usage_user\") AS \"usage_user\" FROM \"cpu\"", + "label": "%" + } + ], + "type": "line" + }, + { + "x": 0, + "y": 0, + "w": 4, + "h": 4, + "name": "usage_system", + "queries": [ + { + "query": "SELECT mean(\"usage_system\") AS \"usage_system\" FROM \"cpu\"", + "label": "%" + } + ], + "type": "line" + } + ], + "name": "lalalalala", + "links": { + "self": "/chronograf/v1/dashboards/4" + } } }, "Routes": { "type": "object", "properties": { - "users": { - "description": "Location of the users endpoint", - "type": "string", - "format": "url" - }, "me": { "description": "Location of the me endpoint.", "type": "string", @@ -2380,17 +3177,13 @@ "type": "string", "format": "url" } - } - }, - "Links": { - "type": "object", - "properties": { - "links": { - "type": "array", - "items": { - "$ref": "#/definitions/Link" - } - } + }, + "example": { + "layouts": "/chronograf/v1/layouts", + "mappings": "/chronograf/v1/mappings", + "sources": "/chronograf/v1/sources", + "me": "/chronograf/v1/me", + "dashboards": "/chronograf/v1/dashboards" } }, "Link": { diff --git a/server/users.go b/server/users.go index 9426ce0f2..bbf4f61db 100644 --- a/server/users.go +++ b/server/users.go @@ -1,9 +1,9 @@ package server import ( - "encoding/json" "fmt" "net/http" + "net/url" "golang.org/x/net/context" @@ -24,120 +24,19 @@ type userResponse struct { // indicates authentication is not needed func newUserResponse(usr *chronograf.User) userResponse { base := "/chronograf/v1/users" + name := "me" if usr != nil { - return userResponse{ - User: usr, - Links: userLinks{ - Self: fmt.Sprintf("%s/%d", base, usr.ID), - }, - } - } - return userResponse{} -} - -// NewUser adds a new valid user to the store -func (h *Service) NewUser(w http.ResponseWriter, r *http.Request) { - var usr *chronograf.User - if err := json.NewDecoder(r.Body).Decode(usr); err != nil { - invalidJSON(w, h.Logger) - return - } - if err := ValidUserRequest(usr); err != nil { - invalidData(w, err, h.Logger) - return + // TODO: Change to usrl.PathEscape for go 1.8 + u := &url.URL{Path: usr.Name} + name = u.String() } - var err error - if usr, err = h.UsersStore.Add(r.Context(), usr); err != nil { - msg := fmt.Errorf("error storing user %v: %v", *usr, err) - unknownErrorWithMessage(w, msg, h.Logger) - return + return userResponse{ + User: usr, + Links: userLinks{ + Self: fmt.Sprintf("%s/%s", base, name), + }, } - - res := newUserResponse(usr) - w.Header().Add("Location", res.Links.Self) - encodeJSON(w, http.StatusCreated, res, h.Logger) -} - -// UserID retrieves a user from the store -func (h *Service) UserID(w http.ResponseWriter, r *http.Request) { - id, err := paramID("id", r) - if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) - return - } - - ctx := r.Context() - usr, err := h.UsersStore.Get(ctx, chronograf.UserID(id)) - if err != nil { - notFound(w, id, h.Logger) - return - } - - res := newUserResponse(usr) - encodeJSON(w, http.StatusOK, res, h.Logger) -} - -// RemoveUser deletes the user from the store -func (h *Service) RemoveUser(w http.ResponseWriter, r *http.Request) { - id, err := paramID("id", r) - if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) - return - } - - usr := &chronograf.User{ID: chronograf.UserID(id)} - ctx := r.Context() - if err = h.UsersStore.Delete(ctx, usr); err != nil { - unknownErrorWithMessage(w, err, h.Logger) - return - } - - w.WriteHeader(http.StatusNoContent) -} - -// UpdateUser handles incremental updates of a data user -func (h *Service) UpdateUser(w http.ResponseWriter, r *http.Request) { - id, err := paramID("id", r) - if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger) - return - } - - ctx := r.Context() - usr, err := h.UsersStore.Get(ctx, chronograf.UserID(id)) - if err != nil { - notFound(w, id, h.Logger) - return - } - - var req chronograf.User - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - invalidJSON(w, h.Logger) - return - } - - usr.Email = req.Email - if err := ValidUserRequest(usr); err != nil { - invalidData(w, err, h.Logger) - return - } - - if err := h.UsersStore.Update(ctx, usr); err != nil { - msg := fmt.Sprintf("Error updating user ID %d", id) - Error(w, http.StatusInternalServerError, msg, h.Logger) - return - } - encodeJSON(w, http.StatusOK, newUserResponse(usr), h.Logger) -} - -// ValidUserRequest checks if email is nonempty -func ValidUserRequest(s *chronograf.User) error { - // email is required - if s.Email == "" { - return fmt.Errorf("Email required") - } - return nil } func getEmail(ctx context.Context) (string, error) { @@ -169,12 +68,14 @@ func (h *Service) Me(w http.ResponseWriter, r *http.Request) { encodeJSON(w, http.StatusOK, res, h.Logger) return } + email, err := getEmail(ctx) if err != nil { invalidData(w, err, h.Logger) return } - usr, err := h.UsersStore.FindByEmail(ctx, email) + + usr, err := h.UsersStore.Get(ctx, email) if err == nil { res := newUserResponse(usr) encodeJSON(w, http.StatusOK, res, h.Logger) @@ -183,15 +84,16 @@ func (h *Service) Me(w http.ResponseWriter, r *http.Request) { // Because we didnt find a user, making a new one user := &chronograf.User{ - Email: email, + Name: email, } - user, err = h.UsersStore.Add(ctx, user) + + newUser, err := h.UsersStore.Add(ctx, user) if err != nil { - msg := fmt.Errorf("error storing user %v: %v", user, err) + msg := fmt.Errorf("error storing user %s: %v", user.Name, err) unknownErrorWithMessage(w, msg, h.Logger) return } - res := newUserResponse(user) + res := newUserResponse(newUser) encodeJSON(w, http.StatusOK, res, h.Logger) } diff --git a/server/users_test.go b/server/users_test.go new file mode 100644 index 000000000..147bf8f3a --- /dev/null +++ b/server/users_test.go @@ -0,0 +1,168 @@ +package server + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/mocks" + "github.com/influxdata/chronograf/oauth2" +) + +type MockUsers struct{} + +func TestService_Me(t *testing.T) { + type fields struct { + UsersStore chronograf.UsersStore + Logger chronograf.Logger + UseAuth bool + } + type args struct { + w *httptest.ResponseRecorder + r *http.Request + } + tests := []struct { + name string + fields fields + args args + principal oauth2.Principal + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Existing user", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: true, + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, name string) (*chronograf.User, error) { + return &chronograf.User{ + Name: "me", + Passwd: "hunter2", + }, nil + }, + }, + }, + principal: oauth2.Principal{ + Subject: "me", + }, + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"name":"me","password":"hunter2","links":{"self":"/chronograf/v1/users/me"}} +`, + }, + { + name: "New user", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: true, + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, name string) (*chronograf.User, error) { + return nil, fmt.Errorf("Unknown User") + }, + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + }, + }, + principal: oauth2.Principal{ + Subject: "secret", + }, + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"name":"secret","password":"","links":{"self":"/chronograf/v1/users/secret"}} +`, + }, + { + name: "Error adding user", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: true, + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, name string) (*chronograf.User, error) { + return nil, fmt.Errorf("Unknown User") + }, + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return nil, fmt.Errorf("Why Heavy?") + }, + }, + Logger: log.New(log.DebugLevel), + }, + principal: oauth2.Principal{ + Subject: "secret", + }, + wantStatus: http.StatusInternalServerError, + wantContentType: "application/json", + wantBody: `{"code":500,"message":"Unknown error: error storing user secret: Why Heavy?"}`, + }, + { + name: "No Auth", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: false, + Logger: log.New(log.DebugLevel), + }, + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/users/me"}} +`, + }, + { + name: "Empty Principal", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + }, + wantStatus: http.StatusUnprocessableEntity, + principal: oauth2.Principal{ + Subject: "", + }, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(context.WithValue(context.Background(), oauth2.PrincipalKey, tt.principal)) + h := &Service{ + UsersStore: tt.fields.UsersStore, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + + h.Me(tt.args.w, tt.args.r) + + resp := tt.args.w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Errorf("%q. Me() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. Me() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. Me() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} diff --git a/ui/src/dashboards/components/DashboardHeader.js b/ui/src/dashboards/components/DashboardHeader.js index 60ff5e8ff..be6fd69a0 100644 --- a/ui/src/dashboards/components/DashboardHeader.js +++ b/ui/src/dashboards/components/DashboardHeader.js @@ -47,7 +47,7 @@ const DashboardHeader = ({
- +
diff --git a/ui/src/shared/components/Dygraph.js b/ui/src/shared/components/Dygraph.js index f819e7799..2cd539a6c 100644 --- a/ui/src/shared/components/Dygraph.js +++ b/ui/src/shared/components/Dygraph.js @@ -174,7 +174,7 @@ export default React.createClass({ render() { return ( -
+
diff --git a/ui/src/shared/components/LineGraph.js b/ui/src/shared/components/LineGraph.js index 9658c8d6f..63c8eeb4e 100644 --- a/ui/src/shared/components/LineGraph.js +++ b/ui/src/shared/components/LineGraph.js @@ -104,7 +104,10 @@ export default React.createClass({ } return ( -
+
{isRefreshing ? this.renderSpinner() : null}
    +
  • Time Range
  • {timeRanges.map((item) => { return (
  • diff --git a/ui/src/shared/constants/index.js b/ui/src/shared/constants/index.js index da53f8e2a..6b0d7cb60 100644 --- a/ui/src/shared/constants/index.js +++ b/ui/src/shared/constants/index.js @@ -468,5 +468,5 @@ export const STROKE_WIDTH = { light: 1.5, }; -export const PRESENTATION_MODE_ANIMATION_DELAY = 250 // In milliseconds. +export const PRESENTATION_MODE_ANIMATION_DELAY = 0 // In milliseconds. export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds. diff --git a/ui/src/shared/middleware/resizeLayout.js b/ui/src/shared/middleware/resizeLayout.js new file mode 100644 index 000000000..cb5608138 --- /dev/null +++ b/ui/src/shared/middleware/resizeLayout.js @@ -0,0 +1,10 @@ +// Trigger resize event to relayout the React Layout plugin + +export default function resizeLayout() { + return next => action => { + next(action); + if (action.type === 'ENABLE_PRESENTATION_MODE' || action.type === 'DISABLE_PRESENTATION_MODE') { + window.dispatchEvent(new Event('resize')); + } + } +} \ No newline at end of file diff --git a/ui/src/store/configureStore.js b/ui/src/store/configureStore.js index 85b4a6e11..44b03fa66 100644 --- a/ui/src/store/configureStore.js +++ b/ui/src/store/configureStore.js @@ -2,6 +2,7 @@ import {createStore, applyMiddleware, compose} from 'redux'; import {combineReducers} from 'redux'; import thunkMiddleware from 'redux-thunk'; import makeQueryExecuter from 'src/shared/middleware/queryExecuter'; +import resizeLayout from 'src/shared/middleware/resizeLayout'; import * as dataExplorerReducers from 'src/data_explorer/reducers'; import * as sharedReducers from 'src/shared/reducers'; import rulesReducer from 'src/kapacitor/reducers/rules'; @@ -20,7 +21,7 @@ const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; export default function configureStore(initialState) { const createPersistentStore = composeEnhancers( persistStateEnhancer(), - applyMiddleware(thunkMiddleware, makeQueryExecuter()), + applyMiddleware(thunkMiddleware, makeQueryExecuter(), resizeLayout), )(createStore); diff --git a/ui/src/style/components/group-by-time-dropdown.scss b/ui/src/style/components/group-by-time-dropdown.scss index 12650a756..04c64eed7 100644 --- a/ui/src/style/components/group-by-time-dropdown.scss +++ b/ui/src/style/components/group-by-time-dropdown.scss @@ -18,6 +18,7 @@ border-color: $g5-pepper; border-width: 2px; background-color: $g3-castle; + white-space: nowrap; } .dropdown-toggle { border-radius: 0px 3px 3px 0; diff --git a/ui/src/style/components/multi-select-dropdown.scss b/ui/src/style/components/multi-select-dropdown.scss index 04e96e614..d349c2633 100644 --- a/ui/src/style/components/multi-select-dropdown.scss +++ b/ui/src/style/components/multi-select-dropdown.scss @@ -1,6 +1,6 @@ .multi-select-dropdown { .dropdown-toggle { - width: 150px; + width: 110px; } &__apply { margin: 0; diff --git a/ui/src/style/fonts/icomoon.eot b/ui/src/style/fonts/icomoon.eot index 10ba73aff..c525fa69a 100755 Binary files a/ui/src/style/fonts/icomoon.eot and b/ui/src/style/fonts/icomoon.eot differ diff --git a/ui/src/style/fonts/icomoon.svg b/ui/src/style/fonts/icomoon.svg index 2c2c06224..1919cb91f 100755 --- a/ui/src/style/fonts/icomoon.svg +++ b/ui/src/style/fonts/icomoon.svg @@ -82,6 +82,7 @@ + diff --git a/ui/src/style/fonts/icomoon.ttf b/ui/src/style/fonts/icomoon.ttf index 44190b233..1cfe84f25 100755 Binary files a/ui/src/style/fonts/icomoon.ttf and b/ui/src/style/fonts/icomoon.ttf differ diff --git a/ui/src/style/fonts/icomoon.woff b/ui/src/style/fonts/icomoon.woff index 8fabc4997..68c21c369 100755 Binary files a/ui/src/style/fonts/icomoon.woff and b/ui/src/style/fonts/icomoon.woff differ diff --git a/ui/src/style/fonts/icomoon.woff2 b/ui/src/style/fonts/icomoon.woff2 index dbadaf8f0..77a391b18 100755 Binary files a/ui/src/style/fonts/icomoon.woff2 and b/ui/src/style/fonts/icomoon.woff2 differ diff --git a/ui/src/style/pages/data-explorer.scss b/ui/src/style/pages/data-explorer.scss index ae420deee..f1c9efafb 100644 --- a/ui/src/style/pages/data-explorer.scss +++ b/ui/src/style/pages/data-explorer.scss @@ -43,3 +43,6 @@ $de-graph-heading-height: 44px; @import 'data-explorer/raw-text'; @import 'data-explorer/tag-list'; @import 'data-explorer/visualization'; + +// Font size in response to screen size +@import 'data-explorer/font-scale'; diff --git a/ui/src/style/pages/data-explorer/font-scale.scss b/ui/src/style/pages/data-explorer/font-scale.scss new file mode 100644 index 000000000..5e7ce73a5 --- /dev/null +++ b/ui/src/style/pages/data-explorer/font-scale.scss @@ -0,0 +1,82 @@ +$breakpoint-a: 1500px; +$breakpoint-b: 1800px; +$breakpoint-c: 2100px; + + +@media only screen and (min-width: $breakpoint-a) { + .data-explorer { + .qeditor--list-item, + .query-builder--tab-label { + font-size: 14px; + } + .query-builder--tab { + height: 38px; + } + .graph-title { + font-size: 16px; + } + .query-builder--column-heading { + font-size: 15px; + } + } +} + +@media only screen and (min-width: $breakpoint-b) { + .data-explorer { + .query-builder--tabs { + width: 320px; + } + .qeditor--list-item, + .query-builder--tab-label { + font-size: 15px; + } + .query-builder--tab { + height: 42px; + } + .query-builder--column-heading { + font-size: 16px; + } + .query-builder--query-preview pre code { + font-size: 13.5px; + } + .toggle-sm .toggle-btn { + font-size: 14px; + } + .btn-xs { + font-size: 13.5px; + } + } +} +@media only screen and (min-width: $breakpoint-c) { + .data-explorer { + .query-builder--tabs { + width: 372px; + } + .qeditor--list-item { + letter-spacing: 0.3px; + } + .query-builder--tab-label { + font-size: 16px; + } + .query-builder--tab { + height: 46px; + } + .query-builder--column-heading { + font-size: 17px; + font-weight: 400; + text-transform: uppercase; + } + .query-builder--query-preview pre code { + font-size: 14px; + } + .toggle-sm .toggle-btn { + font-size: 14px; + } + .btn-xs { + font-size: 14px; + } + .multi-select-dropdown .dropdown-toggle { + width: 140px; + } + } +} \ No newline at end of file diff --git a/ui/src/style/pages/data-explorer/query-editor.scss b/ui/src/style/pages/data-explorer/query-editor.scss index b7f1bf6ca..16d045ea1 100644 --- a/ui/src/style/pages/data-explorer/query-editor.scss +++ b/ui/src/style/pages/data-explorer/query-editor.scss @@ -10,6 +10,8 @@ position: relative; pre { + display: flex; + align-items: center; padding: 9px; border: 0; background-color: $query-editor-tab-inactive; @@ -169,7 +171,7 @@ position: absolute; top: 15px; right: 16px; - width: calc(60% - 16px); + width: calc(50% - 16px); height: 30px; padding: 0; z-index: 10; diff --git a/ui/src/style/pages/data-explorer/tag-list.scss b/ui/src/style/pages/data-explorer/tag-list.scss index 1d011cc14..47173b2ad 100644 --- a/ui/src/style/pages/data-explorer/tag-list.scss +++ b/ui/src/style/pages/data-explorer/tag-list.scss @@ -98,8 +98,8 @@ background-color: $g3-castle; border: 2px solid $g5-pepper; color: $g13-mist; - height: 24px; - border-radius: 12px; + height: 30px; + border-radius: 15px; font-size: 13px; padding-left: 25px; outline: none; diff --git a/ui/src/style/pages/hosts.scss b/ui/src/style/pages/hosts.scss index 0f658a28c..79d8a4b80 100644 --- a/ui/src/style/pages/hosts.scss +++ b/ui/src/style/pages/hosts.scss @@ -37,9 +37,4 @@ display: flex; align-items: center; justify-content: space-between; -} - -/* Hacky way to ensure that legends cannot be obscured by neighboring graphs */ -div:not(.dashboard-edit) .react-grid-item:hover { - z-index: 8999; -} +} \ No newline at end of file diff --git a/ui/src/style/pages/kapacitor.scss b/ui/src/style/pages/kapacitor.scss index 3e7364105..e87897b66 100644 --- a/ui/src/style/pages/kapacitor.scss +++ b/ui/src/style/pages/kapacitor.scss @@ -37,9 +37,19 @@ $kapacitor-font-sm: 13px; height: (300px + ($kap-padding-sm * 2)); position: relative; - > div { - padding: 8px 16px; - position: relative; + & > div { + position: absolute; + top: 0; + left: $kap-padding-sm; + width: calc(100% - #{($kap-padding-sm * 2)}); + height: 100%; + + & > div { + position: absolute; + width: 100%; + height: 100%; + padding: 8px 16px; + } } &:before { @@ -417,12 +427,14 @@ div.qeditor.kapacitor-metric-selector { } .alert-message--endpoint { - display: flex; - align-items: center; width: auto; border-top: 2px solid $kapacitor-divider-color; - > p { + & > div { + display: flex; + align-items: center; + } + p { margin-right: $kap-padding-sm !important; } } diff --git a/ui/src/style/theme/bootstrap-theme.scss b/ui/src/style/theme/bootstrap-theme.scss index e57324f60..9b600c65e 100755 --- a/ui/src/style/theme/bootstrap-theme.scss +++ b/ui/src/style/theme/bootstrap-theme.scss @@ -266,6 +266,9 @@ .icon.user-outline:before { content: "\e91c"; } +.icon.refresh:before { + content: "\e949"; +} .icon.clock:before { content: "\e91b"; } diff --git a/ui/src/style/theme/theme-dark.scss b/ui/src/style/theme/theme-dark.scss index c28053f90..49b4b775b 100644 --- a/ui/src/style/theme/theme-dark.scss +++ b/ui/src/style/theme/theme-dark.scss @@ -261,6 +261,24 @@ input { } } } +.dropdown-header { + height: 32px; + line-height: 30px; + padding: 0 9px; + white-space: nowrap; + font-size: 14px !important; + font-weight: 900; + color: $c-neutrino !important; + text-transform: none !important; + border-bottom: 2px solid $c-pool; + background-color: $c-ocean; + + &:hover { + background-image: none !important; + background-color: $c-ocean !important; + cursor: default; + } +} /* Dropdown Actions */ .dropdown-item {