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 = ({