From f7c85429efb7a4b86c14862cbd1c3e5536cde10b Mon Sep 17 00:00:00 2001 From: Benjamin Schweizer Date: Sun, 10 Dec 2017 18:26:27 +0100 Subject: [PATCH 01/26] add support for RS256 signatures using JWKS --- oauth2/jwt.go | 102 +++++++++++++++++++++++++++++++++++++++++++---- server/server.go | 11 ++--- 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/oauth2/jwt.go b/oauth2/jwt.go index 794f44a23c..c8a2521b24 100644 --- a/oauth2/jwt.go +++ b/oauth2/jwt.go @@ -4,6 +4,11 @@ import ( "context" "fmt" "time" + "io/ioutil" + "encoding/json" + "encoding/base64" + "crypto/x509" + "net/http" gojwt "github.com/dgrijalva/jwt-go" ) @@ -14,13 +19,17 @@ var _ Tokenizer = &JWT{} // JWT represents a javascript web token that can be validated or marshaled into string. type JWT struct { Secret string + Jwksurl string Now func() time.Time } -// NewJWT creates a new JWT using time.Now; secret is used for signing and validating. -func NewJWT(secret string) *JWT { +// NewJWT creates a new JWT using time.Now +// secret is used for signing and validating signatures (HS256/HMAC) +// jwksurl is used for validating RS256 signatures. +func NewJWT(secret string, jwksurl string) *JWT { return &JWT{ Secret: secret, + Jwksurl: jwksurl, Now: DefaultNowTime, } } @@ -53,16 +62,93 @@ func (j *JWT) ValidPrincipal(ctx context.Context, jwtToken Token, lifespan time. gojwt.TimeFunc = j.Now // Check for expected signing method. - alg := func(token *gojwt.Token) (interface{}, error) { - if _, ok := token.Method.(*gojwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(j.Secret), nil - } + alg := j.KeyFunc return j.ValidClaims(jwtToken, lifespan, alg) } +// key verification for HMAC an RSA/RS256 +func (j *JWT) KeyFunc(token *gojwt.Token) (interface{}, error) { + if _, ok := token.Method.(*gojwt.SigningMethodHMAC); ok { + return []byte(j.Secret), nil + } else if _, ok := token.Method.(*gojwt.SigningMethodRSA); ok { + return j.KeyFuncRS256(token) + } + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) +} + +// For the id_token, the recommended signature algorithm is RS256, which +// means we need to verify the token against a public key. This public key +// is available from the key discovery service in JSON Web Key (JWK). +// JWK is specified in RFC 7517. +// +// The location of the key discovery service (JWKSURL) is published in the +// OpenID Provider Configuration Information at /.well-known/openid-configuration +// implements rfc7517 section 4.7 "x5c" (X.509 Certificate Chain) Parameter + +// JWKS nested struct +type JWK struct { + Kty string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + Kid string `json:"kid"` + X5t string `json:"x5t"` + N string `json:"n"` + E string `json:"e"` + X5c []string `json:"x5c"` +} + +type JWKS struct { + Keys []JWK `json:"keys"` +} + +// for RS256 signed JWT tokens, lookup the signing key in the key discovery service +func (j *JWT) KeyFuncRS256(token *gojwt.Token) (interface{}, error) { + // Don't forget to validate the alg is what you expect: + if _, ok := token.Method.(*gojwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("Unsupported signing method: %v", token.Header["alg"]) + } + + // read JWKS document from key discovery service + rr, err := http.Get(j.Jwksurl) + if err != nil { + return nil, err + } + defer rr.Body.Close() + body, err := ioutil.ReadAll(rr.Body) + if err != nil { + return nil, err + } + + // parse json to struct + var jwks JWKS + if err := json.Unmarshal([]byte(body), &jwks); err != nil { + return nil, err + } + + // extract cert when kid and alg match + var cert_pkix []byte + for _, jwk := range jwks.Keys { + if token.Header["kid"] == jwk.Kid && token.Header["alg"] == jwk.Alg { + // FIXME: optionally walk the key chain, see rfc7517 section 4.7 + cert_pkix, err = base64.StdEncoding.DecodeString(jwk.X5c[0]) + if err != nil { + return nil, fmt.Errorf("base64 decode error for JWK kid", token.Header["kid"]) + } + } + } + if cert_pkix == nil { + return nil, fmt.Errorf("no signing key found for kid", token.Header["kid"]) + } + + // parse certificate (from PKIX format) and return signing key + cert, err := x509.ParseCertificate(cert_pkix) + if err != nil { + return nil, err + } + return cert.PublicKey, nil +} + // ValidClaims validates a token with StandardClaims func (j *JWT) ValidClaims(jwtToken Token, lifespan time.Duration, alg gojwt.Keyfunc) (Principal, error) { // 1. Checks for expired tokens diff --git a/server/server.go b/server/server.go index 7687a4f872..97d1c6e3a3 100644 --- a/server/server.go +++ b/server/server.go @@ -56,6 +56,7 @@ type Server struct { BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (e.g. './chronograf-v1.db')" env:"BOLT_PATH" default:"chronograf-v1.db"` CannedPath string `short:"c" long:"canned-path" description:"Path to directory of pre-canned application layouts (/usr/share/chronograf/canned)" env:"CANNED_PATH" default:"canned"` TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"` + JwksUrl string `long:"jwks-url" description:"URL that returns OpenID Key Discovery JWKS document." env:"JWKS_URL"` AuthDuration time.Duration `long:"auth-duration" default:"720h" description:"Total duration of cookie life for authentication (in hours). 0 means authentication expires on browser close." env:"AUTH_DURATION"` SuperAdminFirstUserOnly bool `long:"superadmin-first-user-only" description:"All new users will not be given the SuperAdmin status" env:"SUPERADMIN_FIRST_USER_ONLY"` @@ -141,7 +142,7 @@ func (s *Server) githubOAuth(logger chronograf.Logger, auth oauth2.Authenticator Orgs: s.GithubOrgs, Logger: logger, } - jwt := oauth2.NewJWT(s.TokenSecret) + jwt := oauth2.NewJWT(s.TokenSecret, s.JwksUrl) ghMux := oauth2.NewAuthMux(&gh, auth, jwt, s.Basepath, logger) return &gh, ghMux, s.UseGithub } @@ -155,7 +156,7 @@ func (s *Server) googleOAuth(logger chronograf.Logger, auth oauth2.Authenticator RedirectURL: redirectURL, Logger: logger, } - jwt := oauth2.NewJWT(s.TokenSecret) + jwt := oauth2.NewJWT(s.TokenSecret, s.JwksUrl) goMux := oauth2.NewAuthMux(&google, auth, jwt, s.Basepath, logger) return &google, goMux, s.UseGoogle } @@ -167,7 +168,7 @@ func (s *Server) herokuOAuth(logger chronograf.Logger, auth oauth2.Authenticator Organizations: s.HerokuOrganizations, Logger: logger, } - jwt := oauth2.NewJWT(s.TokenSecret) + jwt := oauth2.NewJWT(s.TokenSecret, s.JwksUrl) hMux := oauth2.NewAuthMux(&heroku, auth, jwt, s.Basepath, logger) return &heroku, hMux, s.UseHeroku } @@ -186,7 +187,7 @@ func (s *Server) genericOAuth(logger chronograf.Logger, auth oauth2.Authenticato APIKey: s.GenericAPIKey, Logger: logger, } - jwt := oauth2.NewJWT(s.TokenSecret) + jwt := oauth2.NewJWT(s.TokenSecret, s.JwksUrl) genMux := oauth2.NewAuthMux(&gen, auth, jwt, s.Basepath, logger) return &gen, genMux, s.UseGenericOAuth2 } @@ -202,7 +203,7 @@ func (s *Server) auth0OAuth(logger chronograf.Logger, auth oauth2.Authenticator) auth0, err := oauth2.NewAuth0(s.Auth0Domain, s.Auth0ClientID, s.Auth0ClientSecret, redirectURL.String(), s.Auth0Organizations, logger) - jwt := oauth2.NewJWT(s.TokenSecret) + jwt := oauth2.NewJWT(s.TokenSecret, s.JwksUrl) genMux := oauth2.NewAuthMux(&auth0, auth, jwt, s.Basepath, logger) if err != nil { From c291cd01b57b8bf6e70ed9baf470c3246f4a0148 Mon Sep 17 00:00:00 2001 From: Benjamin Schweizer Date: Sun, 10 Dec 2017 18:33:50 +0100 Subject: [PATCH 02/26] handle extra id_token in callback --- oauth2/generic.go | 18 +++++++++++++++++- oauth2/jwt.go | 21 +++++++++++++++++++++ oauth2/mux.go | 42 ++++++++++++++++++++++++++++++++++-------- oauth2/oauth2.go | 3 +++ 4 files changed, 75 insertions(+), 9 deletions(-) diff --git a/oauth2/generic.go b/oauth2/generic.go index 0172c70afc..c9e71b2e6d 100644 --- a/oauth2/generic.go +++ b/oauth2/generic.go @@ -9,9 +9,17 @@ import ( "github.com/influxdata/chronograf" "golang.org/x/oauth2" + gojwt "github.com/dgrijalva/jwt-go" ) -var _ Provider = &Generic{} +// Provider interface with optional methods +type ExtendedProvider interface { + Provider + // get PrincipalID from id_token + PrincipalIDFromClaims(claims gojwt.MapClaims) (string, error) +} + +var _ ExtendedProvider = &Generic{} // Generic provides OAuth Login and Callback server and is modeled // after the Github OAuth2 provider. Callback will set an authentication @@ -159,3 +167,11 @@ func ofDomain(requiredDomains []string, email string) bool { } return false } + +// verify optional id_token and extract email address of the user +func (g *Generic) PrincipalIDFromClaims(claims gojwt.MapClaims) (string, error) { + if id, ok := claims[g.APIKey].(string); ok { + return id, nil + } + return "", fmt.Errorf("no claim for %s", g.APIKey) +} diff --git a/oauth2/jwt.go b/oauth2/jwt.go index c8a2521b24..4a73545f37 100644 --- a/oauth2/jwt.go +++ b/oauth2/jwt.go @@ -190,6 +190,27 @@ func (j *JWT) ValidClaims(jwtToken Token, lifespan time.Duration, alg gojwt.Keyf }, nil } +// get claims from id_token +func (j *JWT) GetClaims(tokenString string) (gojwt.MapClaims, error) { + var claims gojwt.MapClaims + + token, err := gojwt.Parse(tokenString, j.KeyFunc) + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, fmt.Errorf("token is not valid") + } + + claims, ok := token.Claims.(gojwt.MapClaims) + if !ok { + return nil, fmt.Errorf("token has no claims") + } + + return claims, nil +} + // Create creates a signed JWT token from user that expires at Principal's ExpireAt time. func (j *JWT) Create(ctx context.Context, user Principal) (Token, error) { // Create a new token object, specifying signing method and the claims diff --git a/oauth2/mux.go b/oauth2/mux.go index f9881cbe66..5f06bfb552 100644 --- a/oauth2/mux.go +++ b/oauth2/mux.go @@ -116,14 +116,40 @@ func (j *AuthMux) Callback() http.Handler { return } - // Using the token get the principal identifier from the provider - oauthClient := conf.Client(r.Context(), token) - id, err := j.Provider.PrincipalID(oauthClient) - if err != nil { - log.Error("Unable to get principal identifier ", err.Error()) - http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) - return - } + // if we received an extra id_token, inspect it + var id string + if tokenString, ok := token.Extra("id_token").(string); ok { + log.Debug("token provides extra id_token") + if provider, ok := j.Provider.(ExtendedProvider); ok { + log.Debug("provider implements PrincipalIDFromClaims()") + claims, err := j.Tokens.GetClaims(tokenString) + if err != nil { + log.Error("parsing extra id_token failed:", err) + http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) + return + } + log.Debug("found claims: ", claims) + if id, err = provider.PrincipalIDFromClaims(claims); err != nil { + log.Error("claim not found:", err) + http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) + return + } + } else { + log.Debug("provider does not implement PrincipalIDFromClaims()") + } + } + + // otherwise perform an additional lookup + if id == "" { + // Using the token get the principal identifier from the provider + oauthClient := conf.Client(r.Context(), token) + id, err = j.Provider.PrincipalID(oauthClient) + if err != nil { + log.Error("Unable to get principal identifier ", err.Error()) + http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) + return + } + } p := Principal{ Subject: id, diff --git a/oauth2/oauth2.go b/oauth2/oauth2.go index ef0c44b51c..6be0869922 100644 --- a/oauth2/oauth2.go +++ b/oauth2/oauth2.go @@ -7,6 +7,7 @@ import ( "time" "golang.org/x/oauth2" + gojwt "github.com/dgrijalva/jwt-go" ) type principalKey string @@ -90,4 +91,6 @@ type Tokenizer interface { ValidPrincipal(ctx context.Context, token Token, lifespan time.Duration) (Principal, error) // ExtendedPrincipal adds the extention to the principal's lifespan. ExtendedPrincipal(ctx context.Context, principal Principal, extension time.Duration) (Principal, error) + // GetClaims returns a map with verified claims + GetClaims(tokenString string) (gojwt.MapClaims, error) } From 0f9fb94e42704defaa5df46ad6d6196d6f3aaa2c Mon Sep 17 00:00:00 2001 From: Benjamin Schweizer Date: Wed, 13 Dec 2017 09:13:11 +0100 Subject: [PATCH 03/26] go fmt + got vet --- oauth2/generic.go | 16 ++--- oauth2/jwt.go | 160 +++++++++++++++++++++++----------------------- oauth2/mux.go | 70 ++++++++++---------- oauth2/oauth2.go | 2 +- server/server.go | 2 +- 5 files changed, 125 insertions(+), 125 deletions(-) diff --git a/oauth2/generic.go b/oauth2/generic.go index c9e71b2e6d..8f6e1f50c6 100644 --- a/oauth2/generic.go +++ b/oauth2/generic.go @@ -7,16 +7,16 @@ import ( "net/http" "strings" + gojwt "github.com/dgrijalva/jwt-go" "github.com/influxdata/chronograf" "golang.org/x/oauth2" - gojwt "github.com/dgrijalva/jwt-go" ) // Provider interface with optional methods type ExtendedProvider interface { - Provider - // get PrincipalID from id_token - PrincipalIDFromClaims(claims gojwt.MapClaims) (string, error) + Provider + // get PrincipalID from id_token + PrincipalIDFromClaims(claims gojwt.MapClaims) (string, error) } var _ ExtendedProvider = &Generic{} @@ -170,8 +170,8 @@ func ofDomain(requiredDomains []string, email string) bool { // verify optional id_token and extract email address of the user func (g *Generic) PrincipalIDFromClaims(claims gojwt.MapClaims) (string, error) { - if id, ok := claims[g.APIKey].(string); ok { - return id, nil - } - return "", fmt.Errorf("no claim for %s", g.APIKey) + if id, ok := claims[g.APIKey].(string); ok { + return id, nil + } + return "", fmt.Errorf("no claim for %s", g.APIKey) } diff --git a/oauth2/jwt.go b/oauth2/jwt.go index 4a73545f37..6c6053ae9e 100644 --- a/oauth2/jwt.go +++ b/oauth2/jwt.go @@ -2,13 +2,13 @@ package oauth2 import ( "context" - "fmt" - "time" - "io/ioutil" - "encoding/json" - "encoding/base64" "crypto/x509" + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" "net/http" + "time" gojwt "github.com/dgrijalva/jwt-go" ) @@ -18,9 +18,9 @@ var _ Tokenizer = &JWT{} // JWT represents a javascript web token that can be validated or marshaled into string. type JWT struct { - Secret string - Jwksurl string - Now func() time.Time + Secret string + Jwksurl string + Now func() time.Time } // NewJWT creates a new JWT using time.Now @@ -28,9 +28,9 @@ type JWT struct { // jwksurl is used for validating RS256 signatures. func NewJWT(secret string, jwksurl string) *JWT { return &JWT{ - Secret: secret, - Jwksurl: jwksurl, - Now: DefaultNowTime, + Secret: secret, + Jwksurl: jwksurl, + Now: DefaultNowTime, } } @@ -62,91 +62,91 @@ func (j *JWT) ValidPrincipal(ctx context.Context, jwtToken Token, lifespan time. gojwt.TimeFunc = j.Now // Check for expected signing method. - alg := j.KeyFunc + alg := j.KeyFunc return j.ValidClaims(jwtToken, lifespan, alg) } // key verification for HMAC an RSA/RS256 func (j *JWT) KeyFunc(token *gojwt.Token) (interface{}, error) { - if _, ok := token.Method.(*gojwt.SigningMethodHMAC); ok { - return []byte(j.Secret), nil - } else if _, ok := token.Method.(*gojwt.SigningMethodRSA); ok { - return j.KeyFuncRS256(token) - } - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + if _, ok := token.Method.(*gojwt.SigningMethodHMAC); ok { + return []byte(j.Secret), nil + } else if _, ok := token.Method.(*gojwt.SigningMethodRSA); ok { + return j.KeyFuncRS256(token) + } + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } // For the id_token, the recommended signature algorithm is RS256, which // means we need to verify the token against a public key. This public key // is available from the key discovery service in JSON Web Key (JWK). // JWK is specified in RFC 7517. -// +// // The location of the key discovery service (JWKSURL) is published in the // OpenID Provider Configuration Information at /.well-known/openid-configuration // implements rfc7517 section 4.7 "x5c" (X.509 Certificate Chain) Parameter // JWKS nested struct type JWK struct { - Kty string `json:"kty"` - Use string `json:"use"` - Alg string `json:"alg"` - Kid string `json:"kid"` - X5t string `json:"x5t"` - N string `json:"n"` - E string `json:"e"` - X5c []string `json:"x5c"` + Kty string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + Kid string `json:"kid"` + X5t string `json:"x5t"` + N string `json:"n"` + E string `json:"e"` + X5c []string `json:"x5c"` } type JWKS struct { - Keys []JWK `json:"keys"` + Keys []JWK `json:"keys"` } // for RS256 signed JWT tokens, lookup the signing key in the key discovery service func (j *JWT) KeyFuncRS256(token *gojwt.Token) (interface{}, error) { - // Don't forget to validate the alg is what you expect: - if _, ok := token.Method.(*gojwt.SigningMethodRSA); !ok { - return nil, fmt.Errorf("Unsupported signing method: %v", token.Header["alg"]) - } + // Don't forget to validate the alg is what you expect: + if _, ok := token.Method.(*gojwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("Unsupported signing method: %v", token.Header["alg"]) + } - // read JWKS document from key discovery service - rr, err := http.Get(j.Jwksurl) - if err != nil { - return nil, err - } - defer rr.Body.Close() - body, err := ioutil.ReadAll(rr.Body) - if err != nil { - return nil, err - } + // read JWKS document from key discovery service + rr, err := http.Get(j.Jwksurl) + if err != nil { + return nil, err + } + defer rr.Body.Close() + body, err := ioutil.ReadAll(rr.Body) + if err != nil { + return nil, err + } - // parse json to struct - var jwks JWKS - if err := json.Unmarshal([]byte(body), &jwks); err != nil { - return nil, err - } + // parse json to struct + var jwks JWKS + if err := json.Unmarshal([]byte(body), &jwks); err != nil { + return nil, err + } - // extract cert when kid and alg match - var cert_pkix []byte - for _, jwk := range jwks.Keys { - if token.Header["kid"] == jwk.Kid && token.Header["alg"] == jwk.Alg { - // FIXME: optionally walk the key chain, see rfc7517 section 4.7 - cert_pkix, err = base64.StdEncoding.DecodeString(jwk.X5c[0]) - if err != nil { - return nil, fmt.Errorf("base64 decode error for JWK kid", token.Header["kid"]) - } - } - } - if cert_pkix == nil { - return nil, fmt.Errorf("no signing key found for kid", token.Header["kid"]) - } + // extract cert when kid and alg match + var cert_pkix []byte + for _, jwk := range jwks.Keys { + if token.Header["kid"] == jwk.Kid && token.Header["alg"] == jwk.Alg { + // FIXME: optionally walk the key chain, see rfc7517 section 4.7 + cert_pkix, err = base64.StdEncoding.DecodeString(jwk.X5c[0]) + if err != nil { + return nil, fmt.Errorf("base64 decode error for JWK kid %v", token.Header["kid"]) + } + } + } + if cert_pkix == nil { + return nil, fmt.Errorf("no signing key found for kid %v", token.Header["kid"]) + } - // parse certificate (from PKIX format) and return signing key - cert, err := x509.ParseCertificate(cert_pkix) - if err != nil { - return nil, err - } - return cert.PublicKey, nil + // parse certificate (from PKIX format) and return signing key + cert, err := x509.ParseCertificate(cert_pkix) + if err != nil { + return nil, err + } + return cert.PublicKey, nil } // ValidClaims validates a token with StandardClaims @@ -192,23 +192,23 @@ func (j *JWT) ValidClaims(jwtToken Token, lifespan time.Duration, alg gojwt.Keyf // get claims from id_token func (j *JWT) GetClaims(tokenString string) (gojwt.MapClaims, error) { - var claims gojwt.MapClaims + var claims gojwt.MapClaims - token, err := gojwt.Parse(tokenString, j.KeyFunc) - if err != nil { - return nil, err - } + token, err := gojwt.Parse(tokenString, j.KeyFunc) + if err != nil { + return nil, err + } - if !token.Valid { - return nil, fmt.Errorf("token is not valid") - } + if !token.Valid { + return nil, fmt.Errorf("token is not valid") + } - claims, ok := token.Claims.(gojwt.MapClaims) - if !ok { - return nil, fmt.Errorf("token has no claims") - } + claims, ok := token.Claims.(gojwt.MapClaims) + if !ok { + return nil, fmt.Errorf("token has no claims") + } - return claims, nil + return claims, nil } // Create creates a signed JWT token from user that expires at Principal's ExpireAt time. diff --git a/oauth2/mux.go b/oauth2/mux.go index 5f06bfb552..5808bf3759 100644 --- a/oauth2/mux.go +++ b/oauth2/mux.go @@ -23,8 +23,8 @@ func NewAuthMux(p Provider, a Authenticator, t Tokenizer, basepath string, l chr Tokens: t, SuccessURL: path.Join(basepath, "/"), FailureURL: path.Join(basepath, "/login"), - Now: DefaultNowTime, - Logger: l, + Now: DefaultNowTime, + Logger: l, } } @@ -116,40 +116,40 @@ func (j *AuthMux) Callback() http.Handler { return } - // if we received an extra id_token, inspect it - var id string - if tokenString, ok := token.Extra("id_token").(string); ok { - log.Debug("token provides extra id_token") - if provider, ok := j.Provider.(ExtendedProvider); ok { - log.Debug("provider implements PrincipalIDFromClaims()") - claims, err := j.Tokens.GetClaims(tokenString) - if err != nil { - log.Error("parsing extra id_token failed:", err) - http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) - return - } - log.Debug("found claims: ", claims) - if id, err = provider.PrincipalIDFromClaims(claims); err != nil { - log.Error("claim not found:", err) - http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) - return - } - } else { - log.Debug("provider does not implement PrincipalIDFromClaims()") - } - } + // if we received an extra id_token, inspect it + var id string + if tokenString, ok := token.Extra("id_token").(string); ok { + log.Debug("token provides extra id_token") + if provider, ok := j.Provider.(ExtendedProvider); ok { + log.Debug("provider implements PrincipalIDFromClaims()") + claims, err := j.Tokens.GetClaims(tokenString) + if err != nil { + log.Error("parsing extra id_token failed:", err) + http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) + return + } + log.Debug("found claims: ", claims) + if id, err = provider.PrincipalIDFromClaims(claims); err != nil { + log.Error("claim not found:", err) + http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) + return + } + } else { + log.Debug("provider does not implement PrincipalIDFromClaims()") + } + } - // otherwise perform an additional lookup - if id == "" { - // Using the token get the principal identifier from the provider - oauthClient := conf.Client(r.Context(), token) - id, err = j.Provider.PrincipalID(oauthClient) - if err != nil { - log.Error("Unable to get principal identifier ", err.Error()) - http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) - return - } - } + // otherwise perform an additional lookup + if id == "" { + // Using the token get the principal identifier from the provider + oauthClient := conf.Client(r.Context(), token) + id, err = j.Provider.PrincipalID(oauthClient) + if err != nil { + log.Error("Unable to get principal identifier ", err.Error()) + http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) + return + } + } p := Principal{ Subject: id, diff --git a/oauth2/oauth2.go b/oauth2/oauth2.go index 6be0869922..8a548470d6 100644 --- a/oauth2/oauth2.go +++ b/oauth2/oauth2.go @@ -6,8 +6,8 @@ import ( "net/http" "time" - "golang.org/x/oauth2" gojwt "github.com/dgrijalva/jwt-go" + "golang.org/x/oauth2" ) type principalKey string diff --git a/server/server.go b/server/server.go index 97d1c6e3a3..2db4dcdd69 100644 --- a/server/server.go +++ b/server/server.go @@ -56,7 +56,7 @@ type Server struct { BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (e.g. './chronograf-v1.db')" env:"BOLT_PATH" default:"chronograf-v1.db"` CannedPath string `short:"c" long:"canned-path" description:"Path to directory of pre-canned application layouts (/usr/share/chronograf/canned)" env:"CANNED_PATH" default:"canned"` TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"` - JwksUrl string `long:"jwks-url" description:"URL that returns OpenID Key Discovery JWKS document." env:"JWKS_URL"` + JwksUrl string `long:"jwks-url" description:"URL that returns OpenID Key Discovery JWKS document." env:"JWKS_URL"` AuthDuration time.Duration `long:"auth-duration" default:"720h" description:"Total duration of cookie life for authentication (in hours). 0 means authentication expires on browser close." env:"AUTH_DURATION"` SuperAdminFirstUserOnly bool `long:"superadmin-first-user-only" description:"All new users will not be given the SuperAdmin status" env:"SUPERADMIN_FIRST_USER_ONLY"` From cd38be3eba048f057ef441b3be777e595fe4b18c Mon Sep 17 00:00:00 2001 From: Benjamin Schweizer Date: Wed, 13 Dec 2017 15:51:20 +0100 Subject: [PATCH 04/26] fixed initialization, added expressive error message --- oauth2/jwt.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/oauth2/jwt.go b/oauth2/jwt.go index 6c6053ae9e..4ba97dc9fa 100644 --- a/oauth2/jwt.go +++ b/oauth2/jwt.go @@ -110,6 +110,10 @@ func (j *JWT) KeyFuncRS256(token *gojwt.Token) (interface{}, error) { } // read JWKS document from key discovery service + if j.Jwksurl == "" { + return nil, fmt.Errorf("JWKSURL not specified, cannot validate RS256 signature") + } + rr, err := http.Get(j.Jwksurl) if err != nil { return nil, err @@ -194,6 +198,7 @@ func (j *JWT) ValidClaims(jwtToken Token, lifespan time.Duration, alg gojwt.Keyf func (j *JWT) GetClaims(tokenString string) (gojwt.MapClaims, error) { var claims gojwt.MapClaims + gojwt.TimeFunc = j.Now token, err := gojwt.Parse(tokenString, j.KeyFunc) if err != nil { return nil, err From b17c89f7556ffb0fe237e2c8fccce4d95d7be92d Mon Sep 17 00:00:00 2001 From: Benjamin Schweizer Date: Wed, 13 Dec 2017 15:55:21 +0100 Subject: [PATCH 05/26] updated existing test cases to interface changes --- oauth2/cookies_test.go | 5 +++++ oauth2/jwt_test.go | 4 ++-- oauth2/oauth2_test.go | 5 +++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/oauth2/cookies_test.go b/oauth2/cookies_test.go index e404137211..7acdfa4f21 100644 --- a/oauth2/cookies_test.go +++ b/oauth2/cookies_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" "time" + gojwt "github.com/dgrijalva/jwt-go" ) type MockTokenizer struct { @@ -32,6 +33,10 @@ func (m *MockTokenizer) ExtendedPrincipal(ctx context.Context, principal Princip return principal, m.ExtendErr } +func (m *MockTokenizer) GetClaims(tokenString string) (gojwt.MapClaims, error) { + return gojwt.MapClaims{}, nil +} + func TestCookieAuthorize(t *testing.T) { var test = []struct { Desc string diff --git a/oauth2/jwt_test.go b/oauth2/jwt_test.go index d8ba1f1eae..5ac600fdea 100644 --- a/oauth2/jwt_test.go +++ b/oauth2/jwt_test.go @@ -152,8 +152,8 @@ func TestSigningMethod(t *testing.T) { j := oauth2.JWT{} if _, err := j.ValidPrincipal(context.Background(), token, 0); err == nil { t.Error("Error was expected while validating incorrectly signed token") - } else if err.Error() != "unexpected signing method: RS256" { - t.Errorf("Error wanted 'unexpected signing method', got %s", err.Error()) + } else if err.Error() != "JWKSURL not specified, cannot validate RS256 signature" { + t.Errorf("Error wanted 'JWKSURL not specified, cannot validate RS256 signature', got %s", err.Error()) } } diff --git a/oauth2/oauth2_test.go b/oauth2/oauth2_test.go index 48e4929ce0..23cc16aa85 100644 --- a/oauth2/oauth2_test.go +++ b/oauth2/oauth2_test.go @@ -10,6 +10,7 @@ import ( goauth "golang.org/x/oauth2" "github.com/influxdata/chronograf" + gojwt "github.com/dgrijalva/jwt-go" ) var _ Provider = &MockProvider{} @@ -71,6 +72,10 @@ func (y *YesManTokenizer) ExtendedPrincipal(ctx context.Context, p Principal, ex return p, nil } +func (m *YesManTokenizer) GetClaims(tokenString string) (gojwt.MapClaims, error) { + return gojwt.MapClaims{}, nil +} + func NewTestTripper(log chronograf.Logger, ts *httptest.Server, rt http.RoundTripper) (*TestTripper, error) { url, err := url.Parse(ts.URL) if err != nil { From 30d03f78b6c7261c3f96b22999dacc9a16c286ba Mon Sep 17 00:00:00 2001 From: Benjamin Schweizer Date: Wed, 13 Dec 2017 16:00:21 +0100 Subject: [PATCH 06/26] added test cases for ValidClaims() --- oauth2/cookies_test.go | 4 +-- oauth2/jwt_test.go | 77 ++++++++++++++++++++++++++++++++++++++++++ oauth2/oauth2_test.go | 4 +-- 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/oauth2/cookies_test.go b/oauth2/cookies_test.go index 7acdfa4f21..18ef96191e 100644 --- a/oauth2/cookies_test.go +++ b/oauth2/cookies_test.go @@ -3,6 +3,7 @@ package oauth2 import ( "context" "fmt" + gojwt "github.com/dgrijalva/jwt-go" "log" "net/http" "net/http/httptest" @@ -10,7 +11,6 @@ import ( "strings" "testing" "time" - gojwt "github.com/dgrijalva/jwt-go" ) type MockTokenizer struct { @@ -34,7 +34,7 @@ func (m *MockTokenizer) ExtendedPrincipal(ctx context.Context, principal Princip } func (m *MockTokenizer) GetClaims(tokenString string) (gojwt.MapClaims, error) { - return gojwt.MapClaims{}, nil + return gojwt.MapClaims{}, nil } func TestCookieAuthorize(t *testing.T) { diff --git a/oauth2/jwt_test.go b/oauth2/jwt_test.go index 5ac600fdea..af02e5833c 100644 --- a/oauth2/jwt_test.go +++ b/oauth2/jwt_test.go @@ -3,6 +3,9 @@ package oauth2_test import ( "context" "errors" + "io" + "net/http" + "net/http/httptest" "reflect" "testing" "time" @@ -157,6 +160,80 @@ func TestSigningMethod(t *testing.T) { } } +func TestGetClaims(t *testing.T) { + var tests = []struct { + Name string + TokenString string + JwksDocument string + Iat int64 + Err error + }{ + { + Name: "Valid Token with RS256 signature verified against correct JWKS document", + TokenString: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSIsImtpZCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSJ9.eyJhdWQiOiJjaHJvbm9ncmFmIiwiaXNzIjoiaHR0cHM6Ly9kc3RjaW1hYWQxcC5kc3QtaXRzLmRlL2FkZnMiLCJpYXQiOjE1MTMxNjU4ODksImV4cCI6MTUxMzE2OTQ4OSwiYXV0aF90aW1lIjoxNTEzMTY1ODg4LCJzdWIiOiJlWVYzamRsZE55RlkxcUZGSDRvQWRCdkRGZmJWZm51RzI5SGlIa1N1andrPSIsInVwbiI6ImJzY0Bkc3QtaXRzLmRlIiwidW5pcXVlX25hbWUiOiJEU1RcXGJzYyIsInNpZCI6IlMtMS01LTIxLTI1MDUxNTEzOTgtMjY2MTAyODEwOS0zNzU0MjY1ODIwLTExMDQifQ.nK51Ui4XN45SVul9igNaKFQd-F63BNstBzW-T5LBVm_ANHCEHyP3_88C3ffkkQIi3PxYacRJGtfswP35ws7YJUcNp-GoGZARqz62NpMtbQyhos6mCaVXwPoxPbrZx4AkMQgxkZwJcOzceX7mpjcT3kCth30chN3lkhzSjGrXe4ZDOAV25liS-dsdBiqDiaTB91sS534GM76qJQxFUs51oSbYTRdCN1VJ0XopMcasfVDzFrtSbyvEIVXlpKK2HplnhheqF4QHrM_3cjV_NGRr3tYLe-AGTdDXKWlJD1GDz1ECXeMGQHPoz3U8cqNsFLYBstIlCgfnBWgWsPZSvJPJUg", + JwksDocument: `{"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"YDBUhqdWksKXdGuX0sytjaUuxhA","x5t":"YDBUhqdWksKXdGuX0sytjaUuxhA","n":"uwVVrs5OJRKeLUk0H5N_b4Jbvff3rxlg3WIeOO-zSSPTC5oFOc5_te0rLgVoNJJB4rNM4A7BEXI885xLrjfL3l3LHqaJetvR0tdLAnkvbUKUiGxnuGnmOsgh491P95pHPIAniy2p64FQoBbTJ0a6cF5LRuPPHKVXgjXjTydvmKrt_IVaWUDgICRsw5Bbv290SahmxcdO3akSgfsZtRkR8SmaMzAPYINi2_8P2evaKAnMQLTgUVkctaEamO_6HJ5f5sWheV7trLekU35xPVkPwShDelefnhyJcO5yICXqXzuewBEni9LrxAEJYN2rYfiFQWJy-pDe5DPUBs-IFTpctQ","e":"AQAB","x5c":["MIIC6DCCAdCgAwIBAgIQPszqLhbrpZlE+jEJTyJg7jANBgkqhkiG9w0BAQsFADAwMS4wLAYDVQQDEyVBREZTIFNpZ25pbmcgLSBkc3RjaW1hYWQxcC5kc3QtaXRzLmRlMB4XDTE3MTIwNDE0MDEwOFoXDTE4MTIwNDE0MDEwOFowMDEuMCwGA1UEAxMlQURGUyBTaWduaW5nIC0gZHN0Y2ltYWFkMXAuZHN0LWl0cy5kZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALsFVa7OTiUSni1JNB+Tf2+CW733968ZYN1iHjjvs0kj0wuaBTnOf7XtKy4FaDSSQeKzTOAOwRFyPPOcS643y95dyx6miXrb0dLXSwJ5L21ClIhsZ7hp5jrIIePdT\/eaRzyAJ4stqeuBUKAW0ydGunBeS0bjzxylV4I1408nb5iq7fyFWllA4CAkbMOQW79vdEmoZsXHTt2pEoH7GbUZEfEpmjMwD2CDYtv\/D9nr2igJzEC04FFZHLWhGpjv+hyeX+bFoXle7ay3pFN+cT1ZD8EoQ3pXn54ciXDuciAl6l87nsARJ4vS68QBCWDdq2H4hUFicvqQ3uQz1AbPiBU6XLUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAPHCisZyPf\/fuuQEW5LyzZSYMwBRYVR6kk\/M2ZNx6TrUEwmOb10RQ3G97bLAshN44g5lWdPYz4EOt6d2o71etIjf79f+IR0MAjEgBB2HThaHcMU9KG229Ftcauie9XeurngMawTRu60YqH7+go8EMf6a1Kdnx37DMy\/1LRlsYJVfEoOCab3GgcIdXrRSYWqsY4SVJZiTPYdqz9vmNPSXXiDSOTl6qXHV\/f53WTS2V5aIQbuJJziXlceusuVNny0o5h+j6ovZ1HhEGAu3lpD+8kY8KUqA4kXMH3VNZqzHBYazJx\/QBB3bG45cZSOvV3gUOnGBgiv9NBWjhvmY0fC3J6Q=="]}]}`, + Iat: int64(1513165889), + }, + { + Name: "Valid Token with RS256 signature verified against correct JWKS document but predated", + TokenString: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSIsImtpZCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSJ9.eyJhdWQiOiJjaHJvbm9ncmFmIiwiaXNzIjoiaHR0cHM6Ly9kc3RjaW1hYWQxcC5kc3QtaXRzLmRlL2FkZnMiLCJpYXQiOjE1MTMxNjU4ODksImV4cCI6MTUxMzE2OTQ4OSwiYXV0aF90aW1lIjoxNTEzMTY1ODg4LCJzdWIiOiJlWVYzamRsZE55RlkxcUZGSDRvQWRCdkRGZmJWZm51RzI5SGlIa1N1andrPSIsInVwbiI6ImJzY0Bkc3QtaXRzLmRlIiwidW5pcXVlX25hbWUiOiJEU1RcXGJzYyIsInNpZCI6IlMtMS01LTIxLTI1MDUxNTEzOTgtMjY2MTAyODEwOS0zNzU0MjY1ODIwLTExMDQifQ.nK51Ui4XN45SVul9igNaKFQd-F63BNstBzW-T5LBVm_ANHCEHyP3_88C3ffkkQIi3PxYacRJGtfswP35ws7YJUcNp-GoGZARqz62NpMtbQyhos6mCaVXwPoxPbrZx4AkMQgxkZwJcOzceX7mpjcT3kCth30chN3lkhzSjGrXe4ZDOAV25liS-dsdBiqDiaTB91sS534GM76qJQxFUs51oSbYTRdCN1VJ0XopMcasfVDzFrtSbyvEIVXlpKK2HplnhheqF4QHrM_3cjV_NGRr3tYLe-AGTdDXKWlJD1GDz1ECXeMGQHPoz3U8cqNsFLYBstIlCgfnBWgWsPZSvJPJUg", + JwksDocument: `{"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"YDBUhqdWksKXdGuX0sytjaUuxhA","x5t":"YDBUhqdWksKXdGuX0sytjaUuxhA","n":"uwVVrs5OJRKeLUk0H5N_b4Jbvff3rxlg3WIeOO-zSSPTC5oFOc5_te0rLgVoNJJB4rNM4A7BEXI885xLrjfL3l3LHqaJetvR0tdLAnkvbUKUiGxnuGnmOsgh491P95pHPIAniy2p64FQoBbTJ0a6cF5LRuPPHKVXgjXjTydvmKrt_IVaWUDgICRsw5Bbv290SahmxcdO3akSgfsZtRkR8SmaMzAPYINi2_8P2evaKAnMQLTgUVkctaEamO_6HJ5f5sWheV7trLekU35xPVkPwShDelefnhyJcO5yICXqXzuewBEni9LrxAEJYN2rYfiFQWJy-pDe5DPUBs-IFTpctQ","e":"AQAB","x5c":["MIIC6DCCAdCgAwIBAgIQPszqLhbrpZlE+jEJTyJg7jANBgkqhkiG9w0BAQsFADAwMS4wLAYDVQQDEyVBREZTIFNpZ25pbmcgLSBkc3RjaW1hYWQxcC5kc3QtaXRzLmRlMB4XDTE3MTIwNDE0MDEwOFoXDTE4MTIwNDE0MDEwOFowMDEuMCwGA1UEAxMlQURGUyBTaWduaW5nIC0gZHN0Y2ltYWFkMXAuZHN0LWl0cy5kZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALsFVa7OTiUSni1JNB+Tf2+CW733968ZYN1iHjjvs0kj0wuaBTnOf7XtKy4FaDSSQeKzTOAOwRFyPPOcS643y95dyx6miXrb0dLXSwJ5L21ClIhsZ7hp5jrIIePdT\/eaRzyAJ4stqeuBUKAW0ydGunBeS0bjzxylV4I1408nb5iq7fyFWllA4CAkbMOQW79vdEmoZsXHTt2pEoH7GbUZEfEpmjMwD2CDYtv\/D9nr2igJzEC04FFZHLWhGpjv+hyeX+bFoXle7ay3pFN+cT1ZD8EoQ3pXn54ciXDuciAl6l87nsARJ4vS68QBCWDdq2H4hUFicvqQ3uQz1AbPiBU6XLUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAPHCisZyPf\/fuuQEW5LyzZSYMwBRYVR6kk\/M2ZNx6TrUEwmOb10RQ3G97bLAshN44g5lWdPYz4EOt6d2o71etIjf79f+IR0MAjEgBB2HThaHcMU9KG229Ftcauie9XeurngMawTRu60YqH7+go8EMf6a1Kdnx37DMy\/1LRlsYJVfEoOCab3GgcIdXrRSYWqsY4SVJZiTPYdqz9vmNPSXXiDSOTl6qXHV\/f53WTS2V5aIQbuJJziXlceusuVNny0o5h+j6ovZ1HhEGAu3lpD+8kY8KUqA4kXMH3VNZqzHBYazJx\/QBB3bG45cZSOvV3gUOnGBgiv9NBWjhvmY0fC3J6Q=="]}]}`, + Iat: int64(1513165889) - 1, + Err: errors.New("Token used before issued"), + }, + { + Name: "Valid Token with RS256 signature verified against correct JWKS document but outdated", + TokenString: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSIsImtpZCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSJ9.eyJhdWQiOiJjaHJvbm9ncmFmIiwiaXNzIjoiaHR0cHM6Ly9kc3RjaW1hYWQxcC5kc3QtaXRzLmRlL2FkZnMiLCJpYXQiOjE1MTMxNjU4ODksImV4cCI6MTUxMzE2OTQ4OSwiYXV0aF90aW1lIjoxNTEzMTY1ODg4LCJzdWIiOiJlWVYzamRsZE55RlkxcUZGSDRvQWRCdkRGZmJWZm51RzI5SGlIa1N1andrPSIsInVwbiI6ImJzY0Bkc3QtaXRzLmRlIiwidW5pcXVlX25hbWUiOiJEU1RcXGJzYyIsInNpZCI6IlMtMS01LTIxLTI1MDUxNTEzOTgtMjY2MTAyODEwOS0zNzU0MjY1ODIwLTExMDQifQ.nK51Ui4XN45SVul9igNaKFQd-F63BNstBzW-T5LBVm_ANHCEHyP3_88C3ffkkQIi3PxYacRJGtfswP35ws7YJUcNp-GoGZARqz62NpMtbQyhos6mCaVXwPoxPbrZx4AkMQgxkZwJcOzceX7mpjcT3kCth30chN3lkhzSjGrXe4ZDOAV25liS-dsdBiqDiaTB91sS534GM76qJQxFUs51oSbYTRdCN1VJ0XopMcasfVDzFrtSbyvEIVXlpKK2HplnhheqF4QHrM_3cjV_NGRr3tYLe-AGTdDXKWlJD1GDz1ECXeMGQHPoz3U8cqNsFLYBstIlCgfnBWgWsPZSvJPJUg", + JwksDocument: `{"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"YDBUhqdWksKXdGuX0sytjaUuxhA","x5t":"YDBUhqdWksKXdGuX0sytjaUuxhA","n":"uwVVrs5OJRKeLUk0H5N_b4Jbvff3rxlg3WIeOO-zSSPTC5oFOc5_te0rLgVoNJJB4rNM4A7BEXI885xLrjfL3l3LHqaJetvR0tdLAnkvbUKUiGxnuGnmOsgh491P95pHPIAniy2p64FQoBbTJ0a6cF5LRuPPHKVXgjXjTydvmKrt_IVaWUDgICRsw5Bbv290SahmxcdO3akSgfsZtRkR8SmaMzAPYINi2_8P2evaKAnMQLTgUVkctaEamO_6HJ5f5sWheV7trLekU35xPVkPwShDelefnhyJcO5yICXqXzuewBEni9LrxAEJYN2rYfiFQWJy-pDe5DPUBs-IFTpctQ","e":"AQAB","x5c":["MIIC6DCCAdCgAwIBAgIQPszqLhbrpZlE+jEJTyJg7jANBgkqhkiG9w0BAQsFADAwMS4wLAYDVQQDEyVBREZTIFNpZ25pbmcgLSBkc3RjaW1hYWQxcC5kc3QtaXRzLmRlMB4XDTE3MTIwNDE0MDEwOFoXDTE4MTIwNDE0MDEwOFowMDEuMCwGA1UEAxMlQURGUyBTaWduaW5nIC0gZHN0Y2ltYWFkMXAuZHN0LWl0cy5kZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALsFVa7OTiUSni1JNB+Tf2+CW733968ZYN1iHjjvs0kj0wuaBTnOf7XtKy4FaDSSQeKzTOAOwRFyPPOcS643y95dyx6miXrb0dLXSwJ5L21ClIhsZ7hp5jrIIePdT\/eaRzyAJ4stqeuBUKAW0ydGunBeS0bjzxylV4I1408nb5iq7fyFWllA4CAkbMOQW79vdEmoZsXHTt2pEoH7GbUZEfEpmjMwD2CDYtv\/D9nr2igJzEC04FFZHLWhGpjv+hyeX+bFoXle7ay3pFN+cT1ZD8EoQ3pXn54ciXDuciAl6l87nsARJ4vS68QBCWDdq2H4hUFicvqQ3uQz1AbPiBU6XLUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAPHCisZyPf\/fuuQEW5LyzZSYMwBRYVR6kk\/M2ZNx6TrUEwmOb10RQ3G97bLAshN44g5lWdPYz4EOt6d2o71etIjf79f+IR0MAjEgBB2HThaHcMU9KG229Ftcauie9XeurngMawTRu60YqH7+go8EMf6a1Kdnx37DMy\/1LRlsYJVfEoOCab3GgcIdXrRSYWqsY4SVJZiTPYdqz9vmNPSXXiDSOTl6qXHV\/f53WTS2V5aIQbuJJziXlceusuVNny0o5h+j6ovZ1HhEGAu3lpD+8kY8KUqA4kXMH3VNZqzHBYazJx\/QBB3bG45cZSOvV3gUOnGBgiv9NBWjhvmY0fC3J6Q=="]}]}`, + Iat: int64(1513165889) + 3601, + Err: errors.New("Token is expired"), + }, + { + Name: "Valid Token with RS256 signature verified against empty JWKS document", + TokenString: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSIsImtpZCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSJ9.eyJhdWQiOiJjaHJvbm9ncmFmIiwiaXNzIjoiaHR0cHM6Ly9kc3RjaW1hYWQxcC5kc3QtaXRzLmRlL2FkZnMiLCJpYXQiOjE1MTMxNjU4ODksImV4cCI6MTUxMzE2OTQ4OSwiYXV0aF90aW1lIjoxNTEzMTY1ODg4LCJzdWIiOiJlWVYzamRsZE55RlkxcUZGSDRvQWRCdkRGZmJWZm51RzI5SGlIa1N1andrPSIsInVwbiI6ImJzY0Bkc3QtaXRzLmRlIiwidW5pcXVlX25hbWUiOiJEU1RcXGJzYyIsInNpZCI6IlMtMS01LTIxLTI1MDUxNTEzOTgtMjY2MTAyODEwOS0zNzU0MjY1ODIwLTExMDQifQ.nK51Ui4XN45SVul9igNaKFQd-F63BNstBzW-T5LBVm_ANHCEHyP3_88C3ffkkQIi3PxYacRJGtfswP35ws7YJUcNp-GoGZARqz62NpMtbQyhos6mCaVXwPoxPbrZx4AkMQgxkZwJcOzceX7mpjcT3kCth30chN3lkhzSjGrXe4ZDOAV25liS-dsdBiqDiaTB91sS534GM76qJQxFUs51oSbYTRdCN1VJ0XopMcasfVDzFrtSbyvEIVXlpKK2HplnhheqF4QHrM_3cjV_NGRr3tYLe-AGTdDXKWlJD1GDz1ECXeMGQHPoz3U8cqNsFLYBstIlCgfnBWgWsPZSvJPJUg", + JwksDocument: "", + Iat: int64(1513165889), + Err: errors.New("unexpected end of JSON input"), + }, + { + Name: "Invalid Token", + Err: errors.New("token contains an invalid number of segments"), + }, + } + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + // mock JWKS server + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + io.WriteString(w, tt.JwksDocument) + })) + defer ts.Close() + + j := oauth2.JWT{ + Jwksurl: ts.URL, + Now: func() time.Time { + return time.Unix(tt.Iat, 0) + }, + } + _, err := j.GetClaims(tt.TokenString) + if tt.Err != nil { + if err != nil { + if tt.Err.Error() != err.Error() { + t.Errorf("Error in test %s expected error: %v actual: %v", tt.Name, tt.Err, err) + } // else: that's what we expect + } else { + t.Errorf("Error in test %s expected error: %v actual: none", tt.Name, tt.Err) + } + } else { + if err != nil { + t.Errorf("Error in tt %s: %v", tt.Name, err) + } // else: that's what we expect + } + }) + } +} + func TestJWT_ExtendedPrincipal(t *testing.T) { history := time.Unix(-446774400, 0) type fields struct { diff --git a/oauth2/oauth2_test.go b/oauth2/oauth2_test.go index 23cc16aa85..6d51249528 100644 --- a/oauth2/oauth2_test.go +++ b/oauth2/oauth2_test.go @@ -9,8 +9,8 @@ import ( goauth "golang.org/x/oauth2" - "github.com/influxdata/chronograf" gojwt "github.com/dgrijalva/jwt-go" + "github.com/influxdata/chronograf" ) var _ Provider = &MockProvider{} @@ -73,7 +73,7 @@ func (y *YesManTokenizer) ExtendedPrincipal(ctx context.Context, p Principal, ex } func (m *YesManTokenizer) GetClaims(tokenString string) (gojwt.MapClaims, error) { - return gojwt.MapClaims{}, nil + return gojwt.MapClaims{}, nil } func NewTestTripper(log chronograf.Logger, ts *httptest.Server, rt http.RoundTripper) (*TestTripper, error) { From c701faa528ec7b96883c1f2647c5549e509fc7a9 Mon Sep 17 00:00:00 2001 From: Benjamin Schweizer Date: Wed, 13 Dec 2017 16:56:23 +0100 Subject: [PATCH 07/26] added PR#2526 to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 628d9a95e0..0b0accdd58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## v1.4.0.0-beta2 [unreleased] ### Features +1. [#2526](https://github.com/influxdata/chronograf/pull/2526): Add support for RS256/JWKS verification, support for id_token parsing (as in ADFS) ### UI Improvements ### Bug Fixes From 1e73251b0605f2b1f55d142f5017cb0f2d353935 Mon Sep 17 00:00:00 2001 From: Benjamin Schweizer Date: Fri, 12 Jan 2018 08:45:18 +0100 Subject: [PATCH 08/26] added test case for mux/oauth2 id_token processing --- oauth2/mux_test.go | 52 +++++++++++++++++++++++++++++++++++++++---- oauth2/oauth2_test.go | 4 ++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/oauth2/mux_test.go b/oauth2/mux_test.go index 7cc13ba34e..9374fa2878 100644 --- a/oauth2/mux_test.go +++ b/oauth2/mux_test.go @@ -19,9 +19,13 @@ var testTime = time.Date(1985, time.October, 25, 18, 0, 0, 0, time.UTC) // a function, and returning the desired handler. Cleanup is still the // responsibility of the test writer, so the httptest.Server's Close() method // should be deferred. -func setupMuxTest(selector func(*AuthMux) http.Handler) (*http.Client, *httptest.Server, *httptest.Server) { +func setupMuxTest(selector func(*AuthMux) http.Handler, body string) (*http.Client, *httptest.Server, *httptest.Server) { provider := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if body != "" { + rw.Header().Set("Content-Type", "application/json") + } rw.WriteHeader(http.StatusOK) + rw.Write([]byte(body)) })) now := func() time.Time { @@ -61,7 +65,7 @@ func Test_AuthMux_Logout_DeletesSessionCookie(t *testing.T) { hc, ts, prov := setupMuxTest(func(j *AuthMux) http.Handler { return j.Logout() - }) + }, "") defer teardownMuxTest(hc, ts, prov) tsURL, _ := url.Parse(ts.URL) @@ -98,7 +102,7 @@ func Test_AuthMux_Login_RedirectsToCorrectURL(t *testing.T) { hc, ts, prov := setupMuxTest(func(j *AuthMux) http.Handler { return j.Login() // Use Login handler for httptest server. - }) + }, "") defer teardownMuxTest(hc, ts, prov) resp, err := hc.Get(ts.URL) @@ -124,7 +128,47 @@ func Test_AuthMux_Login_RedirectsToCorrectURL(t *testing.T) { func Test_AuthMux_Callback_SetsCookie(t *testing.T) { hc, ts, prov := setupMuxTest(func(j *AuthMux) http.Handler { return j.Callback() - }) + }, "") + defer teardownMuxTest(hc, ts, prov) + + tsURL, _ := url.Parse(ts.URL) + + v := url.Values{ + "code": {"4815162342"}, + "state": {"foobar"}, + } + + tsURL.RawQuery = v.Encode() + + resp, err := hc.Get(tsURL.String()) + if err != nil { + t.Fatal("Error communicating with Callback() handler: err", err) + } + + // Ensure we were redirected + if resp.StatusCode < 300 || resp.StatusCode >= 400 { + t.Fatal("Expected to be redirected, but received status code", resp.StatusCode) + } + + // Check that cookie was set + cookies := resp.Cookies() + if count := len(cookies); count != 1 { + t.Fatal("Expected exactly one cookie to be set but found", count) + } + + c := cookies[0] + + if c.Name != DefaultCookieName { + t.Fatal("Expected cookie to be named", DefaultCookieName, "but was", c.Name) + } +} + +func Test_AuthMux_Callback_HandlesIdToken(t *testing.T) { + // body taken from ADFS4 + body := `{"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSJ9.eyJhdWQiOiJ1cm46bWljcm9zb2Z0OnVzZXJpbmZvIiwiaXNzIjoiaHR0cDovL2RzdGNpbWFhZDFwLmRzdC1pdHMuZGUvYWRmcy9zZXJ2aWNlcy90cnVzdCIsImlhdCI6MTUxNTcwMDU2NSwiZXhwIjoxNTE1NzA0MTY1LCJhcHB0eXBlIjoiQ29uZmlkZW50aWFsIiwiYXBwaWQiOiJjaHJvbm9ncmFmIiwiYXV0aG1ldGhvZCI6InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphYzpjbGFzc2VzOlBhc3N3b3JkUHJvdGVjdGVkVHJhbnNwb3J0IiwiYXV0aF90aW1lIjoiMjAxOC0wMS0xMVQxOTo1MToyNS44MDZaIiwidmVyIjoiMS4wIiwic2NwIjoib3BlbmlkIiwic3ViIjoiZVlWM2pkbGROeUZZMXFGRkg0b0FkQnZERmZiVmZudUcyOUhpSGtTdWp3az0ifQ.sf1qJys9LMUp2S232IRK2aTXiPCE93O-cUdYQQz7kg2woyD46KLwwKIYJVqMaqLspTn3OmaIhKtgx5ZXyAEtihODB1GOBK7DBNRBYCS1iqY_v2-Qwjf7hgaNaCqBjs0DZJspfp5G9MTykvD1FOtQNjPOcBW-i2bblG9L9jlmMbOZ3F7wrZMrroTSkiSn_gRiw2SnN8K7w8WrMEXNK2_jg9ZJ7aSHeUSBwkRNFRds2QNho3HWHg-zcsZFdZ4UGSt-6Az_0LY3yENMLj5us5Rl6Qzk_Re2dhFrlnlXlY1v1DEp3icCvvjkv6AeZWjTfW4qETZaCXUKtSyZ7d5_V1CRDQ","token_type":"bearer","expires_in":3600,"resource":"urn:microsoft:userinfo","refresh_token":"X9ZGO4H1bMk2bFeOfpv18BzAuFBzUPKQNfOEfdp60FkAAQAALPEBfj23FPEzajle-hm4DrDXp8-Kj53OqoVGalyZeuR-lfJzxpQXQhRAXZOUTuuQ8AQycByh9AylQYDA0jdMFW4FL4WL_6JhNh2JrtXCv2HQ9ozbUq9F7u_O0cY7u0P2pfNujQfk3ckYn-CMVjXbuwJTve6bXUR0JDp5c195bAVA5eFWyI-2uh432t7viyaIjAVbWxQF4fvimcpF1Et9cGodZHVsrZzGxKRnzwjYkWHsqm9go4KOeSKN6MlcWbjvS1UdMjQXSvoqSI00JnSMC3hxJZFn5JcmAPB1AMnJf4VvXZ5b-aOnwdX09YT8KayWkWekAsuZqTAsFwhZPVCRGWAFAADy0e2fTe6l-U6Cj_2bWsq6Snm1QEpWHXuwOJKWZJH-9yQn8KK3KzRowSzRuACzEIpZS5skrqXs_-2aOaZibNpjCEVyw8fF8GTw3VRLufsSrMQ5pD0KL7TppTGFpaqgwIH1yq6T8aRY4DeyoJkNpnO9cw1wuqnY7oGF-J25sfZ4XNWhk6o5e9A45PXhTilClyDKDLqTfdoIsG1Koc2ywqTIb-XI_EbWR3e4ijy8Kmlehw1kU9_xAG0MmmD2HTyGHZCBRgrskYCcHd-UNgCMrNAb5dZQ8NwpKtEL46qIq4R0lheTRRK8sOWzzuJXmvDEoJiIxqSR3Ma4MOISi-vsIsAuiEL9G1aMOkDRj-kDVmqrdKRAwYnN78AWY5EFfkQJyVBbiG882wBh9S0q3HUUCxzFerOvl4eDlVn6m18rRMz7CVZYBBltGtHRhEOQ4gumICR5JRrXAC50aBmUlhDiiMdbEIwJrvWrkhKE0oAJznqC7gleP0E4EOEh9r6CEGZ7Oj8X9Cdzjbuq2G1JGBm_yUvkhAcV61DjOiIQl35BpOfshveNZf_caUtNMa2i07BBmezve17-2kWGzRunr1BD1vMTz41z-H62fy4McR47WJjdDJnuy4DH5AZYQ6ooVxWCtEqeqRPYpzO0XdOdJGXFqXs9JzDKVXTgnHU443hZBC5H-BJkZDuuJ_ZWNKXf03JhouWkxXcdaMbuaQYOZJsUySVyJ5X4usrBFjW4udZAzy7mua-nJncbvcwoyVXiFlRfZiySXolQ9865N7XUnEk_2PijMLoVDATDbA09XuRySvngNsdsQ27p21dPxChXdtpD5ofNqKJ2FBzFKmxCkuX7L01N1nDpWQTuxhHF0JfxSKG5m3jcTx8Bd7Un94mTuAB7RuglDqkdQB9o4X9NHNGSdqGQaK-xeKoNCFWevk3VZoDoY9w2NqSNV2VIuqhy7SxtDSMjZKC5kiQi5EfGeTYZAvTwMYwaXb7K4WWtscy_ZE15EOCVeYi0hM1Ma8iFFTANkSRyX83Ju4SRphxRKnpKcJ2pPYH784I5HOm5sclhUL3aLeAA161QgxRBSa9YVIZfyXHyWQTcbNucNdhmdUZnKfRv1xtXcS9VAx2yAkoKFehZivEINX0Y500-WZ1eT_RXp0BfCKmJQ8Fu50oTaI-c5h2Q3Gp_LTSODNnMrjJiJxCLD_LD1fd1e8jTYDV3NroGlpWTuTdjMUm-Z1SMXaaJzQGEnNT6F8b6un9228L6YrDC_3MJ5J80VAHL5EO1GesdEWblugCL7AQDtFjNXq0lK8Aoo8X9_hlvDwgfdR16l8QALPT1HJVzlHPG8G3dRe50TKZnl3obU0WXN1KYG1EC4Qa3LyaVCIuGJYOeFqjMINrf7PoM368nS9yhrY08nnoHZbQ7IeA1KsNq2kANeH1doCNfWrXDwn8KxjYxZPEnzvlQ5M1RIzArOqzWL8NbftW1q2yCZZ4RVg0vOTVXsqWFnQIvWK-mkELa7bvByFzbtVHOJpc_2EKBKBNv6IYUENRCu2TOf6w7u42yvng7ccoXRTiUFUlKgVmswf9FzISxFd-YKgrzp3bMhC3gReGqcJuqEwnXPvOAY_BAkVMSd_ZaCFuyclRjFvUxrAg1T_cqOvRIlJ2Qq7z4u7W3BAo9BtFdj8QNLKJXtvvzXTprglRPDNP_QEPAkwZ_Uxa13vdYFcG18WCx4GbWQXchl5B7DnISobcdCH34M-I0xDZN98VWQVmLAfPniDUD30C8pfiYF7tW_EVy958Eg_JWVy0SstYEhV-y-adrJ1Oimjv0ptsWv-yErKBUD14aex9A_QqdnTXZUg.tqMb72eWAkAIvInuLp57NDyGxfYvms3NnhN-mllkYb7Xpd8gVbQFc2mYdzOOhtnfGuakyXYF4rZdJonQwzBO6C9KYuARciUU1Ms4bWPC-aeNO5t-aO_bDZbwC9qMPmq5ZuxG633BARGaw26fr0Z7qhcJMiou_EuaIehYTKkPB-mxtRAhxxyX91qqe0-PJnCHWoxizC4hDCUwp9Jb54tNf34BG3vtkXFX-kUARNfGucgKUkh6RYkhWiMBsMVoyWmkFXB5fYxmCAH5c5wDW6srKdyIDEWZInliuKbYR0p66vg1FfoSi4bBfrsm5NtCtLKG9V6Q0FEIA6tRRgHmKUGpkw","refresh_token_expires_in":28519,"scope":"openid","id_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSIsImtpZCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSJ9.eyJhdWQiOiJjaHJvbm9ncmFmIiwiaXNzIjoiaHR0cHM6Ly9kc3RjaW1hYWQxcC5kc3QtaXRzLmRlL2FkZnMiLCJpYXQiOjE1MTU3MDA1NjUsImV4cCI6MTUxNTcwNDE2NSwiYXV0aF90aW1lIjoxNTE1NzAwMjg1LCJzdWIiOiJlWVYzamRsZE55RlkxcUZGSDRvQWRCdkRGZmJWZm51RzI5SGlIa1N1andrPSIsInVwbiI6ImJzY0Bkc3QtaXRzLmRlIiwidW5pcXVlX25hbWUiOiJEU1RcXGJzYyIsInNpZCI6IlMtMS01LTIxLTI1MDUxNTEzOTgtMjY2MTAyODEwOS0zNzU0MjY1ODIwLTExMDQifQ.XD873K6NVRTJY1700NsflLJGZKFHJfNBjB81SlADVdAHbhnq7wkAZbGEEm8wFqvTKKysUl9EALzmDa2tR9nzohVvmHftIYBO0E-wPBzdzWWX0coEgpVAc-SysP-eIQWLsj8EaodaMkCgKO0FbTWOf4GaGIBZGklrr9EEk8VRSdbXbm6Sv9WVphezEzxq6JJBRBlCVibCnZjR5OYh1Vw_7E7P38ESPbpLY3hYYl2hz4y6dQJqCwGr7YP8KrDlYtbosZYgT7ayxokEJI1udEbX5PbAq5G6mj5rLfSOl85rMg-psZiivoM8dn9lEl2P7oT8rAvMWvQp-FIRQQHwqf9cxw"}` + hc, ts, prov := setupMuxTest(func(j *AuthMux) http.Handler { + return j.Callback() + }, body) defer teardownMuxTest(hc, ts, prov) tsURL, _ := url.Parse(ts.URL) diff --git a/oauth2/oauth2_test.go b/oauth2/oauth2_test.go index 6d51249528..f9d3b13097 100644 --- a/oauth2/oauth2_test.go +++ b/oauth2/oauth2_test.go @@ -45,6 +45,10 @@ func (mp *MockProvider) PrincipalID(provider *http.Client) (string, error) { return mp.Email, nil } +func (mp *MockProvider) PrincipalIDFromClaims(claims gojwt.MapClaims) (string, error) { + return mp.Email, nil +} + func (mp *MockProvider) Scopes() []string { return []string{} } From 84360ef143378d510b60c1c85c99963d44573516 Mon Sep 17 00:00:00 2001 From: Benjamin Schweizer Date: Fri, 12 Jan 2018 08:46:25 +0100 Subject: [PATCH 09/26] fixed handling of empty id_tokens --- oauth2/mux.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/oauth2/mux.go b/oauth2/mux.go index 5808bf3759..cfa9c23c25 100644 --- a/oauth2/mux.go +++ b/oauth2/mux.go @@ -118,10 +118,16 @@ func (j *AuthMux) Callback() http.Handler { // if we received an extra id_token, inspect it var id string - if tokenString, ok := token.Extra("id_token").(string); ok { + if token.Extra("id_token") != "" { log.Debug("token provides extra id_token") if provider, ok := j.Provider.(ExtendedProvider); ok { log.Debug("provider implements PrincipalIDFromClaims()") + var tokenString string + if tokenString, ok = token.Extra("id_token").(string); !ok { + log.Error("cannot cast id_token as string") + http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) + return + } claims, err := j.Tokens.GetClaims(tokenString) if err != nil { log.Error("parsing extra id_token failed:", err) @@ -130,7 +136,7 @@ func (j *AuthMux) Callback() http.Handler { } log.Debug("found claims: ", claims) if id, err = provider.PrincipalIDFromClaims(claims); err != nil { - log.Error("claim not found:", err) + log.Error("requested claim not found in id_token:", err) http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) return } From 227009723dc65a8dab65379c8c5d855e0ac3edf3 Mon Sep 17 00:00:00 2001 From: Benjamin Schweizer Date: Tue, 20 Feb 2018 09:47:42 +0100 Subject: [PATCH 10/26] merged upstream, updated comments, added GroupFromClaims() --- .bumpversion.cfg | 2 +- .gitignore | 1 + CHANGELOG.md | 54 +- Dockerfile | 1 + Gopkg.lock | 112 +- Gopkg.toml | 6 +- Makefile | 6 +- README.md | 10 +- bolt/client.go | 9 + bolt/config.go | 2 +- bolt/config_test.go | 2 +- bolt/internal/internal.go | 61 +- bolt/internal/internal.pb.go | 303 ++-- bolt/internal/internal.proto | 29 +- bolt/internal/internal_test.go | 8 + bolt/mapping.go | 128 ++ bolt/mapping_test.go | 483 ++++++ bolt/organizations.go | 34 +- bolt/organizations_test.go | 41 +- chronograf.go | 59 +- circle.yml | 5 +- cmd/chronoctl/add.go | 120 ++ cmd/chronoctl/list.go | 41 + cmd/chronoctl/main.go | 27 + cmd/chronoctl/util.go | 44 + docs/slides/mnGo/Makefile | 4 +- enterprise/enterprise.go | 8 +- enterprise/enterprise_test.go | 70 +- enterprise/meta.go | 26 +- enterprise/users.go | 2 +- etc/Dockerfile_build | 8 +- etc/README.md | 3 + etc/build.py | 5 +- etc/scripts/chronograf.service | 6 +- etc/scripts/init.sh | 6 +- integrations/server_test.go | 1403 ++++++++++++++++- integrations/testdata/mydash.dashboard | 6 +- kapacitor/client_test.go | 154 +- kapacitor/vars.go | 10 +- mocks/mapping.go | 35 + mocks/store.go | 4 + noop/mappings.go | 33 + oauth2/auth0.go | 22 + oauth2/generic.go | 45 +- oauth2/github.go | 27 +- oauth2/google.go | 16 + oauth2/heroku.go | 28 + oauth2/jwt.go | 30 +- oauth2/mux.go | 13 + oauth2/mux_test.go | 8 +- oauth2/oauth2.go | 5 + oauth2/oauth2_test.go | 18 +- roles/roles.go | 3 + server/auth.go | 41 + server/auth_test.go | 339 ++++ server/cells.go | 76 +- server/cells_test.go | 146 +- server/mapping.go | 250 +++ server/mapping_test.go | 360 +++++ server/me.go | 93 +- server/me_test.go | 327 ++-- server/middle.go | 57 + server/middle_test.go | 196 +++ server/mux.go | 58 +- server/organizations.go | 17 +- server/organizations_test.go | 150 +- server/routes.go | 28 +- server/routes_test.go | 6 +- server/server.go | 1 + server/service.go | 3 +- server/stores.go | 13 + server/swagger.json | 23 +- server/users.go | 80 +- server/users_test.go | 307 +++- ui/package.json | 2 +- ui/spec/shared/reducers/linksSpec.js | 7 +- ui/src/CheckSources.js | 18 +- ui/src/admin/actions/chronograf.js | 111 +- ui/src/admin/apis/chronograf.js | 51 + .../admin/components/chronograf/AdminTabs.js | 28 +- .../components/chronograf/AllUsersTable.js | 215 +++ .../chronograf/AllUsersTableHeader.js | 65 + .../components/chronograf/AllUsersTableRow.js | 114 ++ .../chronograf/AllUsersTableRowNew.js | 183 +++ .../components/chronograf/EmptyUsersTable.js | 46 - .../chronograf/OrganizationsTable.js | 62 +- .../chronograf/OrganizationsTableRow.js | 102 +- .../chronograf/OrganizationsTableRowNew.js | 30 +- .../components/chronograf/ProvidersTable.js | 149 ++ .../chronograf/ProvidersTableRow.js | 150 ++ .../chronograf/ProvidersTableRowNew.js | 116 ++ .../admin/components/chronograf/UsersTable.js | 58 +- .../components/chronograf/UsersTableHeader.js | 2 +- .../components/chronograf/UsersTableRow.js | 17 +- .../components/chronograf/UsersTableRowNew.js | 18 +- ui/src/admin/constants/chronografAdmin.js | 1 + .../admin/constants/chronografTableSizing.js | 9 + ui/src/admin/constants/index.js | 3 - ui/src/admin/containers/ProvidersPage.js | 101 ++ .../containers/chronograf/AllUsersPage.js | 143 ++ .../chronograf/OrganizationsPage.js | 68 +- .../admin/containers/chronograf/UsersPage.js | 21 +- ui/src/admin/reducers/chronograf.js | 58 +- .../components/CellEditorOverlay.js | 246 ++- .../dashboards/components/DisplayOptions.js | 44 +- ui/src/dashboards/components/GaugeOptions.js | 6 +- .../components/SingleStatOptions.js | 70 +- ui/src/dashboards/components/Threshold.js | 11 +- ui/src/dashboards/components/Visualization.js | 8 +- ui/src/dashboards/constants/gaugeColors.js | 97 +- ui/src/data_explorer/components/VisHeader.js | 7 +- ui/src/hosts/containers/HostsPage.js | 138 +- ui/src/index.js | 121 +- ui/src/kapacitor/actions/view/index.js | 15 +- ui/src/kapacitor/components/AlertTabs.js | 84 +- ui/src/kapacitor/components/HandlerOptions.js | 22 +- ui/src/kapacitor/components/KapacitorForm.js | 4 +- ui/src/kapacitor/components/KapacitorRule.js | 41 +- .../kapacitor/components/LogItemHTTPError.js | 2 +- .../components/LogItemInfluxDBDebug.js | 2 +- .../components/LogItemKapacitorDebug.js | 2 +- .../components/LogItemKapacitorError.js | 2 +- .../components/LogItemKapacitorPoint.js | 34 +- ui/src/kapacitor/components/LogsTable.js | 27 +- ui/src/kapacitor/components/LogsTableRow.js | 23 +- ui/src/kapacitor/components/LogsToggle.js | 8 +- ui/src/kapacitor/components/Tickscript.js | 26 +- .../components/TickscriptEditorConsole.js | 37 +- .../kapacitor/components/TickscriptHeader.js | 49 +- .../components/config/AlertaConfig.js | 8 +- .../components/config/HipChatConfig.js | 8 +- .../components/config/OpsGenieConfig.js | 96 +- .../components/config/PagerDutyConfig.js | 8 +- .../components/config/PushoverConfig.js | 8 +- .../kapacitor/components/config/SMTPConfig.js | 27 +- .../components/config/SensuConfig.js | 8 +- .../components/config/SlackConfig.js | 8 +- .../kapacitor/components/config/TalkConfig.js | 8 +- .../components/config/TelegramConfig.js | 8 +- .../components/config/VictorOpsConfig.js | 8 +- ui/src/kapacitor/containers/KapacitorPage.js | 10 +- ui/src/kapacitor/containers/TickscriptPage.js | 48 +- ui/src/shared/actions/auth.js | 43 +- ui/src/shared/actions/links.js | 30 +- ui/src/shared/apis/env.js | 2 +- ui/src/shared/apis/links.js | 17 + ui/src/shared/components/ColorDropdown.js | 10 +- ui/src/shared/components/ConfirmButton.js | 110 ++ ui/src/shared/components/DygraphLegend.js | 2 +- ui/src/shared/components/GaugeChart.js | 7 +- ui/src/shared/components/InputClickToEdit.js | 97 ++ ui/src/shared/components/LineGraph.js | 27 +- ui/src/shared/components/RefreshingGraph.js | 1 + ui/src/shared/components/SingleStat.js | 53 +- ui/src/shared/components/SlideToggle.js | 2 +- ui/src/shared/components/TagInput.js | 63 + ui/src/shared/components/Tags.js | 61 + ui/src/shared/components/TagsAddButton.js | 57 + ui/src/shared/constants/actionTypes.js | 2 - ui/src/shared/constants/colorOperations.js | 98 +- ui/src/shared/constants/index.js | 5 +- ui/src/shared/dispatchers/index.js | 4 +- ui/src/shared/middleware/errors.js | 1 + ui/src/shared/reducers/links.js | 13 +- ui/src/side_nav/components/UserNavBlock.js | 1 - ui/src/status/apis/index.js | 2 +- ui/src/style/chronograf.scss | 6 + .../style/components/ceo-display-options.scss | 10 +- .../style/components/code-mirror-theme.scss | 9 +- ui/src/style/components/color-dropdown.scss | 4 + ui/src/style/components/confirm-button.scss | 105 ++ ui/src/style/components/dygraphs.scss | 16 +- ui/src/style/components/fancy-table.scss | 51 + .../style/components/input-click-to-edit.scss | 63 + ui/src/style/components/input-tag-list.scss | 93 +- .../components/kapacitor-logs-table.scss | 57 +- .../style/components/organizations-table.scss | 180 +-- ui/src/style/components/slide-toggle.scss | 16 +- ui/src/style/modules/mixins.scss | 8 +- ui/src/style/pages/admin.scss | 26 +- ui/src/style/pages/manage-providers.scss | 70 + ui/src/style/pages/tickscript-editor.scss | 61 +- ui/src/style/pages/users.scss | 1 + ui/src/utils/ajax.js | 37 +- 184 files changed, 9102 insertions(+), 1903 deletions(-) create mode 100644 bolt/mapping.go create mode 100644 bolt/mapping_test.go create mode 100644 cmd/chronoctl/add.go create mode 100644 cmd/chronoctl/list.go create mode 100644 cmd/chronoctl/main.go create mode 100644 cmd/chronoctl/util.go create mode 100644 mocks/mapping.go create mode 100644 noop/mappings.go create mode 100644 server/mapping.go create mode 100644 server/mapping_test.go create mode 100644 server/middle.go create mode 100644 server/middle_test.go create mode 100644 ui/src/admin/components/chronograf/AllUsersTable.js create mode 100644 ui/src/admin/components/chronograf/AllUsersTableHeader.js create mode 100644 ui/src/admin/components/chronograf/AllUsersTableRow.js create mode 100644 ui/src/admin/components/chronograf/AllUsersTableRowNew.js delete mode 100644 ui/src/admin/components/chronograf/EmptyUsersTable.js create mode 100644 ui/src/admin/components/chronograf/ProvidersTable.js create mode 100644 ui/src/admin/components/chronograf/ProvidersTableRow.js create mode 100644 ui/src/admin/components/chronograf/ProvidersTableRowNew.js create mode 100644 ui/src/admin/containers/ProvidersPage.js create mode 100644 ui/src/admin/containers/chronograf/AllUsersPage.js create mode 100644 ui/src/shared/apis/links.js create mode 100644 ui/src/shared/components/ConfirmButton.js create mode 100644 ui/src/shared/components/InputClickToEdit.js create mode 100644 ui/src/shared/components/TagInput.js create mode 100644 ui/src/shared/components/Tags.js create mode 100644 ui/src/shared/components/TagsAddButton.js create mode 100644 ui/src/style/components/confirm-button.scss create mode 100644 ui/src/style/components/fancy-table.scss create mode 100644 ui/src/style/components/input-click-to-edit.scss create mode 100644 ui/src/style/pages/manage-providers.scss diff --git a/.bumpversion.cfg b/.bumpversion.cfg index d4d84d67ab..e01d66d400 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.4.0.0 +current_version = 1.4.1.3 files = README.md server/swagger.json parse = (?P\d+)\.(?P\d+)\.(?P\d+)\.(?P\d+) serialize = {major}.{minor}.{patch}.{release} diff --git a/.gitignore b/.gitignore index 7996088afb..80332bd90c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ backup/ # Binaries /chronograf +/chronoctl # Dotfiles .pull-request diff --git a/CHANGELOG.md b/CHANGELOG.md index c2c4d4cf98..ac5f06c0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,54 @@ -## v1.4.1.0 [unreleased] +## v1.4.2.0 [unreleased] ### Features -1. [#2409](https://github.com/influxdata/chronograf/pull/2409): Allow adding multiple event handlers to a rule 1. [#2526](https://github.com/influxdata/chronograf/pull/2526): Add support for RS256/JWKS verification, support for id_token parsing (as in ADFS) -1. [#2709](https://github.com/influxdata/chronograf/pull/2709): Add "send test alert" button to test kapacitor alert configurations" ### UI Improvements -1. [#2698](https://github.com/influxdata/chronograf/pull/2698): Improve clarity of terminology surrounding InfluxDB & Kapacitor connections +### Bug Fixes + +## v1.4.1.3 [2018-02-14] +### Bug Fixes +1. [#2818](https://github.com/influxdata/chronograf/pull/2818): Allow self-signed certificates for Enterprise InfluxDB Meta nodes + +## v1.4.1.2 [2018-02-13] +### Bug Fixes +1. [9321336](https://github.com/influxdata/chronograf/commit/9321336): Respect basepath when fetching server api routes +1. [#2812](https://github.com/influxdata/chronograf/pull/2812): Set default tempVar :interval: with data explorer csv download call. +1. [#2811](https://github.com/influxdata/chronograf/pull/2811): Display series with value of 0 in a cell legend + +## v1.4.1.1 [2018-02-12] +### Features +1. [#2409](https://github.com/influxdata/chronograf/pull/2409): Allow multiple event handlers per rule +1. [#2709](https://github.com/influxdata/chronograf/pull/2709): Add "send test alert" button to test kapacitor alert configurations +1. [#2708](https://github.com/influxdata/chronograf/pull/2708): Link to kapacitor config panel from alert rule builder +1. [#2722](https://github.com/influxdata/chronograf/pull/2722): Add auto refresh widget to hosts list page +1. [#2784](https://github.com/influxdata/chronograf/pull/2784): Update go from 1.9.3 to 1.9.4 +1. [#2765](https://github.com/influxdata/chronograf/pull/2765): Update to go 1.9.3 and node 6.12.3 for releases +1. [#2777](https://github.com/influxdata/chronograf/pull/2777): Allow user to delete themselves +1. [#2703](https://github.com/influxdata/chronograf/pull/2703): Add All Users page, visible only to super admins +1. [#2781](https://github.com/influxdata/chronograf/pull/2781): Introduce chronoctl binary for user CRUD operations +1. [#2699](https://github.com/influxdata/chronograf/pull/2699): Introduce Mappings to allow control over new user organization assignments +### UI Improvements +1. [#2698](https://github.com/influxdata/chronograf/pull/2698): Clarify terminology surrounding InfluxDB & Kapacitor connections +1. [#2746](https://github.com/influxdata/chronograf/pull/2746): Separate saving TICKscript from exiting editor page +1. [#2774](https://github.com/influxdata/chronograf/pull/2774): Enable Save (⌘ + Enter) and Cancel (Escape) hotkeys in Cell Editor Overlay +1. [#2788](https://github.com/influxdata/chronograf/pull/2788): Enable customization of Single Stat "Base Color" + +### Bug Fixes +1. [#2684](https://github.com/influxdata/chronograf/pull/2684): Fix TICKscript Sensu alerts when no group by tags selected +1. [#2756](https://github.com/influxdata/chronograf/pull/2756): Display 200 most-recent TICKscript log messages; prevent overlapping +1. [#2757](https://github.com/influxdata/chronograf/pull/2757): Add "TO" to kapacitor SMTP config; improve config update error messages +1. [#2761](https://github.com/influxdata/chronograf/pull/2761): Remove cli options from sysvinit service file +1. [#2735](https://github.com/influxdata/chronograf/pull/2735): Remove cli options from systemd service file +1. [#2788](https://github.com/influxdata/chronograf/pull/2788): Fix disappearance of text in Single Stat graphs during editing +1. [#2780](https://github.com/influxdata/chronograf/pull/2780): Redirect to Alerts page after saving Alert Rule + +## v1.4.0.1 [2018-1-9] +### Features +1. [#2690](https://github.com/influxdata/chronograf/pull/2690): Add separate CLI flag for canned sources, kapacitors, dashboards, and organizations +1. [#2672](https://github.com/influxdata/chronograf/pull/2672): Add telegraf interval configuration ### Bug Fixes 1. [#2689](https://github.com/influxdata/chronograf/pull/2689): Allow insecure (self-signed) certificates for kapacitor and influxdb - - -## v1.4.0.1 [unreleased] -### UI Improvements -1. [#2690](https://github.com/influxdata/chronograf/pull/2690): Add separate CLI flag for canned sources, kapacitors, dashboards, and organizations +1. [#2664](https://github.com/influxdata/chronograf/pull/2664): Fix positioning of custom time indicator ## v1.4.0.0 [2017-12-22] ### UI Improvements diff --git a/Dockerfile b/Dockerfile index e43b07bf74..ca42be159e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ RUN apk add --update ca-certificates && \ rm /var/cache/apk/* ADD chronograf /usr/bin/chronograf +ADD chronoctl /usr/bin/chronoctl ADD canned/*.json /usr/share/chronograf/canned/ ADD LICENSE /usr/share/chronograf/LICENSE ADD agpl-3.0.md /usr/share/chronograf/agpl-3.0.md diff --git a/Gopkg.lock b/Gopkg.lock index af4f27d25a..d8eee5654c 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -30,7 +30,7 @@ branch = "master" name = "github.com/dustin/go-humanize" packages = ["."] - revision = "259d2a102b871d17f30e3cd9881a642961a1e486" + revision = "bb3d318650d48840a39aa21a027c6630e198e626" [[projects]] name = "github.com/elazarl/go-bindata-assetfs" @@ -39,18 +39,53 @@ [[projects]] name = "github.com/gogo/protobuf" - packages = ["gogoproto","jsonpb","plugin/compare","plugin/defaultcheck","plugin/description","plugin/embedcheck","plugin/enumstringer","plugin/equal","plugin/face","plugin/gostring","plugin/marshalto","plugin/oneofcheck","plugin/populate","plugin/size","plugin/stringer","plugin/testgen","plugin/union","plugin/unmarshal","proto","protoc-gen-gogo","protoc-gen-gogo/descriptor","protoc-gen-gogo/generator","protoc-gen-gogo/grpc","protoc-gen-gogo/plugin","vanity","vanity/command"] + packages = [ + "gogoproto", + "jsonpb", + "plugin/compare", + "plugin/defaultcheck", + "plugin/description", + "plugin/embedcheck", + "plugin/enumstringer", + "plugin/equal", + "plugin/face", + "plugin/gostring", + "plugin/marshalto", + "plugin/oneofcheck", + "plugin/populate", + "plugin/size", + "plugin/stringer", + "plugin/testgen", + "plugin/union", + "plugin/unmarshal", + "proto", + "protoc-gen-gogo", + "protoc-gen-gogo/descriptor", + "protoc-gen-gogo/generator", + "protoc-gen-gogo/grpc", + "protoc-gen-gogo/plugin", + "vanity", + "vanity/command" + ] revision = "6abcf94fd4c97dcb423fdafd42fe9f96ca7e421b" [[projects]] name = "github.com/golang/protobuf" packages = ["proto"] - revision = "8ee79997227bf9b34611aee7946ae64735e6fd93" + revision = "925541529c1fa6821df4e44ce2723319eb2be768" + version = "v1.0.0" [[projects]] name = "github.com/google/go-cmp" - packages = ["cmp","cmp/cmpopts"] - revision = "79b2d888f100ec053545168aa94bcfb322e8bfc8" + packages = [ + "cmp", + "cmp/cmpopts", + "cmp/internal/diff", + "cmp/internal/function", + "cmp/internal/value" + ] + revision = "8099a9787ce5dc5984ed879a3bda47dc730a8e97" + version = "v0.1.0" [[projects]] name = "github.com/google/go-github" @@ -58,19 +93,35 @@ revision = "1bc362c7737e51014af7299e016444b654095ad9" [[projects]] + branch = "master" name = "github.com/google/go-querystring" packages = ["query"] - revision = "9235644dd9e52eeae6fa48efd539fdc351a0af53" + revision = "53e6ce116135b80d037921a7fdd5138cf32d7a8a" [[projects]] name = "github.com/influxdata/influxdb" - packages = ["influxql","influxql/internal","influxql/neldermead","models","pkg/escape"] + packages = [ + "influxql", + "influxql/internal", + "influxql/neldermead", + "models", + "pkg/escape" + ] revision = "cd9363b52cac452113b95554d98a6be51beda24e" version = "v1.1.5" [[projects]] name = "github.com/influxdata/kapacitor" - packages = ["client/v1","pipeline","pipeline/tick","services/k8s/client","tick","tick/ast","tick/stateful","udf/agent"] + packages = [ + "client/v1", + "pipeline", + "pipeline/tick", + "services/k8s/client", + "tick", + "tick/ast", + "tick/stateful", + "udf/agent" + ] revision = "6de30070b39afde111fea5e041281126fe8aae31" [[projects]] @@ -84,15 +135,15 @@ revision = "4cc2832a6e6d1d3b815e2b9d544b2a4dfb3ce8fa" [[projects]] - name = "github.com/jteeuwen/go-bindata" + name = "github.com/kevinburke/go-bindata" packages = ["."] - revision = "a0ff2567cfb70903282db057e799fd826784d41d" + revision = "46eb4c183bfc1ebb527d9d19bcded39476302eb8" [[projects]] - branch = "master" name = "github.com/pkg/errors" packages = ["."] - revision = "ff09b135c25aae272398c51a07235b90a75aa4f0" + revision = "645ef00459ed84a119197bfb8d8205042c6df63d" + version = "v0.8.0" [[projects]] name = "github.com/satori/go.uuid" @@ -107,39 +158,60 @@ [[projects]] name = "github.com/tylerb/graceful" packages = ["."] - revision = "50a48b6e73fcc75b45e22c05b79629a67c79e938" - version = "v1.2.13" + revision = "4654dfbb6ad53cb5e27f37d99b02e16c1872fbbb" + version = "v1.2.15" [[projects]] name = "golang.org/x/net" - packages = ["context","context/ctxhttp"] + packages = [ + "context", + "context/ctxhttp" + ] revision = "749a502dd1eaf3e5bfd4f8956748c502357c0bbe" [[projects]] name = "golang.org/x/oauth2" - packages = [".","github","heroku","internal"] + packages = [ + ".", + "github", + "heroku", + "internal" + ] revision = "1e695b1c8febf17aad3bfa7bf0a819ef94b98ad5" [[projects]] branch = "master" name = "golang.org/x/sys" packages = ["unix"] - revision = "f3918c30c5c2cb527c0b071a27c35120a6c0719a" + revision = "37707fdb30a5b38865cfb95e5aab41707daec7fd" [[projects]] name = "google.golang.org/api" - packages = ["gensupport","googleapi","googleapi/internal/uritemplates","oauth2/v2"] + packages = [ + "gensupport", + "googleapi", + "googleapi/internal/uritemplates", + "oauth2/v2" + ] revision = "bc20c61134e1d25265dd60049f5735381e79b631" [[projects]] name = "google.golang.org/appengine" - packages = ["internal","internal/base","internal/datastore","internal/log","internal/remote_api","internal/urlfetch","urlfetch"] + packages = [ + "internal", + "internal/base", + "internal/datastore", + "internal/log", + "internal/remote_api", + "internal/urlfetch", + "urlfetch" + ] revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a" version = "v1.0.0" [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "a5bd1aa82919723ff8ec5dd9d520329862de8181ca9dba75c6acb3a34df5f1a4" + inputs-digest = "11df631364d11bc05c8f71af1aa735360b5a40a793d32d47d1f1d8c694a55f6f" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 6d3735503d..387b3b26c8 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -1,4 +1,4 @@ -required = ["github.com/jteeuwen/go-bindata","github.com/gogo/protobuf/proto","github.com/gogo/protobuf/jsonpb","github.com/gogo/protobuf/protoc-gen-gogo","github.com/gogo/protobuf/gogoproto"] +required = ["github.com/kevinburke/go-bindata","github.com/gogo/protobuf/proto","github.com/gogo/protobuf/jsonpb","github.com/gogo/protobuf/protoc-gen-gogo","github.com/gogo/protobuf/gogoproto"] [[constraint]] name = "github.com/NYTimes/gziphandler" @@ -41,8 +41,8 @@ required = ["github.com/jteeuwen/go-bindata","github.com/gogo/protobuf/proto","g revision = "4cc2832a6e6d1d3b815e2b9d544b2a4dfb3ce8fa" [[constraint]] - name = "github.com/jteeuwen/go-bindata" - revision = "a0ff2567cfb70903282db057e799fd826784d41d" + name = "github.com/kevinburke/go-bindata" + revision = "46eb4c183bfc1ebb527d9d19bcded39476302eb8" [[constraint]] name = "github.com/satori/go.uuid" diff --git a/Makefile b/Makefile index cf866feb86..98f18eb239 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ VERSION ?= $(shell git describe --always --tags) COMMIT ?= $(shell git rev-parse --short=8 HEAD) -GOBINDATA := $(shell go list -f {{.Root}} github.com/jteeuwen/go-bindata 2> /dev/null) +GOBINDATA := $(shell go list -f {{.Root}} github.com/kevinburke/go-bindata 2> /dev/null) YARN := $(shell command -v yarn 2> /dev/null) SOURCES := $(shell find . -name '*.go' ! -name '*_gen.go' -not -path "./vendor/*" ) @@ -11,6 +11,7 @@ UISOURCES := $(shell find ui -type f -not \( -path ui/build/\* -o -path ui/node_ unexport LDFLAGS LDFLAGS=-ldflags "-s -X main.version=${VERSION} -X main.commit=${COMMIT}" BINARY=chronograf +CTLBINARY=chronoctl .DEFAULT_GOAL := all @@ -22,6 +23,7 @@ dev: dep dev-assets ${BINARY} ${BINARY}: $(SOURCES) .bindata .jsdep .godep go build -o ${BINARY} ${LDFLAGS} ./cmd/chronograf/main.go + go build -o ${CTLBINARY} ${LDFLAGS} ./cmd/chronoctl define CHRONOGIRAFFE ._ o o @@ -73,7 +75,7 @@ dep: .jsdep .godep .godep: ifndef GOBINDATA @echo "Installing go-bindata" - go get -u github.com/jteeuwen/go-bindata/... + go get -u github.com/kevinburke/go-bindata/... endif @touch .godep diff --git a/README.md b/README.md index ae8c8ab6db..1ea9e7adb4 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ option. ## Versions The most recent version of Chronograf is -[v1.4.0.0](https://www.influxdata.com/downloads/). +[v1.4.1.3](https://www.influxdata.com/downloads/). Spotted a bug or have a feature request? Please open [an issue](https://github.com/influxdata/chronograf/issues/new)! @@ -156,7 +156,7 @@ The Chronograf team has identified and is working on the following issues: ## Installation Check out the -[INSTALLATION](https://docs.influxdata.com/chronograf/v1.3/introduction/installation/) +[INSTALLATION](https://docs.influxdata.com/chronograf/v1.4/introduction/installation/) guide to get up and running with Chronograf with as little configuration and code as possible. @@ -178,7 +178,7 @@ By default, chronograf runs on port `8888`. To get started right away with Docker, you can pull down our latest release: ```sh -docker pull chronograf:1.4.0.0 +docker pull chronograf:1.4.1.3 ``` ### From Source @@ -198,10 +198,10 @@ docker pull chronograf:1.4.0.0 ## Documentation -[Getting Started](https://docs.influxdata.com/chronograf/v1.3/introduction/getting-started/) +[Getting Started](https://docs.influxdata.com/chronograf/v1.4/introduction/getting-started/) will get you up and running with Chronograf with as little configuration and code as possible. See our -[guides](https://docs.influxdata.com/chronograf/v1.3/guides/) to get familiar +[guides](https://docs.influxdata.com/chronograf/v1.4/guides/) to get familiar with Chronograf's main features. Documentation for Telegraf, InfluxDB, and Kapacitor are available at diff --git a/bolt/client.go b/bolt/client.go index fad8112469..2515f19755 100644 --- a/bolt/client.go +++ b/bolt/client.go @@ -41,6 +41,7 @@ type Client struct { UsersStore *UsersStore OrganizationsStore *OrganizationsStore ConfigStore *ConfigStore + MappingsStore *MappingsStore } // NewClient initializes all stores @@ -60,6 +61,7 @@ func NewClient() *Client { c.UsersStore = &UsersStore{client: c} c.OrganizationsStore = &OrganizationsStore{client: c} c.ConfigStore = &ConfigStore{client: c} + c.MappingsStore = &MappingsStore{client: c} return c } @@ -151,6 +153,10 @@ func (c *Client) initialize(ctx context.Context) error { if _, err := tx.CreateBucketIfNotExists(BuildBucket); err != nil { return err } + // Always create Mapping bucket. + if _, err := tx.CreateBucketIfNotExists(MappingsBucket); err != nil { + return err + } return nil }); err != nil { return err @@ -184,6 +190,9 @@ func (c *Client) migrate(ctx context.Context, build chronograf.BuildInfo) error if err := c.BuildStore.Migrate(ctx, build); err != nil { return err } + if err := c.MappingsStore.Migrate(ctx); err != nil { + return err + } } return nil } diff --git a/bolt/config.go b/bolt/config.go index 98d6eaca23..432b964b5b 100644 --- a/bolt/config.go +++ b/bolt/config.go @@ -34,7 +34,7 @@ func (s *ConfigStore) Migrate(ctx context.Context) error { func (s *ConfigStore) Initialize(ctx context.Context) error { cfg := chronograf.Config{ Auth: chronograf.AuthConfig{ - SuperAdminNewUsers: true, + SuperAdminNewUsers: false, }, } return s.Update(ctx, &cfg) diff --git a/bolt/config_test.go b/bolt/config_test.go index f23e1b7369..3493891db1 100644 --- a/bolt/config_test.go +++ b/bolt/config_test.go @@ -22,7 +22,7 @@ func TestConfig_Get(t *testing.T) { wants: wants{ config: &chronograf.Config{ Auth: chronograf.AuthConfig{ - SuperAdminNewUsers: true, + SuperAdminNewUsers: false, }, }, }, diff --git a/bolt/internal/internal.go b/bolt/internal/internal.go index d7e6985da5..729bb8627e 100644 --- a/bolt/internal/internal.go +++ b/bolt/internal/internal.go @@ -274,6 +274,10 @@ func MarshalDashboard(d chronograf.Dashboard) ([]byte, error) { Type: c.Type, Axes: axes, Colors: colors, + Legend: &Legend{ + Type: c.Legend.Type, + Orientation: c.Legend.Orientation, + }, } } templates := make([]*Template, len(d.Templates)) @@ -394,6 +398,12 @@ func UnmarshalDashboard(data []byte, d *chronograf.Dashboard) error { } } + legend := chronograf.Legend{} + if c.Legend != nil { + legend.Type = c.Legend.Type + legend.Orientation = c.Legend.Orientation + } + cells[i] = chronograf.DashboardCell{ ID: c.ID, X: c.X, @@ -405,6 +415,7 @@ func UnmarshalDashboard(data []byte, d *chronograf.Dashboard) error { Type: c.Type, Axes: axes, CellColors: colors, + Legend: legend, } } @@ -570,19 +581,16 @@ func UnmarshalRole(data []byte, r *chronograf.Role) error { // UnmarshalRolePB decodes a role from binary protobuf data. func UnmarshalRolePB(data []byte, r *Role) error { - if err := proto.Unmarshal(data, r); err != nil { - return err - } - return nil + return proto.Unmarshal(data, r) } // MarshalOrganization encodes a organization to binary protobuf format. func MarshalOrganization(o *chronograf.Organization) ([]byte, error) { + return MarshalOrganizationPB(&Organization{ ID: o.ID, Name: o.Name, DefaultRole: o.DefaultRole, - Public: o.Public, }) } @@ -600,17 +608,13 @@ func UnmarshalOrganization(data []byte, o *chronograf.Organization) error { o.ID = pb.ID o.Name = pb.Name o.DefaultRole = pb.DefaultRole - o.Public = pb.Public return nil } // UnmarshalOrganizationPB decodes a organization from binary protobuf data. func UnmarshalOrganizationPB(data []byte, o *Organization) error { - if err := proto.Unmarshal(data, o); err != nil { - return err - } - return nil + return proto.Unmarshal(data, o) } // MarshalConfig encodes a config to binary protobuf format. @@ -643,8 +647,43 @@ func UnmarshalConfig(data []byte, c *chronograf.Config) error { // UnmarshalConfigPB decodes a config from binary protobuf data. func UnmarshalConfigPB(data []byte, c *Config) error { - if err := proto.Unmarshal(data, c); err != nil { + return proto.Unmarshal(data, c) +} + +// MarshalMapping encodes a mapping to binary protobuf format. +func MarshalMapping(m *chronograf.Mapping) ([]byte, error) { + + return MarshalMappingPB(&Mapping{ + Provider: m.Provider, + Scheme: m.Scheme, + ProviderOrganization: m.ProviderOrganization, + ID: m.ID, + Organization: m.Organization, + }) +} + +// MarshalMappingPB encodes a mapping to binary protobuf format. +func MarshalMappingPB(m *Mapping) ([]byte, error) { + return proto.Marshal(m) +} + +// UnmarshalMapping decodes a mapping from binary protobuf data. +func UnmarshalMapping(data []byte, m *chronograf.Mapping) error { + var pb Mapping + if err := UnmarshalMappingPB(data, &pb); err != nil { return err } + + m.Provider = pb.Provider + m.Scheme = pb.Scheme + m.ProviderOrganization = pb.ProviderOrganization + m.Organization = pb.Organization + m.ID = pb.ID + return nil } + +// UnmarshalMappingPB decodes a mapping from binary protobuf data. +func UnmarshalMappingPB(data []byte, m *Mapping) error { + return proto.Unmarshal(data, m) +} diff --git a/bolt/internal/internal.pb.go b/bolt/internal/internal.pb.go index 8864e63fad..3fa5963399 100644 --- a/bolt/internal/internal.pb.go +++ b/bolt/internal/internal.pb.go @@ -1,6 +1,5 @@ -// Code generated by protoc-gen-gogo. +// Code generated by protoc-gen-gogo. DO NOT EDIT. // source: internal.proto -// DO NOT EDIT! /* Package internal is a generated protocol buffer package. @@ -13,6 +12,7 @@ It has these top-level messages: Dashboard DashboardCell Color + Legend Axis Template TemplateValue @@ -26,6 +26,7 @@ It has these top-level messages: AlertRule User Role + Mapping Organization Config AuthConfig @@ -219,6 +220,7 @@ type DashboardCell struct { ID string `protobuf:"bytes,8,opt,name=ID,proto3" json:"ID,omitempty"` Axes map[string]*Axis `protobuf:"bytes,9,rep,name=axes" json:"axes,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value"` Colors []*Color `protobuf:"bytes,10,rep,name=colors" json:"colors,omitempty"` + Legend *Legend `protobuf:"bytes,11,opt,name=legend" json:"legend,omitempty"` } func (m *DashboardCell) Reset() { *m = DashboardCell{} } @@ -296,6 +298,13 @@ func (m *DashboardCell) GetColors() []*Color { return nil } +func (m *DashboardCell) GetLegend() *Legend { + if m != nil { + return m.Legend + } + return nil +} + type Color struct { ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` Type string `protobuf:"bytes,2,opt,name=Type,proto3" json:"Type,omitempty"` @@ -344,6 +353,30 @@ func (m *Color) GetValue() string { return "" } +type Legend struct { + Type string `protobuf:"bytes,1,opt,name=Type,proto3" json:"Type,omitempty"` + Orientation string `protobuf:"bytes,2,opt,name=Orientation,proto3" json:"Orientation,omitempty"` +} + +func (m *Legend) Reset() { *m = Legend{} } +func (m *Legend) String() string { return proto.CompactTextString(m) } +func (*Legend) ProtoMessage() {} +func (*Legend) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{4} } + +func (m *Legend) GetType() string { + if m != nil { + return m.Type + } + return "" +} + +func (m *Legend) GetOrientation() string { + if m != nil { + return m.Orientation + } + return "" +} + type Axis struct { LegacyBounds []int64 `protobuf:"varint,1,rep,packed,name=legacyBounds" json:"legacyBounds,omitempty"` Bounds []string `protobuf:"bytes,2,rep,name=bounds" json:"bounds,omitempty"` @@ -357,7 +390,7 @@ type Axis struct { func (m *Axis) Reset() { *m = Axis{} } func (m *Axis) String() string { return proto.CompactTextString(m) } func (*Axis) ProtoMessage() {} -func (*Axis) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{4} } +func (*Axis) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{5} } func (m *Axis) GetLegacyBounds() []int64 { if m != nil { @@ -420,7 +453,7 @@ type Template struct { func (m *Template) Reset() { *m = Template{} } func (m *Template) String() string { return proto.CompactTextString(m) } func (*Template) ProtoMessage() {} -func (*Template) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{5} } +func (*Template) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{6} } func (m *Template) GetID() string { if m != nil { @@ -473,7 +506,7 @@ type TemplateValue struct { func (m *TemplateValue) Reset() { *m = TemplateValue{} } func (m *TemplateValue) String() string { return proto.CompactTextString(m) } func (*TemplateValue) ProtoMessage() {} -func (*TemplateValue) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{6} } +func (*TemplateValue) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{7} } func (m *TemplateValue) GetType() string { if m != nil { @@ -508,7 +541,7 @@ type TemplateQuery struct { func (m *TemplateQuery) Reset() { *m = TemplateQuery{} } func (m *TemplateQuery) String() string { return proto.CompactTextString(m) } func (*TemplateQuery) ProtoMessage() {} -func (*TemplateQuery) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{7} } +func (*TemplateQuery) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{8} } func (m *TemplateQuery) GetCommand() string { if m != nil { @@ -566,7 +599,7 @@ type Server struct { func (m *Server) Reset() { *m = Server{} } func (m *Server) String() string { return proto.CompactTextString(m) } func (*Server) ProtoMessage() {} -func (*Server) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{8} } +func (*Server) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{9} } func (m *Server) GetID() int64 { if m != nil { @@ -635,7 +668,7 @@ type Layout struct { func (m *Layout) Reset() { *m = Layout{} } func (m *Layout) String() string { return proto.CompactTextString(m) } func (*Layout) ProtoMessage() {} -func (*Layout) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{9} } +func (*Layout) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{10} } func (m *Layout) GetID() string { if m != nil { @@ -689,7 +722,7 @@ type Cell struct { func (m *Cell) Reset() { *m = Cell{} } func (m *Cell) String() string { return proto.CompactTextString(m) } func (*Cell) ProtoMessage() {} -func (*Cell) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{10} } +func (*Cell) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{11} } func (m *Cell) GetX() int32 { if m != nil { @@ -783,7 +816,7 @@ type Query struct { func (m *Query) Reset() { *m = Query{} } func (m *Query) String() string { return proto.CompactTextString(m) } func (*Query) ProtoMessage() {} -func (*Query) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{11} } +func (*Query) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{12} } func (m *Query) GetCommand() string { if m != nil { @@ -857,7 +890,7 @@ type TimeShift struct { func (m *TimeShift) Reset() { *m = TimeShift{} } func (m *TimeShift) String() string { return proto.CompactTextString(m) } func (*TimeShift) ProtoMessage() {} -func (*TimeShift) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{12} } +func (*TimeShift) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{13} } func (m *TimeShift) GetLabel() string { if m != nil { @@ -888,7 +921,7 @@ type Range struct { func (m *Range) Reset() { *m = Range{} } func (m *Range) String() string { return proto.CompactTextString(m) } func (*Range) ProtoMessage() {} -func (*Range) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{13} } +func (*Range) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{14} } func (m *Range) GetUpper() int64 { if m != nil { @@ -914,7 +947,7 @@ type AlertRule struct { func (m *AlertRule) Reset() { *m = AlertRule{} } func (m *AlertRule) String() string { return proto.CompactTextString(m) } func (*AlertRule) ProtoMessage() {} -func (*AlertRule) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{14} } +func (*AlertRule) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{15} } func (m *AlertRule) GetID() string { if m != nil { @@ -956,7 +989,7 @@ type User struct { func (m *User) Reset() { *m = User{} } func (m *User) String() string { return proto.CompactTextString(m) } func (*User) ProtoMessage() {} -func (*User) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{15} } +func (*User) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{16} } func (m *User) GetID() uint64 { if m != nil { @@ -1008,7 +1041,7 @@ type Role struct { func (m *Role) Reset() { *m = Role{} } func (m *Role) String() string { return proto.CompactTextString(m) } func (*Role) ProtoMessage() {} -func (*Role) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{16} } +func (*Role) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{17} } func (m *Role) GetOrganization() string { if m != nil { @@ -1024,17 +1057,64 @@ func (m *Role) GetName() string { return "" } +type Mapping struct { + Provider string `protobuf:"bytes,1,opt,name=Provider,proto3" json:"Provider,omitempty"` + Scheme string `protobuf:"bytes,2,opt,name=Scheme,proto3" json:"Scheme,omitempty"` + ProviderOrganization string `protobuf:"bytes,3,opt,name=ProviderOrganization,proto3" json:"ProviderOrganization,omitempty"` + ID string `protobuf:"bytes,4,opt,name=ID,proto3" json:"ID,omitempty"` + Organization string `protobuf:"bytes,5,opt,name=Organization,proto3" json:"Organization,omitempty"` +} + +func (m *Mapping) Reset() { *m = Mapping{} } +func (m *Mapping) String() string { return proto.CompactTextString(m) } +func (*Mapping) ProtoMessage() {} +func (*Mapping) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{18} } + +func (m *Mapping) GetProvider() string { + if m != nil { + return m.Provider + } + return "" +} + +func (m *Mapping) GetScheme() string { + if m != nil { + return m.Scheme + } + return "" +} + +func (m *Mapping) GetProviderOrganization() string { + if m != nil { + return m.ProviderOrganization + } + return "" +} + +func (m *Mapping) GetID() string { + if m != nil { + return m.ID + } + return "" +} + +func (m *Mapping) GetOrganization() string { + if m != nil { + return m.Organization + } + return "" +} + type Organization struct { ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` DefaultRole string `protobuf:"bytes,3,opt,name=DefaultRole,proto3" json:"DefaultRole,omitempty"` - Public bool `protobuf:"varint,4,opt,name=Public,proto3" json:"Public,omitempty"` } func (m *Organization) Reset() { *m = Organization{} } func (m *Organization) String() string { return proto.CompactTextString(m) } func (*Organization) ProtoMessage() {} -func (*Organization) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{17} } +func (*Organization) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{19} } func (m *Organization) GetID() string { if m != nil { @@ -1057,13 +1137,6 @@ func (m *Organization) GetDefaultRole() string { return "" } -func (m *Organization) GetPublic() bool { - if m != nil { - return m.Public - } - return false -} - type Config struct { Auth *AuthConfig `protobuf:"bytes,1,opt,name=Auth" json:"Auth,omitempty"` } @@ -1071,7 +1144,7 @@ type Config struct { func (m *Config) Reset() { *m = Config{} } func (m *Config) String() string { return proto.CompactTextString(m) } func (*Config) ProtoMessage() {} -func (*Config) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{18} } +func (*Config) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{20} } func (m *Config) GetAuth() *AuthConfig { if m != nil { @@ -1087,7 +1160,7 @@ type AuthConfig struct { func (m *AuthConfig) Reset() { *m = AuthConfig{} } func (m *AuthConfig) String() string { return proto.CompactTextString(m) } func (*AuthConfig) ProtoMessage() {} -func (*AuthConfig) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{19} } +func (*AuthConfig) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{21} } func (m *AuthConfig) GetSuperAdminNewUsers() bool { if m != nil { @@ -1104,7 +1177,7 @@ type BuildInfo struct { func (m *BuildInfo) Reset() { *m = BuildInfo{} } func (m *BuildInfo) String() string { return proto.CompactTextString(m) } func (*BuildInfo) ProtoMessage() {} -func (*BuildInfo) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{20} } +func (*BuildInfo) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{22} } func (m *BuildInfo) GetVersion() string { if m != nil { @@ -1125,6 +1198,7 @@ func init() { proto.RegisterType((*Dashboard)(nil), "internal.Dashboard") proto.RegisterType((*DashboardCell)(nil), "internal.DashboardCell") proto.RegisterType((*Color)(nil), "internal.Color") + proto.RegisterType((*Legend)(nil), "internal.Legend") proto.RegisterType((*Axis)(nil), "internal.Axis") proto.RegisterType((*Template)(nil), "internal.Template") proto.RegisterType((*TemplateValue)(nil), "internal.TemplateValue") @@ -1138,6 +1212,7 @@ func init() { proto.RegisterType((*AlertRule)(nil), "internal.AlertRule") proto.RegisterType((*User)(nil), "internal.User") proto.RegisterType((*Role)(nil), "internal.Role") + proto.RegisterType((*Mapping)(nil), "internal.Mapping") proto.RegisterType((*Organization)(nil), "internal.Organization") proto.RegisterType((*Config)(nil), "internal.Config") proto.RegisterType((*AuthConfig)(nil), "internal.AuthConfig") @@ -1147,89 +1222,93 @@ func init() { func init() { proto.RegisterFile("internal.proto", fileDescriptorInternal) } var fileDescriptorInternal = []byte{ - // 1342 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xbc, 0x57, 0xdd, 0x8e, 0xe3, 0xc4, - 0x12, 0x96, 0xe3, 0x38, 0xb1, 0x2b, 0xb3, 0x7b, 0x56, 0x3e, 0xab, 0xb3, 0x3e, 0x7b, 0xa4, 0xa3, - 0x60, 0x81, 0x08, 0x82, 0x1d, 0xd0, 0xac, 0x90, 0x10, 0x02, 0xa4, 0xcc, 0x04, 0x2d, 0xc3, 0xfe, - 0xcd, 0x76, 0x76, 0x86, 0x2b, 0xb4, 0xea, 0x38, 0x95, 0xc4, 0x5a, 0xc7, 0x36, 0x6d, 0x7b, 0x26, - 0xe6, 0x61, 0x90, 0x90, 0x78, 0x02, 0xc4, 0x3d, 0xb7, 0x88, 0x5b, 0xde, 0x81, 0x57, 0xe0, 0x16, - 0x55, 0x77, 0xfb, 0x27, 0x93, 0xb0, 0xda, 0x0b, 0xc4, 0x5d, 0x7f, 0x55, 0xed, 0xea, 0xfa, 0xf9, - 0xaa, 0xba, 0x0d, 0x37, 0xc3, 0x38, 0x47, 0x11, 0xf3, 0xe8, 0x30, 0x15, 0x49, 0x9e, 0xb8, 0x76, - 0x85, 0xfd, 0xdf, 0x3b, 0xd0, 0x9b, 0x26, 0x85, 0x08, 0xd0, 0xbd, 0x09, 0x9d, 0xd3, 0x89, 0x67, - 0x0c, 0x8d, 0x91, 0xc9, 0x3a, 0xa7, 0x13, 0xd7, 0x85, 0xee, 0x13, 0xbe, 0x46, 0xaf, 0x33, 0x34, - 0x46, 0x0e, 0x93, 0x6b, 0x92, 0x3d, 0x2f, 0x53, 0xf4, 0x4c, 0x25, 0xa3, 0xb5, 0x7b, 0x17, 0xec, - 0xf3, 0x8c, 0xac, 0xad, 0xd1, 0xeb, 0x4a, 0x79, 0x8d, 0x49, 0x77, 0xc6, 0xb3, 0xec, 0x2a, 0x11, - 0x73, 0xcf, 0x52, 0xba, 0x0a, 0xbb, 0xb7, 0xc0, 0x3c, 0x67, 0x8f, 0xbc, 0x9e, 0x14, 0xd3, 0xd2, - 0xf5, 0xa0, 0x3f, 0xc1, 0x05, 0x2f, 0xa2, 0xdc, 0xeb, 0x0f, 0x8d, 0x91, 0xcd, 0x2a, 0x48, 0x76, - 0x9e, 0x63, 0x84, 0x4b, 0xc1, 0x17, 0x9e, 0xad, 0xec, 0x54, 0xd8, 0x3d, 0x04, 0xf7, 0x34, 0xce, - 0x30, 0x28, 0x04, 0x4e, 0x5f, 0x86, 0xe9, 0x05, 0x8a, 0x70, 0x51, 0x7a, 0x8e, 0x34, 0xb0, 0x47, - 0x43, 0xa7, 0x3c, 0xc6, 0x9c, 0xd3, 0xd9, 0x20, 0x4d, 0x55, 0xd0, 0xf5, 0xe1, 0x60, 0xba, 0xe2, - 0x02, 0xe7, 0x53, 0x0c, 0x04, 0xe6, 0xde, 0x40, 0xaa, 0xb7, 0x64, 0xb4, 0xe7, 0xa9, 0x58, 0xf2, - 0x38, 0xfc, 0x96, 0xe7, 0x61, 0x12, 0x7b, 0x07, 0x6a, 0x4f, 0x5b, 0x46, 0x59, 0x62, 0x49, 0x84, - 0xde, 0x0d, 0x95, 0x25, 0x5a, 0xfb, 0x3f, 0x19, 0xe0, 0x4c, 0x78, 0xb6, 0x9a, 0x25, 0x5c, 0xcc, - 0x5f, 0x2b, 0xd7, 0xf7, 0xc0, 0x0a, 0x30, 0x8a, 0x32, 0xcf, 0x1c, 0x9a, 0xa3, 0xc1, 0xd1, 0x9d, - 0xc3, 0xba, 0x88, 0xb5, 0x9d, 0x13, 0x8c, 0x22, 0xa6, 0x76, 0xb9, 0x1f, 0x80, 0x93, 0xe3, 0x3a, - 0x8d, 0x78, 0x8e, 0x99, 0xd7, 0x95, 0x9f, 0xb8, 0xcd, 0x27, 0xcf, 0xb5, 0x8a, 0x35, 0x9b, 0x76, - 0x42, 0xb1, 0x76, 0x43, 0xf1, 0x7f, 0xeb, 0xc0, 0x8d, 0xad, 0xe3, 0xdc, 0x03, 0x30, 0x36, 0xd2, - 0x73, 0x8b, 0x19, 0x1b, 0x42, 0xa5, 0xf4, 0xda, 0x62, 0x46, 0x49, 0xe8, 0x4a, 0x72, 0xc3, 0x62, - 0xc6, 0x15, 0xa1, 0x95, 0x64, 0x84, 0xc5, 0x8c, 0x95, 0xfb, 0x0e, 0xf4, 0xbf, 0x29, 0x50, 0x84, - 0x98, 0x79, 0x96, 0xf4, 0xee, 0x5f, 0x8d, 0x77, 0xcf, 0x0a, 0x14, 0x25, 0xab, 0xf4, 0x94, 0x0d, - 0xc9, 0x26, 0x45, 0x0d, 0xb9, 0x26, 0x59, 0x4e, 0xcc, 0xeb, 0x2b, 0x19, 0xad, 0x75, 0x16, 0x15, - 0x1f, 0x28, 0x8b, 0x1f, 0x42, 0x97, 0x6f, 0x30, 0xf3, 0x1c, 0x69, 0xff, 0x8d, 0xbf, 0x48, 0xd8, - 0xe1, 0x78, 0x83, 0xd9, 0xe7, 0x71, 0x2e, 0x4a, 0x26, 0xb7, 0xbb, 0x6f, 0x43, 0x2f, 0x48, 0xa2, - 0x44, 0x64, 0x1e, 0x5c, 0x77, 0xec, 0x84, 0xe4, 0x4c, 0xab, 0xef, 0x3e, 0x00, 0xa7, 0xfe, 0x96, - 0xe8, 0xfb, 0x12, 0x4b, 0x99, 0x09, 0x87, 0xd1, 0xd2, 0x7d, 0x13, 0xac, 0x4b, 0x1e, 0x15, 0xaa, - 0x8a, 0x83, 0xa3, 0x9b, 0x8d, 0x99, 0xf1, 0x26, 0xcc, 0x98, 0x52, 0x7e, 0xdc, 0xf9, 0xc8, 0xf0, - 0x97, 0x60, 0x49, 0xcb, 0x2d, 0x1e, 0x38, 0x15, 0x0f, 0x64, 0x7f, 0x75, 0x5a, 0xfd, 0x75, 0x0b, - 0xcc, 0x2f, 0x70, 0xa3, 0x5b, 0x8e, 0x96, 0x35, 0x5b, 0xba, 0x2d, 0xb6, 0xdc, 0x06, 0xeb, 0x42, - 0x1e, 0xae, 0xaa, 0xa8, 0x80, 0xff, 0xa3, 0x01, 0x5d, 0x3a, 0x9c, 0x6a, 0x1d, 0xe1, 0x92, 0x07, - 0xe5, 0x71, 0x52, 0xc4, 0xf3, 0xcc, 0x33, 0x86, 0xe6, 0xc8, 0x64, 0x5b, 0x32, 0xf7, 0x3f, 0xd0, - 0x9b, 0x29, 0x6d, 0x67, 0x68, 0x8e, 0x1c, 0xa6, 0x11, 0x99, 0x8e, 0xf8, 0x0c, 0x23, 0xed, 0x82, - 0x02, 0xb4, 0x3b, 0x15, 0xb8, 0x08, 0x37, 0xda, 0x0d, 0x8d, 0x48, 0x9e, 0x15, 0x0b, 0x92, 0x2b, - 0x4f, 0x34, 0x22, 0xa7, 0x67, 0x3c, 0xab, 0x8b, 0x4a, 0x6b, 0xb2, 0x9c, 0x05, 0x3c, 0xaa, 0xaa, - 0xaa, 0x80, 0xff, 0xb3, 0x41, 0xdd, 0xae, 0x58, 0xba, 0x93, 0xa1, 0xff, 0x82, 0x4d, 0x0c, 0x7e, - 0x71, 0xc9, 0x85, 0xce, 0x52, 0x9f, 0xf0, 0x05, 0x17, 0xee, 0xfb, 0xd0, 0x93, 0x29, 0xde, 0xd3, - 0x31, 0x95, 0x39, 0x99, 0x15, 0xa6, 0xb7, 0xd5, 0x9c, 0xea, 0xb6, 0x38, 0x55, 0x07, 0x6b, 0xb5, - 0x83, 0xbd, 0x07, 0x16, 0x91, 0xb3, 0x94, 0xde, 0xef, 0xb5, 0xac, 0x28, 0xac, 0x76, 0xf9, 0xe7, - 0x70, 0x63, 0xeb, 0xc4, 0xfa, 0x24, 0x63, 0xfb, 0xa4, 0x86, 0x2e, 0x8e, 0xa6, 0x07, 0x4d, 0xba, - 0x0c, 0x23, 0x0c, 0x72, 0x9c, 0xcb, 0x7c, 0xdb, 0xac, 0xc6, 0xfe, 0xf7, 0x46, 0x63, 0x57, 0x9e, - 0x47, 0xb3, 0x2c, 0x48, 0xd6, 0x6b, 0x1e, 0xcf, 0xb5, 0xe9, 0x0a, 0x52, 0xde, 0xe6, 0x33, 0x6d, - 0xba, 0x33, 0x9f, 0x11, 0x16, 0xa9, 0xae, 0x60, 0x47, 0xa4, 0xee, 0x10, 0x06, 0x6b, 0xe4, 0x59, - 0x21, 0x70, 0x8d, 0x71, 0xae, 0x53, 0xd0, 0x16, 0xb9, 0x77, 0xa0, 0x9f, 0xf3, 0xe5, 0x0b, 0x22, - 0xb9, 0xae, 0x64, 0xce, 0x97, 0x0f, 0xb1, 0x74, 0xff, 0x07, 0xce, 0x22, 0xc4, 0x68, 0x2e, 0x55, - 0xaa, 0x9c, 0xb6, 0x14, 0x3c, 0xc4, 0xd2, 0xff, 0xc5, 0x80, 0xde, 0x14, 0xc5, 0x25, 0x8a, 0xd7, - 0x1a, 0x72, 0xed, 0xcb, 0xc3, 0x7c, 0xc5, 0xe5, 0xd1, 0xdd, 0x7f, 0x79, 0x58, 0xcd, 0xe5, 0x71, - 0x1b, 0xac, 0xa9, 0x08, 0x4e, 0x27, 0xd2, 0x23, 0x93, 0x29, 0x40, 0x6c, 0x1c, 0x07, 0x79, 0x78, - 0x89, 0xfa, 0x46, 0xd1, 0x68, 0x67, 0xf6, 0xd9, 0x7b, 0x66, 0xdf, 0x77, 0x06, 0xf4, 0x1e, 0xf1, - 0x32, 0x29, 0xf2, 0x1d, 0x16, 0x0e, 0x61, 0x30, 0x4e, 0xd3, 0x28, 0x0c, 0xd4, 0xd7, 0x2a, 0xa2, - 0xb6, 0x88, 0x76, 0x3c, 0x6e, 0xe5, 0x57, 0xc5, 0xd6, 0x16, 0xd1, 0xb8, 0x38, 0x91, 0xf3, 0x5d, - 0x0d, 0xeb, 0xd6, 0xb8, 0x50, 0x63, 0x5d, 0x2a, 0x29, 0x09, 0xe3, 0x22, 0x4f, 0x16, 0x51, 0x72, - 0x25, 0xa3, 0xb5, 0x59, 0x8d, 0xfd, 0x5f, 0x3b, 0xd0, 0xfd, 0xa7, 0x66, 0xf2, 0x01, 0x18, 0xa1, - 0x2e, 0xb6, 0x11, 0xd6, 0x13, 0xba, 0xdf, 0x9a, 0xd0, 0x1e, 0xf4, 0x4b, 0xc1, 0xe3, 0x25, 0x66, - 0x9e, 0x2d, 0xa7, 0x4b, 0x05, 0xa5, 0x46, 0xf6, 0x91, 0x1a, 0xcd, 0x0e, 0xab, 0x60, 0xdd, 0x17, - 0xd0, 0xea, 0x8b, 0xf7, 0xf4, 0x14, 0x1f, 0x48, 0x8f, 0xbc, 0xed, 0xb4, 0x5c, 0x1f, 0xde, 0x7f, - 0xdf, 0x4c, 0xfe, 0xc3, 0x00, 0xab, 0x6e, 0xaa, 0x93, 0xed, 0xa6, 0x3a, 0x69, 0x9a, 0x6a, 0x72, - 0x5c, 0x35, 0xd5, 0xe4, 0x98, 0x30, 0x3b, 0xab, 0x9a, 0x8a, 0x9d, 0x51, 0xb1, 0x1e, 0x88, 0xa4, - 0x48, 0x8f, 0x4b, 0x55, 0x55, 0x87, 0xd5, 0x98, 0x98, 0xf8, 0xd5, 0x0a, 0x85, 0x4e, 0xb5, 0xc3, - 0x34, 0x22, 0xde, 0x3e, 0x92, 0x03, 0x47, 0x25, 0x57, 0x01, 0xf7, 0x2d, 0xb0, 0x18, 0x25, 0x4f, - 0x66, 0x78, 0xab, 0x2e, 0x52, 0xcc, 0x94, 0x96, 0x8c, 0xaa, 0xd7, 0x9b, 0x26, 0x70, 0xf5, 0x96, - 0x7b, 0x17, 0x7a, 0xd3, 0x55, 0xb8, 0xc8, 0xab, 0xbb, 0xf0, 0xdf, 0xad, 0x81, 0x15, 0xae, 0x51, - 0xea, 0x98, 0xde, 0xe2, 0x3f, 0x03, 0xa7, 0x16, 0x36, 0xee, 0x18, 0x6d, 0x77, 0x5c, 0xe8, 0x9e, - 0xc7, 0x61, 0x5e, 0xb5, 0x2e, 0xad, 0x29, 0xd8, 0x67, 0x05, 0x8f, 0xf3, 0x30, 0x2f, 0xab, 0xd6, - 0xad, 0xb0, 0x7f, 0x5f, 0xbb, 0x4f, 0xe6, 0xce, 0xd3, 0x14, 0x85, 0x1e, 0x03, 0x0a, 0xc8, 0x43, - 0x92, 0x2b, 0x54, 0x13, 0xdc, 0x64, 0x0a, 0xf8, 0x5f, 0x83, 0x33, 0x8e, 0x50, 0xe4, 0xac, 0x88, - 0x70, 0xdf, 0xcd, 0xf8, 0xe5, 0xf4, 0xe9, 0x93, 0xca, 0x03, 0x5a, 0x37, 0x2d, 0x6f, 0x5e, 0x6b, - 0xf9, 0x87, 0x3c, 0xe5, 0xa7, 0x13, 0xc9, 0x73, 0x93, 0x69, 0xe4, 0xff, 0x60, 0x40, 0x97, 0x66, - 0x4b, 0xcb, 0x74, 0xf7, 0x55, 0x73, 0xe9, 0x4c, 0x24, 0x97, 0xe1, 0x1c, 0x45, 0x15, 0x5c, 0x85, - 0x65, 0xd2, 0x83, 0x15, 0xd6, 0x17, 0xb0, 0x46, 0xc4, 0x35, 0x7a, 0xea, 0x55, 0xbd, 0xd4, 0xe2, - 0x1a, 0x89, 0x99, 0x52, 0xba, 0xff, 0x07, 0x98, 0x16, 0x29, 0x8a, 0xf1, 0x7c, 0x1d, 0xc6, 0xb2, - 0xe8, 0x36, 0x6b, 0x49, 0xfc, 0xcf, 0xd4, 0xe3, 0x71, 0x67, 0x42, 0x19, 0xfb, 0x1f, 0x9a, 0xd7, - 0x3d, 0xf7, 0xa3, 0xed, 0xef, 0xf6, 0x25, 0x72, 0x27, 0xda, 0x21, 0x0c, 0xf4, 0x4b, 0x5b, 0xbe, - 0x5b, 0xf5, 0xb0, 0x6a, 0x89, 0x28, 0xe6, 0xb3, 0x62, 0x16, 0x85, 0x81, 0x8c, 0xd9, 0x66, 0x1a, - 0xf9, 0x47, 0xd0, 0x3b, 0x49, 0xe2, 0x45, 0xb8, 0x74, 0x47, 0xd0, 0x1d, 0x17, 0xf9, 0x4a, 0x9e, - 0x34, 0x38, 0xba, 0xdd, 0x6a, 0xb4, 0x22, 0x5f, 0xa9, 0x3d, 0x4c, 0xee, 0xf0, 0x3f, 0x01, 0x68, - 0x64, 0xf4, 0x7c, 0x6f, 0xa2, 0x7f, 0x82, 0x57, 0x54, 0xa2, 0x4c, 0x5a, 0xb1, 0xd9, 0x1e, 0x8d, - 0xff, 0x29, 0x38, 0xc7, 0x45, 0x18, 0xcd, 0x4f, 0xe3, 0x45, 0x42, 0xad, 0x7a, 0x81, 0x22, 0x6b, - 0xf2, 0x53, 0x41, 0x72, 0x98, 0xba, 0xb6, 0xe6, 0xac, 0x46, 0xb3, 0x9e, 0xfc, 0x03, 0xba, 0xff, - 0x67, 0x00, 0x00, 0x00, 0xff, 0xff, 0xbe, 0x23, 0x76, 0x8f, 0x13, 0x0d, 0x00, 0x00, + // 1406 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xbc, 0x57, 0x5f, 0x8f, 0xdb, 0x44, + 0x10, 0x97, 0x63, 0x3b, 0x89, 0x27, 0xd7, 0x52, 0x99, 0x13, 0x35, 0x45, 0x42, 0xc1, 0x02, 0x11, + 0x04, 0x3d, 0xd0, 0x55, 0x48, 0x08, 0x41, 0xa5, 0xdc, 0x05, 0x95, 0xa3, 0xd7, 0xf6, 0xba, 0xb9, + 0x3b, 0x9e, 0x50, 0xb5, 0x97, 0x4c, 0x12, 0xab, 0x8e, 0x6d, 0xd6, 0xf6, 0x5d, 0xcc, 0x87, 0x41, + 0x42, 0x82, 0x2f, 0x80, 0x78, 0xe7, 0x15, 0xf1, 0x41, 0xf8, 0x0a, 0x3c, 0x21, 0xa1, 0xd9, 0x5d, + 0xff, 0xc9, 0x25, 0xad, 0xfa, 0x80, 0x78, 0xdb, 0xdf, 0xcc, 0x66, 0x76, 0xfe, 0xfc, 0x66, 0xc6, + 0x81, 0x9b, 0x41, 0x94, 0xa1, 0x88, 0x78, 0xb8, 0x97, 0x88, 0x38, 0x8b, 0xdd, 0x6e, 0x89, 0xfd, + 0xbf, 0x5a, 0xd0, 0x1e, 0xc7, 0xb9, 0x98, 0xa0, 0x7b, 0x13, 0x5a, 0x47, 0x23, 0xcf, 0xe8, 0x1b, + 0x03, 0x93, 0xb5, 0x8e, 0x46, 0xae, 0x0b, 0xd6, 0x63, 0xbe, 0x44, 0xaf, 0xd5, 0x37, 0x06, 0x0e, + 0x93, 0x67, 0x92, 0x9d, 0x16, 0x09, 0x7a, 0xa6, 0x92, 0xd1, 0xd9, 0xbd, 0x03, 0xdd, 0xb3, 0x94, + 0xac, 0x2d, 0xd1, 0xb3, 0xa4, 0xbc, 0xc2, 0xa4, 0x3b, 0xe1, 0x69, 0x7a, 0x15, 0x8b, 0xa9, 0x67, + 0x2b, 0x5d, 0x89, 0xdd, 0x5b, 0x60, 0x9e, 0xb1, 0x63, 0xaf, 0x2d, 0xc5, 0x74, 0x74, 0x3d, 0xe8, + 0x8c, 0x70, 0xc6, 0xf3, 0x30, 0xf3, 0x3a, 0x7d, 0x63, 0xd0, 0x65, 0x25, 0x24, 0x3b, 0xa7, 0x18, + 0xe2, 0x5c, 0xf0, 0x99, 0xd7, 0x55, 0x76, 0x4a, 0xec, 0xee, 0x81, 0x7b, 0x14, 0xa5, 0x38, 0xc9, + 0x05, 0x8e, 0x9f, 0x07, 0xc9, 0x39, 0x8a, 0x60, 0x56, 0x78, 0x8e, 0x34, 0xb0, 0x45, 0x43, 0xaf, + 0x3c, 0xc2, 0x8c, 0xd3, 0xdb, 0x20, 0x4d, 0x95, 0xd0, 0xf5, 0x61, 0x67, 0xbc, 0xe0, 0x02, 0xa7, + 0x63, 0x9c, 0x08, 0xcc, 0xbc, 0x9e, 0x54, 0xaf, 0xc9, 0xe8, 0xce, 0x13, 0x31, 0xe7, 0x51, 0xf0, + 0x03, 0xcf, 0x82, 0x38, 0xf2, 0x76, 0xd4, 0x9d, 0xa6, 0x8c, 0xb2, 0xc4, 0xe2, 0x10, 0xbd, 0x1b, + 0x2a, 0x4b, 0x74, 0xf6, 0x7f, 0x33, 0xc0, 0x19, 0xf1, 0x74, 0x71, 0x11, 0x73, 0x31, 0x7d, 0xa5, + 0x5c, 0xdf, 0x05, 0x7b, 0x82, 0x61, 0x98, 0x7a, 0x66, 0xdf, 0x1c, 0xf4, 0xf6, 0x6f, 0xef, 0x55, + 0x45, 0xac, 0xec, 0x1c, 0x62, 0x18, 0x32, 0x75, 0xcb, 0xfd, 0x04, 0x9c, 0x0c, 0x97, 0x49, 0xc8, + 0x33, 0x4c, 0x3d, 0x4b, 0xfe, 0xc4, 0xad, 0x7f, 0x72, 0xaa, 0x55, 0xac, 0xbe, 0xb4, 0x11, 0x8a, + 0xbd, 0x19, 0x8a, 0xff, 0x4f, 0x0b, 0x6e, 0xac, 0x3d, 0xe7, 0xee, 0x80, 0xb1, 0x92, 0x9e, 0xdb, + 0xcc, 0x58, 0x11, 0x2a, 0xa4, 0xd7, 0x36, 0x33, 0x0a, 0x42, 0x57, 0x92, 0x1b, 0x36, 0x33, 0xae, + 0x08, 0x2d, 0x24, 0x23, 0x6c, 0x66, 0x2c, 0xdc, 0x0f, 0xa0, 0xf3, 0x7d, 0x8e, 0x22, 0xc0, 0xd4, + 0xb3, 0xa5, 0x77, 0xaf, 0xd5, 0xde, 0x3d, 0xcd, 0x51, 0x14, 0xac, 0xd4, 0x53, 0x36, 0x24, 0x9b, + 0x14, 0x35, 0xe4, 0x99, 0x64, 0x19, 0x31, 0xaf, 0xa3, 0x64, 0x74, 0xd6, 0x59, 0x54, 0x7c, 0xa0, + 0x2c, 0x7e, 0x0a, 0x16, 0x5f, 0x61, 0xea, 0x39, 0xd2, 0xfe, 0x3b, 0x2f, 0x48, 0xd8, 0xde, 0x70, + 0x85, 0xe9, 0x57, 0x51, 0x26, 0x0a, 0x26, 0xaf, 0xbb, 0xef, 0x43, 0x7b, 0x12, 0x87, 0xb1, 0x48, + 0x3d, 0xb8, 0xee, 0xd8, 0x21, 0xc9, 0x99, 0x56, 0xbb, 0x03, 0x68, 0x87, 0x38, 0xc7, 0x68, 0x2a, + 0x99, 0xd1, 0xdb, 0xbf, 0x55, 0x5f, 0x3c, 0x96, 0x72, 0xa6, 0xf5, 0x77, 0x1e, 0x80, 0x53, 0xbd, + 0x42, 0x44, 0x7f, 0x8e, 0x85, 0xcc, 0x99, 0xc3, 0xe8, 0xe8, 0xbe, 0x0b, 0xf6, 0x25, 0x0f, 0x73, + 0x55, 0xef, 0xde, 0xfe, 0xcd, 0xda, 0xce, 0x70, 0x15, 0xa4, 0x4c, 0x29, 0x3f, 0x6f, 0x7d, 0x66, + 0xf8, 0x73, 0xb0, 0xa5, 0x0f, 0x0d, 0xc6, 0x38, 0x25, 0x63, 0x64, 0x27, 0xb6, 0x1a, 0x9d, 0x78, + 0x0b, 0xcc, 0xaf, 0x71, 0xa5, 0x9b, 0x93, 0x8e, 0x15, 0xaf, 0xac, 0x06, 0xaf, 0x76, 0xc1, 0x3e, + 0x97, 0x8f, 0xab, 0x7a, 0x2b, 0xe0, 0xdf, 0x87, 0xb6, 0x8a, 0xa1, 0xb2, 0x6c, 0x34, 0x2c, 0xf7, + 0xa1, 0xf7, 0x44, 0x04, 0x18, 0x65, 0x8a, 0x29, 0xea, 0xd1, 0xa6, 0xc8, 0xff, 0xd5, 0x00, 0x8b, + 0x9c, 0x27, 0x56, 0x85, 0x38, 0xe7, 0x93, 0xe2, 0x20, 0xce, 0xa3, 0x69, 0xea, 0x19, 0x7d, 0x73, + 0x60, 0xb2, 0x35, 0x99, 0xfb, 0x06, 0xb4, 0x2f, 0x94, 0xb6, 0xd5, 0x37, 0x07, 0x0e, 0xd3, 0x88, + 0x5c, 0x0b, 0xf9, 0x05, 0x86, 0x3a, 0x04, 0x05, 0xe8, 0x76, 0x22, 0x70, 0x16, 0xac, 0x74, 0x18, + 0x1a, 0x91, 0x3c, 0xcd, 0x67, 0x24, 0x57, 0x91, 0x68, 0x44, 0x01, 0x5c, 0xf0, 0xb4, 0xa2, 0x0f, + 0x9d, 0xc9, 0x72, 0x3a, 0xe1, 0x61, 0xc9, 0x1f, 0x05, 0xfc, 0xdf, 0x0d, 0x9a, 0x2b, 0xaa, 0x1f, + 0x36, 0x32, 0xfc, 0x26, 0x74, 0xa9, 0x57, 0x9e, 0x5d, 0x72, 0xa1, 0x03, 0xee, 0x10, 0x3e, 0xe7, + 0xc2, 0xfd, 0x18, 0xda, 0xb2, 0x44, 0x5b, 0x7a, 0xb3, 0x34, 0x27, 0xb3, 0xca, 0xf4, 0xb5, 0x8a, + 0xbd, 0x56, 0x83, 0xbd, 0x55, 0xb0, 0x76, 0x33, 0xd8, 0xbb, 0x60, 0x53, 0x1b, 0x14, 0xd2, 0xfb, + 0xad, 0x96, 0x55, 0xb3, 0xa8, 0x5b, 0xfe, 0x19, 0xdc, 0x58, 0x7b, 0xb1, 0x7a, 0xc9, 0x58, 0x7f, + 0xa9, 0xa6, 0x9b, 0xa3, 0xe9, 0x45, 0x33, 0x35, 0xc5, 0x10, 0x27, 0x19, 0x4e, 0x65, 0xbe, 0xbb, + 0xac, 0xc2, 0xfe, 0x4f, 0x46, 0x6d, 0x57, 0xbe, 0x47, 0x53, 0x73, 0x12, 0x2f, 0x97, 0x3c, 0x9a, + 0x6a, 0xd3, 0x25, 0xa4, 0xbc, 0x4d, 0x2f, 0xb4, 0xe9, 0xd6, 0xf4, 0x82, 0xb0, 0x48, 0x74, 0x05, + 0x5b, 0x22, 0x21, 0xee, 0x2c, 0x91, 0xa7, 0xb9, 0xc0, 0x25, 0x46, 0x99, 0x4e, 0x41, 0x53, 0xe4, + 0xde, 0x86, 0x4e, 0xc6, 0xe7, 0xcf, 0xa8, 0x49, 0x74, 0x25, 0x33, 0x3e, 0x7f, 0x88, 0x85, 0xfb, + 0x16, 0x38, 0xb3, 0x00, 0xc3, 0xa9, 0x54, 0xa9, 0x72, 0x76, 0xa5, 0xe0, 0x21, 0x16, 0xfe, 0x1f, + 0x06, 0xb4, 0xc7, 0x28, 0x2e, 0x51, 0xbc, 0xd2, 0x38, 0x6d, 0xae, 0x29, 0xf3, 0x25, 0x6b, 0xca, + 0xda, 0xbe, 0xa6, 0xec, 0x7a, 0x4d, 0xed, 0x82, 0x3d, 0x16, 0x93, 0xa3, 0x91, 0xf4, 0xc8, 0x64, + 0x0a, 0x10, 0x1b, 0x87, 0x93, 0x2c, 0xb8, 0x44, 0xbd, 0xbb, 0x34, 0xda, 0x98, 0xb2, 0xdd, 0x2d, + 0x53, 0xf6, 0x47, 0x03, 0xda, 0xc7, 0xbc, 0x88, 0xf3, 0x6c, 0x83, 0x85, 0x7d, 0xe8, 0x0d, 0x93, + 0x24, 0x0c, 0x26, 0x6b, 0x9d, 0xd7, 0x10, 0xd1, 0x8d, 0x47, 0x8d, 0xfc, 0xaa, 0xd8, 0x9a, 0x22, + 0x1a, 0x37, 0x87, 0x72, 0x93, 0xa8, 0xb5, 0xd0, 0x18, 0x37, 0x6a, 0x81, 0x48, 0x25, 0x25, 0x61, + 0x98, 0x67, 0xf1, 0x2c, 0x8c, 0xaf, 0x64, 0xb4, 0x5d, 0x56, 0x61, 0xff, 0xcf, 0x16, 0x58, 0xff, + 0xd7, 0xf4, 0xdf, 0x01, 0x23, 0xd0, 0xc5, 0x36, 0x82, 0x6a, 0x17, 0x74, 0x1a, 0xbb, 0xc0, 0x83, + 0x4e, 0x21, 0x78, 0x34, 0xc7, 0xd4, 0xeb, 0xca, 0xe9, 0x52, 0x42, 0xa9, 0x91, 0x7d, 0xa4, 0x96, + 0x80, 0xc3, 0x4a, 0x58, 0xf5, 0x05, 0x34, 0xfa, 0xe2, 0x23, 0xbd, 0x2f, 0x7a, 0xd2, 0x23, 0x6f, + 0x3d, 0x2d, 0xd7, 0xd7, 0xc4, 0x7f, 0x37, 0xd3, 0xff, 0x36, 0xc0, 0xae, 0x9a, 0xea, 0x70, 0xbd, + 0xa9, 0x0e, 0xeb, 0xa6, 0x1a, 0x1d, 0x94, 0x4d, 0x35, 0x3a, 0x20, 0xcc, 0x4e, 0xca, 0xa6, 0x62, + 0x27, 0x54, 0xac, 0x07, 0x22, 0xce, 0x93, 0x83, 0x42, 0x55, 0xd5, 0x61, 0x15, 0x26, 0x26, 0x7e, + 0xbb, 0x40, 0xa1, 0x53, 0xed, 0x30, 0x8d, 0x88, 0xb7, 0xc7, 0x72, 0xe0, 0xa8, 0xe4, 0x2a, 0xe0, + 0xbe, 0x07, 0x36, 0xa3, 0xe4, 0xc9, 0x0c, 0xaf, 0xd5, 0x45, 0x8a, 0x99, 0xd2, 0x92, 0x51, 0xf5, + 0x9d, 0xa8, 0x09, 0x5c, 0x7e, 0x35, 0x7e, 0x08, 0xed, 0xf1, 0x22, 0x98, 0x65, 0xe5, 0xd6, 0x7d, + 0xbd, 0x31, 0xb0, 0x82, 0x25, 0x4a, 0x1d, 0xd3, 0x57, 0xfc, 0xa7, 0xe0, 0x54, 0xc2, 0xda, 0x1d, + 0xa3, 0xe9, 0x8e, 0x0b, 0xd6, 0x59, 0x14, 0x64, 0x65, 0xeb, 0xd2, 0x99, 0x82, 0x7d, 0x9a, 0xf3, + 0x28, 0x0b, 0xb2, 0xa2, 0x6c, 0xdd, 0x12, 0xfb, 0xf7, 0xb4, 0xfb, 0x64, 0xee, 0x2c, 0x49, 0x50, + 0xe8, 0x31, 0xa0, 0x80, 0x7c, 0x24, 0xbe, 0x42, 0x35, 0xc1, 0x4d, 0xa6, 0x80, 0xff, 0x1d, 0x38, + 0xc3, 0x10, 0x45, 0xc6, 0xf2, 0x10, 0xb7, 0x6d, 0xd6, 0x6f, 0xc6, 0x4f, 0x1e, 0x97, 0x1e, 0xd0, + 0xb9, 0x6e, 0x79, 0xf3, 0x5a, 0xcb, 0x3f, 0xe4, 0x09, 0x3f, 0x1a, 0x49, 0x9e, 0x9b, 0x4c, 0x23, + 0xff, 0x67, 0x03, 0x2c, 0x9a, 0x2d, 0x0d, 0xd3, 0xd6, 0xcb, 0xe6, 0xd2, 0x89, 0x88, 0x2f, 0x83, + 0x29, 0x8a, 0x32, 0xb8, 0x12, 0xcb, 0xa4, 0x4f, 0x16, 0x58, 0x2d, 0x70, 0x8d, 0x88, 0x6b, 0xf4, + 0x51, 0x59, 0xf6, 0x52, 0x83, 0x6b, 0x24, 0x66, 0x4a, 0xe9, 0xbe, 0x0d, 0x30, 0xce, 0x13, 0x14, + 0xc3, 0xe9, 0x32, 0x88, 0x64, 0xd1, 0xbb, 0xac, 0x21, 0xf1, 0xef, 0xab, 0xcf, 0xd4, 0x8d, 0x09, + 0x65, 0x6c, 0xff, 0xa4, 0xbd, 0xee, 0xb9, 0xff, 0x8b, 0x01, 0x9d, 0x47, 0x3c, 0x49, 0x82, 0x68, + 0xbe, 0x16, 0x85, 0xf1, 0xc2, 0x28, 0x5a, 0x6b, 0x51, 0xec, 0xc3, 0x6e, 0x79, 0x67, 0xed, 0x7d, + 0x95, 0x85, 0xad, 0x3a, 0x9d, 0x51, 0xab, 0x2a, 0xd6, 0xab, 0x7c, 0xc3, 0x9e, 0xae, 0xdf, 0xd9, + 0x56, 0xf0, 0x8d, 0xaa, 0xf4, 0xa1, 0xa7, 0xff, 0x7b, 0xc8, 0x2f, 0x79, 0x3d, 0x54, 0x1b, 0x22, + 0x7f, 0x1f, 0xda, 0x87, 0x71, 0x34, 0x0b, 0xe6, 0xee, 0x00, 0xac, 0x61, 0x9e, 0x2d, 0xa4, 0xc5, + 0xde, 0xfe, 0x6e, 0xa3, 0xf1, 0xf3, 0x6c, 0xa1, 0xee, 0x30, 0x79, 0xc3, 0xff, 0x02, 0xa0, 0x96, + 0xd1, 0x1f, 0x97, 0xba, 0x1a, 0x8f, 0xf1, 0x8a, 0x28, 0x93, 0x4a, 0x2b, 0x5d, 0xb6, 0x45, 0xe3, + 0x7f, 0x09, 0xce, 0x41, 0x1e, 0x84, 0xd3, 0xa3, 0x68, 0x16, 0xd3, 0xe8, 0x38, 0x47, 0x91, 0xd6, + 0xf5, 0x2a, 0x21, 0xa5, 0x9b, 0xa6, 0x48, 0xd5, 0x43, 0x1a, 0x5d, 0xb4, 0xe5, 0x7f, 0xbf, 0x7b, + 0xff, 0x06, 0x00, 0x00, 0xff, 0xff, 0xfe, 0xe9, 0xd1, 0x8f, 0x0d, 0x0e, 0x00, 0x00, } diff --git a/bolt/internal/internal.proto b/bolt/internal/internal.proto index 87ea5a7f3d..f5fdef691d 100644 --- a/bolt/internal/internal.proto +++ b/bolt/internal/internal.proto @@ -36,6 +36,7 @@ message DashboardCell { string ID = 8; // id is the unique id of the dashboard. MIGRATED FIELD added in 1.2.0-beta6 map axes = 9; // Axes represent the graphical viewport for a cell's visualizations repeated Color colors = 10; // Colors represent encoding data values to color + Legend legend = 11; // Legend is summary information for a cell } message Color { @@ -46,6 +47,11 @@ message Color { string Value = 5; // Value is the data value mapped to this color } +message Legend { + string Type = 1; // Type is how the legend is used + string Orientation = 2; // Orientation is the location of the legend on the cell +} + message Axis { repeated int64 legacyBounds = 1; // legacyBounds are an ordered 2-tuple consisting of lower and upper axis extents, respectively repeated string bounds = 2; // bounds are an arbitrary list of client-defined bounds. @@ -153,15 +159,22 @@ message User { } message Role { - string Organization = 1; // Organization is the ID of the organization that this user has a role in - string Name = 2; // Name is the name of the role of this user in the respective organization + string Organization = 1; // Organization is the ID of the organization that this user has a role in + string Name = 2; // Name is the name of the role of this user in the respective organization +} + +message Mapping { + string Provider = 1; // Provider is the provider that certifies and issues this user's authentication, e.g. GitHub + string Scheme = 2; // Scheme is the scheme used to perform this user's authentication, e.g. OAuth2 or LDAP + string ProviderOrganization = 3; // ProviderOrganization is the group or organizations that you are a part of in an auth provider + string ID = 4; // ID is the unique ID for the mapping + string Organization = 5; // Organization is the organization ID that resource belongs to } message Organization { - string ID = 1; // ID is the unique ID of the organization - string Name = 2; // Name is the organization's name - string DefaultRole = 3; // DefaultRole is the name of the role that is the default for any users added to the organization - bool Public = 4; // Public specifies that users must be explicitly added to the organization + string ID = 1; // ID is the unique ID of the organization + string Name = 2; // Name is the organization's name + string DefaultRole = 3; // DefaultRole is the name of the role that is the default for any users added to the organization } message Config { @@ -173,8 +186,8 @@ message AuthConfig { } message BuildInfo { - string Version = 1; // Version is a descriptive git SHA identifier - string Commit = 2; // Commit is an abbreviated SHA + string Version = 1; // Version is a descriptive git SHA identifier + string Commit = 2; // Commit is an abbreviated SHA } // The following is a vim modeline, it autoconfigures vim to have the diff --git a/bolt/internal/internal_test.go b/bolt/internal/internal_test.go index f659d6c965..47c7067b46 100644 --- a/bolt/internal/internal_test.go +++ b/bolt/internal/internal_test.go @@ -251,6 +251,10 @@ func Test_MarshalDashboard_WithLegacyBounds(t *testing.T) { Value: "100", }, }, + Legend: chronograf.Legend{ + Type: "static", + Orientation: "bottom", + }, Type: "line", }, }, @@ -301,6 +305,10 @@ func Test_MarshalDashboard_WithLegacyBounds(t *testing.T) { Value: "100", }, }, + Legend: chronograf.Legend{ + Type: "static", + Orientation: "bottom", + }, Type: "line", }, }, diff --git a/bolt/mapping.go b/bolt/mapping.go new file mode 100644 index 0000000000..52e643dfc2 --- /dev/null +++ b/bolt/mapping.go @@ -0,0 +1,128 @@ +package bolt + +import ( + "context" + "fmt" + + "github.com/boltdb/bolt" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/bolt/internal" +) + +// Ensure MappingsStore implements chronograf.MappingsStore. +var _ chronograf.MappingsStore = &MappingsStore{} + +var ( + // MappingsBucket is the bucket where organizations are stored. + MappingsBucket = []byte("MappingsV1") +) + +// MappingsStore uses bolt to store and retrieve Mappings +type MappingsStore struct { + client *Client +} + +// Migrate sets the default organization at runtime +func (s *MappingsStore) Migrate(ctx context.Context) error { + return nil +} + +// Add creates a new Mapping in the MappingsStore +func (s *MappingsStore) Add(ctx context.Context, o *chronograf.Mapping) (*chronograf.Mapping, error) { + err := s.client.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(MappingsBucket) + seq, err := b.NextSequence() + if err != nil { + return err + } + o.ID = fmt.Sprintf("%d", seq) + + v, err := internal.MarshalMapping(o) + if err != nil { + return err + } + + return b.Put([]byte(o.ID), v) + }) + + if err != nil { + return nil, err + } + + return o, nil +} + +// All returns all known organizations +func (s *MappingsStore) All(ctx context.Context) ([]chronograf.Mapping, error) { + var mappings []chronograf.Mapping + err := s.each(ctx, func(m *chronograf.Mapping) { + mappings = append(mappings, *m) + }) + + if err != nil { + return nil, err + } + + return mappings, nil +} + +// Delete the organization from MappingsStore +func (s *MappingsStore) Delete(ctx context.Context, o *chronograf.Mapping) error { + _, err := s.get(ctx, o.ID) + if err != nil { + return err + } + if err := s.client.db.Update(func(tx *bolt.Tx) error { + return tx.Bucket(MappingsBucket).Delete([]byte(o.ID)) + }); err != nil { + return err + } + return nil +} + +func (s *MappingsStore) get(ctx context.Context, id string) (*chronograf.Mapping, error) { + var o chronograf.Mapping + err := s.client.db.View(func(tx *bolt.Tx) error { + v := tx.Bucket(MappingsBucket).Get([]byte(id)) + if v == nil { + return chronograf.ErrMappingNotFound + } + return internal.UnmarshalMapping(v, &o) + }) + + if err != nil { + return nil, err + } + + return &o, nil +} + +func (s *MappingsStore) each(ctx context.Context, fn func(*chronograf.Mapping)) error { + return s.client.db.View(func(tx *bolt.Tx) error { + return tx.Bucket(MappingsBucket).ForEach(func(k, v []byte) error { + var m chronograf.Mapping + if err := internal.UnmarshalMapping(v, &m); err != nil { + return err + } + fn(&m) + return nil + }) + }) +} + +// Get returns a Mapping if the id exists. +func (s *MappingsStore) Get(ctx context.Context, id string) (*chronograf.Mapping, error) { + return s.get(ctx, id) +} + +// Update the organization in MappingsStore +func (s *MappingsStore) Update(ctx context.Context, o *chronograf.Mapping) error { + return s.client.db.Update(func(tx *bolt.Tx) error { + if v, err := internal.MarshalMapping(o); err != nil { + return err + } else if err := tx.Bucket(MappingsBucket).Put([]byte(o.ID), v); err != nil { + return err + } + return nil + }) +} diff --git a/bolt/mapping_test.go b/bolt/mapping_test.go new file mode 100644 index 0000000000..745f5df902 --- /dev/null +++ b/bolt/mapping_test.go @@ -0,0 +1,483 @@ +package bolt_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/influxdata/chronograf" +) + +var mappingCmpOptions = cmp.Options{ + cmpopts.IgnoreFields(chronograf.Mapping{}, "ID"), + cmpopts.EquateEmpty(), +} + +func TestMappingStore_Add(t *testing.T) { + type fields struct { + mappings []*chronograf.Mapping + } + type args struct { + mapping *chronograf.Mapping + } + type wants struct { + mapping *chronograf.Mapping + err error + } + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "default with wildcards", + args: args{ + mapping: &chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + }, + wants: wants{ + mapping: &chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + }, + }, + { + name: "simple", + args: args{ + mapping: &chronograf.Mapping{ + Organization: "default", + Provider: "github", + Scheme: "oauth2", + ProviderOrganization: "idk", + }, + }, + wants: wants{ + mapping: &chronograf.Mapping{ + Organization: "default", + Provider: "github", + Scheme: "oauth2", + ProviderOrganization: "idk", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.MappingsStore + ctx := context.Background() + + for _, mapping := range tt.fields.mappings { + // YOLO database prepopulation + _, _ = s.Add(ctx, mapping) + } + + tt.args.mapping, err = s.Add(ctx, tt.args.mapping) + + if (err != nil) != (tt.wants.err != nil) { + t.Errorf("MappingsStore.Add() error = %v, want error %v", err, tt.wants.err) + return + } + + got, err := s.Get(ctx, tt.args.mapping.ID) + if err != nil { + t.Fatalf("failed to get mapping: %v", err) + return + } + if diff := cmp.Diff(got, tt.wants.mapping, mappingCmpOptions...); diff != "" { + t.Errorf("MappingStore.Add():\n-got/+want\ndiff %s", diff) + return + } + }) + } +} + +func TestMappingStore_All(t *testing.T) { + type fields struct { + mappings []*chronograf.Mapping + } + type args struct { + } + type wants struct { + mappings []chronograf.Mapping + err error + } + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "simple", + fields: fields{ + mappings: []*chronograf.Mapping{ + &chronograf.Mapping{ + Organization: "0", + Provider: "google", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + }, + wants: wants{ + mappings: []chronograf.Mapping{ + chronograf.Mapping{ + Organization: "0", + Provider: "google", + Scheme: "ldap", + ProviderOrganization: "*", + }, + chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.MappingsStore + ctx := context.Background() + + for _, mapping := range tt.fields.mappings { + // YOLO database prepopulation + _, _ = s.Add(ctx, mapping) + } + + got, err := s.All(ctx) + + if (err != nil) != (tt.wants.err != nil) { + t.Errorf("MappingsStore.All() error = %v, want error %v", err, tt.wants.err) + return + } + + if diff := cmp.Diff(got, tt.wants.mappings, mappingCmpOptions...); diff != "" { + t.Errorf("MappingStore.All():\n-got/+want\ndiff %s", diff) + return + } + }) + } +} + +func TestMappingStore_Delete(t *testing.T) { + type fields struct { + mappings []*chronograf.Mapping + } + type args struct { + mapping *chronograf.Mapping + } + type wants struct { + err error + } + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "simple", + fields: fields{ + mappings: []*chronograf.Mapping{ + &chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + &chronograf.Mapping{ + Organization: "0", + Provider: "google", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + }, + args: args{ + mapping: &chronograf.Mapping{ + ID: "1", + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + }, + wants: wants{ + err: nil, + }, + }, + { + name: "mapping not found", + fields: fields{ + mappings: []*chronograf.Mapping{ + &chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + &chronograf.Mapping{ + Organization: "0", + Provider: "google", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + }, + args: args{ + mapping: &chronograf.Mapping{ + ID: "0", + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + }, + wants: wants{ + err: chronograf.ErrMappingNotFound, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.MappingsStore + ctx := context.Background() + + for _, mapping := range tt.fields.mappings { + // YOLO database prepopulation + _, _ = s.Add(ctx, mapping) + } + + err = s.Delete(ctx, tt.args.mapping) + + if (err != nil) != (tt.wants.err != nil) { + t.Errorf("MappingsStore.Delete() error = %v, want error %v", err, tt.wants.err) + return + } + }) + } +} + +func TestMappingStore_Get(t *testing.T) { + type fields struct { + mappings []*chronograf.Mapping + } + type args struct { + mappingID string + } + type wants struct { + mapping *chronograf.Mapping + err error + } + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "simple", + fields: fields{ + mappings: []*chronograf.Mapping{ + &chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + &chronograf.Mapping{ + Organization: "0", + Provider: "google", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + }, + args: args{ + mappingID: "1", + }, + wants: wants{ + mapping: &chronograf.Mapping{ + ID: "1", + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + err: nil, + }, + }, + { + name: "mapping not found", + fields: fields{ + mappings: []*chronograf.Mapping{ + &chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + &chronograf.Mapping{ + Organization: "0", + Provider: "google", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + }, + args: args{ + mappingID: "0", + }, + wants: wants{ + err: chronograf.ErrMappingNotFound, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.MappingsStore + ctx := context.Background() + + for _, mapping := range tt.fields.mappings { + // YOLO database prepopulation + _, _ = s.Add(ctx, mapping) + } + + got, err := s.Get(ctx, tt.args.mappingID) + if (err != nil) != (tt.wants.err != nil) { + t.Errorf("MappingsStore.Get() error = %v, want error %v", err, tt.wants.err) + return + } + if diff := cmp.Diff(got, tt.wants.mapping, mappingCmpOptions...); diff != "" { + t.Errorf("MappingStore.Get():\n-got/+want\ndiff %s", diff) + return + } + }) + } +} + +func TestMappingStore_Update(t *testing.T) { + type fields struct { + mappings []*chronograf.Mapping + } + type args struct { + mapping *chronograf.Mapping + } + type wants struct { + mapping *chronograf.Mapping + err error + } + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "simple", + fields: fields{ + mappings: []*chronograf.Mapping{ + &chronograf.Mapping{ + Organization: "default", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + &chronograf.Mapping{ + Organization: "0", + Provider: "google", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + }, + args: args{ + mapping: &chronograf.Mapping{ + ID: "1", + Organization: "default", + Provider: "cool", + Scheme: "it", + ProviderOrganization: "works", + }, + }, + wants: wants{ + mapping: &chronograf.Mapping{ + ID: "1", + Organization: "default", + Provider: "cool", + Scheme: "it", + ProviderOrganization: "works", + }, + err: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.MappingsStore + ctx := context.Background() + + for _, mapping := range tt.fields.mappings { + // YOLO database prepopulation + _, _ = s.Add(ctx, mapping) + } + + err = s.Update(ctx, tt.args.mapping) + if (err != nil) != (tt.wants.err != nil) { + t.Errorf("MappingsStore.Update() error = %v, want error %v", err, tt.wants.err) + return + } + if diff := cmp.Diff(tt.args.mapping, tt.wants.mapping, mappingCmpOptions...); diff != "" { + t.Errorf("MappingStore.Update():\n-got/+want\ndiff %s", diff) + return + } + }) + } +} diff --git a/bolt/organizations.go b/bolt/organizations.go index 84ee787921..458fefafee 100644 --- a/bolt/organizations.go +++ b/bolt/organizations.go @@ -25,8 +25,6 @@ const ( DefaultOrganizationName string = "Default" // DefaultOrganizationRole is the DefaultRole for the Default organization DefaultOrganizationRole string = "member" - // DefaultOrganizationPublic is the Public setting for the Default organization. - DefaultOrganizationPublic bool = true ) // OrganizationsStore uses bolt to store and retrieve Organizations @@ -45,7 +43,14 @@ func (s *OrganizationsStore) CreateDefault(ctx context.Context) error { ID: string(DefaultOrganizationID), Name: DefaultOrganizationName, DefaultRole: DefaultOrganizationRole, - Public: DefaultOrganizationPublic, + } + + m := chronograf.Mapping{ + ID: string(DefaultOrganizationID), + Organization: string(DefaultOrganizationID), + Provider: chronograf.MappingWildcard, + Scheme: chronograf.MappingWildcard, + ProviderOrganization: chronograf.MappingWildcard, } return s.client.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(OrganizationsBucket) @@ -59,6 +64,17 @@ func (s *OrganizationsStore) CreateDefault(ctx context.Context) error { return err } + b = tx.Bucket(MappingsBucket) + v = b.Get(DefaultOrganizationID) + if v != nil { + return nil + } + if v, err := internal.MarshalMapping(&m); err != nil { + return err + } else if err := b.Put(DefaultOrganizationID, v); err != nil { + return err + } + return nil }) } @@ -189,6 +205,18 @@ func (s *OrganizationsStore) Delete(ctx context.Context, o *chronograf.Organizat } } + mappings, err := s.client.MappingsStore.All(ctx) + if err != nil { + return err + } + for _, mapping := range mappings { + if mapping.Organization == o.ID { + if err := s.client.MappingsStore.Delete(ctx, &mapping); err != nil { + return err + } + } + } + return nil } diff --git a/bolt/organizations_test.go b/bolt/organizations_test.go index eb30968d33..c6f5cfe66a 100644 --- a/bolt/organizations_test.go +++ b/bolt/organizations_test.go @@ -170,12 +170,10 @@ func TestOrganizationsStore_All(t *testing.T) { { Name: "EE - Evil Empire", DefaultRole: roles.MemberRoleName, - Public: true, }, { Name: "The Good Place", DefaultRole: roles.EditorRoleName, - Public: true, }, }, }, @@ -183,17 +181,14 @@ func TestOrganizationsStore_All(t *testing.T) { { Name: "EE - Evil Empire", DefaultRole: roles.MemberRoleName, - Public: true, }, { Name: "The Good Place", DefaultRole: roles.EditorRoleName, - Public: true, }, { Name: bolt.DefaultOrganizationName, DefaultRole: bolt.DefaultOrganizationRole, - Public: bolt.DefaultOrganizationPublic, }, }, addFirst: true, @@ -316,52 +311,63 @@ func TestOrganizationsStore_Update(t *testing.T) { addFirst: true, }, { - name: "Update organization name, role, public", + name: "Update organization name, role", fields: fields{}, args: args{ ctx: context.Background(), initial: &chronograf.Organization{ Name: "The Good Place", DefaultRole: roles.ViewerRoleName, - Public: false, }, updates: &chronograf.Organization{ Name: "The Bad Place", - Public: true, DefaultRole: roles.AdminRoleName, }, }, want: &chronograf.Organization{ Name: "The Bad Place", - Public: true, DefaultRole: roles.AdminRoleName, }, addFirst: true, }, { - name: "Update organization name and public", + name: "Update organization name", fields: fields{}, args: args{ ctx: context.Background(), initial: &chronograf.Organization{ Name: "The Good Place", DefaultRole: roles.EditorRoleName, - Public: false, }, updates: &chronograf.Organization{ - Name: "The Bad Place", - Public: true, + Name: "The Bad Place", }, }, want: &chronograf.Organization{ Name: "The Bad Place", DefaultRole: roles.EditorRoleName, - Public: true, }, addFirst: true, }, { - name: "Update organization name - organization already exists", + name: "Update organization name", + fields: fields{}, + args: args{ + ctx: context.Background(), + initial: &chronograf.Organization{ + Name: "The Good Place", + }, + updates: &chronograf.Organization{ + Name: "The Bad Place", + }, + }, + want: &chronograf.Organization{ + Name: "The Bad Place", + }, + addFirst: true, + }, + { + name: "Update organization name - name already taken", fields: fields{ orgs: []chronograf.Organization{ { @@ -409,10 +415,6 @@ func TestOrganizationsStore_Update(t *testing.T) { tt.args.initial.DefaultRole = tt.args.updates.DefaultRole } - if tt.args.updates.Public != tt.args.initial.Public { - tt.args.initial.Public = tt.args.updates.Public - } - if err := s.Update(tt.args.ctx, tt.args.initial); (err != nil) != tt.wantErr { t.Errorf("%q. OrganizationsStore.Update() error = %v, wantErr %v", tt.name, err, tt.wantErr) } @@ -618,7 +620,6 @@ func TestOrganizationsStore_DefaultOrganization(t *testing.T) { ID: string(bolt.DefaultOrganizationID), Name: bolt.DefaultOrganizationName, DefaultRole: bolt.DefaultOrganizationRole, - Public: bolt.DefaultOrganizationPublic, }, wantErr: false, }, diff --git a/chronograf.go b/chronograf.go index 29785e3fd5..fa3c62b660 100644 --- a/chronograf.go +++ b/chronograf.go @@ -25,8 +25,12 @@ const ( ErrInvalidAxis = Error("Unexpected axis in cell. Valid axes are 'x', 'y', and 'y2'") ErrInvalidColorType = Error("Invalid color type. Valid color types are 'min', 'max', 'threshold', 'text', and 'background'") ErrInvalidColor = Error("Invalid color. Accepted color format is #RRGGBB") + ErrInvalidLegend = Error("Invalid legend. Both type and orientation must be set") + ErrInvalidLegendType = Error("Invalid legend type. Valid legend type is 'static'") + ErrInvalidLegendOrient = Error("Invalid orientation type. Valid orientation types are 'top', 'bottom', 'right', 'left'") ErrUserAlreadyExists = Error("user already exists") ErrOrganizationNotFound = Error("organization not found") + ErrMappingNotFound = Error("mapping not found") ErrOrganizationAlreadyExists = Error("organization already exists") ErrCannotDeleteDefaultOrganization = Error("cannot delete default organization") ErrConfigNotFound = Error("cannot find configuration") @@ -398,7 +402,7 @@ type User struct { Name string `json:"name"` Passwd string `json:"password,omitempty"` Permissions Permissions `json:"permissions,omitempty"` - Roles []Role `json:"roles,omitempty"` + Roles []Role `json:"roles"` Provider string `json:"provider,omitempty"` Scheme string `json:"scheme,omitempty"` SuperAdmin bool `json:"superAdmin,omitempty"` @@ -499,6 +503,12 @@ type CellColor struct { Value string `json:"value"` // Value is the data value mapped to this color } +// Legend represents the encoding of data into a legend +type Legend struct { + Type string `json:"type,omitempty"` + Orientation string `json:"orientation,omitempty"` +} + // DashboardCell holds visual and query information for a cell type DashboardCell struct { ID string `json:"i"` @@ -511,6 +521,7 @@ type DashboardCell struct { Axes map[string]Axis `json:"axes"` Type string `json:"type"` CellColors []CellColor `json:"colors"` + Legend Legend `json:"legend"` } // DashboardsStore is the storage and retrieval of dashboards @@ -563,15 +574,52 @@ type LayoutsStore interface { Update(context.Context, Layout) error } +// MappingWildcard is the wildcard value for mappings +const MappingWildcard string = "*" + +// A Mapping is the structure that is used to determine a users +// role within an organization. The high level idea is to grant +// certain roles to certain users without them having to be given +// explicit role within the organization. +// +// One can think of a mapping like so: +// Provider:Scheme:Group -> Organization +// github:oauth2:influxdata -> Happy +// beyondcorp:ldap:influxdata -> TheBillHilliettas +// +// Any of Provider, Scheme, or Group may be provided as a wildcard * +// github:oauth2:* -> MyOrg +// *:*:* -> AllOrg +type Mapping struct { + ID string `json:"id"` + Organization string `json:"organizationId"` + Provider string `json:"provider"` + Scheme string `json:"scheme"` + ProviderOrganization string `json:"providerOrganization"` +} + +// MappingsStore is the storage and retrieval of Mappings +type MappingsStore interface { + // Add creates a new Mapping. + // The Created mapping is returned back to the user with the + // ID field populated. + Add(context.Context, *Mapping) (*Mapping, error) + // All lists all Mapping in the MappingsStore + All(context.Context) ([]Mapping, error) + // Delete removes an Mapping from the MappingsStore + Delete(context.Context, *Mapping) error + // Get retrieves an Mapping from the MappingsStore + Get(context.Context, string) (*Mapping, error) + // Update updates an Mapping in the MappingsStore + Update(context.Context, *Mapping) error +} + // Organization is a group of resources under a common name type Organization struct { ID string `json:"id"` Name string `json:"name"` // DefaultRole is the name of the role that is the default for any users added to the organization DefaultRole string `json:"defaultRole,omitempty"` - // Public specifies whether users must be explicitly added to the organization. - // It is currently only used by the default organization, but that may change in the future. - Public bool `json:"public"` } // OrganizationQuery represents the attributes that a organization may be retrieved by. @@ -610,7 +658,6 @@ type OrganizationsStore interface { } // AuthConfig is the global application config section for auth parameters - type AuthConfig struct { // SuperAdminNewUsers should be true by default to give a seamless upgrade to // 1.4.0 for legacy users. It means that all new users will by default receive @@ -648,7 +695,7 @@ type BuildStore interface { Update(context.Context, BuildInfo) error } -// Environement is the set of front-end exposed environment variables +// Environment is the set of front-end exposed environment variables // that were set on the server type Environment struct { TelegrafSystemInterval time.Duration `json:"telegrafSystemInterval"` diff --git a/circle.yml b/circle.yml index 597de93298..59bb2c5671 100644 --- a/circle.yml +++ b/circle.yml @@ -3,7 +3,7 @@ machine: services: - docker environment: - DOCKER_TAG: chronograf-20171027 + DOCKER_TAG: chronograf-20180207 dependencies: override: @@ -31,6 +31,7 @@ deployment: --upload - sudo chown -R ubuntu:ubuntu /home/ubuntu - cp build/linux/static_amd64/chronograf . + - cp build/linux/static_amd64/chronoctl . - docker build -t chronograf . - docker login -e $QUAY_EMAIL -u "$QUAY_USER" -p $QUAY_PASS quay.io - docker tag chronograf quay.io/influxdb/chronograf:${CIRCLE_SHA1:0:7} @@ -52,6 +53,7 @@ deployment: --bucket dl.influxdata.com/chronograf/releases - sudo chown -R ubuntu:ubuntu /home/ubuntu - cp build/linux/static_amd64/chronograf . + - cp build/linux/static_amd64/chronoctl . - docker build -t chronograf . - docker login -e $QUAY_EMAIL -u "$QUAY_USER" -p $QUAY_PASS quay.io - docker tag chronograf quay.io/influxdb/chronograf:${CIRCLE_SHA1:0:7} @@ -75,6 +77,7 @@ deployment: --bucket dl.influxdata.com/chronograf/releases - sudo chown -R ubuntu:ubuntu /home/ubuntu - cp build/linux/static_amd64/chronograf . + - cp build/linux/static_amd64/chronoctl . - docker build -t chronograf . - docker login -e $QUAY_EMAIL -u "$QUAY_USER" -p $QUAY_PASS quay.io - docker tag chronograf quay.io/influxdb/chronograf:${CIRCLE_SHA1:0:7} diff --git a/cmd/chronoctl/add.go b/cmd/chronoctl/add.go new file mode 100644 index 0000000000..e6762575f6 --- /dev/null +++ b/cmd/chronoctl/add.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "strings" + + "github.com/influxdata/chronograf" +) + +type AddCommand struct { + BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (e.g. './chronograf-v1.db')" env:"BOLT_PATH" default:"chronograf-v1.db"` + ID *uint64 `short:"i" long:"id" description:"Users ID. Must be id for existing user"` + Username string `short:"n" long:"name" description:"Users name. Must be Oauth-able email address or username"` + Provider string `short:"p" long:"provider" description:"Name of the Auth provider (e.g. google, github, auth0, or generic)"` + Scheme string `short:"s" long:"scheme" description:"Authentication scheme that matches auth provider (e.g. oauth2)" default:"oauth2"` + Organizations string `short:"o" long:"orgs" description:"A comma separated list of organizations that the user should be added to" default:"default"` +} + +var addCommand AddCommand + +func (l *AddCommand) Execute(args []string) error { + c, err := NewBoltClient(l.BoltPath) + if err != nil { + return err + } + defer c.Close() + + q := chronograf.UserQuery{ + Name: &l.Username, + Provider: &l.Provider, + Scheme: &l.Scheme, + } + + if l.ID != nil { + q.ID = l.ID + } + + ctx := context.Background() + + user, err := c.UsersStore.Get(ctx, q) + if err != nil && err != chronograf.ErrUserNotFound { + return err + } else if err == chronograf.ErrUserNotFound { + user = &chronograf.User{ + Name: l.Username, + Provider: l.Provider, + Scheme: l.Scheme, + Roles: []chronograf.Role{ + { + Name: "member", + Organization: "default", + }, + }, + SuperAdmin: true, + } + + user, err = c.UsersStore.Add(ctx, user) + if err != nil { + return err + } + } else { + user.SuperAdmin = true + if len(user.Roles) == 0 { + user.Roles = []chronograf.Role{ + { + Name: "member", + Organization: "default", + }, + } + } + if err = c.UsersStore.Update(ctx, user); err != nil { + return err + } + } + + // TODO(desa): Apply mapping to user and update their roles + roles := []chronograf.Role{} +OrgLoop: + for _, org := range strings.Split(l.Organizations, ",") { + // Check to see is user is already a part of the organization + for _, r := range user.Roles { + if r.Organization == org { + continue OrgLoop + } + } + + orgQuery := chronograf.OrganizationQuery{ + ID: &org, + } + o, err := c.OrganizationsStore.Get(ctx, orgQuery) + if err != nil { + return err + } + + role := chronograf.Role{ + Organization: org, + Name: o.DefaultRole, + } + roles = append(roles, role) + } + + user.Roles = append(user.Roles, roles...) + if err = c.UsersStore.Update(ctx, user); err != nil { + return err + } + + w := NewTabWriter() + WriteHeaders(w) + WriteUser(w, user) + w.Flush() + + return nil +} + +func init() { + parser.AddCommand("add-superadmin", + "Creates a new superadmin user", + "The add-user command will create a new user with superadmin status", + &addCommand) +} diff --git a/cmd/chronoctl/list.go b/cmd/chronoctl/list.go new file mode 100644 index 0000000000..6396359adf --- /dev/null +++ b/cmd/chronoctl/list.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" +) + +type ListCommand struct { + BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (e.g. './chronograf-v1.db')" env:"BOLT_PATH" default:"chronograf-v1.db"` +} + +var listCommand ListCommand + +func (l *ListCommand) Execute(args []string) error { + c, err := NewBoltClient(l.BoltPath) + if err != nil { + return err + } + defer c.Close() + + ctx := context.Background() + users, err := c.UsersStore.All(ctx) + if err != nil { + return err + } + + w := NewTabWriter() + WriteHeaders(w) + for _, user := range users { + WriteUser(w, &user) + } + w.Flush() + + return nil +} + +func init() { + parser.AddCommand("list-users", + "Lists users", + "The list-users command will list all users in the chronograf boltdb instance", + &listCommand) +} diff --git a/cmd/chronoctl/main.go b/cmd/chronoctl/main.go new file mode 100644 index 0000000000..3e8f180b96 --- /dev/null +++ b/cmd/chronoctl/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + "os" + + "github.com/jessevdk/go-flags" +) + +type Options struct { +} + +var options Options + +var parser = flags.NewParser(&options, flags.Default) + +func main() { + if _, err := parser.Parse(); err != nil { + if flagsErr, ok := err.(*flags.Error); ok && flagsErr.Type == flags.ErrHelp { + os.Exit(0) + } else { + fmt.Fprintln(os.Stdout) + parser.WriteHelp(os.Stdout) + os.Exit(1) + } + } +} diff --git a/cmd/chronoctl/util.go b/cmd/chronoctl/util.go new file mode 100644 index 0000000000..f40635dbd9 --- /dev/null +++ b/cmd/chronoctl/util.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/bolt" + "github.com/influxdata/chronograf/mocks" +) + +func NewBoltClient(path string) (*bolt.Client, error) { + c := bolt.NewClient() + c.Path = path + + ctx := context.Background() + logger := mocks.NewLogger() + var bi chronograf.BuildInfo + if err := c.Open(ctx, logger, bi); err != nil { + return nil, err + } + + return c, nil +} + +func NewTabWriter() *tabwriter.Writer { + return tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', 0) +} + +func WriteHeaders(w io.Writer) { + fmt.Fprintln(w, "ID\tName\tProvider\tScheme\tSuperAdmin\tOrganization(s)") +} + +func WriteUser(w io.Writer, user *chronograf.User) { + orgs := []string{} + for _, role := range user.Roles { + orgs = append(orgs, role.Organization) + } + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%t\t%s\n", user.ID, user.Name, user.Provider, user.Scheme, user.SuperAdmin, strings.Join(orgs, ",")) +} diff --git a/docs/slides/mnGo/Makefile b/docs/slides/mnGo/Makefile index a43b18e1cc..51a07599bb 100644 --- a/docs/slides/mnGo/Makefile +++ b/docs/slides/mnGo/Makefile @@ -3,7 +3,7 @@ VERSION ?= $(shell git describe --always --tags) COMMIT ?= $(shell git rev-parse --short=8 HEAD) GDM := $(shell command -v gdm 2> /dev/null) -GOBINDATA := $(shell go list -f {{.Root}} github.com/jteeuwen/go-bindata 2> /dev/null) +GOBINDATA := $(shell go list -f {{.Root}} github.com/kevinburke/go-bindata 2> /dev/null) YARN := $(shell command -v yarn 2> /dev/null) SOURCES := $(shell find . -name '*.go' ! -name '*_gen.go') @@ -63,7 +63,7 @@ ifndef GDM endif ifndef GOBINDATA @echo "Installing go-bindata" - go get -u github.com/jteeuwen/go-bindata/... + go get -u github.com/kevinburke/go-bindata/... endif gdm restore @touch .godep diff --git a/enterprise/enterprise.go b/enterprise/enterprise.go index b1a7e5cf56..d003433e3f 100644 --- a/enterprise/enterprise.go +++ b/enterprise/enterprise.go @@ -51,13 +51,13 @@ type Client struct { } // NewClientWithTimeSeries initializes a Client with a known set of TimeSeries. -func NewClientWithTimeSeries(lg chronograf.Logger, mu string, authorizer influx.Authorizer, tls bool, series ...chronograf.TimeSeries) (*Client, error) { +func NewClientWithTimeSeries(lg chronograf.Logger, mu string, authorizer influx.Authorizer, tls, insecure bool, series ...chronograf.TimeSeries) (*Client, error) { metaURL, err := parseMetaURL(mu, tls) if err != nil { return nil, err } - ctrl := NewMetaClient(metaURL, authorizer) + ctrl := NewMetaClient(metaURL, insecure, authorizer) c := &Client{ Ctrl: ctrl, UsersStore: &UserStore{ @@ -85,13 +85,13 @@ func NewClientWithTimeSeries(lg chronograf.Logger, mu string, authorizer influx. // varieties. TLS is used when the URL contains "https" or when the TLS // parameter is set. authorizer will add the correct `Authorization` headers // on the out-bound request. -func NewClientWithURL(mu string, authorizer influx.Authorizer, tls bool, lg chronograf.Logger) (*Client, error) { +func NewClientWithURL(mu string, authorizer influx.Authorizer, tls bool, insecure bool, lg chronograf.Logger) (*Client, error) { metaURL, err := parseMetaURL(mu, tls) if err != nil { return nil, err } - ctrl := NewMetaClient(metaURL, authorizer) + ctrl := NewMetaClient(metaURL, insecure, authorizer) return &Client{ Ctrl: ctrl, UsersStore: &UserStore{ diff --git a/enterprise/enterprise_test.go b/enterprise/enterprise_test.go index 033a518727..58437d35a4 100644 --- a/enterprise/enterprise_test.go +++ b/enterprise/enterprise_test.go @@ -84,6 +84,7 @@ func Test_Enterprise_AdvancesDataNodes(t *testing.T) { Password: "thelake", }, false, + false, chronograf.TimeSeries(m1), chronograf.TimeSeries(m2)) if err != nil { @@ -114,23 +115,53 @@ func Test_Enterprise_NewClientWithURL(t *testing.T) { t.Parallel() urls := []struct { - url string - username string - password string - tls bool - shouldErr bool + name string + url string + username string + password string + tls bool + insecureSkipVerify bool + wantErr bool }{ - {"http://localhost:8086", "", "", false, false}, - {"https://localhost:8086", "", "", false, false}, - {"http://localhost:8086", "username", "password", false, false}, - - {"http://localhost:8086", "", "", true, false}, - {"https://localhost:8086", "", "", true, false}, - - {"localhost:8086", "", "", false, false}, - {"localhost:8086", "", "", true, false}, - - {":http", "", "", false, true}, + { + name: "no tls should have no error", + url: "http://localhost:8086", + }, + { + name: "tls sholuld have no error", + url: "https://localhost:8086", + }, + { + name: "no tls but with basic auth", + url: "http://localhost:8086", + username: "username", + password: "password", + }, + { + name: "tls request but url is not tls should not error", + url: "http://localhost:8086", + tls: true, + }, + { + name: "https with tls and with insecureSkipVerify should not error", + url: "https://localhost:8086", + tls: true, + insecureSkipVerify: true, + }, + { + name: "URL does not require http or https", + url: "localhost:8086", + }, + { + name: "URL with TLS request should not error", + url: "localhost:8086", + tls: true, + }, + { + name: "invalid URL causes error", + url: ":http", + wantErr: true, + }, } for _, testURL := range urls { @@ -141,10 +172,11 @@ func Test_Enterprise_NewClientWithURL(t *testing.T) { Password: testURL.password, }, testURL.tls, + testURL.insecureSkipVerify, log.New(log.DebugLevel)) - if err != nil && !testURL.shouldErr { + if err != nil && !testURL.wantErr { t.Errorf("Unexpected error creating Client with URL %s and TLS preference %t. err: %s", testURL.url, testURL.tls, err.Error()) - } else if err == nil && testURL.shouldErr { + } else if err == nil && testURL.wantErr { t.Errorf("Expected error creating Client with URL %s and TLS preference %t", testURL.url, testURL.tls) } } @@ -159,7 +191,7 @@ func Test_Enterprise_ComplainsIfNotOpened(t *testing.T) { Username: "docbrown", Password: "1.21 gigawatts", }, - false, chronograf.TimeSeries(m1)) + false, false, chronograf.TimeSeries(m1)) if err != nil { t.Error("Expected ErrUnitialized, but was this err:", err) } diff --git a/enterprise/meta.go b/enterprise/meta.go index 984c802c25..23629f4387 100644 --- a/enterprise/meta.go +++ b/enterprise/meta.go @@ -3,6 +3,7 @@ package enterprise import ( "bytes" "context" + "crypto/tls" "encoding/json" "errors" "fmt" @@ -14,6 +15,14 @@ import ( "github.com/influxdata/chronograf/influx" ) +// Shared transports for all clients to prevent leaking connections +var ( + skipVerifyTransport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + defaultTransport = &http.Transport{} +) + type client interface { Do(URL *url.URL, path, method string, authorizer influx.Authorizer, params map[string]string, body io.Reader) (*http.Response, error) } @@ -26,10 +35,12 @@ type MetaClient struct { } // NewMetaClient represents a meta node in an Influx Enterprise cluster -func NewMetaClient(url *url.URL, authorizer influx.Authorizer) *MetaClient { +func NewMetaClient(url *url.URL, InsecureSkipVerify bool, authorizer influx.Authorizer) *MetaClient { return &MetaClient{ - URL: url, - client: &defaultClient{}, + URL: url, + client: &defaultClient{ + InsecureSkipVerify: InsecureSkipVerify, + }, authorizer: authorizer, } } @@ -399,7 +410,8 @@ func (m *MetaClient) Post(ctx context.Context, path string, action interface{}, } type defaultClient struct { - Leader string + Leader string + InsecureSkipVerify bool } // Do is a helper function to interface with Influx Enterprise's Meta API @@ -438,6 +450,12 @@ func (d *defaultClient) Do(URL *url.URL, path, method string, authorizer influx. CheckRedirect: d.AuthedCheckRedirect, } + if d.InsecureSkipVerify { + client.Transport = skipVerifyTransport + } else { + client.Transport = defaultTransport + } + res, err := client.Do(req) if err != nil { return nil, err diff --git a/enterprise/users.go b/enterprise/users.go index 03ad17a8de..d16f160841 100644 --- a/enterprise/users.go +++ b/enterprise/users.go @@ -37,7 +37,7 @@ func (c *UserStore) Delete(ctx context.Context, u *chronograf.User) error { return c.Ctrl.DeleteUser(ctx, u.Name) } -// Number of users in Influx +// Num of users in Influx func (c *UserStore) Num(ctx context.Context) (int, error) { all, err := c.All(ctx) if err != nil { diff --git a/etc/Dockerfile_build b/etc/Dockerfile_build index af8deb426f..8493143ec0 100644 --- a/etc/Dockerfile_build +++ b/etc/Dockerfile_build @@ -12,13 +12,15 @@ RUN apt update && DEBIAN_FRONTEND=noninteractive apt install -y \ ruby-dev \ rpm \ zip \ - python-pip + python-pip \ + autoconf \ + libtool RUN pip install boto requests python-jose --upgrade RUN gem install fpm # Install node -ENV NODE_VERSION v6.11.5 +ENV NODE_VERSION v6.12.3 RUN wget -q https://nodejs.org/dist/latest-v6.x/node-${NODE_VERSION}-linux-x64.tar.gz; \ tar -xvf node-${NODE_VERSION}-linux-x64.tar.gz -C / --strip-components=1; \ rm -f node-${NODE_VERSION}-linux-x64.tar.gz @@ -35,7 +37,7 @@ RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ # Install go ENV GOPATH /root/go -ENV GO_VERSION 1.9.2 +ENV GO_VERSION 1.9.4 ENV GO_ARCH amd64 RUN wget https://storage.googleapis.com/golang/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz; \ tar -C /usr/local/ -xf /go${GO_VERSION}.linux-${GO_ARCH}.tar.gz ; \ diff --git a/etc/README.md b/etc/README.md index 6e541ecee1..d94fd49056 100644 --- a/etc/README.md +++ b/etc/README.md @@ -10,3 +10,6 @@ After updating the Dockerfile_build run and push to quay with: `docker push quay.io/influxdb/builder:chronograf-$(date "+%Y%m%d")` + +### Update circle +Update DOCKER_TAG in circle.yml to the new container. diff --git a/etc/build.py b/etc/build.py index 076e8b6769..e9d0bb53f9 100755 --- a/etc/build.py +++ b/etc/build.py @@ -24,6 +24,7 @@ DATA_DIR = "/var/lib/chronograf" SCRIPT_DIR = "/usr/lib/chronograf/scripts" LOGROTATE_DIR = "/etc/logrotate.d" CANNED_DIR = "/usr/share/chronograf/canned" +RESOURCES_DIR = "/usr/share/chronograf/resources" INIT_SCRIPT = "etc/scripts/init.sh" SYSTEMD_SCRIPT = "etc/scripts/chronograf.service" @@ -74,6 +75,7 @@ for f in CONFIGURATION_FILES: targets = { 'chronograf' : './cmd/chronograf', + 'chronoctl' : './cmd/chronoctl', } supported_builds = { @@ -115,7 +117,8 @@ def create_package_fs(build_root): DATA_DIR[1:], SCRIPT_DIR[1:], LOGROTATE_DIR[1:], - CANNED_DIR[1:] + CANNED_DIR[1:], + RESOURCES_DIR[1:] ] for d in dirs: os.makedirs(os.path.join(build_root, d)) diff --git a/etc/scripts/chronograf.service b/etc/scripts/chronograf.service index ba5c973b1d..272429f865 100644 --- a/etc/scripts/chronograf.service +++ b/etc/scripts/chronograf.service @@ -8,8 +8,12 @@ After=network-online.target [Service] User=chronograf Group=chronograf +Environment="HOST=0.0.0.0" +Environment="PORT=8888" +Environment="BOLT_PATH=/var/lib/chronograf/chronograf-v1.db" +Environment="CANNED_PATH=/usr/share/chronograf/canned" EnvironmentFile=-/etc/default/chronograf -ExecStart=/usr/bin/chronograf --host 0.0.0.0 --port 8888 -b /var/lib/chronograf/chronograf-v1.db -c /usr/share/chronograf/canned $CHRONOGRAF_OPTS +ExecStart=/usr/bin/chronograf $CHRONOGRAF_OPTS KillMode=control-group Restart=on-failure diff --git a/etc/scripts/init.sh b/etc/scripts/init.sh index 739a3d93c1..6b52743f01 100755 --- a/etc/scripts/init.sh +++ b/etc/scripts/init.sh @@ -12,9 +12,13 @@ # Script to execute when starting SCRIPT="/usr/bin/chronograf" +export HOST="0.0.0.0" +export PORT="8888" +export BOLT_PATH="/var/lib/chronograf/chronograf-v1.db" +export CANNED_PATH="/usr/share/chronograf/canned" # Options to pass to the script on startup . /etc/default/chronograf -SCRIPT_OPTS="--host 0.0.0.0 --port 8888 -b /var/lib/chronograf/chronograf-v1.db -c /usr/share/chronograf/canned ${CHRONOGRAF_OPTS}" +SCRIPT_OPTS="${CHRONOGRAF_OPTS}" # User to run the process under RUNAS=chronograf diff --git a/integrations/server_test.go b/integrations/server_test.go index d985d8296d..be45cf01e3 100644 --- a/integrations/server_test.go +++ b/integrations/server_test.go @@ -26,6 +26,7 @@ import ( func TestServer(t *testing.T) { type fields struct { Organizations []chronograf.Organization + Mappings []chronograf.Mapping Users []chronograf.User Sources []chronograf.Source Servers []chronograf.Server @@ -350,8 +351,7 @@ func TestServer(t *testing.T) { }, "id": "default", "name": "Default", - "defaultRole": "member", - "public": true + "defaultRole": "member" }, { "links": { @@ -359,8 +359,7 @@ func TestServer(t *testing.T) { }, "id": "howdy", "name": "An Organization", - "defaultRole": "viewer", - "public": false + "defaultRole": "viewer" } ] }`, @@ -408,8 +407,7 @@ func TestServer(t *testing.T) { }, "id": "howdy", "name": "An Organization", - "defaultRole": "viewer", - "public": false + "defaultRole": "viewer" }`, }, }, @@ -537,6 +535,10 @@ func TestServer(t *testing.T) { "value": "100" } ], + "legend":{ + "type": "static", + "orientation": "bottom" + }, "links": { "self": "/chronograf/v1/dashboards/1000/cells/8f61c619-dd9b-4761-8aa8-577f27247093" } @@ -776,6 +778,10 @@ func TestServer(t *testing.T) { "value": "100" } ], + "legend":{ + "type": "static", + "orientation": "bottom" + }, "links": { "self": "/chronograf/v1/dashboards/1000/cells/8f61c619-dd9b-4761-8aa8-577f27247093" } @@ -899,7 +905,7 @@ func TestServer(t *testing.T) { GithubClientSecret: "not empty", }, method: "GET", - path: "/chronograf/v1/users", + path: "/chronograf/v1/organizations/default/users", principal: oauth2.Principal{ Organization: "default", Subject: "billibob", @@ -931,6 +937,284 @@ func TestServer(t *testing.T) { }, }, }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/organizations/default/users", + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/organizations/default/users" + }, + "users": [ + { + "links": { + "self": "/chronograf/v1/organizations/default/users/1" + }, + "id": "1", + "name": "billibob", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + { + "name": "admin", + "organization": "default" + } + ] + } + ] +}`, + }, + }, + { + name: "GET /users", + subName: "Two users in two organizations; user making request is as SuperAdmin with out raw query param", + fields: fields{ + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + { + ID: 2, // This is artificial, but should be reflective of the users actual ID + Name: "billietta", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "cool", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/organizations/default/users", + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/organizations/default/users" + }, + "users": [ + { + "links": { + "self": "/chronograf/v1/organizations/default/users/1" + }, + "id": "1", + "name": "billibob", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + { + "name": "admin", + "organization": "default" + } + ] + } + ] +} +`, + }, + }, + { + name: "POST /users", + subName: "User making request is as SuperAdmin with raw query param; being created has wildcard role", + fields: fields{ + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + payload: &chronograf.User{ + Name: "user", + Provider: "provider", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "*", + Organization: "default", + }, + }, + }, + method: "POST", + path: "/chronograf/v1/users", + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 201, + body: ` +{ + "links": { + "self": "/chronograf/v1/users/2" + }, + "id": "2", + "name": "user", + "provider": "provider", + "scheme": "oauth2", + "superAdmin": false, + "roles": [ + { + "name": "member", + "organization": "default" + } + ] +} +`, + }, + }, + { + name: "POST /users", + subName: "User making request is as SuperAdmin with raw query param; being created has no roles", + fields: fields{ + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + payload: &chronograf.User{ + Name: "user", + Provider: "provider", + Scheme: "oauth2", + Roles: []chronograf.Role{}, + }, + method: "POST", + path: "/chronograf/v1/users", + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 201, + body: ` +{ + "links": { + "self": "/chronograf/v1/users/2" + }, + "id": "2", + "name": "user", + "provider": "provider", + "scheme": "oauth2", + "superAdmin": false, + "roles": [] +} +`, + }, + }, + { + name: "GET /users", + subName: "Two users in two organizations; user making request is as SuperAdmin with raw query param", + fields: fields{ + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "cool", + DefaultRole: roles.ViewerRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + { + ID: 2, // This is artificial, but should be reflective of the users actual ID + Name: "billietta", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "1", + }, + }, + }, + }, + }, args: args{ server: &server.Server{ GithubClientID: "not empty", @@ -967,9 +1251,93 @@ func TestServer(t *testing.T) { "organization": "default" } ] + }, + { + "links": { + "self": "/chronograf/v1/users/2" + }, + "id": "2", + "name": "billietta", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + { + "name": "admin", + "organization": "1" + } + ] } ] -}`, +} +`, + }, + }, + { + name: "GET /users", + subName: "Two users in two organizations; user making request is as not SuperAdmin with raw query param", + fields: fields{ + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "cool", + DefaultRole: roles.ViewerRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + { + ID: 2, // This is artificial, but should be reflective of the users actual ID + Name: "billietta", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + { + Name: "admin", + Organization: "1", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/users", + principal: oauth2.Principal{ + Organization: "default", + Subject: "billieta", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 403, + body: ` +{ + "code": 403, + "message": "User is not authorized" +} +`, }, }, { @@ -1003,7 +1371,7 @@ func TestServer(t *testing.T) { GithubClientSecret: "not empty", }, method: "POST", - path: "/chronograf/v1/users", + path: "/chronograf/v1/organizations/default/users", payload: &chronograf.User{ Name: "user", Provider: "provider", @@ -1026,7 +1394,7 @@ func TestServer(t *testing.T) { body: ` { "links": { - "self": "/chronograf/v1/users/2" + "self": "/chronograf/v1/organizations/default/users/2" }, "id": "2", "name": "user", @@ -1073,7 +1441,7 @@ func TestServer(t *testing.T) { GithubClientSecret: "not empty", }, method: "POST", - path: "/chronograf/v1/users", + path: "/chronograf/v1/organizations/default/users", payload: &chronograf.User{ Name: "user", Provider: "provider", @@ -1096,7 +1464,7 @@ func TestServer(t *testing.T) { body: ` { "links": { - "self": "/chronograf/v1/users/2" + "self": "/chronograf/v1/organizations/default/users/2" }, "id": "2", "name": "user", @@ -1143,7 +1511,7 @@ func TestServer(t *testing.T) { GithubClientSecret: "not empty", }, method: "POST", - path: "/chronograf/v1/users", + path: "/chronograf/v1/organizations/default/users", payload: &chronograf.User{ Name: "user", Provider: "provider", @@ -1166,7 +1534,7 @@ func TestServer(t *testing.T) { body: ` { "links": { - "self": "/chronograf/v1/users/2" + "self": "/chronograf/v1/organizations/default/users/2" }, "id": "2", "name": "user", @@ -1213,7 +1581,7 @@ func TestServer(t *testing.T) { GithubClientSecret: "not empty", }, method: "POST", - path: "/chronograf/v1/users", + path: "/chronograf/v1/organizations/default/users", payload: &chronograf.User{ Name: "user", Provider: "provider", @@ -1238,6 +1606,246 @@ func TestServer(t *testing.T) { { "code": 401, "message": "User does not have authorization required to set SuperAdmin status. See https://github.com/influxdata/chronograf/issues/2601 for more information." +}`, + }, + }, + { + name: "POST /users", + subName: "Create a New User with in multiple organizations; User on Principal is a SuperAdmin with raw query param", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "cool", + DefaultRole: roles.ViewerRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "POST", + path: "/chronograf/v1/users", + payload: &chronograf.User{ + Name: "user", + Provider: "provider", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "default", + }, + { + Name: roles.EditorRoleName, + Organization: "1", + }, + }, + }, + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 201, + body: ` +{ + "links": { + "self": "/chronograf/v1/users/2" + }, + "id": "2", + "name": "user", + "provider": "provider", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + { + "name": "editor", + "organization": "default" + }, + { + "name": "editor", + "organization": "1" + } + ] +}`, + }, + }, + { + name: "PATCH /users", + subName: "Update user to have no roles", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "cool", + DefaultRole: roles.ViewerRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "PATCH", + path: "/chronograf/v1/users/1", + payload: map[string]interface{}{ + "name": "billibob", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": []chronograf.Role{}, + }, + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/users/1" + }, + "id": "1", + "name": "billibob", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + ] +}`, + }, + }, + { + name: "PATCH /users", + subName: "Update user roles with wildcard", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "cool", + DefaultRole: roles.ViewerRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "PATCH", + path: "/chronograf/v1/users/1", + payload: &chronograf.User{ + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "default", + }, + { + Name: roles.WildcardRoleName, + Organization: "1", + }, + }, + }, + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/users/1" + }, + "id": "1", + "name": "billibob", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + { + "name": "admin", + "organization": "default" + }, + { + "name": "viewer", + "organization": "1" + } + ] }`, }, }, @@ -1267,7 +1875,7 @@ func TestServer(t *testing.T) { GithubClientSecret: "not empty", }, method: "PATCH", - path: "/chronograf/v1/users/1", + path: "/chronograf/v1/organizations/default/users/1", payload: map[string]interface{}{ "id": "1", "superAdmin": false, @@ -1291,6 +1899,74 @@ func TestServer(t *testing.T) { "code": 401, "message": "user cannot modify their own SuperAdmin status" } +`, + }, + }, + { + name: "GET /organization/default/users", + subName: "Organization not set explicitly on principal", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Organizations: []chronograf.Organization{}, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/organizations/default/users", + principal: oauth2.Principal{ + Organization: "", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/organizations/default/users" + }, + "users": [ + { + "links": { + "self": "/chronograf/v1/organizations/default/users/1" + }, + "id": "1", + "name": "billibob", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + { + "name": "admin", + "organization": "default" + } + ] + } + ] +} `, }, }, @@ -1362,27 +2038,24 @@ func TestServer(t *testing.T) { "scheme": "oauth2", "superAdmin": true, "links": { - "self": "/chronograf/v1/users/1" + "self": "/chronograf/v1/organizations/1/users/1" }, "organizations": [ - { - "id": "1", - "name": "Sweet", - "defaultRole": "viewer", - "public": false - }, - { - "id": "default", - "name": "Default", - "defaultRole": "member", - "public": true - } + { + "id": "1", + "name": "Sweet", + "defaultRole": "viewer" + }, + { + "id": "default", + "name": "Default", + "defaultRole": "member" + } ], "currentOrganization": { "id": "1", "name": "Sweet", - "defaultRole": "viewer", - "public": false + "defaultRole": "viewer" } }`, }, @@ -1444,6 +2117,664 @@ func TestServer(t *testing.T) { }`, }, }, + { + name: "GET /me", + subName: "New user hits me for the first time", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Mappings: []chronograf.Mapping{ + { + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "influxdata", + }, + { + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + { + ID: "2", + Organization: "2", + Provider: "github", + Scheme: "*", + ProviderOrganization: "*", + }, + { + ID: "3", + Organization: "3", + Provider: "auth0", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "Sweet", + DefaultRole: roles.ViewerRoleName, + }, + { + ID: "2", + Name: "What", + DefaultRole: roles.EditorRoleName, + }, + { + ID: "3", + Name: "Okay", + DefaultRole: roles.AdminRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{}, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/me", + principal: oauth2.Principal{ + Subject: "billietta", + Issuer: "github", + Group: "influxdata,idk,mimi", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "id": "2", + "name": "billietta", + "roles": [ + { + "name": "viewer", + "organization": "1" + }, + { + "name": "editor", + "organization": "2" + }, + { + "name": "member", + "organization": "default" + } + ], + "provider": "github", + "scheme": "oauth2", + "links": { + "self": "/chronograf/v1/organizations/default/users/2" + }, + "organizations": [ + { + "id": "1", + "name": "Sweet", + "defaultRole": "viewer" + }, + { + "id": "2", + "name": "What", + "defaultRole": "editor" + }, + { + "id": "default", + "name": "Default", + "defaultRole": "member" + } + ], + "currentOrganization": { + "id": "default", + "name": "Default", + "defaultRole": "member" + } +} +`, + }, + }, + { + name: "GET /mappings", + subName: "get all mappings", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Mappings: []chronograf.Mapping{ + { + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "influxdata", + }, + { + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + { + ID: "2", + Organization: "2", + Provider: "github", + Scheme: "*", + ProviderOrganization: "*", + }, + { + ID: "3", + Organization: "3", + Provider: "auth0", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "Sweet", + DefaultRole: roles.ViewerRoleName, + }, + { + ID: "2", + Name: "What", + DefaultRole: roles.EditorRoleName, + }, + { + ID: "3", + Name: "Okay", + DefaultRole: roles.AdminRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/mappings", + principal: oauth2.Principal{ + Subject: "billibob", + Issuer: "github", + Group: "influxdata,idk,mimi", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/mappings" + }, + "mappings": [ + { + "links": { + "self": "/chronograf/v1/mappings/1" + }, + "id": "1", + "organizationId": "1", + "provider": "*", + "scheme": "*", + "providerOrganization": "influxdata" + }, + { + "links": { + "self": "/chronograf/v1/mappings/2" + }, + "id": "2", + "organizationId": "1", + "provider": "*", + "scheme": "*", + "providerOrganization": "*" + }, + { + "links": { + "self": "/chronograf/v1/mappings/3" + }, + "id": "3", + "organizationId": "2", + "provider": "github", + "scheme": "*", + "providerOrganization": "*" + }, + { + "links": { + "self": "/chronograf/v1/mappings/4" + }, + "id": "4", + "organizationId": "3", + "provider": "auth0", + "scheme": "ldap", + "providerOrganization": "*" + }, + { + "links": { + "self": "/chronograf/v1/mappings/default" + }, + "id": "default", + "organizationId": "default", + "provider": "*", + "scheme": "*", + "providerOrganization": "*" + } + ] +} +`, + }, + }, + { + name: "GET /mappings", + subName: "get all mappings - user is not super admin", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Mappings: []chronograf.Mapping{ + { + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "influxdata", + }, + { + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + { + ID: "2", + Organization: "2", + Provider: "github", + Scheme: "*", + ProviderOrganization: "*", + }, + { + ID: "3", + Organization: "3", + Provider: "auth0", + Scheme: "ldap", + ProviderOrganization: "*", + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "Sweet", + DefaultRole: roles.ViewerRoleName, + }, + { + ID: "2", + Name: "What", + DefaultRole: roles.EditorRoleName, + }, + { + ID: "3", + Name: "Okay", + DefaultRole: roles.AdminRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/mappings", + principal: oauth2.Principal{ + Subject: "billibob", + Issuer: "github", + Group: "influxdata,idk,mimi", + }, + }, + wants: wants{ + statusCode: 403, + body: ` +{ + "code": 403, + "message": "User is not authorized" +} +`, + }, + }, + { + name: "POST /mappings", + subName: "create new mapping", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Mappings: []chronograf.Mapping{}, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "Sweet", + DefaultRole: roles.ViewerRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "POST", + path: "/chronograf/v1/mappings", + payload: &chronograf.Mapping{ + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "influxdata", + }, + principal: oauth2.Principal{ + Subject: "billibob", + Issuer: "github", + Group: "influxdata,idk,mimi", + }, + }, + wants: wants{ + statusCode: 201, + body: ` +{ + "links": { + "self": "/chronograf/v1/mappings/1" + }, + "id": "1", + "organizationId": "1", + "provider": "*", + "scheme": "*", + "providerOrganization": "influxdata" +} +`, + }, + }, + { + name: "PUT /mappings", + subName: "update new mapping", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Mappings: []chronograf.Mapping{ + chronograf.Mapping{ + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "influxdata", + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "Sweet", + DefaultRole: roles.ViewerRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "PUT", + path: "/chronograf/v1/mappings/1", + payload: &chronograf.Mapping{ + ID: "1", + Organization: "1", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + principal: oauth2.Principal{ + Subject: "billibob", + Issuer: "github", + Group: "influxdata,idk,mimi", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/mappings/1" + }, + "id": "1", + "organizationId": "1", + "provider": "*", + "scheme": "*", + "providerOrganization": "*" +} +`, + }, + }, + { + name: "GET /", + subName: "signed into default org", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "cool", + DefaultRole: roles.ViewerRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/", + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` + { + "layouts": "/chronograf/v1/layouts", + "users": "/chronograf/v1/organizations/default/users", + "allUsers": "/chronograf/v1/users", + "organizations": "/chronograf/v1/organizations", + "mappings": "/chronograf/v1/mappings", + "sources": "/chronograf/v1/sources", + "me": "/chronograf/v1/me", + "environment": "/chronograf/v1/env", + "dashboards": "/chronograf/v1/dashboards", + "config": { + "self": "/chronograf/v1/config", + "auth": "/chronograf/v1/config/auth" + }, + "auth": [ + { + "name": "github", + "label": "Github", + "login": "/oauth/github/login", + "logout": "/oauth/github/logout", + "callback": "/oauth/github/callback" + } + ], + "logout": "/oauth/logout", + "external": { + "statusFeed": "" + } + } + `, + }, + }, + { + name: "GET /", + subName: "signed into org 1", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "cool", + DefaultRole: roles.ViewerRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + { + Name: "member", + Organization: "1", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/", + principal: oauth2.Principal{ + Organization: "1", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` + { + "layouts": "/chronograf/v1/layouts", + "users": "/chronograf/v1/organizations/1/users", + "allUsers": "/chronograf/v1/users", + "organizations": "/chronograf/v1/organizations", + "mappings": "/chronograf/v1/mappings", + "sources": "/chronograf/v1/sources", + "me": "/chronograf/v1/me", + "environment": "/chronograf/v1/env", + "dashboards": "/chronograf/v1/dashboards", + "config": { + "self": "/chronograf/v1/config", + "auth": "/chronograf/v1/config/auth" + }, + "auth": [ + { + "name": "github", + "label": "Github", + "login": "/oauth/github/login", + "logout": "/oauth/github/logout", + "callback": "/oauth/github/callback" + } + ], + "logout": "/oauth/logout", + "external": { + "statusFeed": "" + } + } + `, + }, + }, } for _, tt := range tests { @@ -1483,6 +2814,16 @@ func TestServer(t *testing.T) { } } + // Populate Organizations + for i, mapping := range tt.fields.Mappings { + o, err := boltdb.MappingsStore.Add(ctx, &mapping) + if err != nil { + t.Fatalf("failed to add mapping: %v", err) + return + } + tt.fields.Mappings[i] = *o + } + // Populate Organizations for i, organization := range tt.fields.Organizations { o, err := boltdb.OrganizationsStore.Add(ctx, &organization) diff --git a/integrations/testdata/mydash.dashboard b/integrations/testdata/mydash.dashboard index a555f2af9d..bb16f9e6d0 100644 --- a/integrations/testdata/mydash.dashboard +++ b/integrations/testdata/mydash.dashboard @@ -86,7 +86,11 @@ "name": "comet", "value": "100" } - ] + ], + "legend": { + "type": "static", + "orientation": "bottom" + } } ], "templates": [ diff --git a/kapacitor/client_test.go b/kapacitor/client_test.go index 0e39bb9661..0560058460 100644 --- a/kapacitor/client_test.go +++ b/kapacitor/client_test.go @@ -1293,7 +1293,157 @@ func TestClient_Create(t *testing.T) { createTaskOptions *client.CreateTaskOptions }{ { - name: "create alert rule", + name: "create alert rule with tags", + fields: fields{ + kapaClient: func(url, username, password string, insecureSkipVerify bool) (KapaClient, error) { + return kapa, nil + }, + Ticker: &Alert{}, + ID: &MockID{ + ID: "howdy", + }, + }, + args: args{ + ctx: context.Background(), + rule: chronograf.AlertRule{ + ID: "howdy", + Name: "myname's", + Query: &chronograf.QueryConfig{ + Database: "db", + RetentionPolicy: "rp", + Measurement: "meas", + GroupBy: chronograf.GroupBy{ + Tags: []string{ + "tag1", + "tag2", + }, + }, + }, + Trigger: Deadman, + TriggerValues: chronograf.TriggerValues{ + Period: "1d", + }, + }, + }, + resTask: client.Task{ + ID: "chronograf-v1-howdy", + Status: client.Enabled, + Type: client.StreamTask, + DBRPs: []client.DBRP{ + { + Database: "db", + RetentionPolicy: "rp", + }, + }, + Link: client.Link{ + Href: "/kapacitor/v1/tasks/chronograf-v1-howdy", + }, + }, + createTaskOptions: &client.CreateTaskOptions{ + TICKscript: `var db = 'db' + +var rp = 'rp' + +var measurement = 'meas' + +var groupBy = ['tag1', 'tag2'] + +var whereFilter = lambda: TRUE + +var period = 1d + +var name = 'myname\'s' + +var idVar = name + ':{{.Group}}' + +var message = '' + +var idTag = 'alertID' + +var levelTag = 'level' + +var messageField = 'message' + +var durationField = 'duration' + +var outputDB = 'chronograf' + +var outputRP = 'autogen' + +var outputMeasurement = 'alerts' + +var triggerType = 'deadman' + +var threshold = 0.0 + +var data = stream + |from() + .database(db) + .retentionPolicy(rp) + .measurement(measurement) + .groupBy(groupBy) + .where(whereFilter) + +var trigger = data + |deadman(threshold, period) + .stateChangesOnly() + .message(message) + .id(idVar) + .idTag(idTag) + .levelTag(levelTag) + .messageField(messageField) + .durationField(durationField) + +trigger + |eval(lambda: "emitted") + .as('value') + .keep('value', messageField, durationField) + |eval(lambda: float("value")) + .as('value') + .keep() + |influxDBOut() + .create() + .database(outputDB) + .retentionPolicy(outputRP) + .measurement(outputMeasurement) + .tag('alertName', name) + .tag('triggerType', triggerType) + +trigger + |httpOut('output') +`, + + ID: "chronograf-v1-howdy", + Type: client.StreamTask, + Status: client.Enabled, + DBRPs: []client.DBRP{ + { + Database: "db", + RetentionPolicy: "rp", + }, + }, + }, + want: &Task{ + ID: "chronograf-v1-howdy", + Href: "/kapacitor/v1/tasks/chronograf-v1-howdy", + HrefOutput: "/kapacitor/v1/tasks/chronograf-v1-howdy/output", + Rule: chronograf.AlertRule{ + Type: "stream", + DBRPs: []chronograf.DBRP{ + { + + DB: "db", + RP: "rp", + }, + }, + Status: "enabled", + ID: "chronograf-v1-howdy", + Name: "chronograf-v1-howdy", + }, + }, + }, + { + name: "create alert rule with no tags", fields: fields{ kapaClient: func(url, username, password string, insecureSkipVerify bool) (KapaClient, error) { return kapa, nil @@ -1348,7 +1498,7 @@ var period = 1d var name = 'myname\'s' -var idVar = name + ':{{.Group}}' +var idVar = name var message = '' diff --git a/kapacitor/vars.go b/kapacitor/vars.go index 1821508727..c55bfae213 100644 --- a/kapacitor/vars.go +++ b/kapacitor/vars.go @@ -123,7 +123,7 @@ func commonVars(rule chronograf.AlertRule) (string, error) { %s var name = '%s' - var idVar = name + ':{{.Group}}' + var idVar = %s var message = '%s' var idTag = '%s' var levelTag = '%s' @@ -143,6 +143,7 @@ func commonVars(rule chronograf.AlertRule) (string, error) { whereFilter(rule.Query), wind, Escape(rule.Name), + idVar(rule.Query), Escape(rule.Message), IDTag, LevelTag, @@ -197,6 +198,13 @@ func groupBy(q *chronograf.QueryConfig) string { return "[" + strings.Join(groups, ",") + "]" } +func idVar(q *chronograf.QueryConfig) string { + if len(q.GroupBy.Tags) > 0 { + return `name + ':{{.Group}}'` + } + return "name" +} + func field(q *chronograf.QueryConfig) (string, error) { if q == nil { return "", fmt.Errorf("No fields set in query") diff --git a/mocks/mapping.go b/mocks/mapping.go new file mode 100644 index 0000000000..60463e3d8f --- /dev/null +++ b/mocks/mapping.go @@ -0,0 +1,35 @@ +package mocks + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +type MappingsStore struct { + AddF func(context.Context, *chronograf.Mapping) (*chronograf.Mapping, error) + AllF func(context.Context) ([]chronograf.Mapping, error) + DeleteF func(context.Context, *chronograf.Mapping) error + UpdateF func(context.Context, *chronograf.Mapping) error + GetF func(context.Context, string) (*chronograf.Mapping, error) +} + +func (s *MappingsStore) Add(ctx context.Context, m *chronograf.Mapping) (*chronograf.Mapping, error) { + return s.AddF(ctx, m) +} + +func (s *MappingsStore) All(ctx context.Context) ([]chronograf.Mapping, error) { + return s.AllF(ctx) +} + +func (s *MappingsStore) Delete(ctx context.Context, m *chronograf.Mapping) error { + return s.DeleteF(ctx, m) +} + +func (s *MappingsStore) Get(ctx context.Context, id string) (*chronograf.Mapping, error) { + return s.GetF(ctx, id) +} + +func (s *MappingsStore) Update(ctx context.Context, m *chronograf.Mapping) error { + return s.UpdateF(ctx, m) +} diff --git a/mocks/store.go b/mocks/store.go index ebc05ea495..8cb27b11b0 100644 --- a/mocks/store.go +++ b/mocks/store.go @@ -9,6 +9,7 @@ import ( // Store is a server.DataStore type Store struct { SourcesStore chronograf.SourcesStore + MappingsStore chronograf.MappingsStore ServersStore chronograf.ServersStore LayoutsStore chronograf.LayoutsStore UsersStore chronograf.UsersStore @@ -36,6 +37,9 @@ func (s *Store) Users(ctx context.Context) chronograf.UsersStore { func (s *Store) Organizations(ctx context.Context) chronograf.OrganizationsStore { return s.OrganizationsStore } +func (s *Store) Mappings(ctx context.Context) chronograf.MappingsStore { + return s.MappingsStore +} func (s *Store) Dashboards(ctx context.Context) chronograf.DashboardsStore { return s.DashboardsStore diff --git a/noop/mappings.go b/noop/mappings.go new file mode 100644 index 0000000000..e6f5a73bb5 --- /dev/null +++ b/noop/mappings.go @@ -0,0 +1,33 @@ +package noop + +import ( + "context" + "fmt" + + "github.com/influxdata/chronograf" +) + +// ensure MappingsStore implements chronograf.MappingsStore +var _ chronograf.MappingsStore = &MappingsStore{} + +type MappingsStore struct{} + +func (s *MappingsStore) All(context.Context) ([]chronograf.Mapping, error) { + return nil, fmt.Errorf("no mappings found") +} + +func (s *MappingsStore) Add(context.Context, *chronograf.Mapping) (*chronograf.Mapping, error) { + return nil, fmt.Errorf("failed to add mapping") +} + +func (s *MappingsStore) Delete(context.Context, *chronograf.Mapping) error { + return fmt.Errorf("failed to delete mapping") +} + +func (s *MappingsStore) Get(ctx context.Context, ID string) (*chronograf.Mapping, error) { + return nil, chronograf.ErrMappingNotFound +} + +func (s *MappingsStore) Update(context.Context, *chronograf.Mapping) error { + return fmt.Errorf("failed to update mapping") +} diff --git a/oauth2/auth0.go b/oauth2/auth0.go index 9c827b680b..1d5c1f4a96 100644 --- a/oauth2/auth0.go +++ b/oauth2/auth0.go @@ -8,6 +8,8 @@ import ( "github.com/influxdata/chronograf" ) +var _ Provider = &Auth0{} + type Auth0 struct { Generic Organizations map[string]bool // the set of allowed organizations users may belong to @@ -41,6 +43,26 @@ func (a *Auth0) PrincipalID(provider *http.Client) (string, error) { return act.Email, nil } +func (a *Auth0) Group(provider *http.Client) (string, error) { + type Account struct { + Email string `json:"email"` + Organization string `json:"organization"` + } + + resp, err := provider.Get(a.Generic.APIURL) + if err != nil { + return "", err + } + + defer resp.Body.Close() + act := Account{} + if err = json.NewDecoder(resp.Body).Decode(&act); err != nil { + return "", err + } + + return act.Organization, nil +} + func NewAuth0(auth0Domain, clientID, clientSecret, redirectURL string, organizations []string, logger chronograf.Logger) (Auth0, error) { domain, err := url.Parse(auth0Domain) if err != nil { diff --git a/oauth2/generic.go b/oauth2/generic.go index 8f6e1f50c6..ba7e684be7 100644 --- a/oauth2/generic.go +++ b/oauth2/generic.go @@ -12,11 +12,12 @@ import ( "golang.org/x/oauth2" ) -// Provider interface with optional methods +// ExtendedProvider extendts the base Provider interface with optional methods type ExtendedProvider interface { Provider // get PrincipalID from id_token PrincipalIDFromClaims(claims gojwt.MapClaims) (string, error) + GroupFromClaims(claims gojwt.MapClaims) (string, error) } var _ ExtendedProvider = &Generic{} @@ -119,6 +120,31 @@ func (g *Generic) PrincipalID(provider *http.Client) (string, error) { return email, nil } +// Group returns the domain that a user belongs to in the +// the generic OAuth. +func (g *Generic) Group(provider *http.Client) (string, error) { + res := struct { + Email string `json:"email"` + }{} + + r, err := provider.Get(g.APIURL) + if err != nil { + return "", err + } + + defer r.Body.Close() + if err = json.NewDecoder(r.Body).Decode(&res); err != nil { + return "", err + } + + email := strings.Split(res.Email, "@") + if len(email) != 2 { + return "", fmt.Errorf("malformed email address, expected %q to contain @ symbol", res.Email) + } + + return email[1], nil +} + // UserEmail represents user's email address type UserEmail struct { Email *string `json:"email,omitempty"` @@ -168,10 +194,25 @@ func ofDomain(requiredDomains []string, email string) bool { return false } -// verify optional id_token and extract email address of the user +// PrincipalIDFromClaims verifies an optional id_token and extracts email address of the user func (g *Generic) PrincipalIDFromClaims(claims gojwt.MapClaims) (string, error) { if id, ok := claims[g.APIKey].(string); ok { return id, nil } return "", fmt.Errorf("no claim for %s", g.APIKey) } + +// GroupFromClaims verifies an optional id_token, extracts the email address of the user and splits off the domain part +func (g *Generic) GroupFromClaims(claims gojwt.MapClaims) (string, error) { + if id, ok := claims[g.APIKey].(string); ok { + email := strings.Split(id, "@") + if len(email) != 2 { + g.Logger.Error("malformed email address, expected %q to contain @ symbol", id) + return "DEFAULT", nil + } + + return email[1], nil + } + + return "", fmt.Errorf("no claim for %s", g.APIKey) +} diff --git a/oauth2/github.go b/oauth2/github.go index 0f3e0e932d..d3a50c7c30 100644 --- a/oauth2/github.go +++ b/oauth2/github.go @@ -6,6 +6,7 @@ import ( "errors" "io" "net/http" + "strings" "github.com/google/go-github/github" "github.com/influxdata/chronograf" @@ -44,9 +45,9 @@ func (g *Github) Secret() string { // we are filtering by organizations. func (g *Github) Scopes() []string { scopes := []string{"user:email"} - if len(g.Orgs) > 0 { - scopes = append(scopes, "read:org") - } + // In order to access a users orgs, we need the "read:org" scope + // even if g.Orgs == 0 + scopes = append(scopes, "read:org") return scopes } @@ -84,6 +85,26 @@ func (g *Github) PrincipalID(provider *http.Client) (string, error) { return email, nil } +// Group returns a comma delimited string of Github organizations +// that a user belongs to in Github +func (g *Github) Group(provider *http.Client) (string, error) { + client := github.NewClient(provider) + orgs, err := getOrganizations(client, g.Logger) + if err != nil { + return "", err + } + + groups := []string{} + for _, org := range orgs { + if org.Login != nil { + groups = append(groups, *org.Login) + continue + } + } + + return strings.Join(groups, ","), nil +} + func randomString(length int) string { k := make([]byte, length) if _, err := io.ReadFull(rand.Reader, k); err != nil { diff --git a/oauth2/google.go b/oauth2/google.go index ee5ff3bb13..59a7ec56a3 100644 --- a/oauth2/google.go +++ b/oauth2/google.go @@ -88,3 +88,19 @@ func (g *Google) PrincipalID(provider *http.Client) (string, error) { g.Logger.Error("Domain '", info.Hd, "' is not a member of required Google domain(s): ", g.Domains) return "", fmt.Errorf("Not in required domain") } + +// Group returns the string of domain a user belongs to in Google +func (g *Google) Group(provider *http.Client) (string, error) { + srv, err := goauth2.New(provider) + if err != nil { + g.Logger.Error("Unable to communicate with Google ", err.Error()) + return "", err + } + info, err := srv.Userinfo.Get().Do() + if err != nil { + g.Logger.Error("Unable to retrieve Google email ", err.Error()) + return "", err + } + + return info.Hd, nil +} diff --git a/oauth2/heroku.go b/oauth2/heroku.go index 831b095df1..9c6f81416b 100644 --- a/oauth2/heroku.go +++ b/oauth2/heroku.go @@ -88,6 +88,34 @@ func (h *Heroku) PrincipalID(provider *http.Client) (string, error) { return account.Email, nil } +// Group returns the Heroku organization that user belongs to. +func (h *Heroku) Group(provider *http.Client) (string, error) { + type DefaultOrg struct { + ID string `json:"id"` + Name string `json:"name"` + } + type Account struct { + Email string `json:"email"` + DefaultOrganization DefaultOrg `json:"default_organization"` + } + + resp, err := provider.Get(HerokuAccountRoute) + if err != nil { + h.Logger.Error("Unable to communicate with Heroku. err:", err) + return "", err + } + defer resp.Body.Close() + d := json.NewDecoder(resp.Body) + + var account Account + if err := d.Decode(&account); err != nil { + h.Logger.Error("Unable to decode response from Heroku. err:", err) + return "", err + } + + return account.DefaultOrganization.Name, nil +} + // Scopes for heroku is "identity" which grants access to user account // information. This will grant us access to the user's email address which is // used as the Principal's identifier. diff --git a/oauth2/jwt.go b/oauth2/jwt.go index 4ba97dc9fa..d1fb288cf9 100644 --- a/oauth2/jwt.go +++ b/oauth2/jwt.go @@ -40,9 +40,18 @@ var _ gojwt.Claims = &Claims{} // Claims extends jwt.StandardClaims' Valid to make sure claims has a subject. type Claims struct { gojwt.StandardClaims - // We were unable to find a standard claim at https://www.iana.org/assignments/jwt/jwt.xhtmldd + // We were unable to find a standard claim at https://www.iana.org/assignments/jwt/jwt.xhtml // that felt appropriate for Organization. As a result, we added a custom `org` field. Organization string `json:"org,omitempty"` + // We were unable to find a standard claim at https://www.iana.org/assignments/jwt/jwt.xhtml + // that felt appropriate for a users Group(s). As a result we added a custom `grp` field. + // Multiple groups may be specified by comma delimiting the various group. + // + // The singlular `grp` was chosen over the `grps` to keep consistent with the JWT naming + // convention (it is common for singlularly named values to actually be arrays, see `given_name`, + // `family_name`, and `middle_name` in the iana link provided above). I should add the discalimer + // I'm currently sick, so this thought process might be off. + Group string `json:"grp,omitempty"` } // Valid adds an empty subject test to the StandardClaims checks. @@ -67,7 +76,7 @@ func (j *JWT) ValidPrincipal(ctx context.Context, jwtToken Token, lifespan time. return j.ValidClaims(jwtToken, lifespan, alg) } -// key verification for HMAC an RSA/RS256 +// KeyFunc verifies HMAC or RSA/RS256 signatures func (j *JWT) KeyFunc(token *gojwt.Token) (interface{}, error) { if _, ok := token.Method.(*gojwt.SigningMethodHMAC); ok { return []byte(j.Secret), nil @@ -86,7 +95,7 @@ func (j *JWT) KeyFunc(token *gojwt.Token) (interface{}, error) { // OpenID Provider Configuration Information at /.well-known/openid-configuration // implements rfc7517 section 4.7 "x5c" (X.509 Certificate Chain) Parameter -// JWKS nested struct +// JWK defines a JSON Web KEy nested struct type JWK struct { Kty string `json:"kty"` Use string `json:"use"` @@ -98,11 +107,12 @@ type JWK struct { X5c []string `json:"x5c"` } +// JWKS defines a JKW[] type JWKS struct { Keys []JWK `json:"keys"` } -// for RS256 signed JWT tokens, lookup the signing key in the key discovery service +// KeyFuncRS256 verifies RS256 signed JWT tokens, it looks up the signing key in the key discovery service func (j *JWT) KeyFuncRS256(token *gojwt.Token) (interface{}, error) { // Don't forget to validate the alg is what you expect: if _, ok := token.Method.(*gojwt.SigningMethodRSA); !ok { @@ -131,22 +141,22 @@ func (j *JWT) KeyFuncRS256(token *gojwt.Token) (interface{}, error) { } // extract cert when kid and alg match - var cert_pkix []byte + var certPkix []byte for _, jwk := range jwks.Keys { if token.Header["kid"] == jwk.Kid && token.Header["alg"] == jwk.Alg { // FIXME: optionally walk the key chain, see rfc7517 section 4.7 - cert_pkix, err = base64.StdEncoding.DecodeString(jwk.X5c[0]) + certPkix, err = base64.StdEncoding.DecodeString(jwk.X5c[0]) if err != nil { return nil, fmt.Errorf("base64 decode error for JWK kid %v", token.Header["kid"]) } } } - if cert_pkix == nil { + if certPkix == nil { return nil, fmt.Errorf("no signing key found for kid %v", token.Header["kid"]) } // parse certificate (from PKIX format) and return signing key - cert, err := x509.ParseCertificate(cert_pkix) + cert, err := x509.ParseCertificate(certPkix) if err != nil { return nil, err } @@ -189,12 +199,13 @@ func (j *JWT) ValidClaims(jwtToken Token, lifespan time.Duration, alg gojwt.Keyf Subject: claims.Subject, Issuer: claims.Issuer, Organization: claims.Organization, + Group: claims.Group, ExpiresAt: exp, IssuedAt: iat, }, nil } -// get claims from id_token +// GetClaims extracts claims from id_token func (j *JWT) GetClaims(tokenString string) (gojwt.MapClaims, error) { var claims gojwt.MapClaims @@ -229,6 +240,7 @@ func (j *JWT) Create(ctx context.Context, user Principal) (Token, error) { NotBefore: user.IssuedAt.Unix(), }, Organization: user.Organization, + Group: user.Group, } token := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims) // Sign and get the complete encoded token as a string using the secret diff --git a/oauth2/mux.go b/oauth2/mux.go index cfa9c23c25..41fe133a43 100644 --- a/oauth2/mux.go +++ b/oauth2/mux.go @@ -118,6 +118,7 @@ func (j *AuthMux) Callback() http.Handler { // if we received an extra id_token, inspect it var id string + group := "DEFAULT" if token.Extra("id_token") != "" { log.Debug("token provides extra id_token") if provider, ok := j.Provider.(ExtendedProvider); ok { @@ -140,6 +141,11 @@ func (j *AuthMux) Callback() http.Handler { http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) return } + if group, err = provider.GroupFromClaims(claims); err != nil { + log.Error("requested claim not found in id_token:", err) + http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) + return + } } else { log.Debug("provider does not implement PrincipalIDFromClaims()") } @@ -155,11 +161,18 @@ func (j *AuthMux) Callback() http.Handler { http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) return } + group, err = j.Provider.Group(oauthClient) + if err != nil { + log.Error("Unable to get OAuth Group", err.Error()) + http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) + return + } } p := Principal{ Subject: id, Issuer: j.Provider.Name(), + Group: group, } ctx := r.Context() err = j.Auth.Authorize(ctx, w, p) diff --git a/oauth2/mux_test.go b/oauth2/mux_test.go index 9374fa2878..734f39cff6 100644 --- a/oauth2/mux_test.go +++ b/oauth2/mux_test.go @@ -31,7 +31,11 @@ func setupMuxTest(selector func(*AuthMux) http.Handler, body string) (*http.Clie now := func() time.Time { return testTime } - mp := &MockProvider{"biff@example.com", provider.URL} + mp := &MockProvider{ + Email: "biff@example.com", + ProviderURL: provider.URL, + Orgs: "", + } mt := &YesManTokenizer{} auth := &cookie{ Name: DefaultCookieName, @@ -164,7 +168,7 @@ func Test_AuthMux_Callback_SetsCookie(t *testing.T) { } func Test_AuthMux_Callback_HandlesIdToken(t *testing.T) { - // body taken from ADFS4 + // body taken from ADFS4 body := `{"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSJ9.eyJhdWQiOiJ1cm46bWljcm9zb2Z0OnVzZXJpbmZvIiwiaXNzIjoiaHR0cDovL2RzdGNpbWFhZDFwLmRzdC1pdHMuZGUvYWRmcy9zZXJ2aWNlcy90cnVzdCIsImlhdCI6MTUxNTcwMDU2NSwiZXhwIjoxNTE1NzA0MTY1LCJhcHB0eXBlIjoiQ29uZmlkZW50aWFsIiwiYXBwaWQiOiJjaHJvbm9ncmFmIiwiYXV0aG1ldGhvZCI6InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphYzpjbGFzc2VzOlBhc3N3b3JkUHJvdGVjdGVkVHJhbnNwb3J0IiwiYXV0aF90aW1lIjoiMjAxOC0wMS0xMVQxOTo1MToyNS44MDZaIiwidmVyIjoiMS4wIiwic2NwIjoib3BlbmlkIiwic3ViIjoiZVlWM2pkbGROeUZZMXFGRkg0b0FkQnZERmZiVmZudUcyOUhpSGtTdWp3az0ifQ.sf1qJys9LMUp2S232IRK2aTXiPCE93O-cUdYQQz7kg2woyD46KLwwKIYJVqMaqLspTn3OmaIhKtgx5ZXyAEtihODB1GOBK7DBNRBYCS1iqY_v2-Qwjf7hgaNaCqBjs0DZJspfp5G9MTykvD1FOtQNjPOcBW-i2bblG9L9jlmMbOZ3F7wrZMrroTSkiSn_gRiw2SnN8K7w8WrMEXNK2_jg9ZJ7aSHeUSBwkRNFRds2QNho3HWHg-zcsZFdZ4UGSt-6Az_0LY3yENMLj5us5Rl6Qzk_Re2dhFrlnlXlY1v1DEp3icCvvjkv6AeZWjTfW4qETZaCXUKtSyZ7d5_V1CRDQ","token_type":"bearer","expires_in":3600,"resource":"urn:microsoft:userinfo","refresh_token":"X9ZGO4H1bMk2bFeOfpv18BzAuFBzUPKQNfOEfdp60FkAAQAALPEBfj23FPEzajle-hm4DrDXp8-Kj53OqoVGalyZeuR-lfJzxpQXQhRAXZOUTuuQ8AQycByh9AylQYDA0jdMFW4FL4WL_6JhNh2JrtXCv2HQ9ozbUq9F7u_O0cY7u0P2pfNujQfk3ckYn-CMVjXbuwJTve6bXUR0JDp5c195bAVA5eFWyI-2uh432t7viyaIjAVbWxQF4fvimcpF1Et9cGodZHVsrZzGxKRnzwjYkWHsqm9go4KOeSKN6MlcWbjvS1UdMjQXSvoqSI00JnSMC3hxJZFn5JcmAPB1AMnJf4VvXZ5b-aOnwdX09YT8KayWkWekAsuZqTAsFwhZPVCRGWAFAADy0e2fTe6l-U6Cj_2bWsq6Snm1QEpWHXuwOJKWZJH-9yQn8KK3KzRowSzRuACzEIpZS5skrqXs_-2aOaZibNpjCEVyw8fF8GTw3VRLufsSrMQ5pD0KL7TppTGFpaqgwIH1yq6T8aRY4DeyoJkNpnO9cw1wuqnY7oGF-J25sfZ4XNWhk6o5e9A45PXhTilClyDKDLqTfdoIsG1Koc2ywqTIb-XI_EbWR3e4ijy8Kmlehw1kU9_xAG0MmmD2HTyGHZCBRgrskYCcHd-UNgCMrNAb5dZQ8NwpKtEL46qIq4R0lheTRRK8sOWzzuJXmvDEoJiIxqSR3Ma4MOISi-vsIsAuiEL9G1aMOkDRj-kDVmqrdKRAwYnN78AWY5EFfkQJyVBbiG882wBh9S0q3HUUCxzFerOvl4eDlVn6m18rRMz7CVZYBBltGtHRhEOQ4gumICR5JRrXAC50aBmUlhDiiMdbEIwJrvWrkhKE0oAJznqC7gleP0E4EOEh9r6CEGZ7Oj8X9Cdzjbuq2G1JGBm_yUvkhAcV61DjOiIQl35BpOfshveNZf_caUtNMa2i07BBmezve17-2kWGzRunr1BD1vMTz41z-H62fy4McR47WJjdDJnuy4DH5AZYQ6ooVxWCtEqeqRPYpzO0XdOdJGXFqXs9JzDKVXTgnHU443hZBC5H-BJkZDuuJ_ZWNKXf03JhouWkxXcdaMbuaQYOZJsUySVyJ5X4usrBFjW4udZAzy7mua-nJncbvcwoyVXiFlRfZiySXolQ9865N7XUnEk_2PijMLoVDATDbA09XuRySvngNsdsQ27p21dPxChXdtpD5ofNqKJ2FBzFKmxCkuX7L01N1nDpWQTuxhHF0JfxSKG5m3jcTx8Bd7Un94mTuAB7RuglDqkdQB9o4X9NHNGSdqGQaK-xeKoNCFWevk3VZoDoY9w2NqSNV2VIuqhy7SxtDSMjZKC5kiQi5EfGeTYZAvTwMYwaXb7K4WWtscy_ZE15EOCVeYi0hM1Ma8iFFTANkSRyX83Ju4SRphxRKnpKcJ2pPYH784I5HOm5sclhUL3aLeAA161QgxRBSa9YVIZfyXHyWQTcbNucNdhmdUZnKfRv1xtXcS9VAx2yAkoKFehZivEINX0Y500-WZ1eT_RXp0BfCKmJQ8Fu50oTaI-c5h2Q3Gp_LTSODNnMrjJiJxCLD_LD1fd1e8jTYDV3NroGlpWTuTdjMUm-Z1SMXaaJzQGEnNT6F8b6un9228L6YrDC_3MJ5J80VAHL5EO1GesdEWblugCL7AQDtFjNXq0lK8Aoo8X9_hlvDwgfdR16l8QALPT1HJVzlHPG8G3dRe50TKZnl3obU0WXN1KYG1EC4Qa3LyaVCIuGJYOeFqjMINrf7PoM368nS9yhrY08nnoHZbQ7IeA1KsNq2kANeH1doCNfWrXDwn8KxjYxZPEnzvlQ5M1RIzArOqzWL8NbftW1q2yCZZ4RVg0vOTVXsqWFnQIvWK-mkELa7bvByFzbtVHOJpc_2EKBKBNv6IYUENRCu2TOf6w7u42yvng7ccoXRTiUFUlKgVmswf9FzISxFd-YKgrzp3bMhC3gReGqcJuqEwnXPvOAY_BAkVMSd_ZaCFuyclRjFvUxrAg1T_cqOvRIlJ2Qq7z4u7W3BAo9BtFdj8QNLKJXtvvzXTprglRPDNP_QEPAkwZ_Uxa13vdYFcG18WCx4GbWQXchl5B7DnISobcdCH34M-I0xDZN98VWQVmLAfPniDUD30C8pfiYF7tW_EVy958Eg_JWVy0SstYEhV-y-adrJ1Oimjv0ptsWv-yErKBUD14aex9A_QqdnTXZUg.tqMb72eWAkAIvInuLp57NDyGxfYvms3NnhN-mllkYb7Xpd8gVbQFc2mYdzOOhtnfGuakyXYF4rZdJonQwzBO6C9KYuARciUU1Ms4bWPC-aeNO5t-aO_bDZbwC9qMPmq5ZuxG633BARGaw26fr0Z7qhcJMiou_EuaIehYTKkPB-mxtRAhxxyX91qqe0-PJnCHWoxizC4hDCUwp9Jb54tNf34BG3vtkXFX-kUARNfGucgKUkh6RYkhWiMBsMVoyWmkFXB5fYxmCAH5c5wDW6srKdyIDEWZInliuKbYR0p66vg1FfoSi4bBfrsm5NtCtLKG9V6Q0FEIA6tRRgHmKUGpkw","refresh_token_expires_in":28519,"scope":"openid","id_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSIsImtpZCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSJ9.eyJhdWQiOiJjaHJvbm9ncmFmIiwiaXNzIjoiaHR0cHM6Ly9kc3RjaW1hYWQxcC5kc3QtaXRzLmRlL2FkZnMiLCJpYXQiOjE1MTU3MDA1NjUsImV4cCI6MTUxNTcwNDE2NSwiYXV0aF90aW1lIjoxNTE1NzAwMjg1LCJzdWIiOiJlWVYzamRsZE55RlkxcUZGSDRvQWRCdkRGZmJWZm51RzI5SGlIa1N1andrPSIsInVwbiI6ImJzY0Bkc3QtaXRzLmRlIiwidW5pcXVlX25hbWUiOiJEU1RcXGJzYyIsInNpZCI6IlMtMS01LTIxLTI1MDUxNTEzOTgtMjY2MTAyODEwOS0zNzU0MjY1ODIwLTExMDQifQ.XD873K6NVRTJY1700NsflLJGZKFHJfNBjB81SlADVdAHbhnq7wkAZbGEEm8wFqvTKKysUl9EALzmDa2tR9nzohVvmHftIYBO0E-wPBzdzWWX0coEgpVAc-SysP-eIQWLsj8EaodaMkCgKO0FbTWOf4GaGIBZGklrr9EEk8VRSdbXbm6Sv9WVphezEzxq6JJBRBlCVibCnZjR5OYh1Vw_7E7P38ESPbpLY3hYYl2hz4y6dQJqCwGr7YP8KrDlYtbosZYgT7ayxokEJI1udEbX5PbAq5G6mj5rLfSOl85rMg-psZiivoM8dn9lEl2P7oT8rAvMWvQp-FIRQQHwqf9cxw"}` hc, ts, prov := setupMuxTest(func(j *AuthMux) http.Handler { return j.Callback() diff --git a/oauth2/oauth2.go b/oauth2/oauth2.go index 8a548470d6..05f38a7414 100644 --- a/oauth2/oauth2.go +++ b/oauth2/oauth2.go @@ -34,6 +34,7 @@ type Principal struct { Subject string Issuer string Organization string + Group string ExpiresAt time.Time IssuedAt time.Time } @@ -54,6 +55,10 @@ type Provider interface { PrincipalID(provider *http.Client) (string, error) // Name is the name of the Provider Name() string + // Group is a comma delimited list of groups and organizations for a provider + // TODO: This will break if there are any group names that contain commas. + // I think this is okay, but I'm not 100% certain. + Group(provider *http.Client) (string, error) } // Mux is a collection of handlers responsible for servicing an Oauth2 interaction between a browser and a provider diff --git a/oauth2/oauth2_test.go b/oauth2/oauth2_test.go index f9d3b13097..3e5f424831 100644 --- a/oauth2/oauth2_test.go +++ b/oauth2/oauth2_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "time" goauth "golang.org/x/oauth2" @@ -17,6 +18,7 @@ var _ Provider = &MockProvider{} type MockProvider struct { Email string + Orgs string ProviderURL string } @@ -49,6 +51,20 @@ func (mp *MockProvider) PrincipalIDFromClaims(claims gojwt.MapClaims) (string, e return mp.Email, nil } +func (mp *MockProvider) GroupFromClaims(claims gojwt.MapClaims) (string, error) { + email := strings.Split(mp.Email, "@") + if len(email) != 2 { + //g.Logger.Error("malformed email address, expected %q to contain @ symbol", id) + return "DEFAULT", nil + } + + return email[1], nil +} + +func (mp *MockProvider) Group(provider *http.Client) (string, error) { + return mp.Orgs, nil +} + func (mp *MockProvider) Scopes() []string { return []string{} } @@ -76,7 +92,7 @@ func (y *YesManTokenizer) ExtendedPrincipal(ctx context.Context, p Principal, ex return p, nil } -func (m *YesManTokenizer) GetClaims(tokenString string) (gojwt.MapClaims, error) { +func (y *YesManTokenizer) GetClaims(tokenString string) (gojwt.MapClaims, error) { return gojwt.MapClaims{}, nil } diff --git a/roles/roles.go b/roles/roles.go index 53b9b9632e..c301a37d12 100644 --- a/roles/roles.go +++ b/roles/roles.go @@ -38,6 +38,9 @@ const ( EditorRoleName = "editor" AdminRoleName = "admin" SuperAdminStatus = "superadmin" + + // Indicatior that the server should retrieve the default role for the organization. + WildcardRoleName = "*" ) var ( diff --git a/server/auth.go b/server/auth.go index b973a19389..be1a08abf0 100644 --- a/server/auth.go +++ b/server/auth.go @@ -11,6 +11,13 @@ import ( "github.com/influxdata/chronograf/roles" ) +// HasAuthorizedToken extracts the token from a request and validates it using the authenticator. +// It is used by routes that need access to the token to populate links request. +func HasAuthorizedToken(auth oauth2.Authenticator, r *http.Request) (oauth2.Principal, error) { + ctx := r.Context() + return auth.Validate(ctx, r) +} + // AuthorizedToken extracts the token and validates; if valid the next handler // will be run. The principal will be sent to the next handler via the request's // Context. It is up to the next handler to determine if the principal has access. @@ -49,6 +56,33 @@ func AuthorizedToken(auth oauth2.Authenticator, logger chronograf.Logger, next h }) } +// RawStoreAccess gives a super admin access to the data store without a facade. +func RawStoreAccess(logger chronograf.Logger, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if isServer := hasServerContext(ctx); isServer { + next(w, r) + return + } + + log := logger. + WithField("component", "raw_store"). + WithField("remote_addr", r.RemoteAddr). + WithField("method", r.Method). + WithField("url", r.URL) + + if isSuperAdmin := hasSuperAdminContext(ctx); isSuperAdmin { + r = r.WithContext(serverContext(ctx)) + } else { + log.Error("User making request is not a SuperAdmin") + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + } + + next(w, r) + } +} + // AuthorizedUser extracts the user name and provider from context. If the // user and provider can be found on the context, we look up the user by their // name and provider. If the user is found, we verify that the user has at at @@ -181,6 +215,13 @@ func hasAuthorizedRole(u *chronograf.User, role string) bool { } switch role { + case roles.MemberRoleName: + for _, r := range u.Roles { + switch r.Name { + case roles.MemberRoleName, roles.ViewerRoleName, roles.EditorRoleName, roles.AdminRoleName: + return true + } + } case roles.ViewerRoleName: for _, r := range u.Roles { switch r.Name { diff --git a/server/auth_test.go b/server/auth_test.go index 06a8a5ebb0..ff1afdf1ff 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -108,6 +108,230 @@ func TestAuthorizedUser(t *testing.T) { hasServerContext: true, authorized: true, }, + { + name: "User with member role is member authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.MemberRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "0", + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: "1337", + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "member", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: false, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "User with viewer role is member authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.ViewerRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "0", + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: "1337", + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "member", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: false, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "User with editor role is member authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "0", + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: "1337", + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "member", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: false, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "User with admin role is member authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "0", + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: "1337", + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "member", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: false, + hasRoleContext: true, + hasServerContext: false, + }, { name: "User with viewer role is viewer authorized", fields: fields{ @@ -1606,3 +1830,118 @@ func TestAuthorizedUser(t *testing.T) { }) } } + +func TestRawStoreAccess(t *testing.T) { + type fields struct { + Logger chronograf.Logger + } + type args struct { + principal *oauth2.Principal + serverContext bool + user *chronograf.User + } + type wants struct { + authorized bool + hasServerContext bool + } + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "middleware already has server context", + fields: fields{ + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + serverContext: true, + }, + wants: wants{ + authorized: true, + hasServerContext: true, + }, + }, + { + name: "user on context is a SuperAdmin", + fields: fields{ + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + user: &chronograf.User{ + SuperAdmin: true, + }, + }, + wants: wants{ + authorized: true, + hasServerContext: true, + }, + }, + { + name: "user on context is a not SuperAdmin", + fields: fields{ + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + user: &chronograf.User{ + SuperAdmin: false, + }, + }, + wants: wants{ + authorized: false, + hasServerContext: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var authorized bool + var hasServerCtx bool + next := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + hasServerCtx = hasServerContext(ctx) + authorized = true + } + fn := RawStoreAccess( + tt.fields.Logger, + next, + ) + + w := httptest.NewRecorder() + url := "http://any.url" + r := httptest.NewRequest( + "GET", + url, + nil, + ) + if tt.args.principal == nil { + r = r.WithContext(context.WithValue(r.Context(), oauth2.PrincipalKey, nil)) + } else { + r = r.WithContext(context.WithValue(r.Context(), oauth2.PrincipalKey, *tt.args.principal)) + } + + if tt.args.serverContext { + r = r.WithContext(serverContext(r.Context())) + } + if tt.args.user != nil { + r = r.WithContext(context.WithValue(r.Context(), UserContextKey, tt.args.user)) + } + fn(w, r) + + if authorized != tt.wants.authorized { + t.Errorf("%q. RawStoreAccess() = %v, expected %v", tt.name, authorized, tt.wants.authorized) + } + + if !authorized && w.Code != http.StatusForbidden { + t.Errorf("%q. RawStoreAccess() Status Code = %v, expected %v", tt.name, w.Code, http.StatusForbidden) + } + + if hasServerCtx != tt.wants.hasServerContext { + t.Errorf("%q. RawStoreAccess().Context().Server = %v, expected %v", tt.name, hasServerCtx, tt.wants.hasServerContext) + } + + }) + } +} diff --git a/server/cells.go b/server/cells.go index ca05fa5242..103ac89ef5 100644 --- a/server/cells.go +++ b/server/cells.go @@ -28,37 +28,31 @@ type dashboardCellResponse struct { func newCellResponse(dID chronograf.DashboardID, cell chronograf.DashboardCell) dashboardCellResponse { base := "/chronograf/v1/dashboards" - newCell := chronograf.DashboardCell{} - newCell.Queries = make([]chronograf.DashboardQuery, len(cell.Queries)) - copy(newCell.Queries, cell.Queries) - - newCell.CellColors = make([]chronograf.CellColor, len(cell.CellColors)) - copy(newCell.CellColors, cell.CellColors) - - // ensure x, y, and y2 axes always returned - labels := []string{"x", "y", "y2"} - newCell.Axes = make(map[string]chronograf.Axis, len(labels)) - - newCell.X = cell.X - newCell.Y = cell.Y - newCell.W = cell.W - newCell.H = cell.H - newCell.Name = cell.Name - newCell.ID = cell.ID - newCell.Type = cell.Type - - for _, lbl := range labels { - if axis, found := cell.Axes[lbl]; !found { - newCell.Axes[lbl] = chronograf.Axis{ - Bounds: []string{}, - } - } else { - newCell.Axes[lbl] = axis - } + if cell.Queries == nil { + cell.Queries = []chronograf.DashboardQuery{} + } + if cell.CellColors == nil { + cell.CellColors = []chronograf.CellColor{} } + // Copy to handle race condition + newAxes := make(map[string]chronograf.Axis, len(cell.Axes)) + for k, v := range cell.Axes { + newAxes[k] = v + } + + // ensure x, y, and y2 axes always returned + for _, lbl := range []string{"x", "y", "y2"} { + if _, found := newAxes[lbl]; !found { + newAxes[lbl] = chronograf.Axis{ + Bounds: []string{}, + } + } + } + cell.Axes = newAxes + return dashboardCellResponse{ - DashboardCell: newCell, + DashboardCell: cell, Links: dashboardCellLinks{ Self: fmt.Sprintf("%s/%d/cells/%s", base, dID, cell.ID), }, @@ -91,7 +85,10 @@ func ValidDashboardCellRequest(c *chronograf.DashboardCell) error { if err != nil { return err } - return HasCorrectColors(c) + if err = HasCorrectColors(c); err != nil { + return err + } + return HasCorrectLegend(c) } // HasCorrectAxes verifies that only permitted axes exist within a DashboardCell @@ -126,6 +123,27 @@ func HasCorrectColors(c *chronograf.DashboardCell) error { return nil } +// HasCorrectLegend verifies that the format of the legend is correct +func HasCorrectLegend(c *chronograf.DashboardCell) error { + // No legend set + if c.Legend.Type == "" && c.Legend.Orientation == "" { + return nil + } + + if c.Legend.Type == "" || c.Legend.Orientation == "" { + return chronograf.ErrInvalidLegend + } + if !oneOf(c.Legend.Orientation, "top", "bottom", "right", "left") { + return chronograf.ErrInvalidLegendOrient + } + + // Remember! if we add other types, update ErrInvalidLegendType + if !oneOf(c.Legend.Type, "static") { + return chronograf.ErrInvalidLegendType + } + return nil +} + // oneOf reports whether a provided string is a member of a variadic list of // valid options func oneOf(prop string, validOpts ...string) bool { diff --git a/server/cells_test.go b/server/cells_test.go index bc3fecc0b6..814d9c6ffb 100644 --- a/server/cells_test.go +++ b/server/cells_test.go @@ -532,7 +532,7 @@ func TestService_ReplaceDashboardCell(t *testing.T) { } } `))), - want: `{"i":"3c5c4102-fa40-4585-a8f9-917c77e37192","x":0,"y":0,"w":4,"h":4,"name":"Untitled Cell","queries":[{"query":"SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time \u003e :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)","queryConfig":{"id":"3cd3eaa4-a4b8-44b3-b69e-0c7bf6b91d9e","database":"telegraf","measurement":"cpu","retentionPolicy":"autogen","fields":[{"value":"mean","type":"func","alias":"mean_usage_user","args":[{"value":"usage_user","type":"field","alias":""}]}],"tags":{"cpu":["ChristohersMBP2.lan"]},"groupBy":{"time":"2s","tags":[]},"areTagsAccepted":true,"fill":"null","rawText":"SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time \u003e :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)","range":{"upper":"","lower":"now() - 15m"},"shifts":[]},"source":""}],"axes":{"x":{"bounds":[],"label":"","prefix":"","suffix":"","base":"","scale":""},"y":{"bounds":[],"label":"","prefix":"","suffix":"","base":"","scale":""},"y2":{"bounds":[],"label":"","prefix":"","suffix":"","base":"","scale":""}},"type":"line","colors":[{"id":"0","type":"min","hex":"#00C9FF","name":"laser","value":"0"},{"id":"1","type":"max","hex":"#9394FF","name":"comet","value":"100"}],"links":{"self":"/chronograf/v1/dashboards/1/cells/3c5c4102-fa40-4585-a8f9-917c77e37192"}} + want: `{"i":"3c5c4102-fa40-4585-a8f9-917c77e37192","x":0,"y":0,"w":4,"h":4,"name":"Untitled Cell","queries":[{"query":"SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time \u003e :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)","queryConfig":{"id":"3cd3eaa4-a4b8-44b3-b69e-0c7bf6b91d9e","database":"telegraf","measurement":"cpu","retentionPolicy":"autogen","fields":[{"value":"mean","type":"func","alias":"mean_usage_user","args":[{"value":"usage_user","type":"field","alias":""}]}],"tags":{"cpu":["ChristohersMBP2.lan"]},"groupBy":{"time":"2s","tags":[]},"areTagsAccepted":true,"fill":"null","rawText":"SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time \u003e :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)","range":{"upper":"","lower":"now() - 15m"},"shifts":[]},"source":""}],"axes":{"x":{"bounds":[],"label":"","prefix":"","suffix":"","base":"","scale":""},"y":{"bounds":[],"label":"","prefix":"","suffix":"","base":"","scale":""},"y2":{"bounds":[],"label":"","prefix":"","suffix":"","base":"","scale":""}},"type":"line","colors":[{"id":"0","type":"min","hex":"#00C9FF","name":"laser","value":"0"},{"id":"1","type":"max","hex":"#9394FF","name":"comet","value":"100"}],"legend":{},"links":{"self":"/chronograf/v1/dashboards/1/cells/3c5c4102-fa40-4585-a8f9-917c77e37192"}} `, }, { @@ -695,7 +695,7 @@ func Test_newCellResponses(t *testing.T) { want []dashboardCellResponse }{ { - name: "foo", + name: "all fields set", dID: chronograf.DashboardID(1), dcells: []chronograf.DashboardCell{ chronograf.DashboardCell{ @@ -752,6 +752,10 @@ func Test_newCellResponses(t *testing.T) { chronograf.CellColor{ID: "0", Type: "min", Hex: "#00C9FF", Name: "laser", Value: "0"}, chronograf.CellColor{ID: "1", Type: "max", Hex: "#9394FF", Name: "comet", Value: "100"}, }, + Legend: chronograf.Legend{ + Type: "static", + Orientation: "bottom", + }, }, }, want: []dashboardCellResponse{ @@ -817,6 +821,50 @@ func Test_newCellResponses(t *testing.T) { Value: "100", }, }, + Legend: chronograf.Legend{ + Type: "static", + Orientation: "bottom", + }, + }, + Links: dashboardCellLinks{ + Self: "/chronograf/v1/dashboards/1/cells/445f8dc0-4d73-4168-8477-f628690d18a3"}, + }, + }, + }, + { + name: "nothing set", + dID: chronograf.DashboardID(1), + dcells: []chronograf.DashboardCell{ + chronograf.DashboardCell{ + ID: "445f8dc0-4d73-4168-8477-f628690d18a3", + X: 0, + Y: 0, + W: 4, + H: 4, + Name: "Untitled Cell", + }, + }, + want: []dashboardCellResponse{ + { + DashboardCell: chronograf.DashboardCell{ + ID: "445f8dc0-4d73-4168-8477-f628690d18a3", + W: 4, + H: 4, + Name: "Untitled Cell", + Queries: []chronograf.DashboardQuery{}, + Axes: map[string]chronograf.Axis{ + "x": chronograf.Axis{ + Bounds: []string{}, + }, + "y": chronograf.Axis{ + Bounds: []string{}, + }, + "y2": chronograf.Axis{ + Bounds: []string{}, + }, + }, + CellColors: []chronograf.CellColor{}, + Legend: chronograf.Legend{}, }, Links: dashboardCellLinks{ Self: "/chronograf/v1/dashboards/1/cells/445f8dc0-4d73-4168-8477-f628690d18a3"}, @@ -832,3 +880,97 @@ func Test_newCellResponses(t *testing.T) { }) } } + +func TestHasCorrectLegend(t *testing.T) { + tests := []struct { + name string + c *chronograf.DashboardCell + wantErr bool + }{ + { + name: "empty legend is ok", + c: &chronograf.DashboardCell{}, + }, + { + name: "must have both an orientation and type", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Type: "static", + }, + }, + wantErr: true, + }, + { + name: "must have both a type and orientation", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Orientation: "bottom", + }, + }, + wantErr: true, + }, + { + name: "invalid types", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Type: "no such type", + Orientation: "bottom", + }, + }, + wantErr: true, + }, + { + name: "invalid orientation", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Type: "static", + Orientation: "no such orientation", + }, + }, + wantErr: true, + }, + { + name: "orientation bottom valid", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Type: "static", + Orientation: "bottom", + }, + }, + }, + { + name: "orientation top valid", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Type: "static", + Orientation: "top", + }, + }, + }, + { + name: "orientation right valid", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Type: "static", + Orientation: "right", + }, + }, + }, + { + name: "orientation left valid", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Type: "static", + Orientation: "left", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := HasCorrectLegend(tt.c); (err != nil) != tt.wantErr { + t.Errorf("HasCorrectLegend() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/server/mapping.go b/server/mapping.go new file mode 100644 index 0000000000..b49f2ce9c7 --- /dev/null +++ b/server/mapping.go @@ -0,0 +1,250 @@ +package server + +import ( + "context" + + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/oauth2" +) + +func (s *Service) mapPrincipalToRoles(ctx context.Context, p oauth2.Principal) ([]chronograf.Role, error) { + mappings, err := s.Store.Mappings(ctx).All(ctx) + if err != nil { + return nil, err + } + roles := []chronograf.Role{} +MappingsLoop: + for _, mapping := range mappings { + if applyMapping(mapping, p) { + org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &mapping.Organization}) + if err != nil { + continue MappingsLoop + } + + for _, role := range roles { + if role.Organization == org.ID { + continue MappingsLoop + } + } + roles = append(roles, chronograf.Role{Organization: org.ID, Name: org.DefaultRole}) + } + } + + return roles, nil +} + +func applyMapping(m chronograf.Mapping, p oauth2.Principal) bool { + switch m.Provider { + case chronograf.MappingWildcard, p.Issuer: + default: + return false + } + + switch m.Scheme { + case chronograf.MappingWildcard, "oauth2": + default: + return false + } + + if m.ProviderOrganization == chronograf.MappingWildcard { + return true + } + + groups := strings.Split(p.Group, ",") + + return matchGroup(m.ProviderOrganization, groups) +} + +func matchGroup(match string, groups []string) bool { + for _, group := range groups { + if match == group { + return true + } + } + + return false +} + +type mappingsRequest chronograf.Mapping + +// Valid determines if a mapping request is valid +func (m *mappingsRequest) Valid() error { + if m.Provider == "" { + return fmt.Errorf("mapping must specify provider") + } + if m.Scheme == "" { + return fmt.Errorf("mapping must specify scheme") + } + if m.ProviderOrganization == "" { + return fmt.Errorf("mapping must specify group") + } + + return nil +} + +type mappingResponse struct { + Links selfLinks `json:"links"` + chronograf.Mapping +} + +func newMappingResponse(m chronograf.Mapping) *mappingResponse { + + return &mappingResponse{ + Links: selfLinks{ + Self: fmt.Sprintf("/chronograf/v1/mappings/%s", m.ID), + }, + Mapping: m, + } +} + +type mappingsResponse struct { + Links selfLinks `json:"links"` + Mappings []*mappingResponse `json:"mappings"` +} + +func newMappingsResponse(ms []chronograf.Mapping) *mappingsResponse { + mappings := []*mappingResponse{} + for _, m := range ms { + mappings = append(mappings, newMappingResponse(m)) + } + return &mappingsResponse{ + Links: selfLinks{ + Self: "/chronograf/v1/mappings", + }, + Mappings: mappings, + } +} + +// Mappings retrives all mappings +func (s *Service) Mappings(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + mappings, err := s.Store.Mappings(ctx).All(ctx) + if err != nil { + Error(w, http.StatusInternalServerError, "failed to retrieve mappings from database", s.Logger) + return + } + + fmt.Printf("mappings: %#v\n", mappings) + + res := newMappingsResponse(mappings) + + encodeJSON(w, http.StatusOK, res, s.Logger) +} + +// NewMapping adds a new mapping +func (s *Service) NewMapping(w http.ResponseWriter, r *http.Request) { + var req mappingsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, s.Logger) + return + } + + if err := req.Valid(); err != nil { + invalidData(w, err, s.Logger) + return + } + + ctx := r.Context() + + // validate that the organization exists + if !s.organizationExists(ctx, req.Organization) { + invalidData(w, fmt.Errorf("organization does not exist"), s.Logger) + return + } + + mapping := &chronograf.Mapping{ + Organization: req.Organization, + Scheme: req.Scheme, + Provider: req.Provider, + ProviderOrganization: req.ProviderOrganization, + } + + m, err := s.Store.Mappings(ctx).Add(ctx, mapping) + if err != nil { + Error(w, http.StatusInternalServerError, "failed to add mapping to database", s.Logger) + return + } + + cu := newMappingResponse(*m) + location(w, cu.Links.Self) + encodeJSON(w, http.StatusCreated, cu, s.Logger) +} + +// UpdateMapping updates a mapping +func (s *Service) UpdateMapping(w http.ResponseWriter, r *http.Request) { + var req mappingsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, s.Logger) + return + } + + if err := req.Valid(); err != nil { + invalidData(w, err, s.Logger) + return + } + + ctx := r.Context() + + // validate that the organization exists + if !s.organizationExists(ctx, req.Organization) { + invalidData(w, fmt.Errorf("organization does not exist"), s.Logger) + return + } + + mapping := &chronograf.Mapping{ + ID: req.ID, + Organization: req.Organization, + Scheme: req.Scheme, + Provider: req.Provider, + ProviderOrganization: req.ProviderOrganization, + } + + err := s.Store.Mappings(ctx).Update(ctx, mapping) + if err != nil { + Error(w, http.StatusInternalServerError, "failed to update mapping in database", s.Logger) + return + } + + cu := newMappingResponse(*mapping) + location(w, cu.Links.Self) + encodeJSON(w, http.StatusOK, cu, s.Logger) +} + +// RemoveMapping removes a mapping +func (s *Service) RemoveMapping(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := httprouter.GetParamFromContext(ctx, "id") + + m, err := s.Store.Mappings(ctx).Get(ctx, id) + if err == chronograf.ErrMappingNotFound { + Error(w, http.StatusNotFound, err.Error(), s.Logger) + return + } + + if err != nil { + Error(w, http.StatusInternalServerError, "failed to retrieve mapping from database", s.Logger) + return + } + + if err := s.Store.Mappings(ctx).Delete(ctx, m); err != nil { + Error(w, http.StatusInternalServerError, "failed to remove mapping from database", s.Logger) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (s *Service) organizationExists(ctx context.Context, orgID string) bool { + if _, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &orgID}); err != nil { + return false + } + + return true +} diff --git a/server/mapping_test.go b/server/mapping_test.go new file mode 100644 index 0000000000..8697084f06 --- /dev/null +++ b/server/mapping_test.go @@ -0,0 +1,360 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "io/ioutil" + "net/http/httptest" + "testing" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/mocks" + "github.com/influxdata/chronograf/roles" +) + +func TestMappings_All(t *testing.T) { + type fields struct { + MappingsStore chronograf.MappingsStore + } + type args struct { + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "get all mappings", + fields: fields{ + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{ + { + Organization: "0", + Provider: chronograf.MappingWildcard, + Scheme: chronograf.MappingWildcard, + ProviderOrganization: chronograf.MappingWildcard, + }, + }, nil + }, + }, + }, + wants: wants{ + statusCode: 200, + contentType: "application/json", + body: `{"links":{"self":"/chronograf/v1/mappings"},"mappings":[{"links":{"self":"/chronograf/v1/mappings/"},"id":"","organizationId":"0","provider":"*","scheme":"*","providerOrganization":"*"}]}`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + MappingsStore: tt.fields.MappingsStore, + }, + Logger: log.New(log.DebugLevel), + } + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://any.url", nil) + s.Mappings(w, r) + + resp := w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wants.statusCode { + t.Errorf("%q. Mappings() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode) + } + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf("%q. Mappings() = %v, want %v", tt.name, content, tt.wants.contentType) + } + if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq { + t.Errorf("%q. Mappings() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body) + } + }) + } +} + +func TestMappings_Add(t *testing.T) { + type fields struct { + MappingsStore chronograf.MappingsStore + OrganizationsStore chronograf.OrganizationsStore + } + type args struct { + mapping *chronograf.Mapping + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "create new mapping", + fields: fields{ + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "0", + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + }, nil + }, + }, + MappingsStore: &mocks.MappingsStore{ + AddF: func(ctx context.Context, m *chronograf.Mapping) (*chronograf.Mapping, error) { + m.ID = "0" + return m, nil + }, + }, + }, + args: args{ + mapping: &chronograf.Mapping{ + Organization: "0", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + }, + wants: wants{ + statusCode: 201, + contentType: "application/json", + body: `{"links":{"self":"/chronograf/v1/mappings/0"},"id":"0","organizationId":"0","provider":"*","scheme":"*","providerOrganization":"*"}`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + MappingsStore: tt.fields.MappingsStore, + OrganizationsStore: tt.fields.OrganizationsStore, + }, + Logger: log.New(log.DebugLevel), + } + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://any.url", nil) + + buf, _ := json.Marshal(tt.args.mapping) + r.Body = ioutil.NopCloser(bytes.NewReader(buf)) + + s.NewMapping(w, r) + + resp := w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wants.statusCode { + t.Errorf("%q. Add() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode) + } + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf("%q. Add() = %v, want %v", tt.name, content, tt.wants.contentType) + } + if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq { + t.Errorf("%q. Add() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body) + } + }) + } +} + +func TestMappings_Update(t *testing.T) { + type fields struct { + MappingsStore chronograf.MappingsStore + OrganizationsStore chronograf.OrganizationsStore + } + type args struct { + mapping *chronograf.Mapping + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "update new mapping", + fields: fields{ + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "0", + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + }, nil + }, + }, + MappingsStore: &mocks.MappingsStore{ + UpdateF: func(ctx context.Context, m *chronograf.Mapping) error { + return nil + }, + }, + }, + args: args{ + mapping: &chronograf.Mapping{ + ID: "1", + Organization: "0", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, + }, + wants: wants{ + statusCode: 200, + contentType: "application/json", + body: `{"links":{"self":"/chronograf/v1/mappings/1"},"id":"1","organizationId":"0","provider":"*","scheme":"*","providerOrganization":"*"}`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + MappingsStore: tt.fields.MappingsStore, + OrganizationsStore: tt.fields.OrganizationsStore, + }, + Logger: log.New(log.DebugLevel), + } + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://any.url", nil) + + buf, _ := json.Marshal(tt.args.mapping) + r.Body = ioutil.NopCloser(bytes.NewReader(buf)) + r = r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.args.mapping.ID, + }, + })) + + s.UpdateMapping(w, r) + + resp := w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wants.statusCode { + t.Errorf("%q. Add() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode) + } + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf("%q. Add() = %v, want %v", tt.name, content, tt.wants.contentType) + } + if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq { + t.Errorf("%q. Add() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body) + } + }) + } +} + +func TestMappings_Remove(t *testing.T) { + type fields struct { + MappingsStore chronograf.MappingsStore + } + type args struct { + id string + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "remove mapping", + fields: fields{ + MappingsStore: &mocks.MappingsStore{ + GetF: func(ctx context.Context, id string) (*chronograf.Mapping, error) { + return &chronograf.Mapping{ + ID: "1", + Organization: "0", + Provider: "*", + Scheme: "*", + ProviderOrganization: "*", + }, nil + }, + DeleteF: func(ctx context.Context, m *chronograf.Mapping) error { + return nil + }, + }, + }, + args: args{}, + wants: wants{ + statusCode: 204, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + MappingsStore: tt.fields.MappingsStore, + }, + Logger: log.New(log.DebugLevel), + } + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://any.url", nil) + + r = r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.args.id, + }, + })) + + s.RemoveMapping(w, r) + + resp := w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wants.statusCode { + t.Errorf("%q. Remove() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode) + } + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf("%q. Remove() = %v, want %v", tt.name, content, tt.wants.contentType) + } + if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq { + t.Errorf("%q. Remove() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body) + } + }) + } +} diff --git a/server/me.go b/server/me.go index 52a36c6ff0..225cc2246b 100644 --- a/server/me.go +++ b/server/me.go @@ -20,16 +20,29 @@ type meLinks struct { type meResponse struct { *chronograf.User Links meLinks `json:"links"` - Organizations []chronograf.Organization `json:"organizations,omitempty"` + Organizations []chronograf.Organization `json:"organizations"` CurrentOrganization *chronograf.Organization `json:"currentOrganization,omitempty"` } +type noAuthMeResponse struct { + Links meLinks `json:"links"` +} + +func newNoAuthMeResponse() noAuthMeResponse { + return noAuthMeResponse{ + Links: meLinks{ + Self: "/chronograf/v1/me", + }, + } +} + // If new user response is nil, return an empty meResponse because it // indicates authentication is not needed -func newMeResponse(usr *chronograf.User) meResponse { - base := "/chronograf/v1/users" +func newMeResponse(usr *chronograf.User, org string) meResponse { + base := "/chronograf/v1" name := "me" if usr != nil { + base = fmt.Sprintf("/chronograf/v1/organizations/%s/users", org) name = PathEscape(fmt.Sprintf("%d", usr.ID)) } @@ -181,7 +194,7 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !s.UseAuth { // If there's no authentication, return an empty user - res := newMeResponse(nil) + res := newNoAuthMeResponse() encodeJSON(w, http.StatusOK, res, s.Logger) return } @@ -200,12 +213,13 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { ctx = context.WithValue(ctx, organizations.ContextKey, p.Organization) serverCtx := serverContext(ctx) + defaultOrg, err := s.Store.Organizations(serverCtx).DefaultOrganization(serverCtx) + if err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + if p.Organization == "" { - defaultOrg, err := s.Store.Organizations(serverCtx).DefaultOrganization(serverCtx) - if err != nil { - unknownErrorWithMessage(w, err, s.Logger) - return - } p.Organization = defaultOrg.ID } @@ -219,35 +233,8 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { return } - defaultOrg, err := s.Store.Organizations(serverCtx).DefaultOrganization(serverCtx) - if err != nil { - unknownErrorWithMessage(w, err, s.Logger) - return - } - + // user exists if usr != nil { - - if defaultOrg.Public || usr.SuperAdmin == true { - // If the default organization is public, or the user is a super admin - // they will always have a role in the default organization - defaultOrgID := defaultOrg.ID - if !hasRoleInDefaultOrganization(usr, defaultOrgID) { - usr.Roles = append(usr.Roles, chronograf.Role{ - Organization: defaultOrgID, - Name: defaultOrg.DefaultRole, - }) - if err := s.Store.Users(serverCtx).Update(serverCtx, usr); err != nil { - unknownErrorWithMessage(w, err, s.Logger) - return - } - } - } - - // If the default org is private and the user has no roles, they should not have access - if !defaultOrg.Public && len(usr.Roles) == 0 { - Error(w, http.StatusForbidden, "This organization is private. To gain access, you must be explicitly added by an administrator.", s.Logger) - return - } currentOrg, err := s.Store.Organizations(serverCtx).Get(serverCtx, chronograf.OrganizationQuery{ID: &p.Organization}) if err == chronograf.ErrOrganizationNotFound { // The intent is to force a the user to go through another auth flow @@ -264,20 +251,14 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { unknownErrorWithMessage(w, err, s.Logger) return } - res := newMeResponse(usr) + + res := newMeResponse(usr, currentOrg.ID) res.Organizations = orgs res.CurrentOrganization = currentOrg encodeJSON(w, http.StatusOK, res, s.Logger) return } - // If users must be explicitly added to the default organization, respond with 403 - // forbidden - if !defaultOrg.Public { - Error(w, http.StatusForbidden, "This organization is private. To gain access, you must be explicitly added by an administrator.", s.Logger) - return - } - // Because we didnt find a user, making a new one user := &chronograf.User{ Name: p.Subject, @@ -286,17 +267,23 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { // support OAuth2. This hard-coding should be removed whenever we add // support for other authentication schemes. Scheme: scheme, - Roles: []chronograf.Role{ - { - Name: defaultOrg.DefaultRole, - // This is the ID of the default organization - Organization: defaultOrg.ID, - }, - }, // TODO(desa): this needs a better name SuperAdmin: s.newUsersAreSuperAdmin(), } + roles, err := s.mapPrincipalToRoles(serverCtx, p) + if err != nil { + Error(w, http.StatusInternalServerError, err.Error(), s.Logger) + return + } + + if len(roles) == 0 { + Error(w, http.StatusForbidden, "This Chronograf is private. To gain access, you must be explicitly added by an administrator.", s.Logger) + return + } + + user.Roles = roles + newUser, err := s.Store.Users(serverCtx).Add(serverCtx, user) if err != nil { msg := fmt.Errorf("error storing user %s: %v", user.Name, err) @@ -314,7 +301,7 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { unknownErrorWithMessage(w, err, s.Logger) return } - res := newMeResponse(newUser) + res := newMeResponse(newUser, currentOrg.ID) res.Organizations = orgs res.CurrentOrganization = currentOrg encodeJSON(w, http.StatusOK, res, s.Logger) diff --git a/server/me_test.go b/server/me_test.go index 6f90e7d32f..6abf9e739f 100644 --- a/server/me_test.go +++ b/server/me_test.go @@ -23,6 +23,7 @@ func TestService_Me(t *testing.T) { type fields struct { UsersStore chronograf.UsersStore OrganizationsStore chronograf.OrganizationsStore + MappingsStore chronograf.MappingsStore ConfigStore chronograf.ConfigStore Logger chronograf.Logger UseAuth bool @@ -56,13 +57,24 @@ func TestService_Me(t *testing.T) { }, }, }, + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{ + { + Organization: "0", + Provider: chronograf.MappingWildcard, + Scheme: chronograf.MappingWildcard, + ProviderOrganization: chronograf.MappingWildcard, + }, + }, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, - Public: false, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -72,13 +84,11 @@ func TestService_Me(t *testing.T) { ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, - Public: false, }, nil case "1": return &chronograf.Organization{ - ID: "1", - Name: "The Bad Place", - Public: false, + ID: "1", + Name: "The Bad Place", }, nil } return nil, nil @@ -108,12 +118,12 @@ func TestService_Me(t *testing.T) { Subject: "me", Issuer: "github", }, - wantStatus: http.StatusForbidden, + wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"code":403,"message":"This organization is private. To gain access, you must be explicitly added by an administrator."}`, + wantBody: `{"name":"me","roles":null,"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[],"currentOrganization":{"id":"0","name":"Default","defaultRole":"viewer"}}`, }, { - name: "Existing user - private default org and user is a super admin", + name: "Existing superadmin - not member of any organization", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest("GET", "http://example.com/foo", nil), @@ -121,13 +131,17 @@ func TestService_Me(t *testing.T) { fields: fields{ UseAuth: true, Logger: log.New(log.DebugLevel), + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{}, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, - Public: false, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -137,13 +151,11 @@ func TestService_Me(t *testing.T) { ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, - Public: true, }, nil case "1": return &chronograf.Organization{ - ID: "1", - Name: "The Bad Place", - Public: true, + ID: "1", + Name: "The Bad Place", }, nil } return nil, nil @@ -176,137 +188,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"me","roles":[{"name":"viewer","organization":"0"}],"provider":"github","scheme":"oauth2","superAdmin":true,"links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"Default","defaultRole":"viewer","public":true}],"currentOrganization":{"id":"0","name":"Default","defaultRole":"viewer","public":true}}`, - }, - { - name: "Existing user - private default org", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest("GET", "http://example.com/foo", nil), - }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - OrganizationsStore: &mocks.OrganizationsStore{ - DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { - return &chronograf.Organization{ - ID: "0", - Name: "Default", - DefaultRole: roles.ViewerRoleName, - Public: false, - }, nil - }, - GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { - switch *q.ID { - case "0": - return &chronograf.Organization{ - ID: "0", - Name: "Default", - DefaultRole: roles.ViewerRoleName, - Public: true, - }, nil - case "1": - return &chronograf.Organization{ - ID: "1", - Name: "The Bad Place", - Public: true, - }, nil - } - return nil, nil - }, - }, - UsersStore: &mocks.UsersStore{ - NumF: func(ctx context.Context) (int, error) { - // This function gets to verify that there is at least one first user - return 1, nil - }, - GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { - if q.Name == nil || q.Provider == nil || q.Scheme == nil { - return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") - } - return &chronograf.User{ - Name: "me", - Provider: "github", - Scheme: "oauth2", - }, nil - }, - UpdateF: func(ctx context.Context, u *chronograf.User) error { - return nil - }, - }, - }, - principal: oauth2.Principal{ - Subject: "me", - Issuer: "github", - }, - wantStatus: http.StatusForbidden, - wantContentType: "application/json", - wantBody: `{"code":403,"message":"This organization is private. To gain access, you must be explicitly added by an administrator."}`, - }, - { - name: "Existing user - default org public", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest("GET", "http://example.com/foo", nil), - }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - OrganizationsStore: &mocks.OrganizationsStore{ - DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { - return &chronograf.Organization{ - ID: "0", - Name: "Default", - DefaultRole: roles.ViewerRoleName, - Public: true, - }, nil - }, - GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { - switch *q.ID { - case "0": - return &chronograf.Organization{ - ID: "0", - Name: "Default", - DefaultRole: roles.ViewerRoleName, - Public: true, - }, nil - case "1": - return &chronograf.Organization{ - ID: "1", - Name: "The Bad Place", - Public: true, - }, nil - } - return nil, nil - }, - }, - UsersStore: &mocks.UsersStore{ - NumF: func(ctx context.Context) (int, error) { - // This function gets to verify that there is at least one first user - return 1, nil - }, - GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { - if q.Name == nil || q.Provider == nil || q.Scheme == nil { - return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") - } - return &chronograf.User{ - Name: "me", - Provider: "github", - Scheme: "oauth2", - }, nil - }, - UpdateF: func(ctx context.Context, u *chronograf.User) error { - return nil - }, - }, - }, - principal: oauth2.Principal{ - Subject: "me", - Issuer: "github", - }, - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"name":"me","roles":[{"name":"viewer","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"Default","defaultRole":"viewer","public":true}],"currentOrganization":{"id":"0","name":"Default","defaultRole":"viewer","public":true}}`, + wantBody: `{"name":"me","roles":null,"provider":"github","scheme":"oauth2","superAdmin":true,"links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[],"currentOrganization":{"id":"0","name":"Default","defaultRole":"viewer"}}`, }, { name: "Existing user - organization doesn't exist", @@ -317,13 +199,17 @@ func TestService_Me(t *testing.T) { fields: fields{ UseAuth: true, Logger: log.New(log.DebugLevel), + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{}, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, - Public: true, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -333,7 +219,6 @@ func TestService_Me(t *testing.T) { ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, - Public: true, }, nil } return nil, chronograf.ErrOrganizationNotFound @@ -365,7 +250,7 @@ func TestService_Me(t *testing.T) { wantBody: `{"code":403,"message":"user's current organization was not found"}`, }, { - name: "new user - default org is public", + name: "default mapping applies to new user", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest("GET", "http://example.com/foo", nil), @@ -380,13 +265,24 @@ func TestService_Me(t *testing.T) { }, }, }, + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{ + { + Organization: "0", + Provider: chronograf.MappingWildcard, + Scheme: chronograf.MappingWildcard, + ProviderOrganization: chronograf.MappingWildcard, + }, + }, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, - Public: true, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -394,7 +290,15 @@ func TestService_Me(t *testing.T) { ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, - Public: true, + }, nil + }, + AllF: func(ctx context.Context) ([]chronograf.Organization, error) { + return []chronograf.Organization{ + chronograf.Organization{ + ID: "0", + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + }, }, nil }, }, @@ -423,8 +327,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"secret","superAdmin":true,"roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","defaultRole":"viewer","public":true}],"currentOrganization":{"id":"0","name":"The Gnarly Default","defaultRole":"viewer","public":true}} -`, + wantBody: `{"name":"secret","superAdmin":true,"roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","defaultRole":"viewer"}}`, }, { name: "New user - New users not super admin, not first user", @@ -442,13 +345,24 @@ func TestService_Me(t *testing.T) { }, }, }, + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{ + { + Organization: "0", + Provider: chronograf.MappingWildcard, + Scheme: chronograf.MappingWildcard, + ProviderOrganization: chronograf.MappingWildcard, + }, + }, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, - Public: true, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -456,7 +370,15 @@ func TestService_Me(t *testing.T) { ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, - Public: true, + }, nil + }, + AllF: func(ctx context.Context) ([]chronograf.Organization, error) { + return []chronograf.Organization{ + chronograf.Organization{ + ID: "0", + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + }, }, nil }, }, @@ -485,8 +407,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"secret","roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}} -`, + wantBody: `{"name":"secret","roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","defaultRole":"viewer"}}`, }, { name: "New user - New users not super admin, first user", @@ -504,13 +425,24 @@ func TestService_Me(t *testing.T) { }, }, }, + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{ + { + Organization: "0", + Provider: chronograf.MappingWildcard, + Scheme: chronograf.MappingWildcard, + ProviderOrganization: chronograf.MappingWildcard, + }, + }, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, - Public: true, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -518,7 +450,15 @@ func TestService_Me(t *testing.T) { ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, - Public: true, + }, nil + }, + AllF: func(ctx context.Context) ([]chronograf.Organization, error) { + return []chronograf.Organization{ + chronograf.Organization{ + ID: "0", + Name: "The Gnarly Default", + DefaultRole: roles.ViewerRoleName, + }, }, nil }, }, @@ -547,8 +487,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"secret","superAdmin":true,"roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}} -`, + wantBody: `{"name":"secret","superAdmin":true,"roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","defaultRole":"viewer"}}`, }, { name: "Error adding user", @@ -565,18 +504,31 @@ func TestService_Me(t *testing.T) { }, }, }, + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{}, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: "0", - Public: true, + ID: "0", + Name: "The Bad Place", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: "0", - Name: "The Bad Place", - Public: true, + ID: "0", + Name: "The Bad Place", + }, nil + }, + AllF: func(ctx context.Context) ([]chronograf.Organization, error) { + return []chronograf.Organization{ + chronograf.Organization{ + ID: "0", + Name: "The Bad Place", + DefaultRole: roles.ViewerRoleName, + }, }, nil }, }, @@ -601,9 +553,9 @@ func TestService_Me(t *testing.T) { Subject: "secret", Issuer: "heroku", }, - wantStatus: http.StatusInternalServerError, + wantStatus: http.StatusForbidden, wantContentType: "application/json", - wantBody: `{"code":500,"message":"Unknown error: error storing user secret: Why Heavy?"}`, + wantBody: `{"code":403,"message":"This Chronograf is private. To gain access, you must be explicitly added by an administrator."}`, }, { name: "No Auth", @@ -624,8 +576,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/users/me"}} -`, + wantBody: `{"links":{"self":"/chronograf/v1/me"}}`, }, { name: "Empty Principal", @@ -659,13 +610,24 @@ func TestService_Me(t *testing.T) { fields: fields{ UseAuth: true, Logger: log.New(log.DebugLevel), + ConfigStore: mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, + MappingsStore: &mocks.MappingsStore{ + AllF: func(ctx context.Context) ([]chronograf.Mapping, error) { + return []chronograf.Mapping{}, nil + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ ID: "0", Name: "The Bad Place", DefaultRole: roles.MemberRoleName, - Public: false, }, nil }, }, @@ -694,7 +656,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusForbidden, wantContentType: "application/json", - wantBody: `{"code":403,"message":"This organization is private. To gain access, you must be explicitly added by an administrator."}`, + wantBody: `{"code":403,"message":"This Chronograf is private. To gain access, you must be explicitly added by an administrator."}`, }, } for _, tt := range tests { @@ -703,12 +665,14 @@ func TestService_Me(t *testing.T) { Store: &mocks.Store{ UsersStore: tt.fields.UsersStore, OrganizationsStore: tt.fields.OrganizationsStore, + MappingsStore: tt.fields.MappingsStore, ConfigStore: tt.fields.ConfigStore, }, Logger: tt.fields.Logger, UseAuth: tt.fields.UseAuth, } + fmt.Println(tt.name) s.Me(tt.args.w, tt.args.r) resp := tt.args.w.Result() @@ -792,7 +756,6 @@ func TestService_UpdateMe(t *testing.T) { ID: "0", Name: "Default", DefaultRole: roles.AdminRoleName, - Public: true, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -805,13 +768,11 @@ func TestService_UpdateMe(t *testing.T) { ID: "0", Name: "Default", DefaultRole: roles.AdminRoleName, - Public: true, }, nil case "1337": return &chronograf.Organization{ - ID: "1337", - Name: "The ShillBillThrilliettas", - Public: true, + ID: "1337", + Name: "The ShillBillThrilliettas", }, nil } return nil, nil @@ -824,7 +785,7 @@ func TestService_UpdateMe(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"},{"name":"admin","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"Default","defaultRole":"admin","public":true},{"id":"1337","name":"The ShillBillThrilliettas","public":true}],"currentOrganization":{"id":"1337","name":"The ShillBillThrilliettas","public":true}}`, + wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/1337/users/0"},"organizations":[{"id":"1337","name":"The ShillBillThrilliettas"}],"currentOrganization":{"id":"1337","name":"The ShillBillThrilliettas"}}`, }, { name: "Change the current User's organization", @@ -866,7 +827,6 @@ func TestService_UpdateMe(t *testing.T) { ID: "0", Name: "Default", DefaultRole: roles.EditorRoleName, - Public: true, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -876,16 +836,14 @@ func TestService_UpdateMe(t *testing.T) { switch *q.ID { case "1337": return &chronograf.Organization{ - ID: "1337", - Name: "The ThrillShilliettos", - Public: false, + ID: "1337", + Name: "The ThrillShilliettos", }, nil case "0": return &chronograf.Organization{ ID: "0", Name: "Default", DefaultRole: roles.EditorRoleName, - Public: true, }, nil } return nil, nil @@ -899,7 +857,7 @@ func TestService_UpdateMe(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"},{"name":"editor","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"Default","defaultRole":"editor","public":true},{"id":"1337","name":"The ThrillShilliettos","public":false}],"currentOrganization":{"id":"1337","name":"The ThrillShilliettos","public":false}}`, + wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/1337/users/0"},"organizations":[{"id":"1337","name":"The ThrillShilliettos"}],"currentOrganization":{"id":"1337","name":"The ThrillShilliettos"}}`, }, { name: "Unable to find requested user in valid organization", @@ -946,9 +904,8 @@ func TestService_UpdateMe(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: "1337", - Name: "The ShillBillThrilliettas", - Public: true, + ID: "1337", + Name: "The ShillBillThrilliettas", }, nil }, }, diff --git a/server/middle.go b/server/middle.go new file mode 100644 index 0000000000..7d2ec52c32 --- /dev/null +++ b/server/middle.go @@ -0,0 +1,57 @@ +package server + +import ( + "net/http" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" +) + +// RouteMatchesPrincipal checks that the organization on context matches the organization +// in the route. +func RouteMatchesPrincipal( + store DataStore, + useAuth bool, + logger chronograf.Logger, + next http.HandlerFunc, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !useAuth { + next(w, r) + return + } + + log := logger. + WithField("component", "org_match"). + WithField("remote_addr", r.RemoteAddr). + WithField("method", r.Method). + WithField("url", r.URL) + + orgID := httprouter.GetParamFromContext(ctx, "oid") + p, err := getValidPrincipal(ctx) + if err != nil { + log.Error("Failed to retrieve principal from context") + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + } + + if p.Organization == "" { + defaultOrg, err := store.Organizations(ctx).DefaultOrganization(ctx) + if err != nil { + log.Error("Failed to look up default organization") + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + } + p.Organization = defaultOrg.ID + } + + if orgID != p.Organization { + log.Error("Route organization does not match the organization on principal") + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + } + + next(w, r) + } +} diff --git a/server/middle_test.go b/server/middle_test.go new file mode 100644 index 0000000000..a57f563708 --- /dev/null +++ b/server/middle_test.go @@ -0,0 +1,196 @@ +package server + +import ( + "context" + "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/oauth2" +) + +func TestRouteMatchesPrincipal(t *testing.T) { + type fields struct { + OrganizationsStore chronograf.OrganizationsStore + Logger chronograf.Logger + } + type args struct { + useAuth bool + principal *oauth2.Principal + routerParams *httprouter.Params + } + type wants struct { + matches bool + } + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "route matches request params", + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "default", + }, nil + }, + }, + }, + args: args{ + useAuth: true, + principal: &oauth2.Principal{ + Subject: "user", + Issuer: "github", + Organization: "default", + }, + routerParams: &httprouter.Params{ + { + Key: "oid", + Value: "default", + }, + }, + }, + wants: wants{ + matches: true, + }, + }, + { + name: "route does not match request params", + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "default", + }, nil + }, + }, + }, + args: args{ + useAuth: true, + principal: &oauth2.Principal{ + Subject: "user", + Issuer: "github", + Organization: "default", + }, + routerParams: &httprouter.Params{ + { + Key: "oid", + Value: "other", + }, + }, + }, + wants: wants{ + matches: false, + }, + }, + { + name: "missing principal", + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "default", + }, nil + }, + }, + }, + args: args{ + useAuth: true, + principal: nil, + routerParams: &httprouter.Params{ + { + Key: "oid", + Value: "other", + }, + }, + }, + wants: wants{ + matches: false, + }, + }, + { + name: "not using auth", + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "default", + }, nil + }, + }, + }, + args: args{ + useAuth: false, + principal: &oauth2.Principal{ + Subject: "user", + Issuer: "github", + Organization: "default", + }, + routerParams: &httprouter.Params{ + { + Key: "oid", + Value: "other", + }, + }, + }, + wants: wants{ + matches: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := &mocks.Store{ + OrganizationsStore: tt.fields.OrganizationsStore, + } + var matches bool + next := func(w http.ResponseWriter, r *http.Request) { + matches = true + } + fn := RouteMatchesPrincipal( + store, + tt.args.useAuth, + tt.fields.Logger, + next, + ) + + w := httptest.NewRecorder() + url := "http://any.url" + r := httptest.NewRequest( + "GET", + url, + nil, + ) + if tt.args.routerParams != nil { + r = r.WithContext(httprouter.WithParams(r.Context(), *tt.args.routerParams)) + } + if tt.args.principal == nil { + r = r.WithContext(context.WithValue(r.Context(), oauth2.PrincipalKey, nil)) + } else { + r = r.WithContext(context.WithValue(r.Context(), oauth2.PrincipalKey, *tt.args.principal)) + } + fn(w, r) + + if matches != tt.wants.matches { + t.Errorf("%q. RouteMatchesPrincipal() = %v, expected %v", tt.name, matches, tt.wants.matches) + } + + if !matches && w.Code != http.StatusForbidden { + t.Errorf("%q. RouteMatchesPrincipal() Status Code = %v, expected %v", tt.name, w.Code, http.StatusForbidden) + } + + }) + } +} diff --git a/server/mux.go b/server/mux.go index aaa1648ea8..0c761d632e 100644 --- a/server/mux.go +++ b/server/mux.go @@ -68,6 +68,16 @@ func NewMux(opts MuxOpts, service Service) http.Handler { hr.NotFound = http.StripPrefix(opts.Basepath, hr.NotFound) } + EnsureMember := func(next http.HandlerFunc) http.HandlerFunc { + return AuthorizedUser( + service.Store, + opts.UseAuth, + roles.MemberRoleName, + opts.Logger, + next, + ) + } + _ = EnsureMember EnsureViewer := func(next http.HandlerFunc) http.HandlerFunc { return AuthorizedUser( service.Store, @@ -105,6 +115,19 @@ func NewMux(opts MuxOpts, service Service) http.Handler { ) } + rawStoreAccess := func(next http.HandlerFunc) http.HandlerFunc { + return RawStoreAccess(opts.Logger, next) + } + + ensureOrgMatches := func(next http.HandlerFunc) http.HandlerFunc { + return RouteMatchesPrincipal( + service.Store, + opts.UseAuth, + opts.Logger, + next, + ) + } + /* Documentation */ router.GET("/swagger.json", Spec()) router.GET("/docs", Redoc("/swagger.json")) @@ -114,9 +137,16 @@ func NewMux(opts MuxOpts, service Service) http.Handler { router.GET("/chronograf/v1/organizations", EnsureAdmin(service.Organizations)) router.POST("/chronograf/v1/organizations", EnsureSuperAdmin(service.NewOrganization)) - router.GET("/chronograf/v1/organizations/:id", EnsureAdmin(service.OrganizationID)) - router.PATCH("/chronograf/v1/organizations/:id", EnsureSuperAdmin(service.UpdateOrganization)) - router.DELETE("/chronograf/v1/organizations/:id", EnsureSuperAdmin(service.RemoveOrganization)) + router.GET("/chronograf/v1/organizations/:oid", EnsureAdmin(service.OrganizationID)) + router.PATCH("/chronograf/v1/organizations/:oid", EnsureSuperAdmin(service.UpdateOrganization)) + router.DELETE("/chronograf/v1/organizations/:oid", EnsureSuperAdmin(service.RemoveOrganization)) + + // Mappings + router.GET("/chronograf/v1/mappings", EnsureSuperAdmin(service.Mappings)) + router.POST("/chronograf/v1/mappings", EnsureSuperAdmin(service.NewMapping)) + + router.PUT("/chronograf/v1/mappings/:id", EnsureSuperAdmin(service.UpdateMapping)) + router.DELETE("/chronograf/v1/mappings/:id", EnsureSuperAdmin(service.RemoveMapping)) // Sources router.GET("/chronograf/v1/sources", EnsureViewer(service.Sources)) @@ -194,12 +224,19 @@ func NewMux(opts MuxOpts, service Service) http.Handler { router.PUT("/chronograf/v1/me", service.UpdateMe(opts.Auth)) // TODO(desa): what to do about admin's being able to set superadmin - router.GET("/chronograf/v1/users", EnsureAdmin(service.Users)) - router.POST("/chronograf/v1/users", EnsureAdmin(service.NewUser)) + router.GET("/chronograf/v1/organizations/:oid/users", EnsureAdmin(ensureOrgMatches(service.Users))) + router.POST("/chronograf/v1/organizations/:oid/users", EnsureAdmin(ensureOrgMatches(service.NewUser))) - router.GET("/chronograf/v1/users/:id", EnsureAdmin(service.UserID)) - router.DELETE("/chronograf/v1/users/:id", EnsureAdmin(service.RemoveUser)) - router.PATCH("/chronograf/v1/users/:id", EnsureAdmin(service.UpdateUser)) + router.GET("/chronograf/v1/organizations/:oid/users/:id", EnsureAdmin(ensureOrgMatches(service.UserID))) + router.DELETE("/chronograf/v1/organizations/:oid/users/:id", EnsureAdmin(ensureOrgMatches(service.RemoveUser))) + router.PATCH("/chronograf/v1/organizations/:oid/users/:id", EnsureAdmin(ensureOrgMatches(service.UpdateUser))) + + router.GET("/chronograf/v1/users", EnsureSuperAdmin(rawStoreAccess(service.Users))) + router.POST("/chronograf/v1/users", EnsureSuperAdmin(rawStoreAccess(service.NewUser))) + + router.GET("/chronograf/v1/users/:id", EnsureSuperAdmin(rawStoreAccess(service.UserID))) + router.DELETE("/chronograf/v1/users/:id", EnsureSuperAdmin(rawStoreAccess(service.RemoveUser))) + router.PATCH("/chronograf/v1/users/:id", EnsureSuperAdmin(rawStoreAccess(service.UpdateUser))) // Dashboards router.GET("/chronograf/v1/dashboards", EnsureViewer(service.Dashboards)) @@ -250,6 +287,11 @@ func NewMux(opts MuxOpts, service Service) http.Handler { CustomLinks: opts.CustomLinks, } + getPrincipal := func(r *http.Request) oauth2.Principal { + p, _ := HasAuthorizedToken(opts.Auth, r) + return p + } + allRoutes.GetPrincipal = getPrincipal router.Handler("GET", "/chronograf/v1/", allRoutes) var out http.Handler diff --git a/server/organizations.go b/server/organizations.go index 5b2227953a..d9e2239542 100644 --- a/server/organizations.go +++ b/server/organizations.go @@ -15,7 +15,6 @@ import ( type organizationRequest struct { Name string `json:"name"` DefaultRole string `json:"defaultRole"` - Public *bool `json:"public"` } func (r *organizationRequest) ValidCreate() error { @@ -27,7 +26,7 @@ func (r *organizationRequest) ValidCreate() error { } func (r *organizationRequest) ValidUpdate() error { - if r.Name == "" && r.DefaultRole == "" && r.Public == nil { + if r.Name == "" && r.DefaultRole == "" { return fmt.Errorf("No fields to update") } @@ -119,10 +118,6 @@ func (s *Service) NewOrganization(w http.ResponseWriter, r *http.Request) { DefaultRole: req.DefaultRole, } - if req.Public != nil { - org.Public = *req.Public - } - res, err := s.Store.Organizations(ctx).Add(ctx, org) if err != nil { Error(w, http.StatusBadRequest, err.Error(), s.Logger) @@ -165,7 +160,7 @@ func (s *Service) NewOrganization(w http.ResponseWriter, r *http.Request) { func (s *Service) OrganizationID(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := httprouter.GetParamFromContext(ctx, "id") + id := httprouter.GetParamFromContext(ctx, "oid") org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &id}) if err != nil { @@ -191,7 +186,7 @@ func (s *Service) UpdateOrganization(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - id := httprouter.GetParamFromContext(ctx, "id") + id := httprouter.GetParamFromContext(ctx, "oid") org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &id}) if err != nil { @@ -207,10 +202,6 @@ func (s *Service) UpdateOrganization(w http.ResponseWriter, r *http.Request) { org.DefaultRole = req.DefaultRole } - if req.Public != nil { - org.Public = *req.Public - } - err = s.Store.Organizations(ctx).Update(ctx, org) if err != nil { Error(w, http.StatusBadRequest, err.Error(), s.Logger) @@ -226,7 +217,7 @@ func (s *Service) UpdateOrganization(w http.ResponseWriter, r *http.Request) { // RemoveOrganization removes an organization in the organizations store func (s *Service) RemoveOrganization(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := httprouter.GetParamFromContext(ctx, "id") + id := httprouter.GetParamFromContext(ctx, "oid") org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &id}) if err != nil { diff --git a/server/organizations_test.go b/server/organizations_test.go index 3315d981b8..51dfd3b0fd 100644 --- a/server/organizations_test.go +++ b/server/organizations_test.go @@ -52,9 +52,8 @@ func TestService_OrganizationID(t *testing.T) { switch *q.ID { case "1337": return &chronograf.Organization{ - ID: "1337", - Name: "The Good Place", - Public: false, + ID: "1337", + Name: "The Good Place", }, nil default: return nil, fmt.Errorf("Organization with ID %s not found", *q.ID) @@ -65,7 +64,38 @@ func TestService_OrganizationID(t *testing.T) { id: "1337", wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place","public":false}`, + wantBody: `{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place"}`, + }, + { + name: "Get Single Organization", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1337": + return &chronograf.Organization{ + ID: "1337", + Name: "The Good Place", + }, nil + default: + return nil, fmt.Errorf("Organization with ID %s not found", *q.ID) + } + }, + }, + }, + id: "1337", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"id":"1337","name":"The Good Place","links":{"self":"/chronograf/v1/organizations/1337"}}`, }, } @@ -82,7 +112,7 @@ func TestService_OrganizationID(t *testing.T) { context.Background(), httprouter.Params{ { - Key: "id", + Key: "oid", Value: tt.id, }, })) @@ -124,7 +154,7 @@ func TestService_Organizations(t *testing.T) { wantBody string }{ { - name: "Get Single Organization", + name: "Get Organizations", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest( @@ -139,14 +169,12 @@ func TestService_Organizations(t *testing.T) { AllF: func(ctx context.Context) ([]chronograf.Organization, error) { return []chronograf.Organization{ chronograf.Organization{ - ID: "1337", - Name: "The Good Place", - Public: false, + ID: "1337", + Name: "The Good Place", }, chronograf.Organization{ - ID: "100", - Name: "The Bad Place", - Public: false, + ID: "100", + Name: "The Bad Place", }, }, nil }, @@ -154,7 +182,7 @@ func TestService_Organizations(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/organizations"},"organizations":[{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place","public":false},{"links":{"self":"/chronograf/v1/organizations/100"},"id":"100","name":"The Bad Place","public":false}]}`, + wantBody: `{"links":{"self":"/chronograf/v1/organizations"},"organizations":[{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place"},{"links":{"self":"/chronograf/v1/organizations/100"},"id":"100","name":"The Bad Place"}]}`, }, } @@ -195,7 +223,6 @@ func TestService_UpdateOrganization(t *testing.T) { w *httptest.ResponseRecorder r *http.Request org *organizationRequest - public bool setPtr bool } tests := []struct { @@ -231,7 +258,6 @@ func TestService_UpdateOrganization(t *testing.T) { ID: "1337", Name: "The Good Place", DefaultRole: roles.ViewerRoleName, - Public: false, }, nil }, }, @@ -239,41 +265,7 @@ func TestService_UpdateOrganization(t *testing.T) { id: "1337", wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"id":"1337","name":"The Bad Place","defaultRole":"viewer","links":{"self":"/chronograf/v1/organizations/1337"},"public":false}`, - }, - { - name: "Update Organization public", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "GET", - "http://any.url", // can be any valid URL as we are bypassing mux - nil, - ), - org: &organizationRequest{}, - public: false, - setPtr: true, - }, - fields: fields{ - Logger: log.New(log.DebugLevel), - OrganizationsStore: &mocks.OrganizationsStore{ - UpdateF: func(ctx context.Context, o *chronograf.Organization) error { - return nil - }, - GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { - return &chronograf.Organization{ - ID: "0", - Name: "The Good Place", - DefaultRole: roles.ViewerRoleName, - Public: true, - }, nil - }, - }, - }, - id: "0", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"id":"0","name":"The Good Place","defaultRole":"viewer","public":false,"links":{"self":"/chronograf/v1/organizations/0"}}`, + wantBody: `{"id":"1337","name":"The Bad Place","defaultRole":"viewer","links":{"self":"/chronograf/v1/organizations/1337"}}`, }, { name: "Update Organization - nothing to update", @@ -297,7 +289,6 @@ func TestService_UpdateOrganization(t *testing.T) { ID: "1337", Name: "The Good Place", DefaultRole: roles.ViewerRoleName, - Public: true, }, nil }, }, @@ -331,7 +322,6 @@ func TestService_UpdateOrganization(t *testing.T) { ID: "1337", Name: "The Good Place", DefaultRole: roles.MemberRoleName, - Public: false, }, nil }, }, @@ -339,7 +329,7 @@ func TestService_UpdateOrganization(t *testing.T) { id: "1337", wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place","defaultRole":"viewer","public":false}`, + wantBody: `{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place","defaultRole":"viewer"}`, }, { name: "Update Organization - invalid update", @@ -411,15 +401,11 @@ func TestService_UpdateOrganization(t *testing.T) { tt.args.r = tt.args.r.WithContext(httprouter.WithParams(context.Background(), httprouter.Params{ { - Key: "id", + Key: "oid", Value: tt.id, }, })) - if tt.args.setPtr { - tt.args.org.Public = &tt.args.public - } - buf, _ := json.Marshal(tt.args.org) tt.args.r.Body = ioutil.NopCloser(bytes.NewReader(buf)) s.UpdateOrganization(tt.args.w, tt.args.r) @@ -503,7 +489,7 @@ func TestService_RemoveOrganization(t *testing.T) { tt.args.r = tt.args.r.WithContext(httprouter.WithParams(context.Background(), httprouter.Params{ { - Key: "id", + Key: "oid", Value: tt.id, }, })) @@ -573,16 +559,54 @@ func TestService_NewOrganization(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ AddF: func(ctx context.Context, o *chronograf.Organization) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: "1337", - Name: "The Good Place", - Public: false, + ID: "1337", + Name: "The Good Place", }, nil }, }, }, wantStatus: http.StatusCreated, wantContentType: "application/json", - wantBody: `{"id":"1337","public":false,"name":"The Good Place","links":{"self":"/chronograf/v1/organizations/1337"}}`, + wantBody: `{"id":"1337","name":"The Good Place","links":{"self":"/chronograf/v1/organizations/1337"}}`, + }, + { + name: "Fail to create Organization - no org name", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://any.url", // can be any valid URL as we are bypassing mux + nil, + ), + user: &chronograf.User{ + ID: 1, + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + }, + org: &organizationRequest{}, + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1, + Name: "bobetta", + Provider: "github", + Scheme: "oauth2", + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + AddF: func(ctx context.Context, o *chronograf.Organization) (*chronograf.Organization, error) { + return nil, nil + }, + }, + }, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"Name required on Chronograf Organization request body"}`, }, { name: "Create Organization - no user on context", diff --git a/server/routes.go b/server/routes.go index 993d4d7493..a74cd6b9e2 100644 --- a/server/routes.go +++ b/server/routes.go @@ -1,9 +1,11 @@ package server import ( + "fmt" "net/http" "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/oauth2" ) // AuthRoute are the routes for each type of OAuth2 provider @@ -31,6 +33,7 @@ func (r *AuthRoutes) Lookup(provider string) (AuthRoute, bool) { type getRoutesResponse struct { Layouts string `json:"layouts"` // Location of the layouts endpoint Users string `json:"users"` // Location of the users endpoint + AllUsers string `json:"allUsers"` // Location of the raw users endpoint Organizations string `json:"organizations"` // Location of the organizations endpoint Mappings string `json:"mappings"` // Location of the application mappings endpoint Sources string `json:"sources"` // Location of the sources endpoint @@ -47,14 +50,15 @@ type getRoutesResponse struct { // external links for the client to know about, such as for JSON feeds or custom side nav buttons. // Optionally, routes for authentication can be returned. type AllRoutes struct { - AuthRoutes []AuthRoute // Location of all auth routes. If no auth, this can be empty. - LogoutLink string // Location of the logout route for all auth routes. If no auth, this can be empty. - StatusFeed string // External link to the JSON Feed for the News Feed on the client's Status Page - CustomLinks map[string]string // Custom external links for client's User menu, as passed in via CLI/ENV - Logger chronograf.Logger + GetPrincipal func(r *http.Request) oauth2.Principal // GetPrincipal is used to retrieve the principal on http request. + AuthRoutes []AuthRoute // Location of all auth routes. If no auth, this can be empty. + LogoutLink string // Location of the logout route for all auth routes. If no auth, this can be empty. + StatusFeed string // External link to the JSON Feed for the News Feed on the client's Status Page + CustomLinks map[string]string // Custom external links for client's User menu, as passed in via CLI/ENV + Logger chronograf.Logger } -// ServeHTTP returns all top level routes and external links within chronograf +// serveHTTP returns all top level routes and external links within chronograf func (a *AllRoutes) ServeHTTP(w http.ResponseWriter, r *http.Request) { customLinks, err := NewCustomLinks(a.CustomLinks) if err != nil { @@ -62,10 +66,20 @@ func (a *AllRoutes) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + org := "default" + if a.GetPrincipal != nil { + // If there is a principal, use the organization to populate the users routes + // otherwise use the default organization + if p := a.GetPrincipal(r); p.Organization != "" { + org = p.Organization + } + } + routes := getRoutesResponse{ Sources: "/chronograf/v1/sources", Layouts: "/chronograf/v1/layouts", - Users: "/chronograf/v1/users", + Users: fmt.Sprintf("/chronograf/v1/organizations/%s/users", org), + AllUsers: "/chronograf/v1/users", Organizations: "/chronograf/v1/organizations", Me: "/chronograf/v1/me", Environment: "/chronograf/v1/env", diff --git a/server/routes_test.go b/server/routes_test.go index aeadcd584c..38ff7b8b35 100644 --- a/server/routes_test.go +++ b/server/routes_test.go @@ -29,7 +29,7 @@ func TestAllRoutes(t *testing.T) { if err := json.Unmarshal(body, &routes); err != nil { t.Error("TestAllRoutes not able to unmarshal JSON response") } - want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[],"external":{"statusFeed":""}} + want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/organizations/default/users","allUsers":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[],"external":{"statusFeed":""}} ` if want != string(body) { t.Errorf("TestAllRoutes\nwanted\n*%s*\ngot\n*%s*", want, string(body)) @@ -67,7 +67,7 @@ func TestAllRoutesWithAuth(t *testing.T) { if err := json.Unmarshal(body, &routes); err != nil { t.Error("TestAllRoutesWithAuth not able to unmarshal JSON response") } - want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[{"name":"github","label":"GitHub","login":"/oauth/github/login","logout":"/oauth/github/logout","callback":"/oauth/github/callback"}],"logout":"/oauth/logout","external":{"statusFeed":""}} + want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/organizations/default/users","allUsers":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[{"name":"github","label":"GitHub","login":"/oauth/github/login","logout":"/oauth/github/logout","callback":"/oauth/github/callback"}],"logout":"/oauth/logout","external":{"statusFeed":""}} ` if want != string(body) { t.Errorf("TestAllRoutesWithAuth\nwanted\n*%s*\ngot\n*%s*", want, string(body)) @@ -100,7 +100,7 @@ func TestAllRoutesWithExternalLinks(t *testing.T) { if err := json.Unmarshal(body, &routes); err != nil { t.Error("TestAllRoutesWithExternalLinks not able to unmarshal JSON response") } - want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[],"external":{"statusFeed":"http://pineapple.life/feed.json","custom":[{"name":"cubeapple","url":"https://cube.apple"}]}} + want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/organizations/default/users","allUsers":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[],"external":{"statusFeed":"http://pineapple.life/feed.json","custom":[{"name":"cubeapple","url":"https://cube.apple"}]}} ` if want != string(body) { t.Errorf("TestAllRoutesWithExternalLinks\nwanted\n*%s*\ngot\n*%s*", want, string(body)) diff --git a/server/server.go b/server/server.go index 093cc90ccf..a816036354 100644 --- a/server/server.go +++ b/server/server.go @@ -485,6 +485,7 @@ func openService(ctx context.Context, buildInfo chronograf.BuildInfo, boltPath s OrganizationsStore: organizations, UsersStore: db.UsersStore, ConfigStore: db.ConfigStore, + MappingsStore: db.MappingsStore, }, Logger: logger, UseAuth: useAuth, diff --git a/server/service.go b/server/service.go index e1df8da8ef..04c9a44fcf 100644 --- a/server/service.go +++ b/server/service.go @@ -48,7 +48,8 @@ func (c *InfluxClient) New(src chronograf.Source, logger chronograf.Logger) (chr } if src.Type == chronograf.InfluxEnterprise && src.MetaURL != "" { tls := strings.Contains(src.MetaURL, "https") - return enterprise.NewClientWithTimeSeries(logger, src.MetaURL, influx.DefaultAuthorization(&src), tls, client) + insecure := src.InsecureSkipVerify + return enterprise.NewClientWithTimeSeries(logger, src.MetaURL, influx.DefaultAuthorization(&src), tls, insecure, client) } return client, nil } diff --git a/server/stores.go b/server/stores.go index 7f9d8ac527..9646a4b7d6 100644 --- a/server/stores.go +++ b/server/stores.go @@ -88,6 +88,7 @@ type DataStore interface { Layouts(ctx context.Context) chronograf.LayoutsStore Users(ctx context.Context) chronograf.UsersStore Organizations(ctx context.Context) chronograf.OrganizationsStore + Mappings(ctx context.Context) chronograf.MappingsStore Dashboards(ctx context.Context) chronograf.DashboardsStore Config(ctx context.Context) chronograf.ConfigStore } @@ -102,6 +103,7 @@ type Store struct { LayoutsStore chronograf.LayoutsStore UsersStore chronograf.UsersStore DashboardsStore chronograf.DashboardsStore + MappingsStore chronograf.MappingsStore OrganizationsStore chronograf.OrganizationsStore ConfigStore chronograf.ConfigStore } @@ -191,3 +193,14 @@ func (s *Store) Config(ctx context.Context) chronograf.ConfigStore { } return &noop.ConfigStore{} } + +// Mappings returns the underlying MappingsStore. +func (s *Store) Mappings(ctx context.Context) chronograf.MappingsStore { + if isServer := hasServerContext(ctx); isServer { + return s.MappingsStore + } + if isSuperAdmin := hasSuperAdminContext(ctx); isSuperAdmin { + return s.MappingsStore + } + return &noop.MappingsStore{} +} diff --git a/server/swagger.json b/server/swagger.json index 0dc228c956..d5f1f5954e 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -3,7 +3,7 @@ "info": { "title": "Chronograf", "description": "API endpoints for Chronograf", - "version": "1.4.0.0" + "version": "1.4.1.3" }, "schemes": ["http"], "basePath": "/chronograf/v1", @@ -550,7 +550,8 @@ "patch": { "tags": ["sources", "users"], "summary": "Update user configuration", - "description": "Update one parameter at a time (one of password, permissions or roles)", + "description": + "Update one parameter at a time (one of password, permissions or roles)", "parameters": [ { "name": "id", @@ -3964,6 +3965,24 @@ "$ref": "#/definitions/DashboardColor" } }, + "legend": { + "description": + "Legend define encoding of the data into a cell's legend", + "type": "object", + "properties": { + "type": { + "description": "type is the style of the legend", + "type": "string", + "enum": ["static"] + }, + "orientation": { + "description": + "orientation is the location of the legend with respect to the cell graph", + "type": "string", + "enum": ["top", "bottom", "left", "right"] + } + } + }, "links": { "type": "object", "properties": { diff --git a/server/users.go b/server/users.go index 3d82406d81..f8b59955f6 100644 --- a/server/users.go +++ b/server/users.go @@ -41,15 +41,15 @@ func (r *userRequest) ValidCreate() error { } func (r *userRequest) ValidUpdate() error { - if len(r.Roles) == 0 { + if r.Roles == nil { return fmt.Errorf("No Roles to update") } return r.ValidRoles() } func (r *userRequest) ValidRoles() error { - orgs := map[string]bool{} if len(r.Roles) > 0 { + orgs := map[string]bool{} for _, r := range r.Roles { if r.Organization == "" { return fmt.Errorf("no organization was provided") @@ -59,10 +59,10 @@ func (r *userRequest) ValidRoles() error { } orgs[r.Organization] = true switch r.Name { - case roles.MemberRoleName, roles.ViewerRoleName, roles.EditorRoleName, roles.AdminRoleName: + case roles.MemberRoleName, roles.ViewerRoleName, roles.EditorRoleName, roles.AdminRoleName, roles.WildcardRoleName: continue default: - return fmt.Errorf("Unknown role %s. Valid roles are 'member', 'viewer', 'editor', and 'admin'", r.Name) + return fmt.Errorf("Unknown role %s. Valid roles are 'member', 'viewer', 'editor', 'admin', and '*'", r.Name) } } } @@ -79,13 +79,19 @@ type userResponse struct { Roles []chronograf.Role `json:"roles"` } -func newUserResponse(u *chronograf.User) *userResponse { +func newUserResponse(u *chronograf.User, org string) *userResponse { // This ensures that any user response with no roles returns an empty array instead of // null when marshaled into JSON. That way, JavaScript doesn't need any guard on the // key existing and it can simply be iterated over. if u.Roles == nil { u.Roles = []chronograf.Role{} } + var selfLink string + if org != "" { + selfLink = fmt.Sprintf("/chronograf/v1/organizations/%s/users/%d", org, u.ID) + } else { + selfLink = fmt.Sprintf("/chronograf/v1/users/%d", u.ID) + } return &userResponse{ ID: u.ID, Name: u.Name, @@ -94,7 +100,7 @@ func newUserResponse(u *chronograf.User) *userResponse { Roles: u.Roles, SuperAdmin: u.SuperAdmin, Links: selfLinks{ - Self: fmt.Sprintf("/chronograf/v1/users/%d", u.ID), + Self: selfLink, }, } } @@ -104,18 +110,25 @@ type usersResponse struct { Users []*userResponse `json:"users"` } -func newUsersResponse(users []chronograf.User) *usersResponse { +func newUsersResponse(users []chronograf.User, org string) *usersResponse { usersResp := make([]*userResponse, len(users)) for i, user := range users { - usersResp[i] = newUserResponse(&user) + usersResp[i] = newUserResponse(&user, org) } sort.Slice(usersResp, func(i, j int) bool { return usersResp[i].ID < usersResp[j].ID }) + + var selfLink string + if org != "" { + selfLink = fmt.Sprintf("/chronograf/v1/organizations/%s/users", org) + } else { + selfLink = "/chronograf/v1/users" + } return &usersResponse{ Users: usersResp, Links: selfLinks{ - Self: "/chronograf/v1/users", + Self: selfLink, }, } } @@ -136,7 +149,9 @@ func (s *Service) UserID(w http.ResponseWriter, r *http.Request) { return } - res := newUserResponse(user) + orgID := httprouter.GetParamFromContext(ctx, "oid") + res := newUserResponse(user, orgID) + location(w, res.Links.Self) encodeJSON(w, http.StatusOK, res, s.Logger) } @@ -162,6 +177,11 @@ func (s *Service) NewUser(w http.ResponseWriter, r *http.Request) { return } + if err := s.validRoles(serverCtx, req.Roles); err != nil { + invalidData(w, err, s.Logger) + return + } + user := &chronograf.User{ Name: req.Name, Provider: req.Provider, @@ -184,7 +204,8 @@ func (s *Service) NewUser(w http.ResponseWriter, r *http.Request) { return } - cu := newUserResponse(res) + orgID := httprouter.GetParamFromContext(ctx, "oid") + cu := newUserResponse(res, orgID) location(w, cu.Links.Self) encodeJSON(w, http.StatusCreated, cu, s.Logger) } @@ -204,15 +225,6 @@ func (s *Service) RemoveUser(w http.ResponseWriter, r *http.Request) { Error(w, http.StatusNotFound, err.Error(), s.Logger) return } - ctxUser, ok := hasUserContext(ctx) - if !ok { - Error(w, http.StatusBadRequest, "failed to retrieve user from context", s.Logger) - return - } - if ctxUser.ID == u.ID { - Error(w, http.StatusForbidden, "user cannot delete themselves", s.Logger) - return - } if err := s.Store.Users(ctx).Delete(ctx, u); err != nil { Error(w, http.StatusBadRequest, err.Error(), s.Logger) return @@ -248,6 +260,12 @@ func (s *Service) UpdateUser(w http.ResponseWriter, r *http.Request) { return } + serverCtx := serverContext(ctx) + if err := s.validRoles(serverCtx, req.Roles); err != nil { + invalidData(w, err, s.Logger) + return + } + // ValidUpdate should ensure that req.Roles is not nil u.Roles = req.Roles @@ -299,7 +317,8 @@ func (s *Service) UpdateUser(w http.ResponseWriter, r *http.Request) { return } - cu := newUserResponse(u) + orgID := httprouter.GetParamFromContext(ctx, "oid") + cu := newUserResponse(u, orgID) location(w, cu.Links.Self) encodeJSON(w, http.StatusOK, cu, s.Logger) } @@ -314,7 +333,8 @@ func (s *Service) Users(w http.ResponseWriter, r *http.Request) { return } - res := newUsersResponse(users) + orgID := httprouter.GetParamFromContext(ctx, "oid") + res := newUsersResponse(users, orgID) encodeJSON(w, http.StatusOK, res, s.Logger) } @@ -341,3 +361,19 @@ func setSuperAdmin(ctx context.Context, req userRequest, user *chronograf.User) return nil } + +func (s *Service) validRoles(ctx context.Context, rs []chronograf.Role) error { + for i, role := range rs { + // verify that the organization exists + org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &role.Organization}) + if err != nil { + return err + } + if role.Name == roles.WildcardRoleName { + role.Name = org.DefaultRole + rs[i] = role + } + } + + return nil +} diff --git a/server/users_test.go b/server/users_test.go index 7641d4922c..019b6453eb 100644 --- a/server/users_test.go +++ b/server/users_test.go @@ -112,9 +112,10 @@ func TestService_UserID(t *testing.T) { func TestService_NewUser(t *testing.T) { type fields struct { - UsersStore chronograf.UsersStore - ConfigStore chronograf.ConfigStore - Logger chronograf.Logger + UsersStore chronograf.UsersStore + OrganizationsStore chronograf.OrganizationsStore + ConfigStore chronograf.ConfigStore + Logger chronograf.Logger } type args struct { w *httptest.ResponseRecorder @@ -204,6 +205,25 @@ func TestService_NewUser(t *testing.T) { }, }, }, + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + case "2": + return &chronograf.Organization{ + ID: "2", + Name: "another", + DefaultRole: roles.MemberRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ AddF: func(ctx context.Context, user *chronograf.User) (*chronograf.User, error) { return &chronograf.User{ @@ -427,14 +447,93 @@ func TestService_NewUser(t *testing.T) { wantContentType: "application/json", wantBody: `{"id":"1338","superAdmin":true,"name":"bob","provider":"github","scheme":"oauth2","roles":[],"links":{"self":"/chronograf/v1/users/1338"}}`, }, + { + name: "Create a new Chronograf User with multiple roles with wildcard default role", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://any.url", + nil, + ), + user: &userRequest{ + Name: "bob", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + { + Name: roles.WildcardRoleName, + Organization: "2", + }, + }, + }, + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + case "2": + return &chronograf.Organization{ + ID: "2", + Name: "another", + DefaultRole: roles.MemberRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, user *chronograf.User) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1338, + Name: "bob", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + { + Name: roles.MemberRoleName, + Organization: "2", + }, + }, + }, nil + }, + }, + }, + wantStatus: http.StatusCreated, + wantContentType: "application/json", + wantBody: `{"id":"1338","superAdmin":false,"name":"bob","provider":"github","scheme":"oauth2","roles":[{"name":"admin","organization":"1"},{"name":"member","organization":"2"}],"links":{"self":"/chronograf/v1/users/1338"}}`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := &Service{ Store: &mocks.Store{ - UsersStore: tt.fields.UsersStore, - ConfigStore: tt.fields.ConfigStore, + UsersStore: tt.fields.UsersStore, + ConfigStore: tt.fields.ConfigStore, + OrganizationsStore: tt.fields.OrganizationsStore, }, Logger: tt.fields.Logger, } @@ -564,8 +663,8 @@ func TestService_RemoveUser(t *testing.T) { }, id: "1339", }, - wantStatus: http.StatusForbidden, - wantBody: `{"code":403,"message":"user cannot delete themselves"}`, + wantStatus: http.StatusNoContent, + wantBody: ``, }, } for _, tt := range tests { @@ -613,8 +712,9 @@ func TestService_RemoveUser(t *testing.T) { func TestService_UpdateUser(t *testing.T) { type fields struct { - UsersStore chronograf.UsersStore - Logger chronograf.Logger + UsersStore chronograf.UsersStore + OrganizationsStore chronograf.OrganizationsStore + Logger chronograf.Logger } type args struct { w *httptest.ResponseRecorder @@ -631,10 +731,76 @@ func TestService_UpdateUser(t *testing.T) { wantContentType string wantBody string }{ + { + name: "Update a Chronograf user - no roles", + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + UpdateF: func(ctx context.Context, user *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + switch *q.ID { + case 1336: + return &chronograf.User{ + ID: 1336, + Name: "bobbetta", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1", + }, + }, + }, nil + default: + return nil, fmt.Errorf("User with ID %d not found", *q.ID) + } + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "PATCH", + "http://any.url", + nil, + ), + userKeyUser: &chronograf.User{ + ID: 0, + Name: "coolUser", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + }, + user: &userRequest{ + ID: 1336, + Roles: []chronograf.Role{}, + }, + }, + id: "1336", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"id":"1336","superAdmin":false,"name":"bobbetta","provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/1336"},"roles":[]}`, + }, { name: "Update a Chronograf user", fields: fields{ Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ UpdateF: func(ctx context.Context, user *chronograf.User) error { return nil @@ -693,6 +859,25 @@ func TestService_UpdateUser(t *testing.T) { name: "Update a Chronograf user roles different orgs", fields: fields{ Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + case "2": + return &chronograf.Organization{ + ID: "2", + Name: "another", + DefaultRole: roles.ViewerRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ UpdateF: func(ctx context.Context, user *chronograf.User) error { return nil @@ -804,6 +989,19 @@ func TestService_UpdateUser(t *testing.T) { name: "SuperAdmin modifying their own SuperAdmin Status - user missing from context", fields: fields{ Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ UpdateF: func(ctx context.Context, user *chronograf.User) error { return nil @@ -857,6 +1055,19 @@ func TestService_UpdateUser(t *testing.T) { name: "SuperAdmin modifying their own SuperAdmin Status", fields: fields{ Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ UpdateF: func(ctx context.Context, user *chronograf.User) error { return nil @@ -917,6 +1128,19 @@ func TestService_UpdateUser(t *testing.T) { name: "Update a SuperAdmin's Roles - without super admin context", fields: fields{ Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ UpdateF: func(ctx context.Context, user *chronograf.User) error { return nil @@ -977,6 +1201,19 @@ func TestService_UpdateUser(t *testing.T) { name: "Update a Chronograf user to super admin - without super admin context", fields: fields{ Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ UpdateF: func(ctx context.Context, user *chronograf.User) error { return nil @@ -1033,6 +1270,19 @@ func TestService_UpdateUser(t *testing.T) { name: "Update a Chronograf user to super admin - with super admin context", fields: fields{ Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ UpdateF: func(ctx context.Context, user *chronograf.User) error { return nil @@ -1090,7 +1340,8 @@ func TestService_UpdateUser(t *testing.T) { t.Run(tt.name, func(t *testing.T) { s := &Service{ Store: &mocks.Store{ - UsersStore: tt.fields.UsersStore, + UsersStore: tt.fields.UsersStore, + OrganizationsStore: tt.fields.OrganizationsStore, }, Logger: tt.fields.Logger, } @@ -1354,7 +1605,7 @@ func TestUserRequest_ValidCreate(t *testing.T) { }, }, wantErr: true, - err: fmt.Errorf("Unknown role BilliettaSpecialRole. Valid roles are 'member', 'viewer', 'editor', and 'admin'"), + err: fmt.Errorf("Unknown role BilliettaSpecialRole. Valid roles are 'member', 'viewer', 'editor', 'admin', and '*'"), }, { name: "Invalid roles - missing organization", @@ -1444,7 +1695,39 @@ func TestUserRequest_ValidUpdate(t *testing.T) { }, }, wantErr: true, - err: fmt.Errorf("Unknown role BillietaSpecialOrg. Valid roles are 'member', 'viewer', 'editor', and 'admin'"), + err: fmt.Errorf("Unknown role BillietaSpecialOrg. Valid roles are 'member', 'viewer', 'editor', 'admin', and '*'"), + }, + { + name: "Valid – roles empty", + args: args{ + u: &userRequest{ + ID: 1337, + Name: "billietta", + Provider: "auth0", + Scheme: "oauth2", + Roles: []chronograf.Role{}, + }, + }, + wantErr: false, + }, + { + name: "Invalid - bad role name", + args: args{ + u: &userRequest{ + ID: 1337, + Name: "billietta", + Provider: "auth0", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "BillietaSpecialOrg", + Organization: "0", + }, + }, + }, + }, + wantErr: true, + err: fmt.Errorf("Unknown role BillietaSpecialOrg. Valid roles are 'member', 'viewer', 'editor', 'admin', and '*'"), }, { name: "Invalid - duplicate organization", diff --git a/ui/package.json b/ui/package.json index 466255a7d3..27933bc66a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "chronograf-ui", - "version": "1.4.0-0", + "version": "1.4.1-3", "private": false, "license": "AGPL-3.0", "description": "", diff --git a/ui/spec/shared/reducers/linksSpec.js b/ui/spec/shared/reducers/linksSpec.js index 58fd7ebb16..cea30d175b 100644 --- a/ui/spec/shared/reducers/linksSpec.js +++ b/ui/spec/shared/reducers/linksSpec.js @@ -2,7 +2,7 @@ import _ from 'lodash' import linksReducer from 'shared/reducers/links' -import {linksReceived} from 'shared/actions/links' +import {linksGetCompleted} from 'shared/actions/links' import {noop} from 'shared/actions/app' const links = { @@ -25,11 +25,10 @@ const links = { } describe('Shared.Reducers.linksReducer', () => { - it('can handle LINKS_RECEIVED', () => { + it('can handle LINKS_GET_COMPLETED', () => { const initial = linksReducer(undefined, noop()) - const actual = linksReducer(initial, linksReceived(links)) + const actual = linksReducer(initial, linksGetCompleted(links)) const expected = links - expect(_.isEqual(actual, expected)).to.equal(true) }) }) diff --git a/ui/src/CheckSources.js b/ui/src/CheckSources.js index d7aea0e607..9872576ba5 100644 --- a/ui/src/CheckSources.js +++ b/ui/src/CheckSources.js @@ -36,13 +36,15 @@ class CheckSources extends Component { } async componentWillMount() { - const {auth: {isUsingAuth, me}} = this.props + const {router, auth: {isUsingAuth, me}} = this.props if (!isUsingAuth || isUserAuthorized(me.role, VIEWER_ROLE)) { await this.props.getSources() + this.setState({isFetching: false}) + } else { + router.push('/purgatory') + return } - - this.setState({isFetching: false}) } shouldComponentUpdate(nextProps) { @@ -66,7 +68,7 @@ class CheckSources extends Component { params, errorThrown, sources, - auth: {isUsingAuth, me, me: {organizations, currentOrganization}}, + auth: {isUsingAuth, me, me: {organizations = [], currentOrganization}}, notify, getSources, } = nextProps @@ -81,6 +83,14 @@ class CheckSources extends Component { return router.push('/') } + if (!isFetching && isUsingAuth && !organizations.length) { + notify( + 'error', + 'You have been removed from all organizations. Please contact your administrator.' + ) + return router.push('/purgatory') + } + if ( me.superAdmin && !organizations.find(o => o.id === currentOrganization.id) diff --git a/ui/src/admin/actions/chronograf.js b/ui/src/admin/actions/chronograf.js index d7a64731ef..8f78e3192a 100644 --- a/ui/src/admin/actions/chronograf.js +++ b/ui/src/admin/actions/chronograf.js @@ -10,6 +10,10 @@ import { createOrganization as createOrganizationAJAX, updateOrganization as updateOrganizationAJAX, deleteOrganization as deleteOrganizationAJAX, + getMappings as getMappingsAJAX, + createMapping as createMappingAJAX, + updateMapping as updateMappingAJAX, + deleteMapping as deleteMappingAJAX, } from 'src/admin/apis/chronograf' import {publishAutoDismissingNotification} from 'shared/dispatchers' @@ -94,6 +98,35 @@ export const removeOrganization = organization => ({ }, }) +export const loadMappings = ({mappings}) => ({ + type: 'CHRONOGRAF_LOAD_MAPPINGS', + payload: { + mappings, + }, +}) + +export const updateMapping = (staleMapping, updatedMapping) => ({ + type: 'CHRONOGRAF_UPDATE_MAPPING', + payload: { + staleMapping, + updatedMapping, + }, +}) + +export const addMapping = mapping => ({ + type: 'CHRONOGRAF_ADD_MAPPING', + payload: { + mapping, + }, +}) + +export const removeMapping = mapping => ({ + type: 'CHRONOGRAF_REMOVE_MAPPING', + payload: { + mapping, + }, +}) + // async actions (thunks) export const loadUsersAsync = url => async dispatch => { try { @@ -113,6 +146,62 @@ export const loadOrganizationsAsync = url => async dispatch => { } } +export const loadMappingsAsync = () => async dispatch => { + try { + const {data} = await getMappingsAJAX() + dispatch(loadMappings(data)) + } catch (error) { + dispatch(errorThrown(error)) + } +} + +export const createMappingAsync = (url, mapping) => async dispatch => { + const mappingWithTempId = {...mapping, _tempID: uuid.v4()} + dispatch(addMapping(mappingWithTempId)) + try { + const {data} = await createMappingAJAX(url, mapping) + dispatch(updateMapping(mappingWithTempId, data)) + } catch (error) { + const message = `${_.upperFirst( + _.toLower(error.data.message) + )}: Scheme: ${mapping.scheme} Provider: ${mapping.provider}` + dispatch(errorThrown(error, message)) + setTimeout( + () => dispatch(removeMapping(mappingWithTempId)), + REVERT_STATE_DELAY + ) + } +} + +export const deleteMappingAsync = mapping => async dispatch => { + dispatch(removeMapping(mapping)) + try { + await deleteMappingAJAX(mapping) + dispatch( + publishAutoDismissingNotification( + 'success', + `Mapping deleted: ${mapping.id} ${mapping.scheme}` + ) + ) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(addMapping(mapping)) + } +} + +export const updateMappingAsync = ( + staleMapping, + updatedMapping +) => async dispatch => { + dispatch(updateMapping(staleMapping, updatedMapping)) + try { + await updateMappingAJAX(updatedMapping) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(updateMapping(updatedMapping, staleMapping)) + } +} + export const createUserAsync = (url, user) => async dispatch => { // temp uuid is added to be able to disambiguate a created user that has the // same scheme, provider, and name as an existing user @@ -131,7 +220,11 @@ export const createUserAsync = (url, user) => async dispatch => { } } -export const updateUserAsync = (user, updatedUser) => async dispatch => { +export const updateUserAsync = ( + user, + updatedUser, + successMessage +) => async dispatch => { dispatch(updateUser(user, updatedUser)) try { // currently the request will be rejected if name, provider, or scheme, or @@ -145,12 +238,7 @@ export const updateUserAsync = (user, updatedUser) => async dispatch => { provider: null, scheme: null, }) - dispatch( - publishAutoDismissingNotification( - 'success', - `User updated: ${user.scheme}::${user.provider}::${user.name}` - ) - ) + dispatch(publishAutoDismissingNotification('success', successMessage)) // it's not necessary to syncUser again but it's useful for good // measure and for the clarity of insight in the redux story dispatch(syncUser(user, data)) @@ -160,14 +248,19 @@ export const updateUserAsync = (user, updatedUser) => async dispatch => { } } -export const deleteUserAsync = user => async dispatch => { +export const deleteUserAsync = ( + user, + {isAbsoluteDelete} = {} +) => async dispatch => { dispatch(removeUser(user)) try { await deleteUserAJAX(user) dispatch( publishAutoDismissingNotification( 'success', - `User removed from organization: ${user.scheme}::${user.provider}::${user.name}` + `${user.name} has been removed from ${isAbsoluteDelete + ? 'all organizations and deleted' + : 'the current organization'}` ) ) } catch (error) { diff --git a/ui/src/admin/apis/chronograf.js b/ui/src/admin/apis/chronograf.js index 3008849280..ed529b0e21 100644 --- a/ui/src/admin/apis/chronograf.js +++ b/ui/src/admin/apis/chronograf.js @@ -102,3 +102,54 @@ export const deleteOrganization = async organization => { throw error } } + +// Mappings +export const createMapping = async (url, mapping) => { + try { + return await AJAX({ + method: 'POST', + resource: 'mappings', + data: mapping, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const getMappings = async () => { + try { + return await AJAX({ + method: 'GET', + resource: 'mappings', + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const updateMapping = async mapping => { + try { + return await AJAX({ + method: 'PUT', + url: mapping.links.self, + data: mapping, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const deleteMapping = async mapping => { + try { + return await AJAX({ + method: 'DELETE', + url: mapping.links.self, + }) + } catch (error) { + console.error(error) + throw error + } +} diff --git a/ui/src/admin/components/chronograf/AdminTabs.js b/ui/src/admin/components/chronograf/AdminTabs.js index 8d4be7f9f8..c9e4f8e925 100644 --- a/ui/src/admin/components/chronograf/AdminTabs.js +++ b/ui/src/admin/components/chronograf/AdminTabs.js @@ -9,14 +9,30 @@ import { import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'shared/components/Tabs' import OrganizationsPage from 'src/admin/containers/chronograf/OrganizationsPage' import UsersPage from 'src/admin/containers/chronograf/UsersPage' +import ProvidersPage from 'src/admin/containers/ProvidersPage' +import AllUsersPage from 'src/admin/containers/chronograf/AllUsersPage' -const ORGANIZATIONS_TAB_NAME = 'Organizations' -const USERS_TAB_NAME = 'Users' +const ORGANIZATIONS_TAB_NAME = 'All Orgs' +const PROVIDERS_TAB_NAME = 'Org Mappings' +const CURRENT_ORG_USERS_TAB_NAME = 'Current Org' +const ALL_USERS_TAB_NAME = 'All Users' const AdminTabs = ({ me: {currentOrganization: meCurrentOrganization, role: meRole, id: meID}, }) => { const tabs = [ + { + requiredRole: ADMIN_ROLE, + type: CURRENT_ORG_USERS_TAB_NAME, + component: ( + + ), + }, + { + requiredRole: SUPERADMIN_ROLE, + type: ALL_USERS_TAB_NAME, + component: , + }, { requiredRole: SUPERADMIN_ROLE, type: ORGANIZATIONS_TAB_NAME, @@ -25,11 +41,9 @@ const AdminTabs = ({ ), }, { - requiredRole: ADMIN_ROLE, - type: USERS_TAB_NAME, - component: ( - - ), + requiredRole: SUPERADMIN_ROLE, + type: PROVIDERS_TAB_NAME, + component: , }, ].filter(t => isUserAuthorized(meRole, t.requiredRole)) diff --git a/ui/src/admin/components/chronograf/AllUsersTable.js b/ui/src/admin/components/chronograf/AllUsersTable.js new file mode 100644 index 0000000000..29de6b8cf3 --- /dev/null +++ b/ui/src/admin/components/chronograf/AllUsersTable.js @@ -0,0 +1,215 @@ +import React, {Component, PropTypes} from 'react' + +import uuid from 'node-uuid' + +import AllUsersTableHeader from 'src/admin/components/chronograf/AllUsersTableHeader' +import AllUsersTableRowNew from 'src/admin/components/chronograf/AllUsersTableRowNew' +import AllUsersTableRow from 'src/admin/components/chronograf/AllUsersTableRow' + +import {ALL_USERS_TABLE} from 'src/admin/constants/chronografTableSizing' +const { + colOrganizations, + colProvider, + colScheme, + colSuperAdmin, + colActions, +} = ALL_USERS_TABLE + +class AllUsersTable extends Component { + constructor(props) { + super(props) + + this.state = { + isCreatingUser: false, + } + } + + handleUpdateAuthConfig = fieldName => updatedValue => { + const { + actionsConfig: {updateAuthConfigAsync}, + authConfig, + links, + } = this.props + const updatedAuthConfig = { + ...authConfig, + [fieldName]: updatedValue, + } + updateAuthConfigAsync(links.config.auth, authConfig, updatedAuthConfig) + } + + handleAddToOrganization = user => organization => { + // '*' tells the server to fill in the current defaultRole of that org + const newRoles = user.roles.concat({ + organization: organization.id, + name: '*', + }) + this.props.onUpdateUserRoles( + user, + newRoles, + `${user.name} has been added to ${organization.name}` + ) + } + + handleRemoveFromOrganization = user => role => { + const newRoles = user.roles.filter( + r => r.organization !== role.organization + ) + const {name} = this.props.organizations.find( + o => o.id === role.organization + ) + this.props.onUpdateUserRoles( + user, + newRoles, + `${user.name} has been removed from ${name}` + ) + } + + handleChangeSuperAdmin = user => newStatus => { + this.props.onUpdateUserSuperAdmin(user, newStatus) + } + + handleClickCreateUser = () => { + this.setState({isCreatingUser: true}) + } + + handleBlurCreateUserRow = () => { + this.setState({isCreatingUser: false}) + } + + render() { + const { + users, + organizations, + onCreateUser, + authConfig, + meID, + notify, + onDeleteUser, + isLoading, + } = this.props + + const {isCreatingUser} = this.state + if (isLoading) { + return ( +
+
+
+
+
+ ) + } + return ( +
+ +
+ + + + + + + + + + + + {users.length + ? users.map(user => + + ) + : + + } + {isCreatingUser + ? + : null} + +
Username + Organizations + ProviderScheme + SuperAdmin + +
+

No Users to display

+
+
+
+ ) + } +} + +const {arrayOf, bool, func, shape, string} = PropTypes + +AllUsersTable.propTypes = { + links: shape({ + config: shape({ + auth: string.isRequired, + }).isRequired, + }).isRequired, + users: arrayOf( + shape({ + id: string, + links: shape({ + self: string.isRequired, + }), + name: string.isRequired, + provider: string.isRequired, + roles: arrayOf( + shape({ + name: string.isRequired, + organization: string.isRequired, + }) + ), + scheme: string.isRequired, + superAdmin: bool, + }) + ).isRequired, + organizations: arrayOf( + shape({ + name: string.isRequired, + id: string.isRequired, + }) + ), + onCreateUser: func.isRequired, + onUpdateUserRoles: func.isRequired, + onUpdateUserSuperAdmin: func.isRequired, + onDeleteUser: func.isRequired, + actionsConfig: shape({ + getAuthConfigAsync: func.isRequired, + updateAuthConfigAsync: func.isRequired, + }), + authConfig: shape({ + superAdminNewUsers: bool, + }), + meID: string.isRequired, + notify: func.isRequired, + isLoading: bool.isRequired, +} + +export default AllUsersTable diff --git a/ui/src/admin/components/chronograf/AllUsersTableHeader.js b/ui/src/admin/components/chronograf/AllUsersTableHeader.js new file mode 100644 index 0000000000..634df211bd --- /dev/null +++ b/ui/src/admin/components/chronograf/AllUsersTableHeader.js @@ -0,0 +1,65 @@ +import React, {PropTypes} from 'react' + +import SlideToggle from 'shared/components/SlideToggle' + +const AllUsersTableHeader = ({ + numUsers, + numOrganizations, + onClickCreateUser, + isCreatingUser, + authConfig: {superAdminNewUsers}, + onChangeAuthConfig, +}) => { + const numUsersString = `${numUsers} User${numUsers === 1 ? '' : 's'}` + const numOrganizationsString = `${numOrganizations} Org${numOrganizations === + 1 + ? '' + : 's'}` + + return ( +
+

+ {numUsersString} across {numOrganizationsString} +

+
+
+ + All new users are SuperAdmins +
+ +
+
+ ) +} + +const {bool, func, number, shape} = PropTypes + +AllUsersTableHeader.defaultProps = { + numUsers: 0, + numOrganizations: 0, + isCreatingUser: false, +} + +AllUsersTableHeader.propTypes = { + numUsers: number.isRequired, + numOrganizations: number.isRequired, + onClickCreateUser: func, + isCreatingUser: bool.isRequired, + onChangeAuthConfig: func.isRequired, + authConfig: shape({ + superAdminNewUsers: bool, + }), +} + +export default AllUsersTableHeader diff --git a/ui/src/admin/components/chronograf/AllUsersTableRow.js b/ui/src/admin/components/chronograf/AllUsersTableRow.js new file mode 100644 index 0000000000..5c8d5cbbe2 --- /dev/null +++ b/ui/src/admin/components/chronograf/AllUsersTableRow.js @@ -0,0 +1,114 @@ +import React, {PropTypes} from 'react' + +import Tags from 'shared/components/Tags' +import SlideToggle from 'shared/components/SlideToggle' +import ConfirmButton from 'shared/components/ConfirmButton' + +import {ALL_USERS_TABLE} from 'src/admin/constants/chronografTableSizing' +const { + colOrganizations, + colProvider, + colScheme, + colSuperAdmin, + colActions, +} = ALL_USERS_TABLE + +const AllUsersTableRow = ({ + organizations, + user, + onAddToOrganization, + onRemoveFromOrganization, + onChangeSuperAdmin, + onDelete, + meID, +}) => { + const dropdownOrganizationsItems = organizations + .filter(o => !user.roles.find(role => role.organization === o.id)) + .map(o => ({ + ...o, + text: o.name, + })) + + const userIsMe = user.id === meID + + const userOrganizations = user.roles.map(r => ({ + ...r, + name: organizations.find(o => r.organization === o.id).name, + })) + + const wrappedDelete = () => onDelete(user) + + const removeWarning = userIsMe + ? 'Delete your user record\nand log yourself out?' + : 'Delete this user?' + + return ( + + + {userIsMe + ? + + {user.name} + + : + {user.name} + } + + + + + + {user.provider} + + + {user.scheme} + + + + + + + + + ) +} + +const {arrayOf, func, shape, string} = PropTypes + +AllUsersTableRow.propTypes = { + user: shape(), + organization: shape({ + name: string.isRequired, + id: string.isRequired, + }), + onAddToOrganization: func.isRequired, + onRemoveFromOrganization: func.isRequired, + onChangeSuperAdmin: func.isRequired, + onDelete: func.isRequired, + meID: string.isRequired, + organizations: arrayOf( + shape({ + id: string.isRequired, + name: string.isRequired, + }) + ), +} + +export default AllUsersTableRow diff --git a/ui/src/admin/components/chronograf/AllUsersTableRowNew.js b/ui/src/admin/components/chronograf/AllUsersTableRowNew.js new file mode 100644 index 0000000000..b20d53d193 --- /dev/null +++ b/ui/src/admin/components/chronograf/AllUsersTableRowNew.js @@ -0,0 +1,183 @@ +import React, {Component, PropTypes} from 'react' + +import Dropdown from 'shared/components/Dropdown' + +import {ALL_USERS_TABLE} from 'src/admin/constants/chronografTableSizing' +const { + colOrganizations, + colProvider, + colScheme, + colSuperAdmin, + colActions, +} = ALL_USERS_TABLE + +const nullOrganization = {id: undefined, name: 'None'} +const nullRole = {name: '*', organization: undefined} + +class AllUsersTableRowNew extends Component { + constructor(props) { + super(props) + + this.state = { + name: '', + provider: '', + scheme: 'oauth2', + role: { + ...nullRole, + }, + } + } + + handleInputChange = fieldName => e => { + this.setState({[fieldName]: e.target.value.trim()}) + } + + handleConfirmCreateUser = () => { + const {onBlur, onCreateUser} = this.props + const {name, provider, scheme, role, superAdmin} = this.state + const newUser = { + name, + provider, + scheme, + superAdmin, + // since you can only choose one organization, there is only one role in a new row + // if no organization is selected ie the "None" organization, + // then set roles to an empty array + roles: role.organization === undefined ? [] : [role], + } + onCreateUser(newUser) + onBlur() + } + + handleInputFocus = e => { + e.target.select() + } + + handleSelectOrganization = newOrganization => { + // if "None" was selected for organization, create a "null role" from the predefined null role + // else create a new role with the organization as the newOrganization's id + const newRole = + newOrganization.id === undefined + ? { + ...nullRole, + } + : { + organization: newOrganization.id, + name: '*', // '*' causes the server to determine the current defaultRole of the selected organization + } + this.setState({role: newRole}) + } + + handleKeyDown = e => { + const {name, provider} = this.state + const preventCreate = !name || !provider + + if (e.key === 'Escape') { + this.props.onBlur() + } + + if (e.key === 'Enter') { + if (preventCreate) { + return this.props.notify( + 'warning', + 'User must have a name and provider' + ) + } + this.handleConfirmCreateUser() + } + } + + render() { + const {organizations, onBlur} = this.props + const {name, provider, scheme, role} = this.state + + const dropdownOrganizationsItems = [ + {...nullOrganization}, + ...organizations, + ].map(o => ({ + ...o, + text: o.name, + })) + const selectedRole = dropdownOrganizationsItems.find( + o => role.organization === o.id + ) + + const preventCreate = !name || !provider + + return ( + + + + + + + + + + + + + + + — + + + + + + + ) + } +} + +const {arrayOf, func, shape, string} = PropTypes + +AllUsersTableRowNew.propTypes = { + organizations: arrayOf( + shape({ + id: string.isRequired, + name: string.isRequired, + }) + ), + onBlur: func.isRequired, + onCreateUser: func.isRequired, + notify: func.isRequired, +} + +export default AllUsersTableRowNew diff --git a/ui/src/admin/components/chronograf/EmptyUsersTable.js b/ui/src/admin/components/chronograf/EmptyUsersTable.js deleted file mode 100644 index 3ded5aea42..0000000000 --- a/ui/src/admin/components/chronograf/EmptyUsersTable.js +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react' - -import UsersTableHeader from 'src/admin/components/chronograf/UsersTableHeader' - -import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' - -import {USERS_TABLE} from 'src/admin/constants/chronografTableSizing' - -const EmptyUsersTable = () => { - const { - colRole, - colSuperAdmin, - colProvider, - colScheme, - colActions, - } = USERS_TABLE - - return ( -
- -
- - - - - - - - - - - - - -
Username - Role - - SuperAdmin - ProviderScheme -
-
-
- ) -} - -export default EmptyUsersTable diff --git a/ui/src/admin/components/chronograf/OrganizationsTable.js b/ui/src/admin/components/chronograf/OrganizationsTable.js index 272d574878..8485043572 100644 --- a/ui/src/admin/components/chronograf/OrganizationsTable.js +++ b/ui/src/admin/components/chronograf/OrganizationsTable.js @@ -2,14 +2,8 @@ import React, {Component, PropTypes} from 'react' import uuid from 'node-uuid' -import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' - import OrganizationsTableRow from 'src/admin/components/chronograf/OrganizationsTableRow' import OrganizationsTableRowNew from 'src/admin/components/chronograf/OrganizationsTableRowNew' -import QuestionMarkTooltip from 'shared/components/QuestionMarkTooltip' -import SlideToggle from 'shared/components/SlideToggle' - -import {PUBLIC_TOOLTIP} from 'src/admin/constants/index' class OrganizationsTable extends Component { constructor(props) { @@ -40,10 +34,7 @@ class OrganizationsTable extends Component { onDeleteOrg, onRenameOrg, onChooseDefaultRole, - onTogglePublic, currentOrganization, - authConfig: {superAdminNewUsers}, - onChangeAuthConfig, } = this.props const {isCreatingOrganization} = this.state @@ -52,6 +43,15 @@ class OrganizationsTable extends Component { ? '' : 's'}` + if (!organizations.length) { + return ( +
+
+
+
+
+ ) + } return (
@@ -67,15 +67,13 @@ class OrganizationsTable extends Component {
-
-
-
Name
-
- Public{' '} - +
+
+
Name
+
+ Default Role
-
Default Role
-
+
{isCreatingOrganization ? )} - - - - - - - - - - - - - -
Config -
- - All new users are SuperAdmins
-
) } } -const {arrayOf, bool, func, shape, string} = PropTypes +const {arrayOf, func, shape, string} = PropTypes OrganizationsTable.propTypes = { organizations: arrayOf( @@ -138,11 +113,6 @@ OrganizationsTable.propTypes = { onCreateOrg: func.isRequired, onDeleteOrg: func.isRequired, onRenameOrg: func.isRequired, - onTogglePublic: func.isRequired, onChooseDefaultRole: func.isRequired, - onChangeAuthConfig: func.isRequired, - authConfig: shape({ - superAdminNewUsers: bool, - }), } export default OrganizationsTable diff --git a/ui/src/admin/components/chronograf/OrganizationsTableRow.js b/ui/src/admin/components/chronograf/OrganizationsTableRow.js index 93690854a2..aa7b56e7a1 100644 --- a/ui/src/admin/components/chronograf/OrganizationsTableRow.js +++ b/ui/src/admin/components/chronograf/OrganizationsTableRow.js @@ -3,9 +3,9 @@ import {connect} from 'react-redux' import {bindActionCreators} from 'redux' import {withRouter} from 'react-router' -import SlideToggle from 'shared/components/SlideToggle' import ConfirmButtons from 'shared/components/ConfirmButtons' import Dropdown from 'shared/components/Dropdown' +import InputClickToEdit from 'shared/components/InputClickToEdit' import {meChangeOrganizationAsync} from 'shared/actions/auth' @@ -32,9 +32,7 @@ class OrganizationsTableRow extends Component { super(props) this.state = { - isEditing: false, isDeleting: false, - workingName: this.props.organization.name, } } @@ -44,55 +42,10 @@ class OrganizationsTableRow extends Component { await meChangeOrganization(links.me, {organization: organization.id}) router.push('') } - - handleNameClick = () => { - this.setState({isEditing: true}) + handleUpdateOrgName = newName => { + const {organization, onRename} = this.props + onRename(organization, newName) } - - handleConfirmRename = () => { - const {onRename, organization} = this.props - const {workingName} = this.state - - onRename(organization, workingName) - this.setState({workingName, isEditing: false}) - } - - handleCancelRename = () => { - const {organization} = this.props - - this.setState({ - workingName: organization.name, - isEditing: false, - }) - } - - handleInputChange = e => { - this.setState({workingName: e.target.value}) - } - - handleInputBlur = () => { - const {organization} = this.props - const {workingName} = this.state - - if (organization.name === workingName) { - this.handleCancelRename() - } else { - this.handleConfirmRename() - } - } - - handleKeyDown = e => { - if (e.key === 'Enter') { - this.handleInputBlur() - } else if (e.key === 'Escape') { - this.handleCancelRename() - } - } - - handleFocus = e => { - e.target.select() - } - handleDeleteClick = () => { this.setState({isDeleting: true}) } @@ -106,18 +59,13 @@ class OrganizationsTableRow extends Component { onDelete(organization) } - handleTogglePublic = () => { - const {organization, onTogglePublic} = this.props - onTogglePublic(organization) - } - handleChooseDefaultRole = role => { const {organization, onChooseDefaultRole} = this.props onChooseDefaultRole(organization, role.name) } render() { - const {workingName, isEditing, isDeleting} = this.state + const {isDeleting} = this.state const {organization, currentOrganization} = this.props const dropdownRolesItems = USER_ROLES.map(role => ({ @@ -126,12 +74,12 @@ class OrganizationsTableRow extends Component { })) const defaultRoleClassName = isDeleting - ? 'orgs-table--default-role editing' - : 'orgs-table--default-role' + ? 'fancytable--td orgs-table--default-role deleting' + : 'fancytable--td orgs-table--default-role' return ( -
-
+
+
{organization.id === currentOrganization.id ? }
- {isEditing - ? (this.inputRef = r)} - /> - :
- {workingName} - -
} - {organization.id === DEFAULT_ORG_ID - ?
- -
- :
} +
-
- (this.inputRef = r)} - /> -
+
+
+
+ (this.inputRef = r)} + /> +
+
{ + this.setState({isCreatingMap: true}) + } + + handleCancelCreateMap = () => { + this.setState({isCreatingMap: false}) + } + + handleCreateMap = newMap => { + this.props.onCreateMap(newMap) + this.setState({isCreatingMap: false}) + } + + render() { + const { + mappings = [], + organizations, + onUpdateMap, + onDeleteMap, + isLoading, + } = this.props + const {isCreatingMap} = this.state + + const tableTitle = + mappings.length === 1 ? '1 Map' : `${mappings.length} Maps` + + // define scheme options + const SCHEMES = [{text: '*'}, {text: 'oauth2'}] + + if (isLoading) { + return ( +
+
+
+
+
+ ) + } + + return ( +
+
+

+ {tableTitle} +

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

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

+ +
+
} +
+ ) + } +} + +const {arrayOf, bool, func, shape, string} = PropTypes + +ProvidersTable.propTypes = { + mappings: arrayOf( + shape({ + id: string, + scheme: string, + provider: string, + providerOrganization: string, + organizationId: string, + }) + ).isRequired, + organizations: arrayOf( + shape({ + id: string, // when optimistically created, organization will not have an id + name: string.isRequired, + }) + ).isRequired, + onCreateMap: func.isRequired, + onUpdateMap: func.isRequired, + onDeleteMap: func.isRequired, + isLoading: bool.isRequired, +} +export default ProvidersTable diff --git a/ui/src/admin/components/chronograf/ProvidersTableRow.js b/ui/src/admin/components/chronograf/ProvidersTableRow.js new file mode 100644 index 0000000000..ba42f9a2b4 --- /dev/null +++ b/ui/src/admin/components/chronograf/ProvidersTableRow.js @@ -0,0 +1,150 @@ +import React, {Component, PropTypes} from 'react' + +import ConfirmButtons from 'shared/components/ConfirmButtons' +import Dropdown from 'shared/components/Dropdown' +import InputClickToEdit from 'shared/components/InputClickToEdit' + +import {DEFAULT_MAPPING_ID} from 'src/admin/constants/chronografAdmin' + +class ProvidersTableRow extends Component { + constructor(props) { + super(props) + + this.state = { + ...this.props.mapping, + isDeleting: false, + } + } + + handleDeleteClick = () => { + this.setState({isDeleting: true}) + } + + handleDismissDeleteConfirmation = () => { + this.setState({isDeleting: false}) + } + + handleDeleteMap = mapping => { + const {onDelete} = this.props + this.setState({isDeleting: false}) + onDelete(mapping) + } + + handleUpdateMapping = changes => { + const {onUpdate, mapping} = this.props + const newState = {...mapping, ...changes} + this.setState(newState) + onUpdate(mapping, newState) + } + + handleChangeProvider = provider => this.handleUpdateMapping({provider}) + + handleChangeProviderOrg = providerOrganization => + this.handleUpdateMapping({providerOrganization}) + + handleChooseOrganization = ({id: organizationId}) => + this.handleUpdateMapping({organizationId}) + + handleChooseScheme = ({text: scheme}) => this.handleUpdateMapping({scheme}) + + render() { + const { + scheme, + provider, + providerOrganization, + organizationId, + isDeleting, + } = this.state + const {organizations, mapping, schemes, rowIndex} = this.props + + const selectedOrg = organizations.find(o => o.id === organizationId) + const orgDropdownItems = organizations.map(role => ({ + ...role, + text: role.name, + })) + + const organizationIdClassName = isDeleting + ? 'fancytable--td provider--redirect deleting' + : 'fancytable--td provider--redirect' + + const isDefaultMapping = DEFAULT_MAPPING_ID === mapping.id + return ( +
+ + + +
+ +
+
+ +
+ {isDeleting + ? + : } +
+ ) + } +} + +const {arrayOf, func, number, shape, string} = PropTypes + +ProvidersTableRow.propTypes = { + mapping: shape({ + id: string, + scheme: string, + provider: string, + providerOrganization: string, + organizationId: string, + }), + organizations: arrayOf( + shape({ + id: string.isRequired, + name: string.isRequired, + }) + ), + schemes: arrayOf( + shape({ + text: string.isRequired, + }) + ), + rowIndex: number, + onDelete: func.isRequired, + onUpdate: func.isRequired, +} + +export default ProvidersTableRow diff --git a/ui/src/admin/components/chronograf/ProvidersTableRowNew.js b/ui/src/admin/components/chronograf/ProvidersTableRowNew.js new file mode 100644 index 0000000000..d99f32d54d --- /dev/null +++ b/ui/src/admin/components/chronograf/ProvidersTableRowNew.js @@ -0,0 +1,116 @@ +import React, {Component, PropTypes} from 'react' + +import ConfirmButtons from 'shared/components/ConfirmButtons' +import Dropdown from 'shared/components/Dropdown' +import InputClickToEdit from 'shared/components/InputClickToEdit' + +class ProvidersTableRowNew extends Component { + constructor(props) { + super(props) + + this.state = { + scheme: '*', + provider: null, + providerOrganization: null, + organizationId: 'default', + } + } + + handleChooseScheme = scheme => { + this.setState({scheme: scheme.text}) + } + + handleChangeProvider = provider => { + this.setState({provider}) + } + + handleChangeProviderOrg = providerOrganization => { + this.setState({providerOrganization}) + } + + handleChooseOrganization = org => { + this.setState({organizationId: org.id}) + } + + handleSaveNewMapping = () => { + const {onCreate} = this.props + onCreate(this.state) + } + + render() { + const {scheme, provider, providerOrganization, organizationId} = this.state + + const {organizations, onCancel, schemes, rowIndex} = this.props + + const selectedOrg = organizations.find(o => o.id === organizationId) + + const dropdownItems = organizations.map(role => ({ + ...role, + text: role.name, + })) + + const preventCreate = !provider || !providerOrganization + + return ( +
+ + + +
+ +
+
+ +
+ +
+ ) + } +} + +const {arrayOf, func, number, shape, string} = PropTypes + +ProvidersTableRowNew.propTypes = { + organizations: arrayOf( + shape({ + id: string.isRequired, + name: string.isRequired, + }) + ).isRequired, + schemes: arrayOf( + shape({ + text: string.isRequired, + }) + ), + rowIndex: number, + onCreate: func.isRequired, + onCancel: func.isRequired, +} + +export default ProvidersTableRowNew diff --git a/ui/src/admin/components/chronograf/UsersTable.js b/ui/src/admin/components/chronograf/UsersTable.js index 9da6c5d8ea..d0f026ceee 100644 --- a/ui/src/admin/components/chronograf/UsersTable.js +++ b/ui/src/admin/components/chronograf/UsersTable.js @@ -2,8 +2,6 @@ import React, {Component, PropTypes} from 'react' import uuid from 'node-uuid' -import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' - import UsersTableHeader from 'src/admin/components/chronograf/UsersTableHeader' import UsersTableRowNew from 'src/admin/components/chronograf/UsersTableRowNew' import UsersTableRow from 'src/admin/components/chronograf/UsersTableRow' @@ -23,10 +21,6 @@ class UsersTable extends Component { this.props.onUpdateUserRole(user, currentRole, newRole) } - handleChangeSuperAdmin = user => newStatus => { - this.props.onUpdateUserSuperAdmin(user, newStatus) - } - handleDeleteUser = user => { this.props.onDeleteUser(user) } @@ -40,17 +34,27 @@ class UsersTable extends Component { } render() { - const {organization, users, onCreateUser, meID, notify} = this.props + const { + organization, + users, + onCreateUser, + meID, + notify, + isLoading, + } = this.props const {isCreatingUser} = this.state - const { - colRole, - colSuperAdmin, - colProvider, - colScheme, - colActions, - } = USERS_TABLE + const {colRole, colProvider, colScheme, colActions} = USERS_TABLE + if (isLoading) { + return ( +
+
+
+
+
+ ) + } return (
Role - - - SuperAdmin - - Provider Scheme @@ -86,31 +85,21 @@ class UsersTable extends Component { notify={notify} /> : null} - {users.length || !isCreatingUser + {users.length ? users.map(user => ) : - -

No Users to display

- - } - > - -

No Users to display

- -
+ +

No Users to display

+ } @@ -138,7 +127,6 @@ UsersTable.propTypes = { }) ), scheme: string.isRequired, - superAdmin: bool, }) ).isRequired, organization: shape({ @@ -147,10 +135,10 @@ UsersTable.propTypes = { }), onCreateUser: func.isRequired, onUpdateUserRole: func.isRequired, - onUpdateUserSuperAdmin: func.isRequired, onDeleteUser: func.isRequired, meID: string.isRequired, notify: func.isRequired, + isLoading: bool.isRequired, } export default UsersTable diff --git a/ui/src/admin/components/chronograf/UsersTableHeader.js b/ui/src/admin/components/chronograf/UsersTableHeader.js index 48179cf15b..608a61fbc3 100644 --- a/ui/src/admin/components/chronograf/UsersTableHeader.js +++ b/ui/src/admin/components/chronograf/UsersTableHeader.js @@ -26,7 +26,7 @@ class UsersTableHeader extends Component { disabled={isCreatingUser || !onClickCreateUser} > - Create User + Add User
) diff --git a/ui/src/admin/components/chronograf/UsersTableRow.js b/ui/src/admin/components/chronograf/UsersTableRow.js index 561ca99684..ca02b56d60 100644 --- a/ui/src/admin/components/chronograf/UsersTableRow.js +++ b/ui/src/admin/components/chronograf/UsersTableRow.js @@ -1,9 +1,6 @@ import React, {PropTypes} from 'react' -import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' - import Dropdown from 'shared/components/Dropdown' -import SlideToggle from 'shared/components/SlideToggle' import DeleteConfirmTableCell from 'shared/components/DeleteConfirmTableCell' import {USER_ROLES} from 'src/admin/constants/chronografAdmin' @@ -13,11 +10,10 @@ const UsersTableRow = ({ user, organization, onChangeUserRole, - onChangeSuperAdmin, onDelete, meID, }) => { - const {colRole, colSuperAdmin, colProvider, colScheme} = USERS_TABLE + const {colRole, colProvider, colScheme} = USERS_TABLE const dropdownRolesItems = USER_ROLES.map(r => ({ ...r, @@ -53,16 +49,6 @@ const UsersTableRow = ({ /> - - - - - {user.provider} @@ -89,7 +75,6 @@ UsersTableRow.propTypes = { id: string.isRequired, }), onChangeUserRole: func.isRequired, - onChangeSuperAdmin: func.isRequired, onDelete: func.isRequired, meID: string.isRequired, } diff --git a/ui/src/admin/components/chronograf/UsersTableRowNew.js b/ui/src/admin/components/chronograf/UsersTableRowNew.js index 7b63c3e46c..4af835e089 100644 --- a/ui/src/admin/components/chronograf/UsersTableRowNew.js +++ b/ui/src/admin/components/chronograf/UsersTableRowNew.js @@ -1,7 +1,5 @@ import React, {Component, PropTypes} from 'react' -import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' - import Dropdown from 'shared/components/Dropdown' import {USERS_TABLE} from 'src/admin/constants/chronografTableSizing' @@ -25,13 +23,12 @@ class UsersTableRowNew extends Component { handleConfirmCreateUser = () => { const {onBlur, onCreateUser, organization} = this.props - const {name, provider, scheme, role, superAdmin} = this.state + const {name, provider, scheme, role} = this.state const newUser = { name, provider, scheme, - superAdmin, roles: [ { name: role, @@ -72,13 +69,7 @@ class UsersTableRowNew extends Component { } render() { - const { - colRole, - colProvider, - colScheme, - colSuperAdmin, - colActions, - } = USERS_TABLE + const {colRole, colProvider, colScheme, colActions} = USERS_TABLE const {onBlur} = this.props const {name, provider, scheme, role} = this.state @@ -108,11 +99,6 @@ class UsersTableRowNew extends Component { className="dropdown-stretch" /> - - - — - - authenticate unless an Admin explicitly
adds them to the organization.' diff --git a/ui/src/admin/containers/ProvidersPage.js b/ui/src/admin/containers/ProvidersPage.js new file mode 100644 index 0000000000..037eaf0202 --- /dev/null +++ b/ui/src/admin/containers/ProvidersPage.js @@ -0,0 +1,101 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' + +import * as adminChronografActionCreators from 'src/admin/actions/chronograf' +import {publishAutoDismissingNotification} from 'shared/dispatchers' + +import ProvidersTable from 'src/admin/components/chronograf/ProvidersTable' + +class ProvidersPage extends Component { + constructor(props) { + super(props) + + this.state = {isLoading: true} + } + + async componentDidMount() { + const { + links, + actions: {loadOrganizationsAsync, loadMappingsAsync}, + } = this.props + + await Promise.all([ + loadOrganizationsAsync(links.organizations), + loadMappingsAsync(links.mappings), + ]) + + this.setState({isLoading: false}) + } + + handleCreateMap = mapping => { + this.props.actions.createMappingAsync(this.props.links.mappings, mapping) + } + + handleUpdateMap = (staleMap, updatedMap) => { + this.props.actions.updateMappingAsync(staleMap, updatedMap) + } + + handleDeleteMap = mapping => { + this.props.actions.deleteMappingAsync(mapping) + } + + render() { + const {organizations, mappings = []} = this.props + const {isLoading} = this.state + + return ( + + ) + } +} + +const {arrayOf, func, shape, string} = PropTypes + +ProvidersPage.propTypes = { + links: shape({ + organizations: string.isRequired, + }), + organizations: arrayOf( + shape({ + id: string.isRequired, + name: string.isRequired, + }) + ), + mappings: arrayOf( + shape({ + id: string, + scheme: string, + provider: string, + providerOrganization: string, + organizationId: string, + }) + ), + actions: shape({ + loadOrganizationsAsync: func.isRequired, + }), + notify: func.isRequired, +} + +const mapStateToProps = ({ + links, + adminChronograf: {organizations, mappings}, +}) => ({ + links, + organizations, + mappings, +}) + +const mapDispatchToProps = dispatch => ({ + actions: bindActionCreators(adminChronografActionCreators, dispatch), + notify: bindActionCreators(publishAutoDismissingNotification, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(ProvidersPage) diff --git a/ui/src/admin/containers/chronograf/AllUsersPage.js b/ui/src/admin/containers/chronograf/AllUsersPage.js new file mode 100644 index 0000000000..8cc482444c --- /dev/null +++ b/ui/src/admin/containers/chronograf/AllUsersPage.js @@ -0,0 +1,143 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' + +import * as adminChronografActionCreators from 'src/admin/actions/chronograf' +import * as configActionCreators from 'shared/actions/config' +import {publishAutoDismissingNotification} from 'shared/dispatchers' + +import AllUsersTable from 'src/admin/components/chronograf/AllUsersTable' + +class AllUsersPage extends Component { + constructor(props) { + super(props) + + this.state = { + isLoading: true, + } + } + + componentDidMount() { + const {links, actionsConfig: {getAuthConfigAsync}} = this.props + getAuthConfigAsync(links.config.auth) + } + + handleCreateUser = user => { + const {links, actionsAdmin: {createUserAsync}} = this.props + createUserAsync(links.allUsers, user) + } + + handleUpdateUserRoles = (user, roles, successMessage) => { + const {actionsAdmin: {updateUserAsync}} = this.props + const updatedUser = {...user, roles} + updateUserAsync(user, updatedUser, successMessage) + } + + handleUpdateUserSuperAdmin = (user, superAdmin) => { + const {actionsAdmin: {updateUserAsync}} = this.props + const updatedUser = {...user, superAdmin} + updateUserAsync( + user, + updatedUser, + `${user.name}'s SuperAdmin status has been updated` + ) + } + + handleDeleteUser = user => { + const {actionsAdmin: {deleteUserAsync}} = this.props + deleteUserAsync(user, {isAbsoluteDelete: true}) + } + + async componentWillMount() { + const { + links, + actionsAdmin: {loadOrganizationsAsync, loadUsersAsync}, + } = this.props + + this.setState({isLoading: true}) + + await Promise.all([ + loadOrganizationsAsync(links.organizations), + loadUsersAsync(links.allUsers), + ]) + + this.setState({isLoading: false}) + } + + render() { + const { + organizations, + meID, + users, + authConfig, + actionsConfig, + links, + notify, + } = this.props + + return ( + + ) + } +} + +const {arrayOf, bool, func, shape, string} = PropTypes + +AllUsersPage.propTypes = { + links: shape({ + users: string.isRequired, + config: shape({ + auth: string.isRequired, + }).isRequired, + }), + meID: string.isRequired, + users: arrayOf(shape), + organizations: arrayOf(shape), + actionsAdmin: shape({ + loadUsersAsync: func.isRequired, + loadOrganizationsAsync: func.isRequired, + createUserAsync: func.isRequired, + updateUserAsync: func.isRequired, + deleteUserAsync: func.isRequired, + }), + actionsConfig: shape({ + getAuthConfigAsync: func.isRequired, + updateAuthConfigAsync: func.isRequired, + }), + authConfig: shape({ + superAdminNewUsers: bool, + }), + notify: func.isRequired, +} + +const mapStateToProps = ({ + links, + adminChronograf: {organizations, users}, + config: {auth: authConfig}, +}) => ({ + links, + organizations, + users, + authConfig, +}) + +const mapDispatchToProps = dispatch => ({ + actionsAdmin: bindActionCreators(adminChronografActionCreators, dispatch), + actionsConfig: bindActionCreators(configActionCreators, dispatch), + notify: bindActionCreators(publishAutoDismissingNotification, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(AllUsersPage) diff --git a/ui/src/admin/containers/chronograf/OrganizationsPage.js b/ui/src/admin/containers/chronograf/OrganizationsPage.js index 1324b60450..17bb253c7a 100644 --- a/ui/src/admin/containers/chronograf/OrganizationsPage.js +++ b/ui/src/admin/containers/chronograf/OrganizationsPage.js @@ -3,20 +3,14 @@ import {connect} from 'react-redux' import {bindActionCreators} from 'redux' import * as adminChronografActionCreators from 'src/admin/actions/chronograf' -import * as configActionCreators from 'shared/actions/config' import {getMeAsync} from 'shared/actions/auth' import OrganizationsTable from 'src/admin/components/chronograf/OrganizationsTable' class OrganizationsPage extends Component { componentDidMount() { - const { - links, - actionsAdmin: {loadOrganizationsAsync}, - actionsConfig: {getAuthConfigAsync}, - } = this.props + const {links, actionsAdmin: {loadOrganizationsAsync}} = this.props loadOrganizationsAsync(links.organizations) - getAuthConfigAsync(links.config.auth) } handleCreateOrganization = async organization => { @@ -42,14 +36,6 @@ class OrganizationsPage extends Component { getMe({shouldResetMe: false}) } - handleTogglePublic = organization => { - const {actionsAdmin: {updateOrganizationAsync}} = this.props - updateOrganizationAsync(organization, { - ...organization, - public: !organization.public, - }) - } - handleChooseDefaultRole = (organization, defaultRole) => { const {actionsAdmin: {updateOrganizationAsync}} = this.props updateOrganizationAsync(organization, {...organization, defaultRole}) @@ -57,44 +43,28 @@ class OrganizationsPage extends Component { this.refreshMe() } - handleUpdateAuthConfig = fieldName => updatedValue => { - const { - actionsConfig: {updateAuthConfigAsync}, - authConfig, - links, - } = this.props - const updatedAuthConfig = { - ...authConfig, - [fieldName]: updatedValue, - } - updateAuthConfigAsync(links.config.auth, authConfig, updatedAuthConfig) - } - render() { - const {meCurrentOrganization, organizations, authConfig, me} = this.props + const {meCurrentOrganization, organizations, me} = this.props const organization = organizations.find( o => o.id === meCurrentOrganization.id ) - return organizations.length - ? - :
+ return ( + + ) } } -const {arrayOf, bool, func, shape, string} = PropTypes +const {arrayOf, func, shape, string} = PropTypes OrganizationsPage.propTypes = { links: shape({ @@ -116,18 +86,11 @@ OrganizationsPage.propTypes = { updateOrganizationAsync: func.isRequired, deleteOrganizationAsync: func.isRequired, }), - actionsConfig: shape({ - getAuthConfigAsync: func.isRequired, - updateAuthConfigAsync: func.isRequired, - }), getMe: func.isRequired, meCurrentOrganization: shape({ name: string.isRequired, id: string.isRequired, }), - authConfig: shape({ - superAdminNewUsers: bool, - }), me: shape({ organizations: arrayOf( shape({ @@ -142,18 +105,15 @@ OrganizationsPage.propTypes = { const mapStateToProps = ({ links, adminChronograf: {organizations}, - config: {auth: authConfig}, auth: {me}, }) => ({ links, organizations, - authConfig, me, }) const mapDispatchToProps = dispatch => ({ actionsAdmin: bindActionCreators(adminChronografActionCreators, dispatch), - actionsConfig: bindActionCreators(configActionCreators, dispatch), getMe: bindActionCreators(getMeAsync, dispatch), }) diff --git a/ui/src/admin/containers/chronograf/UsersPage.js b/ui/src/admin/containers/chronograf/UsersPage.js index a8d21a051c..986deaddeb 100644 --- a/ui/src/admin/containers/chronograf/UsersPage.js +++ b/ui/src/admin/containers/chronograf/UsersPage.js @@ -5,7 +5,6 @@ import {bindActionCreators} from 'redux' import * as adminChronografActionCreators from 'src/admin/actions/chronograf' import {publishAutoDismissingNotification} from 'shared/dispatchers' -import EmptyUsersTable from 'src/admin/components/chronograf/EmptyUsersTable' import UsersTable from 'src/admin/components/chronograf/UsersTable' class UsersPage extends Component { @@ -28,18 +27,16 @@ class UsersPage extends Component { const newRoles = user.roles.map( r => (r.organization === currentRole.organization ? updatedRole : r) ) - updateUserAsync(user, {...user, roles: newRoles}) - } - - handleUpdateUserSuperAdmin = (user, superAdmin) => { - const {actions: {updateUserAsync}} = this.props - const updatedUser = {...user, superAdmin} - updateUserAsync(user, updatedUser) + updateUserAsync( + user, + {...user, roles: newRoles}, + `${user.name} is now a ${name}` + ) } handleDeleteUser = user => { const {actions: {deleteUserAsync}} = this.props - deleteUserAsync(user) + deleteUserAsync(user, {isAbsoluteDelete: false}) } async componentWillMount() { @@ -68,10 +65,6 @@ class UsersPage extends Component { } = this.props const {isLoading} = this.state - if (isLoading) { - return - } - const organization = organizations.find( o => o.id === meCurrentOrganization.id ) @@ -83,9 +76,9 @@ class UsersPage extends Component { organization={organization} onCreateUser={this.handleCreateUser} onUpdateUserRole={this.handleUpdateUserRole} - onUpdateUserSuperAdmin={this.handleUpdateUserSuperAdmin} onDeleteUser={this.handleDeleteUser} notify={notify} + isLoading={isLoading} /> ) } diff --git a/ui/src/admin/reducers/chronograf.js b/ui/src/admin/reducers/chronograf.js index 621f7a7a4d..01d744f4af 100644 --- a/ui/src/admin/reducers/chronograf.js +++ b/ui/src/admin/reducers/chronograf.js @@ -3,8 +3,9 @@ import {isSameUser} from 'shared/reducers/helpers/auth' const initialState = { users: [], organizations: [], + mappings: [], authConfig: { - superAdminNewUsers: true, + superAdminNewUsers: false, }, } @@ -20,7 +21,7 @@ const adminChronograf = (state = initialState, action) => { case 'CHRONOGRAF_ADD_USER': { const {user} = action.payload - return {...state, users: [user, ...state.users]} + return {...state, users: [...state.users, user]} } case 'CHRONOGRAF_UPDATE_USER': { @@ -58,7 +59,10 @@ const adminChronograf = (state = initialState, action) => { case 'CHRONOGRAF_ADD_ORGANIZATION': { const {organization} = action.payload - return {...state, organizations: [organization, ...state.organizations]} + return { + ...state, + organizations: [organization, ...state.organizations], + } } case 'CHRONOGRAF_RENAME_ORGANIZATION': { @@ -94,9 +98,57 @@ const adminChronograf = (state = initialState, action) => { ), } } + + case 'CHRONOGRAF_LOAD_MAPPINGS': { + const {mappings} = action.payload + return { + ...state, + mappings, + } + } + + case 'CHRONOGRAF_UPDATE_MAPPING': { + const {staleMapping, updatedMapping} = action.payload + return { + ...state, + mappings: state.mappings.map(m => + replaceMapping(m, staleMapping, updatedMapping) + ), + } + } + + case 'CHRONOGRAF_ADD_MAPPING': { + const {mapping} = action.payload + return { + ...state, + mappings: [...state.mappings, mapping], + } + } + + case 'CHRONOGRAF_REMOVE_MAPPING': { + const {mapping} = action.payload + return { + ...state, + mappings: state.mappings.filter( + m => + mapping._tempID + ? m._tempID !== mapping._tempID + : m.id !== mapping.id + ), + } + } } return state } +function replaceMapping(m, staleMapping, updatedMapping) { + if (staleMapping._tempID && m._tempID === staleMapping._tempID) { + return {...updatedMapping} + } else if (m.id === staleMapping.id) { + return {...updatedMapping} + } + return m +} + export default adminChronograf diff --git a/ui/src/dashboards/components/CellEditorOverlay.js b/ui/src/dashboards/components/CellEditorOverlay.js index c3bdb55a1c..5b82c428d1 100644 --- a/ui/src/dashboards/components/CellEditorOverlay.js +++ b/ui/src/dashboards/components/CellEditorOverlay.js @@ -28,9 +28,10 @@ import { DEFAULT_VALUE_MIN, DEFAULT_VALUE_MAX, GAUGE_COLORS, - SINGLE_STAT_TEXT, - SINGLE_STAT_BG, - validateColors, + validateGaugeColors, + validateSingleStatColors, + getSingleStatType, + stringifyColorValues, } from 'src/dashboards/constants/gaugeColors' class CellEditorOverlay extends Component { @@ -49,7 +50,8 @@ class CellEditorOverlay extends Component { source, })) ) - const colorsTypeContainsText = _.some(colors, {type: SINGLE_STAT_TEXT}) + + const singleStatType = getSingleStatType(colors) this.state = { cellWorkingName: name, @@ -58,8 +60,9 @@ class CellEditorOverlay extends Component { activeQueryIndex: 0, isDisplayOptionsTabActive: false, axes, - colorSingleStatText: colorsTypeContainsText, - colors: validateColors(colors, type, colorsTypeContainsText), + singleStatType, + gaugeColors: validateGaugeColors(colors), + singleStatColors: validateSingleStatColors(colors, singleStatType), } } @@ -76,27 +79,25 @@ class CellEditorOverlay extends Component { } } - handleAddThreshold = () => { - const {colors, cellWorkingType} = this.state - const sortedColors = _.sortBy(colors, color => Number(color.value)) + componentDidMount = () => { + this.overlayRef.focus() + } + + handleAddGaugeThreshold = () => { + const {gaugeColors} = this.state + const sortedColors = _.sortBy(gaugeColors, color => color.value) if (sortedColors.length <= MAX_THRESHOLDS) { const randomColor = _.random(0, GAUGE_COLORS.length - 1) - const maxValue = - cellWorkingType === 'gauge' - ? Number(sortedColors[sortedColors.length - 1].value) - : DEFAULT_VALUE_MAX - const minValue = - cellWorkingType === 'gauge' - ? Number(sortedColors[0].value) - : DEFAULT_VALUE_MIN + const maxValue = sortedColors[sortedColors.length - 1].value + const minValue = sortedColors[0].value - const colorsValues = _.mapValues(colors, 'value') + const colorsValues = _.mapValues(gaugeColors, 'value') let randomValue do { - randomValue = `${_.round(_.random(minValue, maxValue, true), 2)}` + randomValue = _.round(_.random(minValue, maxValue, true), 2) } while (_.includes(colorsValues, randomValue)) const newThreshold = { @@ -107,68 +108,134 @@ class CellEditorOverlay extends Component { name: GAUGE_COLORS[randomColor].name, } - this.setState({colors: [...colors, newThreshold]}) + this.setState({gaugeColors: [...gaugeColors, newThreshold]}) } } + handleAddSingleStatThreshold = () => { + const {singleStatColors, singleStatType} = this.state + + const randomColor = _.random(0, GAUGE_COLORS.length - 1) + + const maxValue = DEFAULT_VALUE_MIN + const minValue = DEFAULT_VALUE_MAX + + let randomValue = _.round(_.random(minValue, maxValue, true), 2) + + if (singleStatColors.length > 0) { + const colorsValues = _.mapValues(singleStatColors, 'value') + do { + randomValue = _.round(_.random(minValue, maxValue, true), 2) + } while (_.includes(colorsValues, randomValue)) + } + + const newThreshold = { + type: singleStatType, + id: uuid.v4(), + value: randomValue, + hex: GAUGE_COLORS[randomColor].hex, + name: GAUGE_COLORS[randomColor].name, + } + + this.setState({singleStatColors: [...singleStatColors, newThreshold]}) + } + handleDeleteThreshold = threshold => () => { - const {colors} = this.state + const {cellWorkingType} = this.state - const newColors = colors.filter(color => color.id !== threshold.id) + if (cellWorkingType === 'gauge') { + const gaugeColors = this.state.gaugeColors.filter( + color => color.id !== threshold.id + ) - this.setState({colors: newColors}) + this.setState({gaugeColors}) + } + + if (cellWorkingType === 'single-stat') { + const singleStatColors = this.state.singleStatColors.filter( + color => color.id !== threshold.id + ) + + this.setState({singleStatColors}) + } } handleChooseColor = threshold => chosenColor => { - const {colors} = this.state + const {cellWorkingType} = this.state - const newColors = colors.map( - color => - color.id === threshold.id - ? {...color, hex: chosenColor.hex, name: chosenColor.name} - : color - ) + if (cellWorkingType === 'gauge') { + const gaugeColors = this.state.gaugeColors.map( + color => + color.id === threshold.id + ? {...color, hex: chosenColor.hex, name: chosenColor.name} + : color + ) - this.setState({colors: newColors}) + this.setState({gaugeColors}) + } + + if (cellWorkingType === 'single-stat') { + const singleStatColors = this.state.singleStatColors.map( + color => + color.id === threshold.id + ? {...color, hex: chosenColor.hex, name: chosenColor.name} + : color + ) + + this.setState({singleStatColors}) + } } - handleUpdateColorValue = (threshold, newValue) => { - const {colors} = this.state - const newColors = colors.map( - color => (color.id === threshold.id ? {...color, value: newValue} : color) - ) - this.setState({colors: newColors}) + handleUpdateColorValue = (threshold, value) => { + const {cellWorkingType} = this.state + + if (cellWorkingType === 'gauge') { + const gaugeColors = this.state.gaugeColors.map( + color => (color.id === threshold.id ? {...color, value} : color) + ) + + this.setState({gaugeColors}) + } + + if (cellWorkingType === 'single-stat') { + const singleStatColors = this.state.singleStatColors.map( + color => (color.id === threshold.id ? {...color, value} : color) + ) + + this.setState({singleStatColors}) + } } - handleValidateColorValue = (threshold, e) => { - const {colors, cellWorkingType} = this.state - const sortedColors = _.sortBy(colors, color => Number(color.value)) - const thresholdValue = Number(threshold.value) - const targetValueNumber = Number(e.target.value) + handleValidateColorValue = (threshold, targetValue) => { + const {gaugeColors, singleStatColors, cellWorkingType} = this.state + const thresholdValue = threshold.value let allowedToUpdate = false if (cellWorkingType === 'single-stat') { // If type is single-stat then value only has to be unique - return !sortedColors.some(color => color.value === e.target.value) + const sortedColors = _.sortBy(singleStatColors, color => color.value) + return !sortedColors.some(color => color.value === targetValue) } - const minValue = Number(sortedColors[0].value) - const maxValue = Number(sortedColors[sortedColors.length - 1].value) + const sortedColors = _.sortBy(gaugeColors, color => color.value) + + const minValue = sortedColors[0].value + const maxValue = sortedColors[sortedColors.length - 1].value // If lowest value, make sure it is less than the next threshold if (thresholdValue === minValue) { - const nextValue = Number(sortedColors[1].value) - allowedToUpdate = targetValueNumber < nextValue + const nextValue = sortedColors[1].value + allowedToUpdate = targetValue < nextValue } // If highest value, make sure it is greater than the previous threshold if (thresholdValue === maxValue) { - const previousValue = Number(sortedColors[sortedColors.length - 2].value) - allowedToUpdate = previousValue < targetValueNumber + const previousValue = sortedColors[sortedColors.length - 2].value + allowedToUpdate = previousValue < targetValue } // If not min or max, make sure new value is greater than min, less than max, and unique if (thresholdValue !== minValue && thresholdValue !== maxValue) { - const greaterThanMin = targetValueNumber > minValue - const lessThanMax = targetValueNumber < maxValue + const greaterThanMin = targetValue > minValue + const lessThanMax = targetValue < maxValue const colorsWithoutMinOrMax = sortedColors.slice( 1, @@ -176,7 +243,7 @@ class CellEditorOverlay extends Component { ) const isUnique = !colorsWithoutMinOrMax.some( - color => color.value === e.target.value + color => color.value === targetValue ) allowedToUpdate = greaterThanMin && lessThanMax && isUnique @@ -185,16 +252,15 @@ class CellEditorOverlay extends Component { return allowedToUpdate } - handleToggleSingleStatText = () => { - const {colors, colorSingleStatText} = this.state - const formattedColors = colors.map(color => ({ + handleToggleSingleStatType = type => () => { + const singleStatColors = this.state.singleStatColors.map(color => ({ ...color, - type: colorSingleStatText ? SINGLE_STAT_BG : SINGLE_STAT_TEXT, + type, })) this.setState({ - colorSingleStatText: !colorSingleStatText, - colors: formattedColors, + singleStatType: type, + singleStatColors, }) } @@ -294,7 +360,8 @@ class CellEditorOverlay extends Component { cellWorkingType: type, cellWorkingName: name, axes, - colors, + gaugeColors, + singleStatColors, } = this.state const {cell} = this.props @@ -310,6 +377,13 @@ class CellEditorOverlay extends Component { } }) + let colors = [] + if (type === 'gauge') { + colors = stringifyColorValues(gaugeColors) + } else if (type === 'single-stat' || type === 'line-plus-single-stat') { + colors = stringifyColorValues(singleStatColors) + } + this.props.onSave({ ...cell, name, @@ -320,14 +394,8 @@ class CellEditorOverlay extends Component { }) } - handleSelectGraphType = graphType => () => { - const {colors, colorSingleStatText} = this.state - const validatedColors = validateColors( - colors, - graphType, - colorSingleStatText - ) - this.setState({cellWorkingType: graphType, colors: validatedColors}) + handleSelectGraphType = cellWorkingType => () => { + this.setState({cellWorkingType}) } handleClickDisplayOptionsTab = isDisplayOptionsTabActive => () => { @@ -442,6 +510,23 @@ class CellEditorOverlay extends Component { return prevQuery.source } + handleKeyDown = e => { + if (e.key === 'Enter' && e.metaKey && e.target === this.overlayRef) { + this.handleSaveCell() + } + if (e.key === 'Enter' && e.metaKey && e.target !== this.overlayRef) { + e.target.blur() + setTimeout(this.handleSaveCell, 50) + } + if (e.key === 'Escape' && e.target === this.overlayRef) { + this.props.onCancel() + } + if (e.key === 'Escape' && e.target !== this.overlayRef) { + e.target.blur() + this.overlayRef.focus() + } + } + render() { const { onCancel, @@ -453,13 +538,14 @@ class CellEditorOverlay extends Component { const { axes, - colors, + gaugeColors, + singleStatColors, activeQueryIndex, cellWorkingName, cellWorkingType, isDisplayOptionsTabActive, queriesWorkingDraft, - colorSingleStatText, + singleStatType, } = this.state const queryActions = { @@ -471,8 +557,16 @@ class CellEditorOverlay extends Component { (!!query.measurement && !!query.database && !!query.fields.length) || !!query.rawText + const visualizationColors = + cellWorkingType === 'gauge' ? gaugeColors : singleStatColors + return ( -
+
(this.overlayRef = r)} + > { const { - colors, + gaugeColors, + singleStatColors, onSetBase, onSetScale, onSetLabel, @@ -43,13 +44,14 @@ class DisplayOptions extends Component { onSetPrefixSuffix, onSetYAxisBoundMin, onSetYAxisBoundMax, - onAddThreshold, + onAddGaugeThreshold, + onAddSingleStatThreshold, onDeleteThreshold, onChooseColor, onValidateColorValue, onUpdateColorValue, - colorSingleStatText, - onToggleSingleStatText, + singleStatType, + onToggleSingleStatType, onSetSuffix, } = this.props const {axes, axes: {y: {suffix}}} = this.state @@ -58,27 +60,27 @@ class DisplayOptions extends Component { case 'gauge': return ( ) case 'single-stat': return ( ) default: @@ -111,10 +113,11 @@ class DisplayOptions extends Component { ) } } -const {arrayOf, bool, func, shape, string} = PropTypes +const {arrayOf, func, number, shape, string} = PropTypes DisplayOptions.propTypes = { - onAddThreshold: func.isRequired, + onAddGaugeThreshold: func.isRequired, + onAddSingleStatThreshold: func.isRequired, onDeleteThreshold: func.isRequired, onChooseColor: func.isRequired, onValidateColorValue: func.isRequired, @@ -129,18 +132,27 @@ DisplayOptions.propTypes = { onSetLabel: func.isRequired, onSetBase: func.isRequired, axes: shape({}).isRequired, - colors: arrayOf( + gaugeColors: arrayOf( shape({ type: string.isRequired, hex: string.isRequired, id: string.isRequired, name: string.isRequired, - value: string.isRequired, + value: number.isRequired, + }).isRequired + ), + singleStatColors: arrayOf( + shape({ + type: string.isRequired, + hex: string.isRequired, + id: string.isRequired, + name: string.isRequired, + value: number.isRequired, }).isRequired ), queryConfigs: arrayOf(shape()).isRequired, - colorSingleStatText: bool.isRequired, - onToggleSingleStatText: func.isRequired, + singleStatType: string.isRequired, + onToggleSingleStatType: func.isRequired, } export default DisplayOptions diff --git a/ui/src/dashboards/components/GaugeOptions.js b/ui/src/dashboards/components/GaugeOptions.js index 8a70c37bf3..4c42cc8e0a 100644 --- a/ui/src/dashboards/components/GaugeOptions.js +++ b/ui/src/dashboards/components/GaugeOptions.js @@ -19,7 +19,7 @@ const GaugeOptions = ({ }) => { const disableMaxColor = colors.length > MIN_THRESHOLDS const disableAddThreshold = colors.length > MAX_THRESHOLDS - const sortedColors = _.sortBy(colors, color => Number(color.value)) + const sortedColors = _.sortBy(colors, color => color.value) return ( { + const {hex, name} = color + return {hex, name} +} const SingleStatOptions = ({ suffix, onSetSuffix, @@ -15,12 +26,12 @@ const SingleStatOptions = ({ onChooseColor, onValidateColorValue, onUpdateColorValue, - colorSingleStatText, - onToggleSingleStatText, + singleStatType, + onToggleSingleStatType, }) => { const disableAddThreshold = colors.length > MAX_THRESHOLDS - const sortedColors = _.sortBy(colors, color => Number(color.value)) + const sortedColors = _.sortBy(colors, color => color.value) return ( Add Threshold - {sortedColors.map(color => - + {sortedColors.map( + color => + color.id === SINGLE_STAT_BASE + ?
+
Base Color
+ +
+ : )}
@@ -54,14 +76,18 @@ const SingleStatOptions = ({
  • Background
  • Text
  • @@ -83,7 +109,7 @@ const SingleStatOptions = ({ ) } -const {arrayOf, bool, func, shape, string} = PropTypes +const {arrayOf, func, number, shape, string} = PropTypes SingleStatOptions.defaultProps = { colors: [], @@ -96,7 +122,7 @@ SingleStatOptions.propTypes = { hex: string.isRequired, id: string.isRequired, name: string.isRequired, - value: string.isRequired, + value: number.isRequired, }).isRequired ), onAddThreshold: func.isRequired, @@ -104,8 +130,8 @@ SingleStatOptions.propTypes = { onChooseColor: func.isRequired, onValidateColorValue: func.isRequired, onUpdateColorValue: func.isRequired, - colorSingleStatText: bool.isRequired, - onToggleSingleStatText: func.isRequired, + singleStatType: string.isRequired, + onToggleSingleStatType: func.isRequired, onSetSuffix: func.isRequired, suffix: string.isRequired, } diff --git a/ui/src/dashboards/components/Threshold.js b/ui/src/dashboards/components/Threshold.js index d01d76c4c5..df274f6da0 100644 --- a/ui/src/dashboards/components/Threshold.js +++ b/ui/src/dashboards/components/Threshold.js @@ -16,14 +16,15 @@ class Threshold extends Component { handleChangeWorkingValue = e => { const {threshold, onValidateColorValue, onUpdateColorValue} = this.props + const targetValue = Number(e.target.value) - const valid = onValidateColorValue(threshold, e) + const valid = onValidateColorValue(threshold, targetValue) if (valid) { - onUpdateColorValue(threshold, e.target.value) + onUpdateColorValue(threshold, targetValue) } - this.setState({valid, workingValue: e.target.value}) + this.setState({valid, workingValue: targetValue}) } handleBlur = () => { @@ -98,7 +99,7 @@ class Threshold extends Component { } } -const {bool, func, shape, string} = PropTypes +const {bool, func, number, shape, string} = PropTypes Threshold.propTypes = { visualizationType: string.isRequired, @@ -107,7 +108,7 @@ Threshold.propTypes = { hex: string.isRequired, id: string.isRequired, name: string.isRequired, - value: string.isRequired, + value: number.isRequired, }).isRequired, disableMaxColor: bool, onChooseColor: func.isRequired, diff --git a/ui/src/dashboards/components/Visualization.js b/ui/src/dashboards/components/Visualization.js index 2d6764d708..51d345a216 100644 --- a/ui/src/dashboards/components/Visualization.js +++ b/ui/src/dashboards/components/Visualization.js @@ -3,6 +3,8 @@ import RefreshingGraph from 'shared/components/RefreshingGraph' import buildQueries from 'utils/buildQueriesForGraphs' import VisualizationName from 'src/dashboards/components/VisualizationName' +import {stringifyColorValues} from 'src/dashboards/constants/gaugeColors' + const DashVisualization = ( { axes, @@ -23,7 +25,7 @@ const DashVisualization = (
    { - if (type === 'single-stat') { - // Single stat colors should all have type of 'text' or 'background' - const colorType = colorSingleStatText ? SINGLE_STAT_TEXT : SINGLE_STAT_BG - return colors ? colors.map(color => ({...color, type: colorType})) : null - } +export const DEFAULT_SINGLESTAT_COLORS = [ + { + type: SINGLE_STAT_TEXT, + hex: GAUGE_COLORS[11].hex, + id: SINGLE_STAT_BASE, + name: GAUGE_COLORS[11].name, + value: 0, + }, +] + +export const validateSingleStatColors = (colors, type) => { if (!colors || colors.length === 0) { - return DEFAULT_COLORS - } - if (type === 'gauge') { - // Gauge colors should have a type of min, any number of thresholds, and a max - const formatttedColors = _.sortBy(colors, color => - Number(color.value) - ).map(c => ({ - ...c, - type: COLOR_TYPE_THRESHOLD, - })) - formatttedColors[0].type = COLOR_TYPE_MIN - formatttedColors[formatttedColors.length - 1].type = COLOR_TYPE_MAX - return formatttedColors + return DEFAULT_SINGLESTAT_COLORS } - return colors.length >= MIN_THRESHOLDS ? colors : DEFAULT_COLORS + let containsBaseColor = false + + const formattedColors = colors.map(color => { + if (color.id === SINGLE_STAT_BASE) { + // Check for existance of base color + containsBaseColor = true + return {...color, value: Number(color.value), type} + } + // Single stat colors should all have type of 'text' or 'background' + return {...color, value: Number(color.value), type} + }) + + const formattedColorsWithBase = [ + ...formattedColors, + DEFAULT_SINGLESTAT_COLORS[0], + ] + + return containsBaseColor ? formattedColors : formattedColorsWithBase +} + +export const getSingleStatType = colors => { + const type = _.get(colors, ['0', 'type'], false) + + if (type) { + if (_.includes([SINGLE_STAT_TEXT, SINGLE_STAT_BG], type)) { + return type + } + } + + return SINGLE_STAT_TEXT +} + +export const validateGaugeColors = colors => { + if (!colors || colors.length < MIN_THRESHOLDS) { + return DEFAULT_GAUGE_COLORS + } + + // Gauge colors should have a type of min, any number of thresholds, and a max + const formattedColors = _.sortBy(colors, color => + Number(color.value) + ).map(color => ({ + ...color, + value: Number(color.value), + type: COLOR_TYPE_THRESHOLD, + })) + + formattedColors[0].type = COLOR_TYPE_MIN + formattedColors[formattedColors.length - 1].type = COLOR_TYPE_MAX + + return formattedColors +} + +export const stringifyColorValues = colors => { + return colors.map(color => ({...color, value: `${color.value}`})) } diff --git a/ui/src/data_explorer/components/VisHeader.js b/ui/src/data_explorer/components/VisHeader.js index 2d3325456e..478f0a38b1 100644 --- a/ui/src/data_explorer/components/VisHeader.js +++ b/ui/src/data_explorer/components/VisHeader.js @@ -5,10 +5,15 @@ import _ from 'lodash' import {fetchTimeSeriesAsync} from 'shared/actions/timeSeries' import {resultsToCSV} from 'src/shared/parsing/resultsToCSV.js' import download from 'src/external/download.js' +import {TEMPLATES} from 'src/data_explorer/constants' const getCSV = (query, errorThrown) => async () => { try { - const {results} = await fetchTimeSeriesAsync({source: query.host, query}) + const {results} = await fetchTimeSeriesAsync({ + source: query.host, + query, + tempVars: TEMPLATES, + }) const {flag, name, CSVString} = resultsToCSV(results) if (flag === 'no_data') { errorThrown('no data', 'There are no data to download.') diff --git a/ui/src/hosts/containers/HostsPage.js b/ui/src/hosts/containers/HostsPage.js index a58c446803..e76201d00c 100644 --- a/ui/src/hosts/containers/HostsPage.js +++ b/ui/src/hosts/containers/HostsPage.js @@ -1,12 +1,16 @@ import React, {PropTypes, Component} from 'react' import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' import _ from 'lodash' import HostsTable from 'src/hosts/components/HostsTable' import SourceIndicator from 'shared/components/SourceIndicator' +import AutoRefreshDropdown from 'shared/components/AutoRefreshDropdown' +import ManualRefresh from 'src/shared/components/ManualRefresh' import {getCpuAndLoadForHosts, getLayouts, getAppsForHosts} from '../apis' import {getEnv} from 'src/shared/apis/env' +import {setAutoRefresh} from 'shared/actions/app' class HostsPage extends Component { constructor(props) { @@ -19,59 +23,26 @@ class HostsPage extends Component { } } - async componentDidMount() { + async fetchHostsData() { const {source, links, addFlashMessage} = this.props - const {telegrafSystemInterval} = await getEnv(links.environment) - - const hostsError = 'Unable to get apps for hosts' - let hosts, layouts - - try { - const [h, {data}] = await Promise.all([ - getCpuAndLoadForHosts( - source.links.proxy, - source.telegraf, - telegrafSystemInterval - ), - getLayouts(), - new Promise(resolve => { - this.setState({hostsLoading: true}) - resolve() - }), - ]) - - hosts = h - layouts = data.layouts - - this.setState({ - hosts, - hostsLoading: false, - }) - } catch (error) { - this.setState({ - hostsError: error.toString(), - hostsLoading: false, - }) - - console.error(error) - } - - if (!hosts || !layouts) { - addFlashMessage({type: 'error', text: hostsError}) - return this.setState({ - hostsError, - hostsLoading: false, - }) - } - + const hostsError = 'Unable to get hosts' try { + const hosts = await getCpuAndLoadForHosts( + source.links.proxy, + source.telegraf, + telegrafSystemInterval + ) + if (!hosts) { + throw new Error(hostsError) + } const newHosts = await getAppsForHosts( source.links.proxy, hosts, - layouts, + this.layouts, source.telegraf ) + this.setState({ hosts: newHosts, hostsError: '', @@ -87,8 +58,50 @@ class HostsPage extends Component { } } + async componentDidMount() { + const {addFlashMessage, autoRefresh} = this.props + + this.setState({hostsLoading: true}) // Only print this once + const {data} = await getLayouts() + this.layouts = data.layouts + if (!this.layouts) { + const layoutError = 'Unable to get apps for hosts' + addFlashMessage({type: 'error', text: layoutError}) + this.setState({ + hostsError: layoutError, + hostsLoading: false, + }) + return + } + await this.fetchHostsData() + if (autoRefresh) { + this.intervalID = setInterval(() => this.fetchHostsData(), autoRefresh) + } + } + + componentWillReceiveProps(nextProps) { + if (this.props.manualRefresh !== nextProps.manualRefresh) { + this.fetchHostsData() + } + if (this.props.autoRefresh !== nextProps.autoRefresh) { + clearInterval(this.intervalID) + + if (nextProps.autoRefresh) { + this.intervalID = setInterval( + () => this.fetchHostsData(), + nextProps.autoRefresh + ) + } + } + } + render() { - const {source} = this.props + const { + source, + autoRefresh, + onChooseAutoRefresh, + onManualRefresh, + } = this.props const {hosts, hostsLoading, hostsError} = this.state return (
    @@ -99,6 +112,12 @@ class HostsPage extends Component {
    +
@@ -119,13 +138,20 @@ class HostsPage extends Component {
) } + + componentWillUnmount() { + clearInterval(this.intervalID) + this.intervalID = false + } } -const {func, shape, string} = PropTypes +const {func, shape, string, number} = PropTypes -const mapStateToProps = ({links}) => { +const mapStateToProps = state => { + const {app: {persisted: {autoRefresh}}, links} = state return { links, + autoRefresh, } } @@ -143,6 +169,20 @@ HostsPage.propTypes = { environment: string.isRequired, }), addFlashMessage: func, + autoRefresh: number.isRequired, + manualRefresh: number, + onChooseAutoRefresh: func.isRequired, + onManualRefresh: func.isRequired, } -export default connect(mapStateToProps, null)(HostsPage) +HostsPage.defaultProps = { + manualRefresh: 0, +} + +const mapDispatchToProps = dispatch => ({ + onChooseAutoRefresh: bindActionCreators(setAutoRefresh, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)( + ManualRefresh(HostsPage) +) diff --git a/ui/src/index.js b/ui/src/index.js index 287d24a73d..bd5160a30d 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -35,6 +35,7 @@ import {AdminChronografPage, AdminInfluxDBPage} from 'src/admin' import {SourcePage, ManageSources} from 'src/sources' import NotFound from 'shared/components/NotFound' +import {getLinksAsync} from 'shared/actions/links' import {getMeAsync} from 'shared/actions/auth' import {disablePresentationMode} from 'shared/actions/app' @@ -73,11 +74,27 @@ window.addEventListener('keyup', event => { const history = syncHistoryWithStore(browserHistory, store) const Root = React.createClass({ - componentWillMount() { - this.flushErrorsQueue() - this.checkAuth() + getInitialState() { + return { + ready: false, + } }, + async componentWillMount() { + this.flushErrorsQueue() + + try { + await this.getLinks() + await this.checkAuth() + this.setState({ready: true}) + } catch (error) { + dispatch(errorThrown(error)) + } + }, + + getLinks: bindActionCreators(getLinksAsync, dispatch), + getMe: bindActionCreators(getMeAsync, dispatch), + async checkAuth() { try { await this.performHeartbeat({shouldResetMe: true}) @@ -86,8 +103,6 @@ const Root = React.createClass({ } }, - getMe: bindActionCreators(getMeAsync, dispatch), - async performHeartbeat({shouldResetMe = false} = {}) { await this.getMe({shouldResetMe}) @@ -107,44 +122,66 @@ const Root = React.createClass({ }, render() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - + return !this.state.ready // eslint-disable-line no-negated-condition + ?
+ : + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - ) + + + }, }) diff --git a/ui/src/kapacitor/actions/view/index.js b/ui/src/kapacitor/actions/view/index.js index bba7b407f1..bbeef0d8cf 100644 --- a/ui/src/kapacitor/actions/view/index.js +++ b/ui/src/kapacitor/actions/view/index.js @@ -195,16 +195,10 @@ export const updateRuleStatus = (rule, status) => dispatch => { }) } -export const createTask = ( - kapacitor, - task, - router, - sourceID -) => async dispatch => { +export const createTask = (kapacitor, task) => async dispatch => { try { const {data} = await createTaskAJAX(kapacitor, task) - router.push(`/sources/${sourceID}/alert-rules`) - dispatch(publishNotification('success', 'You made a TICKscript!')) + dispatch(publishNotification('success', 'TICKscript successfully created')) return data } catch (error) { if (!error) { @@ -220,20 +214,17 @@ export const updateTask = ( kapacitor, task, ruleID, - router, sourceID ) => async dispatch => { try { const {data} = await updateTaskAJAX(kapacitor, task, ruleID, sourceID) - router.push(`/sources/${sourceID}/alert-rules`) - dispatch(publishNotification('success', 'TICKscript updated successully')) + dispatch(publishNotification('success', 'TICKscript saved')) return data } catch (error) { if (!error) { dispatch(errorThrown('Could not communicate with server')) return } - return error.data } } diff --git a/ui/src/kapacitor/components/AlertTabs.js b/ui/src/kapacitor/components/AlertTabs.js index 726eae8285..6aac73d014 100644 --- a/ui/src/kapacitor/components/AlertTabs.js +++ b/ui/src/kapacitor/components/AlertTabs.js @@ -27,7 +27,6 @@ class AlertTabs extends Component { super(props) this.state = { - selectedHandler: 'smtp', configSections: null, } } @@ -42,18 +41,17 @@ class AlertTabs extends Component { } } - refreshKapacitorConfig = kapacitor => { - getKapacitorConfig(kapacitor) - .then(({data: {sections}}) => { - this.setState({configSections: sections}) - }) - .catch(() => { - this.setState({configSections: null}) - this.props.addFlashMessage({ - type: 'error', - text: 'There was an error getting the Kapacitor config', - }) + refreshKapacitorConfig = async kapacitor => { + try { + const {data: {sections}} = await getKapacitorConfig(kapacitor) + this.setState({configSections: sections}) + } catch (error) { + this.setState({configSections: null}) + this.props.addFlashMessage({ + type: 'error', + text: 'There was an error getting the Kapacitor config', }) + } } getSection = (sections, section) => { @@ -72,23 +70,29 @@ class AlertTabs extends Component { return this.getSection(sections, section) } - handleSaveConfig = section => properties => { + handleSaveConfig = section => async properties => { if (section !== '') { const propsToSend = this.sanitizeProperties(section, properties) - updateKapacitorConfigSection(this.props.kapacitor, section, propsToSend) - .then(() => { - this.refreshKapacitorConfig(this.props.kapacitor) - this.props.addFlashMessage({ - type: 'success', - text: `Alert configuration for ${section} successfully saved.`, - }) + try { + await updateKapacitorConfigSection( + this.props.kapacitor, + section, + propsToSend + ) + this.refreshKapacitorConfig(this.props.kapacitor) + this.props.addFlashMessage({ + type: 'success', + text: `Alert configuration for ${section} successfully saved.`, }) - .catch(() => { - this.props.addFlashMessage({ - type: 'error', - text: `There was an error saving the alert configuration for ${section}.`, - }) + return true + } catch ({data: {error}}) { + const errorMsg = _.join(_.drop(_.split(error, ': '), 2), ': ') + this.props.addFlashMessage({ + type: 'error', + text: `There was an error saving the alert configuration for ${section}: ${errorMsg}`, }) + return false + } } } @@ -96,11 +100,18 @@ class AlertTabs extends Component { e.preventDefault() try { - await testAlertOutput(this.props.kapacitor, section) - this.props.addFlashMessage({ - type: 'success', - text: `Successfully triggered an alert to ${section}. If the alert does not reach its destination, please check your configuration settings.`, - }) + const {data} = await testAlertOutput(this.props.kapacitor, section) + if (data.success) { + this.props.addFlashMessage({ + type: 'success', + text: `Successfully triggered an alert to ${section}. If the alert does not reach its destination, please check your configuration settings.`, + }) + } else { + this.props.addFlashMessage({ + type: 'error', + text: `There was an error sending an alert to ${section}: ${data.message}`, + }) + } } catch (error) { this.props.addFlashMessage({ type: 'error', @@ -123,8 +134,14 @@ class AlertTabs extends Component { return cleanProps } + getInitialIndex = (supportedConfigs, hash) => { + const index = _.indexOf(_.keys(supportedConfigs), _.replace(hash, '#', '')) + return index >= 0 ? index : 0 + } + render() { const {configSections} = this.state + const {hash} = this.props if (!configSections) { return null @@ -252,7 +269,6 @@ class AlertTabs extends Component { />, }, } - return (
@@ -261,7 +277,10 @@ class AlertTabs extends Component {
- + {_.reduce( configSections, @@ -312,6 +331,7 @@ AlertTabs.propTypes = { }).isRequired, }), addFlashMessage: func.isRequired, + hash: string.isRequired, } export default AlertTabs diff --git a/ui/src/kapacitor/components/HandlerOptions.js b/ui/src/kapacitor/components/HandlerOptions.js index 9a8861326b..5b2c6fdaea 100644 --- a/ui/src/kapacitor/components/HandlerOptions.js +++ b/ui/src/kapacitor/components/HandlerOptions.js @@ -65,7 +65,7 @@ class HandlerOptions extends Component { ) @@ -85,7 +85,7 @@ class HandlerOptions extends Component { ) @@ -94,7 +94,7 @@ class HandlerOptions extends Component { ) @@ -103,7 +103,7 @@ class HandlerOptions extends Component { ) @@ -112,7 +112,7 @@ class HandlerOptions extends Component { ) @@ -121,7 +121,7 @@ class HandlerOptions extends Component { ) @@ -130,7 +130,7 @@ class HandlerOptions extends Component { ) @@ -139,7 +139,7 @@ class HandlerOptions extends Component { ) @@ -148,7 +148,7 @@ class HandlerOptions extends Component { ) @@ -157,7 +157,7 @@ class HandlerOptions extends Component { ) diff --git a/ui/src/kapacitor/components/KapacitorForm.js b/ui/src/kapacitor/components/KapacitorForm.js index 9395994120..c98326a4c7 100644 --- a/ui/src/kapacitor/components/KapacitorForm.js +++ b/ui/src/kapacitor/components/KapacitorForm.js @@ -109,7 +109,7 @@ class KapacitorForm extends Component { // TODO: move these to another page. they dont belong on this page renderAlertOutputs() { - const {exists, kapacitor, addFlashMessage, source} = this.props + const {exists, kapacitor, addFlashMessage, source, hash} = this.props if (exists) { return ( @@ -117,6 +117,7 @@ class KapacitorForm extends Component { source={source} kapacitor={kapacitor} addFlashMessage={addFlashMessage} + hash={hash} /> ) } @@ -154,6 +155,7 @@ KapacitorForm.propTypes = { source: shape({}).isRequired, addFlashMessage: func.isRequired, exists: bool.isRequired, + hash: string.isRequired, } export default KapacitorForm diff --git a/ui/src/kapacitor/components/KapacitorRule.js b/ui/src/kapacitor/components/KapacitorRule.js index bde83ec222..af6d48d9fb 100644 --- a/ui/src/kapacitor/components/KapacitorRule.js +++ b/ui/src/kapacitor/components/KapacitorRule.js @@ -25,7 +25,7 @@ class KapacitorRule extends Component { this.setState({timeRange}) } - handleCreate = link => { + handleCreate = pathname => { const { addFlashMessage, queryConfigs, @@ -42,7 +42,7 @@ class KapacitorRule extends Component { createRule(kapacitor, newRule) .then(() => { - router.push(link || `/sources/${source.id}/alert-rules`) + router.push(pathname || `/sources/${source.id}/alert-rules`) addFlashMessage({type: 'success', text: 'Rule successfully created'}) }) .catch(() => { @@ -53,7 +53,7 @@ class KapacitorRule extends Component { }) } - handleEdit = link => { + handleEdit = pathname => { const {addFlashMessage, queryConfigs, rule, router, source} = this.props const updatedRule = Object.assign({}, rule, { query: queryConfigs[rule.queryID], @@ -61,7 +61,7 @@ class KapacitorRule extends Component { editRule(updatedRule) .then(() => { - router.push(link || `/sources/${source.id}/alert-rules`) + router.push(pathname || `/sources/${source.id}/alert-rules`) addFlashMessage({ type: 'success', text: `${rule.name} successfully saved!`, @@ -75,14 +75,28 @@ class KapacitorRule extends Component { }) } - handleSaveToConfig = () => { - const {rule, configLink, router} = this.props - if (this.validationError()) { - router.push(configLink) - } else if (rule.id === DEFAULT_RULE_ID) { - this.handleCreate(configLink) + handleSave = () => { + const {rule} = this.props + if (rule.id === DEFAULT_RULE_ID) { + this.handleCreate() } else { - this.handleEdit(configLink) + this.handleEdit() + } + } + + handleSaveToConfig = configName => () => { + const {rule, configLink, router} = this.props + const pathname = `${configLink}#${configName}` + if (this.validationError()) { + router.push({ + pathname, + }) + return + } + if (rule.id === DEFAULT_RULE_ID) { + this.handleCreate(pathname) + } else { + this.handleEdit(pathname) } } @@ -157,13 +171,12 @@ class KapacitorRule extends Component { } = this.props const {chooseTrigger, updateRuleValues} = ruleActions const {timeRange} = this.state + return (
diff --git a/ui/src/kapacitor/components/LogItemHTTPError.js b/ui/src/kapacitor/components/LogItemHTTPError.js index fa0e79694f..c2f1d74078 100644 --- a/ui/src/kapacitor/components/LogItemHTTPError.js +++ b/ui/src/kapacitor/components/LogItemHTTPError.js @@ -10,7 +10,7 @@ const LogItemHTTPError = ({logItem}) =>
HTTP Server
-
+
ERROR: {logItem.msg}
diff --git a/ui/src/kapacitor/components/LogItemInfluxDBDebug.js b/ui/src/kapacitor/components/LogItemInfluxDBDebug.js index b6be11705d..bbbd292df1 100644 --- a/ui/src/kapacitor/components/LogItemInfluxDBDebug.js +++ b/ui/src/kapacitor/components/LogItemInfluxDBDebug.js @@ -10,7 +10,7 @@ const LogItemInfluxDBDebug = ({logItem}) =>
InfluxDB
-
+
DEBUG: {logItem.msg}
diff --git a/ui/src/kapacitor/components/LogItemKapacitorDebug.js b/ui/src/kapacitor/components/LogItemKapacitorDebug.js index 9b99d51290..3f694dd4eb 100644 --- a/ui/src/kapacitor/components/LogItemKapacitorDebug.js +++ b/ui/src/kapacitor/components/LogItemKapacitorDebug.js @@ -10,7 +10,7 @@ const LogItemKapacitorDebug = ({logItem}) =>
Kapacitor
-
+
DEBUG: {logItem.msg}
diff --git a/ui/src/kapacitor/components/LogItemKapacitorError.js b/ui/src/kapacitor/components/LogItemKapacitorError.js index 1d4ca573db..83fa42b81f 100644 --- a/ui/src/kapacitor/components/LogItemKapacitorError.js +++ b/ui/src/kapacitor/components/LogItemKapacitorError.js @@ -10,7 +10,7 @@ const LogItemKapacitorError = ({logItem}) =>
Kapacitor
-
+
ERROR: {logItem.msg}
diff --git a/ui/src/kapacitor/components/LogItemKapacitorPoint.js b/ui/src/kapacitor/components/LogItemKapacitorPoint.js index 6a7639330c..898f656bb4 100644 --- a/ui/src/kapacitor/components/LogItemKapacitorPoint.js +++ b/ui/src/kapacitor/components/LogItemKapacitorPoint.js @@ -1,18 +1,26 @@ import React, {PropTypes} from 'react' -const renderKeysAndValues = object => { +const renderKeysAndValues = (object, name) => { if (!object) { return -- } - const objKeys = Object.keys(object) - const objValues = Object.values(object) - const objElements = objKeys.map((objKey, i) => -
- {objKey}: {objValues[i]} + const sortedObjKeys = Object.keys(object).sort() + + return ( +
+

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

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

Logs

-
-
- {logs.length - ? - - )} - /> - :
} + {`${numLogsToRender} Most Recent Logs`}
+ + {logs + .slice(0, numLogsToRender) + .map(log => )} +
const {arrayOf, shape, string} = PropTypes diff --git a/ui/src/kapacitor/components/LogsTableRow.js b/ui/src/kapacitor/components/LogsTableRow.js index 32b658c67f..83c12bcec0 100644 --- a/ui/src/kapacitor/components/LogsTableRow.js +++ b/ui/src/kapacitor/components/LogsTableRow.js @@ -8,31 +8,31 @@ import LogItemKapacitorError from 'src/kapacitor/components/LogItemKapacitorErro import LogItemKapacitorDebug from 'src/kapacitor/components/LogItemKapacitorDebug' import LogItemInfluxDBDebug from 'src/kapacitor/components/LogItemInfluxDBDebug' -const LogsTableRow = ({logItem, index}) => { +const LogsTableRow = ({logItem}) => { if (logItem.service === 'sessions') { - return + return } if (logItem.service === 'http' && logItem.msg === 'http request') { - return + return } if (logItem.service === 'kapacitor' && logItem.msg === 'point') { - return + return } if (logItem.service === 'httpd_server_errors' && logItem.lvl === 'error') { - return + return } if (logItem.service === 'kapacitor' && logItem.lvl === 'error') { - return + return } if (logItem.service === 'kapacitor' && logItem.lvl === 'debug') { - return + return } if (logItem.service === 'influxdb' && logItem.lvl === 'debug') { - return + return } return ( -
+
@@ -43,7 +43,7 @@ const LogsTableRow = ({logItem, index}) => {
{logItem.service || '--'}
-
+
{logItem.msg || '--'}
@@ -53,7 +53,7 @@ const LogsTableRow = ({logItem, index}) => { ) } -const {number, shape, string} = PropTypes +const {shape, string} = PropTypes LogsTableRow.propTypes = { logItem: shape({ @@ -62,7 +62,6 @@ LogsTableRow.propTypes = { lvl: string.isRequired, msg: string.isRequired, }).isRequired, - index: number, } export default LogsTableRow diff --git a/ui/src/kapacitor/components/LogsToggle.js b/ui/src/kapacitor/components/LogsToggle.js index eedddafbfd..9820b621b4 100644 --- a/ui/src/kapacitor/components/LogsToggle.js +++ b/ui/src/kapacitor/components/LogsToggle.js @@ -1,16 +1,16 @@ import React, {PropTypes} from 'react' -const LogsToggle = ({areLogsVisible, onToggleLogsVisbility}) => +const LogsToggle = ({areLogsVisible, onToggleLogsVisibility}) =>
  • Editor
  • Editor + Logs
  • @@ -20,7 +20,7 @@ const {bool, func} = PropTypes LogsToggle.propTypes = { areLogsVisible: bool, - onToggleLogsVisbility: func.isRequired, + onToggleLogsVisibility: func.isRequired, } export default LogsToggle diff --git a/ui/src/kapacitor/components/Tickscript.js b/ui/src/kapacitor/components/Tickscript.js index 2a54dd093a..65320d0561 100644 --- a/ui/src/kapacitor/components/Tickscript.js +++ b/ui/src/kapacitor/components/Tickscript.js @@ -8,29 +8,36 @@ import LogsTable from 'src/kapacitor/components/LogsTable' const Tickscript = ({ onSave, + onExit, task, logs, - validation, + consoleMessage, onSelectDbrps, onChangeScript, onChangeType, onChangeID, + unsavedChanges, isNewTickscript, areLogsVisible, areLogsEnabled, - onToggleLogsVisbility, + onToggleLogsVisibility, }) =>
    -
    +
    - +
    {areLogsVisible ? : null}
    @@ -53,12 +63,13 @@ const {arrayOf, bool, func, shape, string} = PropTypes Tickscript.propTypes = { logs: arrayOf(shape()).isRequired, onSave: func.isRequired, + onExit: func.isRequired, source: shape({ id: string, }), areLogsVisible: bool, areLogsEnabled: bool, - onToggleLogsVisbility: func.isRequired, + onToggleLogsVisibility: func.isRequired, task: shape({ id: string, script: string, @@ -66,10 +77,11 @@ Tickscript.propTypes = { }).isRequired, onChangeScript: func.isRequired, onSelectDbrps: func.isRequired, - validation: string, + consoleMessage: string, onChangeType: func.isRequired, onChangeID: func.isRequired, isNewTickscript: bool.isRequired, + unsavedChanges: bool, } export default Tickscript diff --git a/ui/src/kapacitor/components/TickscriptEditorConsole.js b/ui/src/kapacitor/components/TickscriptEditorConsole.js index 5b9ccf5fd5..9940bf92ce 100644 --- a/ui/src/kapacitor/components/TickscriptEditorConsole.js +++ b/ui/src/kapacitor/components/TickscriptEditorConsole.js @@ -1,22 +1,31 @@ import React, {PropTypes} from 'react' -const TickscriptEditorConsole = ({validation}) => -
    -
    - {validation - ?

    - {validation} -

    - :

    - Save your TICKscript to validate it -

    } -
    -
    +const TickscriptEditorConsole = ({consoleMessage, unsavedChanges}) => { + let consoleOutput = 'TICKscript is valid' + let consoleClass = 'tickscript-console--valid' -const {string} = PropTypes + if (consoleMessage) { + consoleOutput = consoleMessage + consoleClass = 'tickscript-console--error' + } else if (unsavedChanges) { + consoleOutput = 'You have unsaved changes, save to validate TICKscript' + consoleClass = 'tickscript-console--default' + } + + return ( +
    +

    + {consoleOutput} +

    +
    + ) +} + +const {bool, string} = PropTypes TickscriptEditorConsole.propTypes = { - validation: string, + consoleMessage: string, + unsavedChanges: bool, } export default TickscriptEditorConsole diff --git a/ui/src/kapacitor/components/TickscriptHeader.js b/ui/src/kapacitor/components/TickscriptHeader.js index c830887aeb..babe033d8c 100644 --- a/ui/src/kapacitor/components/TickscriptHeader.js +++ b/ui/src/kapacitor/components/TickscriptHeader.js @@ -2,14 +2,17 @@ import React, {PropTypes} from 'react' import SourceIndicator from 'shared/components/SourceIndicator' import LogsToggle from 'src/kapacitor/components/LogsToggle' +import ConfirmButton from 'src/shared/components/ConfirmButton' const TickscriptHeader = ({ task: {id}, onSave, + onExit, + unsavedChanges, areLogsVisible, areLogsEnabled, isNewTickscript, - onToggleLogsVisbility, + onToggleLogsVisibility, }) =>
    @@ -20,18 +23,40 @@ const TickscriptHeader = ({ }
    - + {isNewTickscript + ? + : } + {unsavedChanges + ? + : }
    @@ -41,9 +66,10 @@ const {arrayOf, bool, func, shape, string} = PropTypes TickscriptHeader.propTypes = { isNewTickscript: bool, onSave: func, + onExit: func.isRequired, areLogsVisible: bool, areLogsEnabled: bool, - onToggleLogsVisbility: func.isRequired, + onToggleLogsVisibility: func.isRequired, task: shape({ dbrps: arrayOf( shape({ @@ -52,6 +78,7 @@ TickscriptHeader.propTypes = { }) ), }), + unsavedChanges: bool, } export default TickscriptHeader diff --git a/ui/src/kapacitor/components/config/AlertaConfig.js b/ui/src/kapacitor/components/config/AlertaConfig.js index 33eccff321..492bc1d13d 100644 --- a/ui/src/kapacitor/components/config/AlertaConfig.js +++ b/ui/src/kapacitor/components/config/AlertaConfig.js @@ -10,7 +10,7 @@ class AlertaConfig extends Component { } } - handleSubmit = e => { + handleSubmit = async e => { e.preventDefault() const properties = { @@ -20,8 +20,10 @@ class AlertaConfig extends Component { url: this.url.value, } - this.props.onSave(properties) - this.setState({testEnabled: true}) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } } disableTest = () => { diff --git a/ui/src/kapacitor/components/config/HipChatConfig.js b/ui/src/kapacitor/components/config/HipChatConfig.js index e5cc205ad3..a8ebe9e549 100644 --- a/ui/src/kapacitor/components/config/HipChatConfig.js +++ b/ui/src/kapacitor/components/config/HipChatConfig.js @@ -12,7 +12,7 @@ class HipchatConfig extends Component { } } - handleSubmit = e => { + handleSubmit = async e => { e.preventDefault() const properties = { @@ -21,8 +21,10 @@ class HipchatConfig extends Component { token: this.token.value, } - this.props.onSave(properties) - this.setState({testEnabled: true}) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } } disableTest = () => { diff --git a/ui/src/kapacitor/components/config/OpsGenieConfig.js b/ui/src/kapacitor/components/config/OpsGenieConfig.js index 70436afc0f..db9e00c581 100644 --- a/ui/src/kapacitor/components/config/OpsGenieConfig.js +++ b/ui/src/kapacitor/components/config/OpsGenieConfig.js @@ -1,7 +1,7 @@ import React, {PropTypes, Component} from 'react' -import _ from 'lodash' import RedactedInput from './RedactedInput' +import TagInput from 'shared/components/TagInput' class OpsGenieConfig extends Component { constructor(props) { @@ -16,7 +16,7 @@ class OpsGenieConfig extends Component { } } - handleSubmit = e => { + handleSubmit = async e => { e.preventDefault() const properties = { @@ -25,8 +25,10 @@ class OpsGenieConfig extends Component { recipients: this.state.currentRecipients, } - this.props.onSave(properties) - this.setState({testEnabled: true}) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } } disableTest = () => { @@ -43,13 +45,13 @@ class OpsGenieConfig extends Component { }) } - handleDeleteTeam = team => () => { + handleDeleteTeam = team => { this.setState({ currentTeams: this.state.currentTeams.filter(t => t !== team), }) } - handleDeleteRecipient = recipient => () => { + handleDeleteRecipient = recipient => { this.setState({ currentRecipients: this.state.currentRecipients.filter( r => r !== recipient @@ -114,7 +116,7 @@ class OpsGenieConfig extends Component { } } -const {array, arrayOf, bool, func, shape, string} = PropTypes +const {array, bool, func, shape} = PropTypes OpsGenieConfig.propTypes = { config: shape({ @@ -129,84 +131,4 @@ OpsGenieConfig.propTypes = { enabled: bool.isRequired, } -class TagInput extends Component { - constructor(props) { - super(props) - } - - handleAddTag = e => { - if (e.key === 'Enter') { - e.preventDefault() - const newItem = e.target.value.trim() - const {tags, onAddTag} = this.props - if (!this.shouldAddToList(newItem, tags)) { - return - } - - this.input.value = '' - onAddTag(newItem) - this.props.disableTest() - } - } - - shouldAddToList(item, tags) { - return !_.isEmpty(item) && !tags.find(l => l === item) - } - - render() { - const {title, tags, onDeleteTag} = this.props - - return ( -
    - - (this.input = r)} - onKeyDown={this.handleAddTag} - /> - -
    - ) - } -} - -TagInput.propTypes = { - onAddTag: func.isRequired, - onDeleteTag: func.isRequired, - tags: arrayOf(string).isRequired, - title: string.isRequired, - disableTest: func.isRequired, -} - -const Tags = ({tags, onDeleteTag}) => -
    - {tags.map(item => { - return - })} -
    - -Tags.propTypes = { - tags: arrayOf(string), - onDeleteTag: func, -} - -const Tag = ({item, onDelete}) => - - - {item} - - - - -Tag.propTypes = { - item: string, - onDelete: func, -} - export default OpsGenieConfig diff --git a/ui/src/kapacitor/components/config/PagerDutyConfig.js b/ui/src/kapacitor/components/config/PagerDutyConfig.js index 2a15826ce3..ecf79dbd9c 100644 --- a/ui/src/kapacitor/components/config/PagerDutyConfig.js +++ b/ui/src/kapacitor/components/config/PagerDutyConfig.js @@ -9,7 +9,7 @@ class PagerDutyConfig extends Component { } } - handleSubmit = e => { + handleSubmit = async e => { e.preventDefault() const properties = { @@ -17,8 +17,10 @@ class PagerDutyConfig extends Component { url: this.url.value, } - this.props.onSave(properties) - this.setState({testEnabled: true}) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } } disableTest = () => { diff --git a/ui/src/kapacitor/components/config/PushoverConfig.js b/ui/src/kapacitor/components/config/PushoverConfig.js index d8efd29c64..5ad4665acb 100644 --- a/ui/src/kapacitor/components/config/PushoverConfig.js +++ b/ui/src/kapacitor/components/config/PushoverConfig.js @@ -13,7 +13,7 @@ class PushoverConfig extends Component { } } - handleSubmit = e => { + handleSubmit = async e => { e.preventDefault() const properties = { @@ -22,8 +22,10 @@ class PushoverConfig extends Component { 'user-key': this.userKey.value, } - this.props.onSave(properties) - this.setState({testEnabled: true}) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } } disableTest = () => { diff --git a/ui/src/kapacitor/components/config/SMTPConfig.js b/ui/src/kapacitor/components/config/SMTPConfig.js index 2b4a027d74..b77c0a1110 100644 --- a/ui/src/kapacitor/components/config/SMTPConfig.js +++ b/ui/src/kapacitor/components/config/SMTPConfig.js @@ -8,19 +8,21 @@ class SMTPConfig extends Component { } } - handleSubmit = e => { + handleSubmit = async e => { e.preventDefault() const properties = { host: this.host.value, port: this.port.value, from: this.from.value, + to: this.to.value ? [this.to.value] : [], username: this.username.value, password: this.password.value, } - - this.props.onSave(properties) - this.setState({testEnabled: true}) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } } disableTest = () => { @@ -28,7 +30,7 @@ class SMTPConfig extends Component { } render() { - const {host, port, from, username, password} = this.props.config.options + const {host, port, from, username, password, to} = this.props.config.options return (
    @@ -56,7 +58,7 @@ class SMTPConfig extends Component { />
    -
    +
    +
    + + (this.to = r)} + defaultValue={to || ''} + onChange={this.disableTest} + /> +
    +
    { + handleSubmit = async e => { e.preventDefault() const properties = { @@ -16,8 +16,10 @@ class SensuConfig extends Component { addr: this.addr.value, } - this.props.onSave(properties) - this.setState({testEnabled: true}) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } } disableTest = () => { diff --git a/ui/src/kapacitor/components/config/SlackConfig.js b/ui/src/kapacitor/components/config/SlackConfig.js index 462373317e..e26e560a4d 100644 --- a/ui/src/kapacitor/components/config/SlackConfig.js +++ b/ui/src/kapacitor/components/config/SlackConfig.js @@ -10,14 +10,16 @@ class SlackConfig extends Component { } } - handleSubmit = e => { + handleSubmit = async e => { e.preventDefault() const properties = { url: this.url.value, channel: this.channel.value, } - this.props.onSave(properties) - this.setState({testEnabled: true}) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } } disableTest = () => { this.setState({testEnabled: false}) diff --git a/ui/src/kapacitor/components/config/TalkConfig.js b/ui/src/kapacitor/components/config/TalkConfig.js index 6c3bc8ebe7..573766fea8 100644 --- a/ui/src/kapacitor/components/config/TalkConfig.js +++ b/ui/src/kapacitor/components/config/TalkConfig.js @@ -10,7 +10,7 @@ class TalkConfig extends Component { } } - handleSubmit = e => { + handleSubmit = async e => { e.preventDefault() const properties = { @@ -18,8 +18,10 @@ class TalkConfig extends Component { author_name: this.author.value, } - this.props.onSave(properties) - this.setState({testEnabled: true}) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } } disableTest = () => { diff --git a/ui/src/kapacitor/components/config/TelegramConfig.js b/ui/src/kapacitor/components/config/TelegramConfig.js index bf3200bfa0..e4f4114f8e 100644 --- a/ui/src/kapacitor/components/config/TelegramConfig.js +++ b/ui/src/kapacitor/components/config/TelegramConfig.js @@ -12,7 +12,7 @@ class TelegramConfig extends Component { } } - handleSubmit = e => { + handleSubmit = async e => { e.preventDefault() let parseMode @@ -31,8 +31,10 @@ class TelegramConfig extends Component { token: this.token.value, } - this.props.onSave(properties) - this.setState({testEnabled: true}) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } } disableTest = () => { diff --git a/ui/src/kapacitor/components/config/VictorOpsConfig.js b/ui/src/kapacitor/components/config/VictorOpsConfig.js index ba8c929b40..958611f279 100644 --- a/ui/src/kapacitor/components/config/VictorOpsConfig.js +++ b/ui/src/kapacitor/components/config/VictorOpsConfig.js @@ -10,7 +10,7 @@ class VictorOpsConfig extends Component { } } - handleSubmit = e => { + handleSubmit = async e => { e.preventDefault() const properties = { @@ -19,8 +19,10 @@ class VictorOpsConfig extends Component { url: this.url.value, } - this.props.onSave(properties) - this.setState({testEnabled: true}) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } } disableTest = () => { diff --git a/ui/src/kapacitor/containers/KapacitorPage.js b/ui/src/kapacitor/containers/KapacitorPage.js index 55b6c1c04d..0fbf3d6c7e 100644 --- a/ui/src/kapacitor/containers/KapacitorPage.js +++ b/ui/src/kapacitor/containers/KapacitorPage.js @@ -55,7 +55,7 @@ class KapacitorPage extends Component { const {value, name} = e.target this.setState(prevState => { - const update = {[name]: value.trim()} + const update = {[name]: value} return {kapacitor: {...prevState.kapacitor, ...update}} }) } @@ -70,7 +70,7 @@ class KapacitorPage extends Component { router, } = this.props const {kapacitor} = this.state - + kapacitor.name = kapacitor.name.trim() const isNameTaken = kapacitors.some(k => k.name === kapacitor.name) const isNew = !params.id @@ -136,9 +136,9 @@ class KapacitorPage extends Component { } render() { - const {source, addFlashMessage} = this.props + const {source, addFlashMessage, location, params} = this.props + const hash = (location && location.hash) || (params && params.hash) || '' const {kapacitor, exists} = this.state - return ( ) } @@ -168,6 +169,7 @@ KapacitorPage.propTypes = { url: string.isRequired, kapacitors: array, }), + location: shape({pathname: string, hash: string}).isRequired, } export default withRouter(KapacitorPage) diff --git a/ui/src/kapacitor/containers/TickscriptPage.js b/ui/src/kapacitor/containers/TickscriptPage.js index b73a11edf3..0f456cdfcc 100644 --- a/ui/src/kapacitor/containers/TickscriptPage.js +++ b/ui/src/kapacitor/containers/TickscriptPage.js @@ -24,11 +24,12 @@ class TickscriptPage extends Component { dbrps: [], type: 'stream', }, - validation: '', + consoleMessage: '', isEditingID: true, logs: [], areLogsEnabled: false, failStr: '', + unsavedChanges: false, } } @@ -172,9 +173,10 @@ class TickscriptPage extends Component { } else { response = await createTask(kapacitor, task, router, sourceID) } - - if (response && response.code === 500) { - return this.setState({validation: response.message}) + if (response.code) { + this.setState({unsavedChanges: true, consoleMessage: response.message}) + } else { + this.setState({unsavedChanges: false, consoleMessage: ''}) } } catch (error) { console.error(error) @@ -182,37 +184,57 @@ class TickscriptPage extends Component { } } + handleExit = () => { + const {source: {id: sourceID}, router} = this.props + + return router.push(`/sources/${sourceID}/alert-rules`) + } + handleChangeScript = tickscript => { - this.setState({task: {...this.state.task, tickscript}}) + this.setState({ + task: {...this.state.task, tickscript}, + unsavedChanges: true, + }) } handleSelectDbrps = dbrps => { - this.setState({task: {...this.state.task, dbrps}}) + this.setState({task: {...this.state.task, dbrps}, unsavedChanges: true}) } handleChangeType = type => () => { - this.setState({task: {...this.state.task, type}}) + this.setState({task: {...this.state.task, type}, unsavedChanges: true}) } handleChangeID = e => { - this.setState({task: {...this.state.task, id: e.target.value}}) + this.setState({ + task: {...this.state.task, id: e.target.value}, + unsavedChanges: true, + }) } - handleToggleLogsVisbility = () => { + handleToggleLogsVisibility = () => { this.setState({areLogsVisible: !this.state.areLogsVisible}) } render() { const {source} = this.props - const {task, validation, logs, areLogsVisible, areLogsEnabled} = this.state - + const { + task, + logs, + areLogsVisible, + areLogsEnabled, + unsavedChanges, + consoleMessage, + } = this.state return ( ) } diff --git a/ui/src/shared/actions/auth.js b/ui/src/shared/actions/auth.js index 5e8308debd..d5d2258eb8 100644 --- a/ui/src/shared/actions/auth.js +++ b/ui/src/shared/actions/auth.js @@ -1,11 +1,11 @@ import {getMe as getMeAJAX, updateMe as updateMeAJAX} from 'shared/apis/auth' -import {linksReceived} from 'shared/actions/links' +import {getLinksAsync} from 'shared/actions/links' import {publishAutoDismissingNotification} from 'shared/dispatchers' import {errorThrown} from 'shared/actions/errors' -import {LONG_NOTIFICATION_DISMISS_DELAY} from 'shared/constants' +import {NOTIFICATION_DISMISS_DELAY} from 'shared/constants' export const authExpired = auth => ({ type: 'AUTH_EXPIRED', @@ -58,18 +58,8 @@ export const getMeAsync = ({shouldResetMe = false} = {}) => async dispatch => { } try { // These non-me objects are added to every response by some AJAX trickery - const { - data: me, - auth, - users, - meLink, - config, - external, - logoutLink, - organizations, - environment, - } = await getMeAJAX() - + const {data: me, auth, logoutLink} = await getMeAJAX() + // TODO: eventually, get the links for auth and logout out of here and into linksGetCompleted dispatch( meGetCompleted({ me, @@ -77,23 +67,20 @@ export const getMeAsync = ({shouldResetMe = false} = {}) => async dispatch => { logoutLink, }) ) - - dispatch( - linksReceived({ - external, - users, - organizations, - me: meLink, - config, - environment, - }) - ) // TODO: put this before meGetCompleted... though for some reason it doesn't fire the first time then } catch (error) { dispatch(meGetFailed()) dispatch(errorThrown(error)) } } +// meChangeOrganizationAsync is for switching the user's current organization. +// +// Global links state also needs to be refreshed upon organization change so +// that Admin Chronograf / Current Org User tab's link is valid, but this is +// happening automatically because we are using a browser redirect to reload +// the application. If at some point we stop using a redirect and instead +// make it a seamless SPA experience, a la issue #2463, we'll need to make sure +// links are still refreshed. export const meChangeOrganizationAsync = ( url, organization @@ -109,11 +96,15 @@ export const meChangeOrganizationAsync = ( 'success', `Now logged in to '${me.currentOrganization .name}' as '${currentRole.name}'`, - LONG_NOTIFICATION_DISMISS_DELAY + NOTIFICATION_DISMISS_DELAY ) ) dispatch(meChangeOrganizationCompleted()) dispatch(meGetCompleted({me, auth, logoutLink})) + + // refresh links after every successful meChangeOrganization to refresh + // /organizations/:id/users link for Admin / Current Org Users page to load + dispatch(getLinksAsync()) // TODO: reload sources upon me change org if non-refresh behavior preferred // instead of current behavior on both invocations of meChangeOrganization, // which is to refresh index via router.push('') diff --git a/ui/src/shared/actions/links.js b/ui/src/shared/actions/links.js index 3aa840a31b..97abbf76a2 100644 --- a/ui/src/shared/actions/links.js +++ b/ui/src/shared/actions/links.js @@ -1,6 +1,30 @@ -import * as actionTypes from 'shared/constants/actionTypes' +import {getLinks as getLinksAJAX} from 'shared/apis/links' -export const linksReceived = links => ({ - type: actionTypes.LINKS_RECEIVED, +import {errorThrown} from 'shared/actions/errors' + +import {linksLink} from 'shared/constants' + +const linksGetRequested = () => ({ + type: 'LINKS_GET_REQUESTED', +}) + +export const linksGetCompleted = links => ({ + type: 'LINKS_GET_COMPLETED', payload: {links}, }) + +const linksGetFailed = () => ({ + type: 'LINKS_GET_FAILED', +}) + +export const getLinksAsync = () => async dispatch => { + dispatch(linksGetRequested()) + try { + const {data} = await getLinksAJAX() + dispatch(linksGetCompleted(data)) + } catch (error) { + const message = `Failed to retrieve api links from ${linksLink}` + dispatch(errorThrown(error, message)) + dispatch(linksGetFailed()) + } +} diff --git a/ui/src/shared/apis/env.js b/ui/src/shared/apis/env.js index 77070e9c86..b7c6133e85 100644 --- a/ui/src/shared/apis/env.js +++ b/ui/src/shared/apis/env.js @@ -13,7 +13,7 @@ export const getEnv = async url => { return data } catch (error) { - console.error('Error retreieving envs: ', error) + console.error('Error retrieving envs: ', error) return DEFAULT_ENVS } } diff --git a/ui/src/shared/apis/links.js b/ui/src/shared/apis/links.js new file mode 100644 index 0000000000..00523d70e2 --- /dev/null +++ b/ui/src/shared/apis/links.js @@ -0,0 +1,17 @@ +import {getAJAX, setAJAXLinks} from 'utils/ajax' + +import {linksLink} from 'shared/constants' + +export const getLinks = async () => { + try { + const response = await getAJAX(linksLink) + // TODO: Remove use of links entirely from within AJAX function so that + // call to setAJAXLinks is not necessary. See issue #1486. + setAJAXLinks({updatedLinks: response.data}) + + return response + } catch (error) { + console.error(error) + throw error + } +} diff --git a/ui/src/shared/components/ColorDropdown.js b/ui/src/shared/components/ColorDropdown.js index b91058df64..8d8275e603 100644 --- a/ui/src/shared/components/ColorDropdown.js +++ b/ui/src/shared/components/ColorDropdown.js @@ -33,11 +33,12 @@ class ColorDropdown extends Component { render() { const {visible} = this.state - const {colors, selected, disabled} = this.props + const {colors, selected, disabled, stretchToFit} = this.props - const dropdownClassNames = visible - ? 'color-dropdown open' - : 'color-dropdown' + const dropdownClassNames = classnames('color-dropdown', { + open: visible, + 'color-dropdown--stretch': stretchToFit, + }) const toggleClassNames = classnames( 'btn btn-sm btn-default color-dropdown--toggle', {active: visible, 'color-dropdown__disabled': disabled} @@ -103,6 +104,7 @@ ColorDropdown.propTypes = { name: string.isRequired, }) ).isRequired, + stretchToFit: bool, disabled: bool, } diff --git a/ui/src/shared/components/ConfirmButton.js b/ui/src/shared/components/ConfirmButton.js new file mode 100644 index 0000000000..9d29f840fd --- /dev/null +++ b/ui/src/shared/components/ConfirmButton.js @@ -0,0 +1,110 @@ +import React, {Component, PropTypes} from 'react' + +import OnClickOutside from 'shared/components/OnClickOutside' + +class ConfirmButton extends Component { + constructor(props) { + super(props) + + this.state = { + expanded: false, + } + } + + handleButtonClick = () => { + if (this.props.disabled) { + return + } + this.setState({expanded: !this.state.expanded}) + } + + handleConfirmClick = () => { + this.setState({expanded: false}) + this.props.confirmAction() + } + + handleClickOutside = () => { + this.setState({expanded: false}) + } + + calculatePosition = () => { + if (!this.buttonDiv || !this.tooltipDiv) { + return '' + } + + const windowWidth = window.innerWidth + const buttonRect = this.buttonDiv.getBoundingClientRect() + const tooltipRect = this.tooltipDiv.getBoundingClientRect() + + const rightGap = windowWidth - buttonRect.right + + if (tooltipRect.width / 2 > rightGap) { + return 'left' + } + + return 'bottom' + } + + render() { + const { + text, + confirmText, + type, + size, + square, + icon, + disabled, + customClass, + } = this.props + const {expanded} = this.state + + const customClassString = customClass ? ` ${customClass}` : '' + const squareString = square ? ' btn-square' : '' + const expandedString = expanded ? ' active' : '' + const disabledString = disabled ? ' disabled' : '' + + const classname = `confirm-button btn ${type} ${size}${customClassString}${squareString}${expandedString}${disabledString}` + + return ( +
    (this.buttonDiv = r)} + > + {icon && } + {text && text} +
    +
    (this.tooltipDiv = r)} + > + {confirmText} +
    +
    +
    + ) + } +} + +const {bool, func, string} = PropTypes + +ConfirmButton.defaultProps = { + confirmText: 'Confirm', + type: 'btn-default', + size: 'btn-sm', + square: false, +} +ConfirmButton.propTypes = { + text: string, + confirmText: string, + confirmAction: func.isRequired, + type: string, + size: string, + square: bool, + icon: string, + disabled: bool, + customClass: string, +} + +export default OnClickOutside(ConfirmButton) diff --git a/ui/src/shared/components/DygraphLegend.js b/ui/src/shared/components/DygraphLegend.js index 2366aad5e3..3d9b8ec740 100644 --- a/ui/src/shared/components/DygraphLegend.js +++ b/ui/src/shared/components/DygraphLegend.js @@ -29,7 +29,7 @@ const DygraphLegend = ({ isFilterVisible, onToggleFilter, }) => { - const withValues = series.filter(s => s.y) + const withValues = series.filter(s => !_.isNil(s.y)) const sorted = _.sortBy( withValues, ({y, label}) => (sortType === 'numeric' ? y : label) diff --git a/ui/src/shared/components/GaugeChart.js b/ui/src/shared/components/GaugeChart.js index bc49f9bf89..a329a89ecf 100644 --- a/ui/src/shared/components/GaugeChart.js +++ b/ui/src/shared/components/GaugeChart.js @@ -2,7 +2,10 @@ import React, {PropTypes, PureComponent} from 'react' import lastValues from 'shared/parsing/lastValues' import Gauge from 'shared/components/Gauge' -import {DEFAULT_COLORS} from 'src/dashboards/constants/gaugeColors' +import { + DEFAULT_GAUGE_COLORS, + stringifyColorValues, +} from 'src/dashboards/constants/gaugeColors' import {DASHBOARD_LAYOUT_ROW_HEIGHT} from 'shared/constants' class GaugeChart extends PureComponent { @@ -60,7 +63,7 @@ class GaugeChart extends PureComponent { const {arrayOf, bool, number, shape, string} = PropTypes GaugeChart.defaultProps = { - colors: DEFAULT_COLORS, + colors: stringifyColorValues(DEFAULT_GAUGE_COLORS), } GaugeChart.propTypes = { diff --git a/ui/src/shared/components/InputClickToEdit.js b/ui/src/shared/components/InputClickToEdit.js new file mode 100644 index 0000000000..478ca57c40 --- /dev/null +++ b/ui/src/shared/components/InputClickToEdit.js @@ -0,0 +1,97 @@ +import React, {Component, PropTypes} from 'react' + +class InputClickToEdit extends Component { + constructor(props) { + super(props) + + this.state = { + isEditing: null, + value: this.props.value, + } + } + + handleCancel = () => { + this.setState({ + isEditing: false, + value: this.props.value, + }) + } + + handleInputClick = () => { + this.setState({isEditing: true}) + } + + handleInputBlur = e => { + const {onUpdate, value} = this.props + + if (value !== e.target.value) { + onUpdate(e.target.value) + } + + this.setState({isEditing: false, value: e.target.value}) + } + + handleKeyDown = e => { + if (e.key === 'Enter') { + this.handleInputBlur(e) + } + if (e.key === 'Escape') { + this.handleCancel() + } + } + + handleFocus = e => { + e.target.select() + } + + render() { + const {isEditing, value} = this.state + const {wrapperClass, disabled, tabIndex, placeholder} = this.props + + const divStyle = value ? 'input-cte' : 'input-cte__empty' + + return disabled + ?
    +
    + {value} +
    +
    + :
    + {isEditing + ? (this.inputRef = r)} + tabIndex={tabIndex} + placeholder={placeholder} + /> + :
    + {value || placeholder} + +
    } +
    + } +} + +const {func, bool, number, string} = PropTypes + +InputClickToEdit.propTypes = { + wrapperClass: string.isRequired, + value: string, + onUpdate: func.isRequired, + disabled: bool, + tabIndex: number, + placeholder: string, +} + +export default InputClickToEdit diff --git a/ui/src/shared/components/LineGraph.js b/ui/src/shared/components/LineGraph.js index 101ca72b99..9650a773c6 100644 --- a/ui/src/shared/components/LineGraph.js +++ b/ui/src/shared/components/LineGraph.js @@ -40,6 +40,7 @@ class LineGraph extends Component { axes, cell, title, + colors, onZoom, queries, timeRange, @@ -83,6 +84,14 @@ class LineGraph extends Component { ? SINGLE_STAT_LINE_COLORS : overrideLineColors + let prefix + let suffix + + if (axes) { + prefix = axes.y.prefix + suffix = axes.y.suffix + } + return (
    {isRefreshing ? : null} @@ -106,7 +115,14 @@ class LineGraph extends Component { options={options} /> {showSingleStat - ? + ? : null}
    ) @@ -170,6 +186,15 @@ LineGraph.propTypes = { resizeCoords: shape(), queries: arrayOf(shape({}).isRequired).isRequired, data: arrayOf(shape({}).isRequired).isRequired, + colors: arrayOf( + shape({ + type: string.isRequired, + hex: string.isRequired, + id: string.isRequired, + name: string.isRequired, + value: string.isRequired, + }).isRequired + ), } export default LineGraph diff --git a/ui/src/shared/components/RefreshingGraph.js b/ui/src/shared/components/RefreshingGraph.js index 05b7917de3..00bca981f5 100644 --- a/ui/src/shared/components/RefreshingGraph.js +++ b/ui/src/shared/components/RefreshingGraph.js @@ -76,6 +76,7 @@ const RefreshingGraph = ({ return ( color.type === SINGLE_STAT_TEXT) - if (colors && colors.length > 0) { - className = 'single-stat single-stat--colored' - const sortedColors = _.sortBy(colors, color => Number(color.value)) - const nearestCrossedThreshold = sortedColors - .filter(color => lastValue > color.value) - .pop() - - const colorizeText = _.some(colors, {type: SINGLE_STAT_TEXT}) - - if (colorizeText) { - textColor = nearestCrossedThreshold - ? nearestCrossedThreshold.hex - : '#292933' - } else { - bgColor = nearestCrossedThreshold - ? nearestCrossedThreshold.hex - : '#292933' - textColor = isBackgroundLight(bgColor) ? darkText : lightText - } - } + const {bgColor, textColor} = generateSingleStatHexs( + colors, + lineGraph, + colorizeText, + lastValue + ) return (
    + {prefix} {roundedValue} {suffix} + {lineGraph &&
    }
    ) @@ -85,7 +74,9 @@ SingleStat.propTypes = { value: string.isRequired, }).isRequired ), + prefix: string, suffix: string, + lineGraph: bool, } export default SingleStat diff --git a/ui/src/shared/components/SlideToggle.js b/ui/src/shared/components/SlideToggle.js index fa84675f86..3597e9ab14 100644 --- a/ui/src/shared/components/SlideToggle.js +++ b/ui/src/shared/components/SlideToggle.js @@ -31,7 +31,7 @@ class SlideToggle extends Component { const className = `slide-toggle slide-toggle__${size} ${active ? 'active' - : null} ${disabled ? 'disabled' : null}` + : ''} ${disabled ? 'disabled' : ''}` return (
    diff --git a/ui/src/shared/components/TagInput.js b/ui/src/shared/components/TagInput.js new file mode 100644 index 0000000000..6fd7d6f039 --- /dev/null +++ b/ui/src/shared/components/TagInput.js @@ -0,0 +1,63 @@ +import React, {Component, PropTypes} from 'react' +import _ from 'lodash' + +import Tags from 'shared/components/Tags' + +class TagInput extends Component { + handleAddTag = e => { + if (e.key === 'Enter') { + e.preventDefault() + const newItem = e.target.value.trim() + const {tags, onAddTag} = this.props + if (!this.shouldAddToList(newItem, tags)) { + return + } + + this.input.value = '' + onAddTag(newItem) + this.props.disableTest() + } + } + + handleDeleteTag = item => { + this.props.onDeleteTag(item) + } + + shouldAddToList(item, tags) { + return !_.isEmpty(item) && !tags.find(l => l === item) + } + + render() { + const {title, tags} = this.props + + return ( +
    + + (this.input = r)} + onKeyDown={this.handleAddTag} + /> + +
    + ) + } +} + +const {arrayOf, func, string} = PropTypes + +TagInput.propTypes = { + onAddTag: func.isRequired, + onDeleteTag: func.isRequired, + tags: arrayOf(string).isRequired, + title: string.isRequired, + disableTest: func, +} + +export default TagInput diff --git a/ui/src/shared/components/Tags.js b/ui/src/shared/components/Tags.js new file mode 100644 index 0000000000..7a0897ed43 --- /dev/null +++ b/ui/src/shared/components/Tags.js @@ -0,0 +1,61 @@ +import React, {Component, PropTypes} from 'react' +import TagsAddButton from 'src/shared/components/TagsAddButton' +import ConfirmButton from 'src/shared/components/ConfirmButton' + +const Tags = ({tags, onDeleteTag, addMenuItems, addMenuChoose}) => +
    + {tags.map(item => { + return ( + + ) + })} + {addMenuItems.length && addMenuChoose + ? + : null} +
    + +class Tag extends Component { + handleClickDelete = item => () => { + this.props.onDelete(item) + } + + render() { + const {item} = this.props + return ( + + + {item.text || item.name || item} + + { + + } + + ) + } +} + +const {arrayOf, func, oneOfType, shape, string} = PropTypes + +Tags.propTypes = { + tags: arrayOf(oneOfType([shape(), string])), + onDeleteTag: func, + addMenuItems: arrayOf(shape({})), + addMenuChoose: func, +} + +Tag.propTypes = { + item: oneOfType([shape(), string]), + onDelete: func, +} + +export default Tags diff --git a/ui/src/shared/components/TagsAddButton.js b/ui/src/shared/components/TagsAddButton.js new file mode 100644 index 0000000000..e7354ae923 --- /dev/null +++ b/ui/src/shared/components/TagsAddButton.js @@ -0,0 +1,57 @@ +import React, {Component, PropTypes} from 'react' + +import OnClickOutside from 'shared/components/OnClickOutside' +import uuid from 'node-uuid' + +class TagsAddButton extends Component { + constructor(props) { + super(props) + + this.state = {open: false} + } + + handleButtonClick = () => { + this.setState({open: !this.state.open}) + } + + handleMenuClick = item => () => { + this.setState({open: false}) + this.props.onChoose(item) + } + + handleClickOutside = () => { + this.setState({open: false}) + } + + render() { + const {open} = this.state + const {items} = this.props + + const classname = `tags-add${open ? ' open' : ''}` + return ( +
    + +
    + {items.map(item => +
    + {item.text} +
    + )} +
    +
    + ) + } +} + +const {array, func} = PropTypes + +TagsAddButton.propTypes = { + items: array.isRequired, + onChoose: func.isRequired, +} + +export default OnClickOutside(TagsAddButton) diff --git a/ui/src/shared/constants/actionTypes.js b/ui/src/shared/constants/actionTypes.js index 63f1554903..6ecf85c06f 100644 --- a/ui/src/shared/constants/actionTypes.js +++ b/ui/src/shared/constants/actionTypes.js @@ -2,5 +2,3 @@ export const TEMPLATE_VARIABLE_SELECTED = 'TEMPLATE_VARIABLE_SELECTED' export const TEMPLATE_VARIABLES_SELECTED_BY_NAME = 'TEMPLATE_VARIABLES_SELECTED_BY_NAME' - -export const LINKS_RECEIVED = 'LINKS_RECEIVED' diff --git a/ui/src/shared/constants/colorOperations.js b/ui/src/shared/constants/colorOperations.js index 89258f6bf6..b1dbf6ba0b 100644 --- a/ui/src/shared/constants/colorOperations.js +++ b/ui/src/shared/constants/colorOperations.js @@ -1,3 +1,9 @@ +import _ from 'lodash' +import { + GAUGE_COLORS, + SINGLE_STAT_BASE, +} from 'src/dashboards/constants/gaugeColors' + const hexToRgb = hex => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) return result @@ -16,9 +22,93 @@ const averageRgbValues = valuesObject => { const trueNeutralGrey = 128 -export const isBackgroundLight = backgroundColor => { - const averageBackground = averageRgbValues(hexToRgb(backgroundColor)) - const isLight = averageBackground > trueNeutralGrey +const getLegibleTextColor = bgColorHex => { + const averageBackground = averageRgbValues(hexToRgb(bgColorHex)) + const isBackgroundLight = averageBackground > trueNeutralGrey - return isLight + const darkText = '#292933' + const lightText = '#ffffff' + + return isBackgroundLight ? darkText : lightText +} + +const findNearestCrossedThreshold = (colors, lastValue) => { + const sortedColors = _.sortBy(colors, color => Number(color.value)) + const nearestCrossedThreshold = sortedColors + .filter(color => lastValue > color.value) + .pop() + + return nearestCrossedThreshold +} + +export const generateSingleStatHexs = ( + colors, + containsLineGraph, + colorizeText, + lastValue +) => { + const defaultColoring = {bgColor: null, textColor: GAUGE_COLORS[11].hex} + + if (!colors.length || !lastValue) { + return defaultColoring + } + + // baseColor is expected in all cases + const baseColor = colors.find(color => (color.id = SINGLE_STAT_BASE)) || { + hex: defaultColoring.textColor, + } + + // If the single stat is above a line graph never have a background color + if (containsLineGraph) { + return baseColor + ? {bgColor: null, textColor: baseColor.hex} + : defaultColoring + } + + // When there is only a base color and it's applied to the text + if (colorizeText && colors.length === 1) { + return baseColor + ? {bgColor: null, textColor: baseColor.hex} + : defaultColoring + } + + // When there's multiple colors and they're applied to the text + if (colorizeText && colors.length > 1) { + const nearestCrossedThreshold = findNearestCrossedThreshold( + colors, + lastValue + ) + const bgColor = null + const textColor = nearestCrossedThreshold.hex + + return {bgColor, textColor} + } + + // When there is only a base color and it's applued to the background + if (colors.length === 1) { + const bgColor = baseColor.hex + const textColor = getLegibleTextColor(bgColor) + + return {bgColor, textColor} + } + + // When there are multiple colors and they're applied to the background + if (colors.length > 1) { + const nearestCrossedThreshold = findNearestCrossedThreshold( + colors, + lastValue + ) + + const bgColor = nearestCrossedThreshold + ? nearestCrossedThreshold.hex + : baseColor.hex + const textColor = getLegibleTextColor(bgColor) + + return {bgColor, textColor} + } + + // If all else fails, use safe default + const bgColor = null + const textColor = baseColor.hex + return {bgColor, textColor} } diff --git a/ui/src/shared/constants/index.js b/ui/src/shared/constants/index.js index 0bc9e48ac2..63d64e782e 100644 --- a/ui/src/shared/constants/index.js +++ b/ui/src/shared/constants/index.js @@ -386,8 +386,7 @@ export const HEARTBEAT_INTERVAL = 10000 // ms export const PRESENTATION_MODE_ANIMATION_DELAY = 0 // In milliseconds. export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds. -export const SHORT_NOTIFICATION_DISMISS_DELAY = 2000 // in milliseconds -export const LONG_NOTIFICATION_DISMISS_DELAY = 4000 // in milliseconds +export const NOTIFICATION_DISMISS_DELAY = 4000 // in milliseconds export const REVERT_STATE_DELAY = 1500 // ms @@ -424,3 +423,5 @@ export const DEFAULT_SOURCE = { insecureSkipVerify: false, metaUrl: '', } + +export const linksLink = '/chronograf/v1' diff --git a/ui/src/shared/dispatchers/index.js b/ui/src/shared/dispatchers/index.js index 3ffce9dbdf..9a813330e3 100644 --- a/ui/src/shared/dispatchers/index.js +++ b/ui/src/shared/dispatchers/index.js @@ -5,7 +5,7 @@ import { import {delayEnablePresentationMode} from 'shared/actions/app' import {PRESENTATION_MODE_NOTIFICATION_DELAY} from 'shared/constants' -import {SHORT_NOTIFICATION_DISMISS_DELAY} from 'shared/constants' +import {NOTIFICATION_DISMISS_DELAY} from 'shared/constants' export function delayDismissNotification(type, delay) { return dispatch => { @@ -16,7 +16,7 @@ export function delayDismissNotification(type, delay) { export const publishAutoDismissingNotification = ( type, message, - delay = SHORT_NOTIFICATION_DISMISS_DELAY + delay = NOTIFICATION_DISMISS_DELAY ) => dispatch => { dispatch(publishNotification(type, message)) dispatch(delayDismissNotification(type, delay)) diff --git a/ui/src/shared/middleware/errors.js b/ui/src/shared/middleware/errors.js index 93510b1fb2..ebfaf24156 100644 --- a/ui/src/shared/middleware/errors.js +++ b/ui/src/shared/middleware/errors.js @@ -11,6 +11,7 @@ const actionsAllowedDuringBlackout = [ 'ME_', 'NOTIFICATION_', 'ERROR_', + 'LINKS_', ] const notificationsBlackoutDuration = 5000 let allowNotifications = true // eslint-disable-line diff --git a/ui/src/shared/reducers/links.js b/ui/src/shared/reducers/links.js index 3a3b4662e9..647f498daf 100644 --- a/ui/src/shared/reducers/links.js +++ b/ui/src/shared/reducers/links.js @@ -1,5 +1,3 @@ -import * as actionTypes from 'shared/constants/actionTypes' - const initialState = { external: {statusFeed: ''}, custom: [], @@ -7,16 +5,13 @@ const initialState = { const linksReducer = (state = initialState, action) => { switch (action.type) { - case actionTypes.LINKS_RECEIVED: { + case 'LINKS_GET_COMPLETED': { const {links} = action.payload - - return links - } - - default: { - return state + return {...links} } } + + return state } export default linksReducer diff --git a/ui/src/side_nav/components/UserNavBlock.js b/ui/src/side_nav/components/UserNavBlock.js index fe8f60b669..4883dcf96e 100644 --- a/ui/src/side_nav/components/UserNavBlock.js +++ b/ui/src/side_nav/components/UserNavBlock.js @@ -14,7 +14,6 @@ import {SUPERADMIN_ROLE} from 'src/auth/Authorized' class UserNavBlock extends Component { handleChangeCurrentOrganization = organizationID => async () => { const {router, links, meChangeOrganization} = this.props - await meChangeOrganization(links.me, {organization: organizationID}) router.push('') } diff --git a/ui/src/status/apis/index.js b/ui/src/status/apis/index.js index 595e658c03..5a943586b5 100644 --- a/ui/src/status/apis/index.js +++ b/ui/src/status/apis/index.js @@ -9,5 +9,5 @@ export const fetchJSONFeed = url => // https://stackoverflow.com/questions/22968406/how-to-skip-the-options-preflight-request-in-angularjs headers: {'Content-Type': 'text/plain; charset=UTF-8'}, }, - true // don't prefix route of external link with basepath + {excludeBasepath: true} // don't prefix route of external link with basepath ) diff --git a/ui/src/style/chronograf.scss b/ui/src/style/chronograf.scss index 5a377d739c..443ef10d6c 100644 --- a/ui/src/style/chronograf.scss +++ b/ui/src/style/chronograf.scss @@ -28,17 +28,22 @@ // Components @import 'components/ceo-display-options'; +@import 'components/confirm-button'; @import 'components/confirm-buttons'; @import 'components/code-mirror-theme'; @import 'components/color-dropdown'; +@import 'components/confirm-button'; +@import 'components/confirm-buttons'; @import 'components/custom-time-range'; @import 'components/dygraphs'; @import 'components/fancy-scrollbars'; +@import 'components/fancy-table'; @import 'components/fill-query'; @import 'components/flip-toggle'; @import 'components/function-selector'; @import 'components/graph-tips'; @import 'components/graph'; +@import 'components/input-click-to-edit'; @import 'components/input-tag-list'; @import 'components/newsfeed'; @import 'components/opt-in'; @@ -66,6 +71,7 @@ @import 'pages/admin'; @import 'pages/users'; @import 'pages/tickscript-editor'; +@import 'pages/manage-providers'; // TODO @import 'unsorted'; diff --git a/ui/src/style/components/ceo-display-options.scss b/ui/src/style/components/ceo-display-options.scss index 4d5b473225..a381d4bbd0 100644 --- a/ui/src/style/components/ceo-display-options.scss +++ b/ui/src/style/components/ceo-display-options.scss @@ -242,8 +242,16 @@ button.btn.btn-primary.btn-sm.gauge-controls--add-threshold { .gauge-controls--input { flex: 1 0 0; - margin: 0 4px; + margin: 0 0 0 4px; } +.gauge-controls--section .color-dropdown { + margin-left: 4px; +} +.gauge-controls--section .color-dropdown.color-dropdown--stretch { + width: auto; + flex: 1 0 0; +} + /* Cell Editor Overlay - Single-Stat Controls diff --git a/ui/src/style/components/code-mirror-theme.scss b/ui/src/style/components/code-mirror-theme.scss index 1311d32a7b..24e889b065 100644 --- a/ui/src/style/components/code-mirror-theme.scss +++ b/ui/src/style/components/code-mirror-theme.scss @@ -23,10 +23,17 @@ .CodeMirror-vscrollbar { @include custom-scrollbar-round($g2-kevlar,$g6-smoke); } +.CodeMirror-hscrollbar { + @include custom-scrollbar-round($g0-obsidian,$g6-smoke); +} .cm-s-material .CodeMirror-gutters { - background-color: fade-out($g4-onyx, 0.7); + @include gradient-v($g2-kevlar, $g0-obsidian) border: none; } +.cm-s-material .CodeMirror-gutters .CodeMirror-gutter { + background-color: fade-out($g4-onyx, 0.75); + height: calc(100% + 30px); +} .CodeMirror-gutter.CodeMirror-linenumbers { width: 60px; } diff --git a/ui/src/style/components/color-dropdown.scss b/ui/src/style/components/color-dropdown.scss index ab7dd42bc1..b892cd0b22 100644 --- a/ui/src/style/components/color-dropdown.scss +++ b/ui/src/style/components/color-dropdown.scss @@ -11,6 +11,10 @@ $color-dropdown--circle: 14px; position: relative; } +.color-dropdown.color-dropdown--stretch { + width: 100%; +} + .color-dropdown--toggle { width: 100%; position: relative; diff --git a/ui/src/style/components/confirm-button.scss b/ui/src/style/components/confirm-button.scss new file mode 100644 index 0000000000..74bc7a1935 --- /dev/null +++ b/ui/src/style/components/confirm-button.scss @@ -0,0 +1,105 @@ +/* + Confirm Button + ---------------------------------------------------------------------------- + This button requires a second click to confirm the action +*/ + +.confirm-button { + .confirm-button--tooltip { + visibility: hidden; + transition: all; + position: absolute; + z-index: 1; + + + &.bottom { + top: calc(100% + 4px); + left: 50%; + transform: translateX(-50%); + } + + &.left { + top: 50%; + right: calc(100% + 4px); + transform: translateY(-50%); + } + } +} +.confirm-button--confirmation { + white-space: pre; + max-width: 210px; + border-radius: 3px; + background-color: $c-curacao; + opacity: 0; + padding: 0 7px; + color: $g20-white; + font-size: 13px; + font-weight: 600; + text-align: center; + transition: + opacity 0.25s ease, + background-color 0.25s ease; + + &:after { + content: ''; + border: 8px solid transparent; + border-bottom-color: $c-curacao; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + transition: border-color 0.25s ease; + z-index: 100; + } + + &:hover { + background-color: $c-dreamsicle; + cursor: pointer; + } + &:hover:after { + border-bottom-color: $c-dreamsicle; + } +} + +.confirm-button--tooltip.bottom .confirm-button--confirmation:after { + bottom: 100%; + left: 50%; + border-bottom-color: $c-curacao; + transform: translateX(-50%); +} +.confirm-button--tooltip.bottom .confirm-button--confirmation:hover:after { + border-bottom-color: $c-dreamsicle; +} +.confirm-button--tooltip.left .confirm-button--confirmation:after { + left: 100%; + top: 50%; + border-left-color: $c-curacao; + transform: translateY(-50%); +} +.confirm-button--tooltip.left .confirm-button--confirmation:hover:after { + border-left-color: $c-dreamsicle; +} + +.confirm-button.active { + z-index: 999; + + .confirm-button--tooltip { + visibility: visible; + } + .confirm-button--confirmation { + opacity: 1; + } +} +.confirm-button.btn.btn-default.btn-xs.btn-xxs { + padding-right: 0; + margin-top: -1px; + + &:hover { + background-color:$g5-pepper; + color: $g14-chromium; + } + + span.icon.remove { + margin-right: 0; + } +} diff --git a/ui/src/style/components/dygraphs.scss b/ui/src/style/components/dygraphs.scss index 1e46b2b080..d5fe745f89 100644 --- a/ui/src/style/components/dygraphs.scss +++ b/ui/src/style/components/dygraphs.scss @@ -80,6 +80,7 @@ height: calc(100% - 2px); pointer-events: none; border-radius: 3px; + transition: background-color 0.25s ease, color 0.25s ease; @include no-user-select(); color: $c-laser; @@ -92,15 +93,13 @@ height: 100% !important; } } -.single-stat.single-stat--colored { - transition: background-color 0.25s ease, color 0.25s ease; -} .single-stat--value { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); - width: calc(100% - 32px); + width: auto; + max-width: calc(100% - 32px); text-align: center; font-size: 54px; line-height: 54px; @@ -115,15 +114,18 @@ } } .single-stat--shadow { - position: relative; - display: inline-block; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; } .single-stat--shadow:after { content: ''; position: absolute; top: 50%; left: 50%; - width: 110%; + width: 90%; height: 0; transform: translate(-50%,-50%); box-shadow: fade-out($g2-kevlar, 0.3) 0 0 50px 30px; diff --git a/ui/src/style/components/fancy-table.scss b/ui/src/style/components/fancy-table.scss new file mode 100644 index 0000000000..7b11d2fe3b --- /dev/null +++ b/ui/src/style/components/fancy-table.scss @@ -0,0 +1,51 @@ +/* + Styles for Fancy Tables + ------------------------------------------------------------------------------ +*/ + +$fancytable--table--margin: 4px; + +.fancytable--row, +.fancytable--labels { + width: 100%; + display: flex; + flex-wrap: nowrap; + align-items: center; + + > div:not(.confirm-buttons) { + margin-right: $fancytable--table--margin; + } +} +.fancytable--row { + margin-bottom: $fancytable--table--margin; + position: relative; + + &:last-of-type { + margin-bottom: 0; + } +} +.fancytable--labels { + border-bottom: 2px solid $g5-pepper; + margin-bottom: 10px; + @include no-user-select(); +} +.fancytable--th, +.fancytable--td { + font-weight: 500; + height: 34px; + line-height: 34px; + font-size: 13px; +} +.fancytable--th { + color: $g17-whisper; + padding: 0 11px; + + &:last-of-type { + margin-right: 0; + } +} +.fancytable--td { + display: flex; + align-items: center; + color: $g13-mist; +} diff --git a/ui/src/style/components/input-click-to-edit.scss b/ui/src/style/components/input-click-to-edit.scss new file mode 100644 index 0000000000..75ce1eccf4 --- /dev/null +++ b/ui/src/style/components/input-click-to-edit.scss @@ -0,0 +1,63 @@ +/* + Click to Edit Input Styles + ------------------------------------------------------------------------------ +*/ +.input-cte, +.input-cte__disabled { + @include no-user-select(); + height: 30px; + width: 100%; + font-weight: 600; + font-size: 13px; + line-height: 28px; + padding: 0 11px; + border-radius: 4px; + border-style: solid; + border-width: 2px; +} + +.input-cte { + border-color: $g2-kevlar; + background-color: $g2-kevlar; + color: $g13-mist; + position: relative; + transition: + color 0.4s ease, + background-color 0.4s ease, + border-color 0.4s ease; + + > span.icon { + position: absolute; + top: 50%; + right: 11px; + transform: translateY(-50%); + color: $g8-storm; + opacity: 0; + transition: opacity 0.25s ease; + } + + &:hover { + color: $g20-white; + background-color: $g5-pepper; + border-color: $g5-pepper; + cursor: text; + + > span.icon {opacity: 1;} + } +} +.input-cte__disabled { + border-color: $g4-onyx; + background-color: $g4-onyx; + font-style: italic; + color: $g9-mountain; +} + +.input-cte__empty { + @extend .input-cte; + font-style: italic; + color: $g9-mountain; + + &:hover { + color: $g9-mountain; + } +} \ No newline at end of file diff --git a/ui/src/style/components/input-tag-list.scss b/ui/src/style/components/input-tag-list.scss index 43cd070915..a560cd6e95 100644 --- a/ui/src/style/components/input-tag-list.scss +++ b/ui/src/style/components/input-tag-list.scss @@ -1,22 +1,36 @@ -.input-tag-list { - padding: 0 11px; - margin: 2px 0px; - font-size: 0; -} +/* + Input Tag List Styles + --------------------------------------------- +*/ + $input-tag-item-height: 24px; -.input-tag-item { - display: inline-block; - white-space: nowrap; + +.input-tag-list { + margin: 1px -1px; + display: flex; + flex-wrap: wrap; +} +.tag-input + .input-tag-list { + padding: 0 11px; + margin: 4px -1px; +} + +.input-tag-item, +.tags-add { height: $input-tag-item-height; line-height: $input-tag-item-height; - padding: 0 9px; border-radius: 3px; + margin: 1px; +} + +.input-tag-item { + white-space: nowrap; + padding: 0 9px; font-size: 12px; font-weight: 600; background-color: $g5-pepper; color: $g18-cloud; cursor: default; - margin: 2px; } .input-tag-item .icon { @@ -30,3 +44,62 @@ $input-tag-item-height: 24px; cursor: pointer; } } + +// Optional Add Tags Button +.tags-add { + position: relative; + width: $input-tag-item-height; + background-color: $c-pool; + color: $g20-white; + text-align: center; + transition: background-color 0.25s ease; + + .icon { + font-size: 14px; + } + + &:hover, + &.open { + cursor: pointer; + background-color: $c-laser; + } +} +.tags-add--menu { + position: absolute; + top: 100%; + left: 0; + z-index: 99; + display: none; + flex-direction: column; + align-items: stretch; + border-radius: 4px; + overflow: auto; + max-height: 200px; + @include gradient-h($c-ocean,$c-pool); +} +.tags-add--menu-item { + text-align: left; + font-size: 13px; + line-height: 13px; + font-weight: 500; + padding: 7px 9px; + white-space: nowrap; + color: $c-yeti; + transition: color 0.25s ease; + @include no-user-select(); + + &:hover { + cursor: pointer; + color: $g20-white; + @include gradient-h($c-laser,$c-pool); + } + &:active, + &:active:focus { + cursor: pointer; + color: $g20-white; + @include gradient-h($c-sapphire,$c-pool); + } +} +.tags-add.open .tags-add--menu { + display: flex; +} diff --git a/ui/src/style/components/kapacitor-logs-table.scss b/ui/src/style/components/kapacitor-logs-table.scss index ff1eb79853..60ef3f295a 100644 --- a/ui/src/style/components/kapacitor-logs-table.scss +++ b/ui/src/style/components/kapacitor-logs-table.scss @@ -9,11 +9,10 @@ $logs-row-indent: 6px; $logs-level-dot: 8px; $logs-margin: 4px; -.logs-table--container { +.logs-table { width: 50%; position: relative; height: 100%; - @include gradient-v($g3-castle,$g1-raven); } .logs-table--header { display: flex; @@ -23,28 +22,28 @@ $logs-margin: 4px; height: $logs-table-header-height; padding: 0 $logs-table-padding 0 ($logs-table-padding / 2); background-color: $g4-onyx; + white-space: nowrap; + font-size: 17px; + @include no-user-select(); + letter-spacing: 0.015em; + font-weight: 500; } -.logs-table--panel { +.logs-table--container { position: absolute !important; top: $logs-table-header-height; left: 0; width: 100%; height: calc(100% - #{$logs-table-header-height}) !important; + @include gradient-v(mix($g3-castle, $g2-kevlar),mix($g1-raven, $g0-obsidian)); } -.logs-table { - height: 100%; -} .logs-table--row { - height: 87px; // Fixed height, required for Infinite Scroll, allows for 2 tags / fields per line + position: relative; padding: 8px ($logs-table-padding - 16px) 8px ($logs-table-padding / 2); border-bottom: 2px solid $g3-castle; transition: background-color 0.25s ease; - &:hover { - background-color: $g4-onyx; - } - &:first-child { + &:last-of-type { border-bottom: none; } } @@ -62,21 +61,22 @@ $logs-margin: 4px; &.debug {background-color: $c-comet;} &.info {background-color: $g6-smoke;} &.warn {background-color: $c-pineapple;} - &.ok {background-color: $c-rainforest;} + &.ok {background-color: $c-pool;} &.error {background-color: $c-dreamsicle;} } .logs-table--timestamp { font-family: $code-font; font-weight: 500; - font-size: 11px; + font-size: 13px; color: $g9-mountain; flex: 1 0 0; } .logs-table--details { display: flex; align-items: flex-start; + flex-wrap: wrap; font-size: 13px; - color: $g13-mist; + color: $g11-sidewalk; font-weight: 600; padding-left: ($logs-level-dot + $logs-row-indent); @@ -85,6 +85,10 @@ $logs-margin: 4px; } /* Logs Table Item Types */ +.logs-table--service, +.logs-table--column h1 { + margin-top: 2px; +} .logs-table--session { text-transform: capitalize; font-style: italic; @@ -92,16 +96,33 @@ $logs-margin: 4px; .logs-table--service { width: 140px; } -.logs-table--blah { +.logs-table--columns { display: flex; flex: 1 0 0; + flex-wrap: wrap; } -.logs-table--key-values { +.logs-table--column { color: $g11-sidewalk; flex: 1 0 50%; } +.logs-table--column h1 { + font-size: 13px; + font-weight: 700; + margin: 0; + letter-spacing: normal; + line-height: 1.42857143em; + text-transform: uppercase; + color: $g16-pearl; +} .logs-table--key-value { + white-space: nowrap; + span { + color: $c-rainforest; + } } -.logs-table--key-value span { - color: $c-pool; +.logs-table--scrollbox { + width: 100%; + max-height: 300px; + overflow-y: auto; + @include custom-scrollbar-round($g0-obsidian,$c-rainforest); } diff --git a/ui/src/style/components/organizations-table.scss b/ui/src/style/components/organizations-table.scss index 308bf8689c..f5cf958fd1 100644 --- a/ui/src/style/components/organizations-table.scss +++ b/ui/src/style/components/organizations-table.scss @@ -4,91 +4,45 @@ Is not actually a table */ -.orgs-table--org { +$orgs-table--active-width: 102px; +$orgs-table--public-width: 90px; +$orgs-table--default-role-width: 130px; +$orgs-table--delete-width: 30px; + +.orgs-table--name { + flex: 1 0 0; +} +.orgs-table--public { + width: $orgs-table--public-width; + text-align: center; +} +.orgs-table--default-role { + width: $orgs-table--default-role-width; +} +.orgs-table--delete { + width: $orgs-table--delete-width; +} +.orgs-table--active { + width: $orgs-table--active-width; + justify-content: center; + @include no-user-select(); + + .btn {width: 100%;} +} +.orgs-table--default-role.deleting { + width: ( + $orgs-table--default-role-width - $fancytable--table--margin - + $orgs-table--delete-width + ); +} +.orgs-table--public-toggle { + height: 30px; width: 100%; display: flex; align-items: center; - margin-bottom: 8px; - position: relative; - - &:last-of-type { - margin-bottom: 0; - } -} -.orgs-table--id { - padding: 0 11px; - width: 60px; - height: 30px; - line-height: 30px; - font-size: 13px; - color: $g13-mist; - font-weight: 500; -} -.orgs-table--active { - padding: 0 4px 0 0; - width: 102px; - height: 30px; - - button.btn { - width: 100%; - } -} -.orgs-table--name, -.orgs-table--name-disabled, -input[type="text"].form-control.orgs-table--input { - flex: 1 0 0; - font-weight: 600; - font-size: 13px; - margin-right: 4px; -} -.orgs-table--name, -.orgs-table--name-disabled { - @include no-user-select(); - padding: 0 11px; - border-radius: 4px; - height: 30px; - line-height: 28px; - border-style: solid; - border-width: 2px; -} -.orgs-table--name { - border-color: $g2-kevlar; - background-color: $g2-kevlar; - color: $g13-mist; - position: relative; - transition: color 0.4s ease, background-color 0.4s ease, - border-color 0.4s ease; - - > span.icon { - position: absolute; - top: 50%; - right: 11px; - transform: translateY(-50%); - color: $g8-storm; - opacity: 0; - transition: opacity 0.25s ease; - } - - &:hover { - color: $g20-white; - background-color: $g5-pepper; - border-color: $g5-pepper; - cursor: text; - - > span.icon { - opacity: 1; - } - } -} - -.orgs-table--public { - height: 30px; - margin-right: 4px; - text-align: center; - width: 88px; + justify-content: center; background-color: $g4-onyx; border-radius: 4px; - line-height: 30px; position: relative; > .slide-toggle { @@ -103,69 +57,3 @@ input[type="text"].form-control.orgs-table--input { @include no-user-select(); } } - -.orgs-table--default-role, -.orgs-table--default-role-disabled { - width: 100px; - height: 30px; - margin-right: 4px; -} -.orgs-table--default-role.editing { - width: 96px; -} -.orgs-table--default-role-disabled { - background-color: $g4-onyx; - font-style: italic; - color: $g9-mountain; - padding: 0 11px; - line-height: 30px; - font-size: 13px; - font-weight: 600; - @include no-user-select(); -} -.orgs-table--delete { - height: 30px; - width: 30px; -} - -/* Table Headers */ -.orgs-table--org-labels { - display: flex; - align-items: center; - border-bottom: 2px solid $g5-pepper; - margin-bottom: 10px; - width: 100%; - @include no-user-select(); - - > .orgs-table--name, - > .orgs-table--name:hover, - > .orgs-table--public, - > .orgs-table--active { - transition: none; - background-color: transparent; - border-color: transparent; - } - - > .orgs-table--id, - > .orgs-table--name, - > .orgs-table--name:hover, - > .orgs-table--default-role, - > .orgs-table--public, - > .orgs-table--active { - color: $g17-whisper; - font-weight: 500; - } - > .orgs-table--default-role, - > .orgs-table--public, - > .orgs-table--active { - line-height: 30px; - font-size: 13px; - padding: 0 11px; - } -} - - -/* Config table beneath organizations table */ -.panel .panel-body table.table.superadmin-config { - margin-top: 60px; -} diff --git a/ui/src/style/components/slide-toggle.scss b/ui/src/style/components/slide-toggle.scss index eec03eb93a..3a5c99db0c 100644 --- a/ui/src/style/components/slide-toggle.scss +++ b/ui/src/style/components/slide-toggle.scss @@ -20,12 +20,12 @@ transition: background-color 0.25s ease, transform 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); - background-color: $g4-onyx; + background-color: $g6-smoke; transform: translate(0,-50%); border-radius: 50%; .slide-toggle:hover & { - background-color: $g6-smoke; + background-color: $g8-storm; } } @@ -44,14 +44,22 @@ &:hover, &.active, &.active:hover { - background-color: $g6-smoke; + background-color: $g2-kevlar; cursor: not-allowed; } .slide-toggle--knob, &:hover .slide-toggle--knob, &.active .slide-toggle--knob, &.active:hover .slide-toggle--knob { - background-color: $g3-castle; + opacity: 0.25; + } + .slide-toggle--knob, + &:hover .slide-toggle--knob { + background-color: $g6-smoke; + } + &.active .slide-toggle--knob, + &.active:hover .slide-toggle--knob { + background-color: $c-rainforest; } } diff --git a/ui/src/style/modules/mixins.scss b/ui/src/style/modules/mixins.scss index e2b623dea1..ad8bb37750 100644 --- a/ui/src/style/modules/mixins.scss +++ b/ui/src/style/modules/mixins.scss @@ -71,16 +71,18 @@ $scrollbar-offset: 3px; width: $scrollbar-width; border-top-right-radius: $radius; border-top-left-radius: $radius; - border-bottom-right-radius: $radius; border-bottom-left-radius: $radius; + border-bottom-right-radius: $radius; &-button { background-color: $trackColor; } &-track { background-color: $trackColor; - border-top-right-radius: $radius; - border-bottom-right-radius: $radius; + border-top-right-radius: ($scrollbar-width / 2); + border-top-left-radius: ($scrollbar-width / 2); + border-bottom-left-radius: ($scrollbar-width / 2); + border-bottom-right-radius: ($scrollbar-width / 2); } &-track-piece { background-color: $trackColor; diff --git a/ui/src/style/pages/admin.scss b/ui/src/style/pages/admin.scss index a46587fbe9..249636e954 100644 --- a/ui/src/style/pages/admin.scss +++ b/ui/src/style/pages/admin.scss @@ -47,7 +47,7 @@ font-size: 17px; font-weight: 400 !important; color: $g12-forge; - padding: 0; + padding: 6px 0; } .panel-body {min-height: 300px;} .panel-heading + .panel-body {padding-top: 0;} @@ -179,3 +179,27 @@ pre.admin-table--query { .table-highlight > tbody > tr:hover {background-color: $g5-pepper;} } + +/* + Chronograf Admin + ---------------------------------------------------------------------------- +*/ +.all-users-admin-toggle { + display: flex; + align-items: center; + margin-right: 30px; + + span { + display: inline-block; + margin-left: 8px; + font-weight: 500; + font-style: italic; + color: $g10-wolf; + transition: color 0.25s ease; + @include no-user-select(); + } + .slide-toggle.active + span { + font-style: normal; + color: $g15-platinum; + } +} diff --git a/ui/src/style/pages/manage-providers.scss b/ui/src/style/pages/manage-providers.scss new file mode 100644 index 0000000000..aaf73fbd98 --- /dev/null +++ b/ui/src/style/pages/manage-providers.scss @@ -0,0 +1,70 @@ +/* + Styles for the Manage Poviders Page + ------------------------------------------------------------------------------ +*/ + +$provider--id-width: 60px; +$provider--scheme-width: 150px; +$provider--provider-width: 150px; +$provider--providerorg-width: 210px; +$provider--redirect-width: 220px; +$provider--delete-width: 30px; + + +.provider--id {width: $provider--id-width;} +.provider--scheme {width: $provider--scheme-width;} +.provider--provider {width: $provider--provider-width;} +.provider--providerorg {width: $provider--providerorg-width;} +.provider--redirect {width: $provider--redirect-width;} +.provider--delete { + width: $provider--delete-width; + min-width: $provider--delete-width; +} +.provider--arrow {flex: 1 0 0;} + +.fancytable--td.provider--id, +.fancytable--th.provider--id { + padding: 0 8px; +} +.provider--redirect.deleting { + width: ($provider--redirect-width - $fancytable--table--margin - $provider--delete-width); +} + +.provider--arrow { + display: flex; + align-items: center; + min-width: 36px; + + &.fancytable--td { + padding: 0 8px; + } + + > span { + position: relative; + height: 2px; + width: 100%; + @include gradient-h($c-pool,$c-star); + + &:before, + &:after { + content: ''; + position: absolute; + top: 50%; + transform: translateY(-50%); + } + &:before { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: $c-pool; + left: 0px; + } + &:after { + right: -6px; + border-style: solid; + border-color: transparent; + border-width: 6px; + border-left-color: $c-star; + } + } +} diff --git a/ui/src/style/pages/tickscript-editor.scss b/ui/src/style/pages/tickscript-editor.scss index 3f70bb942a..6fb01847ff 100644 --- a/ui/src/style/pages/tickscript-editor.scss +++ b/ui/src/style/pages/tickscript-editor.scss @@ -3,28 +3,26 @@ ---------------------------------------------------------------------------- */ -$tickscript-console-height: 60px; +$tickscript-controls-height: 60px; .tickscript { flex: 1 0 0; + position: relative; } .tickscript-controls, .tickscript-console, .tickscript-editor { - padding: 0; - margin: 0; width: 100%; - position: relative; } -.tickscript-controls, -.tickscript-console { - height: $tickscript-console-height; +.tickscript-console, +.tickscript-controls { + padding: 0 60px; + display: flex; } .tickscript-controls { - display: flex; align-items: center; + height: $tickscript-controls-height; justify-content: space-between; - padding: 0 60px; background-color: $g3-castle; } .tickscript-controls--name { @@ -42,29 +40,42 @@ $tickscript-console-height: 60px; > * {margin-left: 8px;} } -.tickscript-console--output { - padding: 0 60px; - font-family: $code-font; - font-weight: 600; - display: flex; - align-items: center; - background-color: $g2-kevlar; - border-bottom: 2px solid $g3-castle; - position: relative; - height: 100%; - width: 100%; - border-radius: $radius $radius 0 0; +.tickscript-console { + align-items: flex-start; + height: $tickscript-controls-height * 2.25; + border-top: 2px solid $g3-castle; + background-color: $g0-obsidian; + overflow-y: scroll; + @include custom-scrollbar($g0-obsidian,$g4-onyx); > p { - margin: 0; + position: relative; + padding-left: 16px; + font-family: $code-font; + margin: 11px 0; + font-weight: 700; + word-wrap: break-word; + word-break: break-word; + + &:before { + content: '>'; + position: absolute; + top: 0; + left: 0; + } } } .tickscript-console--default { - color: $g10-wolf; - font-style: italic; + color: $g13-mist; +} +.tickscript-console--valid { + color: $c-rainforest; +} +.tickscript-console--error { + color: $c-dreamsicle; } .tickscript-editor { - height: calc(100% - #{$tickscript-console-height * 2}); + height: calc(100% - #{$tickscript-controls-height * 3.25}); } /* diff --git a/ui/src/style/pages/users.scss b/ui/src/style/pages/users.scss index 9878d4b7ea..5b32a48ff6 100644 --- a/ui/src/style/pages/users.scss +++ b/ui/src/style/pages/users.scss @@ -109,6 +109,7 @@ table.table.chronograf-admin-table tbody tr.chronograf-admin-table--new-user { /* Highlight "Me" in the users table */ .chronograf-user--me { color: $c-rainforest; + white-space: nowrap; > span.icon { display: inline-block; diff --git a/ui/src/utils/ajax.js b/ui/src/utils/ajax.js index 48e9591ef2..a7cb0c3ab4 100644 --- a/ui/src/utils/ajax.js +++ b/ui/src/utils/ajax.js @@ -2,6 +2,10 @@ import axios from 'axios' let links +export const setAJAXLinks = ({updatedLinks}) => { + links = updatedLinks +} + // do not prefix route with basepath, ex. for external links const addBasepath = (url, excludeBasepath) => { const basepath = window.basepath || '' @@ -15,6 +19,7 @@ const generateResponseWithLinks = (response, newLinks) => { logout, external, users, + allUsers, organizations, me: meLink, config, @@ -27,6 +32,7 @@ const generateResponseWithLinks = (response, newLinks) => { logoutLink: logout, external, users, + allUsers, organizations, meLink, config, @@ -36,28 +42,24 @@ const generateResponseWithLinks = (response, newLinks) => { const AJAX = async ( {url, resource, id, method = 'GET', data = {}, params = {}, headers = {}}, - excludeBasepath + {excludeBasepath} = {} ) => { try { - let response + if (!links) { + console.error( + `AJAX function has no links. Trying to reach url ${url}, resource ${resource}, id ${id}, method ${method}` + ) + } url = addBasepath(url, excludeBasepath) - if (!links) { - const linksRes = (response = await axios({ - url: addBasepath('/chronograf/v1', excludeBasepath), - method: 'GET', - })) - links = linksRes.data - } - if (resource) { url = id ? addBasepath(`${links[resource]}/${id}`, excludeBasepath) : addBasepath(`${links[resource]}`, excludeBasepath) } - response = await axios({ + const response = await axios({ url, method, data, @@ -65,19 +67,22 @@ const AJAX = async ( headers, }) - return generateResponseWithLinks(response, links) + // TODO: Just return the unadulterated response without grafting auth, me, + // and logoutLink onto this object, once those are retrieved via their own + // AJAX request and action creator. + return links ? generateResponseWithLinks(response, links) : response } catch (error) { const {response} = error - throw generateResponseWithLinks(response, links) // eslint-disable-line no-throw-literal + throw links ? generateResponseWithLinks(response, links) : response // eslint-disable-line no-throw-literal } } -export const get = async url => { +export const getAJAX = async url => { try { - return await AJAX({ + return await axios({ method: 'GET', - url, + url: addBasepath(url), }) } catch (error) { console.error(error) From 1723292504d3b54af52f6f1ad809781bcca585b8 Mon Sep 17 00:00:00 2001 From: Iris Scholten Date: Fri, 23 Mar 2018 16:10:33 -0700 Subject: [PATCH 11/26] update GraphOptionsTimeFormat.test --- .../components/GraphOptionsTimeFormat.tsx | 2 +- .../components/GraphOptionsTimeFormat.test.tsx | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/ui/src/dashboards/components/GraphOptionsTimeFormat.tsx b/ui/src/dashboards/components/GraphOptionsTimeFormat.tsx index 366ffc92d3..dc2dfe338e 100644 --- a/ui/src/dashboards/components/GraphOptionsTimeFormat.tsx +++ b/ui/src/dashboards/components/GraphOptionsTimeFormat.tsx @@ -35,7 +35,7 @@ class GraphOptionsTimeFormat extends PureComponent { return this.props.onTimeFormatChange } - public handleChangeFormat = format => { + public handleChangeFormat = (format: string) => { this.onTimeFormatChange(format) this.setState({format}) } diff --git a/ui/test/dashboards/components/GraphOptionsTimeFormat.test.tsx b/ui/test/dashboards/components/GraphOptionsTimeFormat.test.tsx index b5248e59fd..288c8c2496 100644 --- a/ui/test/dashboards/components/GraphOptionsTimeFormat.test.tsx +++ b/ui/test/dashboards/components/GraphOptionsTimeFormat.test.tsx @@ -68,7 +68,7 @@ describe('Dashboards.Components.GraphOptionsTimeFormat', () => { }) describe('when input is not custom', () => { - it('sets the state custom format to false', () => { + it('sets the state custom format to false and calls onTimeFormatChange', () => { const onTimeFormatChange = jest.fn() const instance = setup({ onTimeFormatChange, @@ -81,5 +81,20 @@ describe('Dashboards.Components.GraphOptionsTimeFormat', () => { }) }) }) + + describe('#handleChangeFormat', () => { + it('sets state format to format and calls onTimeFormatChange', () => { + const onTimeFormatChange = jest.fn() + const format = 'mmmmmm' + const instance = setup({ + onTimeFormatChange, + }).instance() as GraphOptionsTimeFormat + + instance.handleChangeFormat(format) + expect(instance.state.format).toBe(format) + expect(onTimeFormatChange).toBeCalledWith(format) + expect(onTimeFormatChange).toHaveBeenCalledTimes(1) + }) + }) }) }) From 5d62e4a059a070a03b7e4fd095000bdeeb91f488 Mon Sep 17 00:00:00 2001 From: Iris Scholten Date: Fri, 23 Mar 2018 17:13:21 -0700 Subject: [PATCH 12/26] update GraphOptionsSortBy.test --- .../components/GraphOptionsSortBy.test.tsx | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/ui/test/dashboards/components/GraphOptionsSortBy.test.tsx b/ui/test/dashboards/components/GraphOptionsSortBy.test.tsx index 568e2ab840..321773da04 100644 --- a/ui/test/dashboards/components/GraphOptionsSortBy.test.tsx +++ b/ui/test/dashboards/components/GraphOptionsSortBy.test.tsx @@ -8,8 +8,9 @@ import {Dropdown} from 'src/shared/components/Dropdown' const defaultProps = { onChooseSortBy: () => {}, selected: { - displayName: 'here', - internalName: 'boom', + displayName: '', + internalName: '', + visible: true, }, sortByOptions: [], } @@ -24,23 +25,35 @@ const setup = (override = {}) => { describe('Dashboards.Components.GraphOptionsSortBy', () => { describe('rendering', () => { it('renders component', () => { - const {wrapper} = setup() + const selected = { + displayName: 'here', + internalName: 'boom', + visible: true, + } + const {wrapper} = setup({ + selected, + }) const dropdown = wrapper.find(Dropdown) const label = wrapper.find('label') - expect(dropdown.props().selected).toEqual('here') + expect(dropdown.props().selected).toEqual(selected.displayName) expect(dropdown.exists()).toBe(true) expect(label.exists()).toBe(true) }) describe('when selected display name is not available', () => { it('render internal name as selected', () => { - const {wrapper} = setup({selected: {internalName: 'boom'}}) + const selected = { + displayName: '', + internalName: 'boom', + visible: true, + } + const {wrapper} = setup({selected}) const dropdown = wrapper.find(Dropdown) - expect(dropdown.props().selected).toEqual('boom') + expect(dropdown.props().selected).toEqual(selected.internalName) }) }) }) From 51fc44d50f958b9f0507cd8d77d4ff242d65a404 Mon Sep 17 00:00:00 2001 From: Iris Scholten Date: Fri, 23 Mar 2018 17:28:55 -0700 Subject: [PATCH 13/26] move tooltip for time format link to constants and update time format tests --- .../components/GraphOptionsTimeFormat.tsx | 16 +++++++--------- ui/src/shared/constants/tableGraph.js | 3 +++ .../components/GraphOptionsTimeFormat.test.tsx | 17 +++++++++++++++-- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/ui/src/dashboards/components/GraphOptionsTimeFormat.tsx b/ui/src/dashboards/components/GraphOptionsTimeFormat.tsx index 9799e429ae..9df87f6737 100644 --- a/ui/src/dashboards/components/GraphOptionsTimeFormat.tsx +++ b/ui/src/dashboards/components/GraphOptionsTimeFormat.tsx @@ -6,6 +6,7 @@ import { FORMAT_OPTIONS, TIME_FORMAT_CUSTOM, TIME_FORMAT_DEFAULT, + TIME_FORMAT_TOOLTIP_LINK, } from 'src/shared/constants/tableGraph' interface TimeFormatOptions { @@ -51,8 +52,7 @@ class GraphOptionsTimeFormat extends PureComponent { public render() { const {format, customFormat} = this.state - const tipUrl = 'http://momentjs.com/docs/#/parsing/string-format/' - const tipContent = `For information on formatting, see
    ${tipUrl}` + const tipContent = `For information on formatting, see
    ${TIME_FORMAT_TOOLTIP_LINK}` const formatOption = FORMAT_OPTIONS.find(op => op.text === format) const showCustom = !formatOption || customFormat @@ -61,14 +61,13 @@ class GraphOptionsTimeFormat extends PureComponent {
    { className="dropdown-stretch" onChoose={this.handleChooseFormat} /> - {showCustom && ( + {showCustom &&
    { placeholder="Enter custom format..." appearAsNormalInput={true} /> -
    - )} +
    }
    ) } diff --git a/ui/src/shared/constants/tableGraph.js b/ui/src/shared/constants/tableGraph.js index 77f63cc7ba..9cf1dfe52c 100644 --- a/ui/src/shared/constants/tableGraph.js +++ b/ui/src/shared/constants/tableGraph.js @@ -8,6 +8,9 @@ export const NULL_HOVER_TIME = '0' export const TIME_FORMAT_DEFAULT = 'MM/DD/YYYY HH:mm:ss.ss' export const TIME_FORMAT_CUSTOM = 'Custom' +export const TIME_FORMAT_TOOLTIP_LINK = + 'http://momentjs.com/docs/#/parsing/string-format/' + export const TIME_FIELD_DEFAULT = { internalName: 'time', displayName: '', diff --git a/ui/test/dashboards/components/GraphOptionsTimeFormat.test.tsx b/ui/test/dashboards/components/GraphOptionsTimeFormat.test.tsx index 288c8c2496..50748a2fe1 100644 --- a/ui/test/dashboards/components/GraphOptionsTimeFormat.test.tsx +++ b/ui/test/dashboards/components/GraphOptionsTimeFormat.test.tsx @@ -6,7 +6,10 @@ import GraphOptionsTimeFormat from 'src/dashboards/components/GraphOptionsTimeFo import {Dropdown} from 'src/shared/components/Dropdown' import InputClickToEdit from 'src/shared/components/InputClickToEdit' import QuestionMarkTooltip from 'src/shared/components/QuestionMarkTooltip' -import {TIME_FORMAT_CUSTOM} from 'src/shared/constants/tableGraph' +import { + TIME_FORMAT_CUSTOM, + TIME_FORMAT_TOOLTIP_LINK, +} from 'src/shared/constants/tableGraph' const setup = (override = {}) => { const props = { @@ -36,8 +39,14 @@ describe('Dashboards.Components.GraphOptionsTimeFormat', () => { wrapper.setState({customFormat: true}) + const label = wrapper.find('label') + const link = label.find('a') + expect(wrapper.find(Dropdown).exists()).toBe(true) - expect(wrapper.find(QuestionMarkTooltip).exists()).toBe(true) + expect(label.exists()).toBe(true) + expect(link.exists()).toBe(true) + expect(link.prop('href')).toBe(TIME_FORMAT_TOOLTIP_LINK) + expect(link.find(QuestionMarkTooltip).exists()).toBe(true) expect(wrapper.find(InputClickToEdit).exists()).toBe(true) }) }) @@ -48,10 +57,14 @@ describe('Dashboards.Components.GraphOptionsTimeFormat', () => { const wrapper = setup({timeFormat}) const dropdown = wrapper.find(Dropdown) const input = wrapper.find(InputClickToEdit) + const label = wrapper.find('label') + const link = label.find('a') expect(dropdown.prop('selected')).toBe(TIME_FORMAT_CUSTOM) expect(input.exists()).toBe(true) expect(input.prop('value')).toBe(timeFormat) + expect(link.exists()).toBe(true) + expect(link.find(QuestionMarkTooltip).exists()).toBe(true) }) }) }) From 32ddfa7c0b9d559b528ab4ae527e22b3ac993f09 Mon Sep 17 00:00:00 2001 From: Iris Scholten Date: Fri, 23 Mar 2018 17:42:39 -0700 Subject: [PATCH 14/26] add tests for GraphOptionsFixFirstColumn --- .../GraphOptionsFixFirstColumn.test.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ui/test/dashboards/components/GraphOptionsFixFirstColumn.test.tsx b/ui/test/dashboards/components/GraphOptionsFixFirstColumn.test.tsx index e4da2b42c6..36c36ddd1a 100644 --- a/ui/test/dashboards/components/GraphOptionsFixFirstColumn.test.tsx +++ b/ui/test/dashboards/components/GraphOptionsFixFirstColumn.test.tsx @@ -26,5 +26,23 @@ describe('Dashboards.Components.GraphOptionsFixFirstColumn', () => { expect(checkbox.exists()).toBe(true) expect(checkbox.prop('type')).toBe('checkbox') }) + + describe('if fixed is true', () => { + it('input is checked', () => { + const {wrapper} = setup() + const checkbox = wrapper.find('input') + + expect(checkbox.prop('checked')).toBe(true) + }) + }) + + describe('if fixed is false', () => { + it('input is not checked', () => { + const {wrapper} = setup({fixed: false}) + const checkbox = wrapper.find('input') + + expect(checkbox.prop('checked')).toBe(false) + }) + }) }) }) From efccecad724c7a224a674b31121d1c8af23fd08f Mon Sep 17 00:00:00 2001 From: Iris Scholten Date: Fri, 23 Mar 2018 17:53:34 -0700 Subject: [PATCH 15/26] update GraphOptionsCustomizableField.test --- .../GraphOptionsCustomizableField.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ui/test/dashboards/components/GraphOptionsCustomizableField.test.tsx b/ui/test/dashboards/components/GraphOptionsCustomizableField.test.tsx index efd71333a1..52dc1d214b 100644 --- a/ui/test/dashboards/components/GraphOptionsCustomizableField.test.tsx +++ b/ui/test/dashboards/components/GraphOptionsCustomizableField.test.tsx @@ -45,6 +45,23 @@ describe('Dashboards.Components.GraphOptionsCustomizableField', () => { }) }) + describe('when there is an displayName', () => { + it('display name displayed by input click to edit', () => { + const internalName = 'test' + const displayName = 'TESTING' + const {wrapper} = setup({internalName, displayName}) + const label = wrapper.find('div').last() + const icon = wrapper.find('span') + const input = wrapper.find(InputClickToEdit) + + expect(label.exists()).toBe(true) + expect(label.children().contains(internalName)).toBe(true) + expect(icon.exists()).toBe(true) + expect(input.exists()).toBe(true) + expect(input.prop('value')).toBe(displayName) + }) + }) + describe('when visible is false', () => { it('displays disabled inputClickToEdit', () => { const visible = false From 3550b2110606a72867767d86f72a08a156afbd21 Mon Sep 17 00:00:00 2001 From: Iris Scholten Date: Fri, 23 Mar 2018 17:56:23 -0700 Subject: [PATCH 16/26] update GraphOptionsCustomizeFields.test --- .../components/GraphOptionsCustomizeFields.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/test/dashboards/components/GraphOptionsCustomizeFields.test.tsx b/ui/test/dashboards/components/GraphOptionsCustomizeFields.test.tsx index b4d504b23b..c393a1502e 100644 --- a/ui/test/dashboards/components/GraphOptionsCustomizeFields.test.tsx +++ b/ui/test/dashboards/components/GraphOptionsCustomizeFields.test.tsx @@ -24,12 +24,12 @@ describe('Dashboards.Components.GraphOptionsCustomizableField', () => { const fields = [TIME_FIELD_DEFAULT] const {wrapper} = setup({fields}) const label = wrapper.find('label') - const CustomizableFields = wrapper.find(GraphOptionsCustomizableField) + const customizableFields = wrapper.find(GraphOptionsCustomizableField) const Scrollbox = wrapper.find(FancyScrollbar) expect(label.exists()).toBe(true) - expect(CustomizableFields.exists()).toBe(true) - expect(CustomizableFields.length).toBe(fields.length) + expect(customizableFields.exists()).toBe(true) + expect(customizableFields.length).toBe(fields.length) expect(Scrollbox.exists()).toBe(true) }) }) From 5789f41b7cb0ef8288665fc171af6e5e5cdde0bc Mon Sep 17 00:00:00 2001 From: Iris Scholten Date: Fri, 23 Mar 2018 18:39:45 -0700 Subject: [PATCH 17/26] update getters in TableOptions.test --- ui/src/dashboards/components/TableOptions.tsx | 4 +- .../components/TableOptions.test.tsx | 98 ++++++++++++++++++- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/ui/src/dashboards/components/TableOptions.tsx b/ui/src/dashboards/components/TableOptions.tsx index 39c1dd6567..f6433e9862 100644 --- a/ui/src/dashboards/components/TableOptions.tsx +++ b/ui/src/dashboards/components/TableOptions.tsx @@ -53,7 +53,7 @@ export class TableOptions extends PureComponent { return fieldNames || [] } - get timeColumn() { + get timeField() { return ( this.fieldNames.find(f => f.internalName === 'time') || TIME_FIELD_DEFAULT ) @@ -63,7 +63,7 @@ export class TableOptions extends PureComponent { const {dataLabels} = this.props return _.isEmpty(dataLabels) - ? [this.timeColumn] + ? [this.timeField] : dataLabels.map(label => { const existing = this.fieldNames.find(f => f.internalName === label) return ( diff --git a/ui/test/dashboards/components/TableOptions.test.tsx b/ui/test/dashboards/components/TableOptions.test.tsx index 6dda3556e6..96c7985748 100644 --- a/ui/test/dashboards/components/TableOptions.test.tsx +++ b/ui/test/dashboards/components/TableOptions.test.tsx @@ -9,6 +9,7 @@ import {TableOptions} from 'src/dashboards/components/TableOptions' import FancyScrollbar from 'src/shared/components/FancyScrollbar' import ThresholdsList from 'src/shared/components/ThresholdsList' import ThresholdsListTypeToggle from 'src/shared/components/ThresholdsListTypeToggle' +import {TIME_FIELD_DEFAULT} from 'src/shared/constants/tableGraph' const defaultProps = { dataLabels: [], @@ -32,12 +33,105 @@ const setup = (override = {}) => { const wrapper = shallow() - return {wrapper, props} + const instance = wrapper.instance() as TableOptions + + return {wrapper, instance, props} } +const dataLabels = ['time', 'foo', 'bar'] describe('Dashboards.Components.TableOptions', () => { describe('getters', () => { - describe('computedColumnNames', () => {}) + describe('fieldNames', () => { + describe('if fieldNames are passed in tableOptions as props', () => { + it('returns fieldNames', () => { + const fieldNames = [ + {internalName: 'time', displayName: 'TIME', visible: true}, + {internalName: 'foo', displayName: 'BAR', visible: false}, + ] + const {instance} = setup({tableOptions: {fieldNames}}) + + expect(instance.fieldNames).toBe(fieldNames) + }) + }) + + describe('if fieldNames are not passed in tableOptions as props', () => { + it('returns empty array', () => { + const {instance} = setup() + + expect(instance.fieldNames).toEqual([]) + }) + }) + }) + + describe('timeField', () => { + describe('if time field in fieldNames', () => { + it('returns time field', () => { + const timeField = { + internalName: 'time', + displayName: 'TIME', + visible: true, + } + const fieldNames = [ + timeField, + {internalName: 'foo', displayName: 'BAR', visible: false}, + ] + const {instance} = setup({tableOptions: {fieldNames}}) + + expect(instance.timeField).toBe(timeField) + }) + }) + + describe('if time field not in fieldNames', () => { + it('returns default time field', () => { + const fieldNames = [ + {internalName: 'foo', displayName: 'BAR', visible: false}, + ] + const {instance} = setup({tableOptions: {fieldNames}}) + + expect(instance.timeField).toBe(TIME_FIELD_DEFAULT) + }) + }) + }) + + describe('computedFieldNames', () => { + describe('if dataLabels are not passed in', () => { + it('returns an array of the time column', () => { + const {instance} = setup() + + expect(instance.computedFieldNames).toEqual([TIME_FIELD_DEFAULT]) + }) + }) + + describe('if dataLabels are passed in', () => { + describe('if dataLabel has a matching fieldName', () => { + it('returns array with the matching fieldName', () => { + const fieldNames = [ + {internalName: 'foo', displayName: 'bar', visible: true}, + ] + const dataLabels = ['foo'] + const {instance} = setup({tableOptions: {fieldNames}, dataLabels}) + + expect(instance.computedFieldNames).toEqual(fieldNames) + }) + }) + + describe('if dataLabel does not have a matching fieldName', () => { + it('returns array with a new fieldName created for it', () => { + const fieldNames = [ + {internalName: 'time', displayName: 'bar', visible: true}, + ] + const unmatchedLabel = 'foo' + const dataLabels = ['time', unmatchedLabel] + const {instance} = setup({tableOptions: {fieldNames}, dataLabels}) + + expect(instance.computedFieldNames).toEqual([ + ...fieldNames, + {internalName: unmatchedLabel, displayName: '', visible: true}, + ]) + }) + }) + }) + }) }) describe('rendering', () => { From 39674cab1909d58e45f7c1e6ddd6d8439770f9b7 Mon Sep 17 00:00:00 2001 From: Iris Scholten Date: Mon, 26 Mar 2018 09:54:02 -0700 Subject: [PATCH 18/26] fix prettier errors --- .../dashboards/components/GraphOptionsTimeFormat.tsx | 10 ++++++---- ui/test/dashboards/components/TableOptions.test.tsx | 1 - 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/ui/src/dashboards/components/GraphOptionsTimeFormat.tsx b/ui/src/dashboards/components/GraphOptionsTimeFormat.tsx index 9df87f6737..24512e98f3 100644 --- a/ui/src/dashboards/components/GraphOptionsTimeFormat.tsx +++ b/ui/src/dashboards/components/GraphOptionsTimeFormat.tsx @@ -61,13 +61,14 @@ class GraphOptionsTimeFormat extends PureComponent {
    { className="dropdown-stretch" onChoose={this.handleChooseFormat} /> - {showCustom && + {showCustom && (
    { placeholder="Enter custom format..." appearAsNormalInput={true} /> -
    } +
    + )}
    ) } diff --git a/ui/test/dashboards/components/TableOptions.test.tsx b/ui/test/dashboards/components/TableOptions.test.tsx index 96c7985748..c016ceacf2 100644 --- a/ui/test/dashboards/components/TableOptions.test.tsx +++ b/ui/test/dashboards/components/TableOptions.test.tsx @@ -37,7 +37,6 @@ const setup = (override = {}) => { return {wrapper, instance, props} } -const dataLabels = ['time', 'foo', 'bar'] describe('Dashboards.Components.TableOptions', () => { describe('getters', () => { From 1939c674acca33f6ec22b65bb5f5a393482a7361 Mon Sep 17 00:00:00 2001 From: Iris Scholten Date: Mon, 26 Mar 2018 11:24:05 -0700 Subject: [PATCH 19/26] add tests for timeSeriesToTableGraph function --- ui/test/utils/timeSeriesToDygraph.test.js | 175 +++++++++++++++++++++- 1 file changed, 174 insertions(+), 1 deletion(-) diff --git a/ui/test/utils/timeSeriesToDygraph.test.js b/ui/test/utils/timeSeriesToDygraph.test.js index b00c2f01e0..c5df3a8798 100644 --- a/ui/test/utils/timeSeriesToDygraph.test.js +++ b/ui/test/utils/timeSeriesToDygraph.test.js @@ -1,4 +1,6 @@ -import timeSeriesToDygraph from 'src/utils/timeSeriesToDygraph' +import timeSeriesToDygraph, { + timeSeriesToTableGraph, +} from 'src/utils/timeSeriesToDygraph' describe('timeSeriesToDygraph', () => { it('parses a raw InfluxDB response into a dygraph friendly data format', () => { @@ -288,3 +290,174 @@ describe('timeSeriesToDygraph', () => { expect(actual.labels).toEqual(expected) }) }) + +describe('timeSeriesToTableGraph', () => { + it('parses raw data into a nested array of data', () => { + const influxResponse = [ + { + response: { + results: [ + { + series: [ + { + name: 'mb', + columns: ['time', 'f1'], + values: [[1000, 1], [2000, 2]], + }, + ], + }, + { + series: [ + { + name: 'ma', + columns: ['time', 'f1'], + values: [[1000, 1], [2000, 2]], + }, + ], + }, + { + series: [ + { + name: 'mc', + columns: ['time', 'f2'], + values: [[2000, 3], [4000, 4]], + }, + ], + }, + { + series: [ + { + name: 'mc', + columns: ['time', 'f1'], + values: [[2000, 3], [4000, 4]], + }, + ], + }, + ], + }, + }, + ] + + const actual = timeSeriesToTableGraph(influxResponse) + const expected = [ + ['time', 'ma.f1', 'mb.f1', 'mc.f1', 'mc.f2'], + [1000, 1, 1, null, null], + [2000, 2, 2, 3, 3], + [4000, null, null, 4, 4], + ] + expect(actual.data).toEqual(expected) + }) + + it('returns labels starting with time and then alphabetized', () => { + const influxResponse = [ + { + response: { + results: [ + { + series: [ + { + name: 'mb', + columns: ['time', 'f1'], + values: [[1000, 1], [2000, 2]], + }, + ], + }, + { + series: [ + { + name: 'ma', + columns: ['time', 'f1'], + values: [[1000, 1], [2000, 2]], + }, + ], + }, + { + series: [ + { + name: 'mc', + columns: ['time', 'f2'], + values: [[2000, 3], [4000, 4]], + }, + ], + }, + { + series: [ + { + name: 'mc', + columns: ['time', 'f1'], + values: [[2000, 3], [4000, 4]], + }, + ], + }, + ], + }, + }, + ] + + const actual = timeSeriesToTableGraph(influxResponse) + const expected = ['time', 'ma.f1', 'mb.f1', 'mc.f1', 'mc.f2'] + + expect(actual.labels).toEqual(expected) + }) + + it('parses raw data into a table-readable format with the first row being labels', () => { + const influxResponse = [ + { + response: { + results: [ + { + series: [ + { + name: 'mb', + columns: ['time', 'f1'], + values: [[1000, 1], [2000, 2]], + }, + ], + }, + { + series: [ + { + name: 'ma', + columns: ['time', 'f1'], + values: [[1000, 1], [2000, 2]], + }, + ], + }, + { + series: [ + { + name: 'mc', + columns: ['time', 'f2'], + values: [[2000, 3], [4000, 4]], + }, + ], + }, + { + series: [ + { + name: 'mc', + columns: ['time', 'f1'], + values: [[2000, 3], [4000, 4]], + }, + ], + }, + ], + }, + }, + ] + + const actual = timeSeriesToTableGraph(influxResponse) + const expected = ['time', 'ma.f1', 'mb.f1', 'mc.f1', 'mc.f2'] + + expect(actual.data[0]).toEqual(expected) + }) + + it('returns an array of an empty array if there is an empty response', () => { + const influxResponse = [] + + const actual = timeSeriesToTableGraph(influxResponse) + const expected = [[]] + + expect(actual.data).toEqual(expected) + }) +}) From aad5bce0523f2b8bd578ebe50b4a29e557565303 Mon Sep 17 00:00:00 2001 From: Iris Scholten Date: Mon, 26 Mar 2018 12:20:54 -0700 Subject: [PATCH 20/26] add test for processing TableGraph data --- ui/src/shared/components/TableGraph.js | 9 +- ui/test/shared/components/TableGraph.test.tsx | 223 ++++++++++++++++++ 2 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 ui/test/shared/components/TableGraph.test.tsx diff --git a/ui/src/shared/components/TableGraph.js b/ui/src/shared/components/TableGraph.js index 3ae2f5f594..55b583e0c2 100644 --- a/ui/src/shared/components/TableGraph.js +++ b/ui/src/shared/components/TableGraph.js @@ -18,11 +18,11 @@ import { VERTICAL_TIME_AXIS_DEFAULT, calculateTimeColumnWidth, } from 'src/shared/constants/tableGraph' -const DEFAULT_SORT = ASCENDING +export const DEFAULT_SORT = ASCENDING import {generateThresholdsListHexs} from 'shared/constants/colorOperations' -const filterInvisibleColumns = (data, fieldNames) => { +export const filterInvisibleColumns = (data, fieldNames) => { const visibility = {} const filteredData = data.map((row, i) => { return row.filter((col, j) => { @@ -36,7 +36,7 @@ const filterInvisibleColumns = (data, fieldNames) => { return filteredData[0].length ? filteredData : [[]] } -const processData = ( +export const processData = ( data, sortFieldName, direction, @@ -386,10 +386,9 @@ class TableGraph extends Component { } } -const {arrayOf, bool, number, shape, string, func} = PropTypes +const {arrayOf, bool, shape, string, func} = PropTypes TableGraph.propTypes = { - cellHeight: number, data: arrayOf(shape()), tableOptions: shape({ timeFormat: string.isRequired, diff --git a/ui/test/shared/components/TableGraph.test.tsx b/ui/test/shared/components/TableGraph.test.tsx new file mode 100644 index 0000000000..f8547f85f3 --- /dev/null +++ b/ui/test/shared/components/TableGraph.test.tsx @@ -0,0 +1,223 @@ +import React from 'react' + +import {shallow} from 'enzyme' + +import TableGraph, { + filterInvisibleColumns, + processData, + DEFAULT_SORT, +} from 'src/shared/components/TableGraph' + +const setup = (override = []) => { + const props = { + data: [], + tableOptions: { + timeFormat: '', + verticalTimeAxis: true, + sortBy: { + internalName: '', + displayName: '', + visible: true, + }, + wrapping: '', + fieldNames: [], + fixFirstColumn: true, + }, + hoverTime: '', + onSetHoverTime: () => {}, + colors: [], + setDataLabels: () => {}, + ...override, + } + + const data = [ + ['time', 'f1', 'f2'], + [1000, 3000, 2000], + [2000, 1000, 3000], + [3000, 2000, 1000], + ] + + const wrapper = shallow() + const instance = wrapper.instance() as TableGraph + return {wrapper, instance, props, data} +} + +describe('Components.Shared.TableGraph', () => { + describe('functions', () => { + describe('filterInvisibleColumns', () => { + it("returns a nested array of that only include columns whose corresponding fieldName's visibility is true", () => { + const {data} = setup() + + const fieldNames = [ + {internalName: 'time', displayName: 'Time', visible: true}, + {internalName: 'f1', displayName: '', visible: false}, + {internalName: 'f2', displayName: 'F2', visible: false}, + ] + + const actual = filterInvisibleColumns(data, fieldNames) + const expected = [['time'], [1000], [2000], [3000]] + expect(actual).toEqual(expected) + }) + + it('returns an array of an empty array if all fieldNames are not visible', () => { + const {data} = setup() + + const fieldNames = [ + {internalName: 'time', displayName: 'Time', visible: false}, + {internalName: 'f1', displayName: '', visible: false}, + {internalName: 'f2', displayName: 'F2', visible: false}, + ] + + const actual = filterInvisibleColumns(data, fieldNames) + const expected = [[]] + expect(actual).toEqual(expected) + }) + }) + + describe('processData', () => { + it('sorts the data based on the provided sortFieldName', () => { + const {data} = setup() + const sortFieldName = 'f1' + const direction = DEFAULT_SORT + const verticalTimeAxis = true + + const fieldNames = [ + {internalName: 'time', displayName: 'Time', visible: true}, + {internalName: 'f1', displayName: '', visible: true}, + {internalName: 'f2', displayName: 'F2', visible: true}, + ] + + const actual = processData( + data, + sortFieldName, + direction, + verticalTimeAxis, + fieldNames + ) + const expected = [ + ['time', 'f1', 'f2'], + [2000, 1000, 3000], + [3000, 2000, 1000], + [1000, 3000, 2000], + ] + + expect(actual.processedData).toEqual(expected) + }) + + it('filters out invisible columns', () => { + const {data} = setup() + const sortFieldName = 'time' + const direction = DEFAULT_SORT + const verticalTimeAxis = true + + const fieldNames = [ + {internalName: 'time', displayName: 'Time', visible: true}, + {internalName: 'f1', displayName: '', visible: false}, + {internalName: 'f2', displayName: 'F2', visible: true}, + ] + + const actual = processData( + data, + sortFieldName, + direction, + verticalTimeAxis, + fieldNames + ) + const expected = [ + ['time', 'f2'], + [1000, 2000], + [2000, 3000], + [3000, 1000], + ] + + expect(actual.processedData).toEqual(expected) + }) + + it('filters out invisible columns after sorting', () => { + const {data} = setup() + const sortFieldName = 'f1' + const direction = DEFAULT_SORT + const verticalTimeAxis = true + + const fieldNames = [ + {internalName: 'time', displayName: 'Time', visible: true}, + {internalName: 'f1', displayName: '', visible: false}, + {internalName: 'f2', displayName: 'F2', visible: true}, + ] + + const actual = processData( + data, + sortFieldName, + direction, + verticalTimeAxis, + fieldNames + ) + const expected = [ + ['time', 'f2'], + [2000, 3000], + [3000, 1000], + [1000, 2000], + ] + + expect(actual.processedData).toEqual(expected) + }) + + describe('if verticalTimeAxis is false', () => { + it('transforms data', () => { + const {data} = setup() + const sortFieldName = 'time' + const direction = DEFAULT_SORT + const verticalTimeAxis = false + + const fieldNames = [ + {internalName: 'time', displayName: 'Time', visible: true}, + {internalName: 'f1', displayName: '', visible: true}, + {internalName: 'f2', displayName: 'F2', visible: true}, + ] + + const actual = processData( + data, + sortFieldName, + direction, + verticalTimeAxis, + fieldNames + ) + const expected = [ + ['time', 1000, 2000, 3000], + ['f1', 3000, 1000, 2000], + ['f2', 2000, 3000, 1000], + ] + + expect(actual.processedData).toEqual(expected) + }) + + it('transforms data after filtering out invisible columns', () => { + const {data} = setup() + const sortFieldName = 'f1' + const direction = DEFAULT_SORT + const verticalTimeAxis = false + + const fieldNames = [ + {internalName: 'time', displayName: 'Time', visible: true}, + {internalName: 'f1', displayName: '', visible: false}, + {internalName: 'f2', displayName: 'F2', visible: true}, + ] + + const actual = processData( + data, + sortFieldName, + direction, + verticalTimeAxis, + fieldNames + ) + const expected = [ + ['time', 2000, 3000, 1000], + ['f2', 3000, 1000, 2000], + ] + + expect(actual.processedData).toEqual(expected) + }) + }) + }) + }) +}) From 041887492ec29adfc43757800b547f215c0bb03d Mon Sep 17 00:00:00 2001 From: Benjamin Schweizer Date: Wed, 28 Mar 2018 13:34:30 +0200 Subject: [PATCH 21/26] gofmt --- oauth2/mux.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/oauth2/mux.go b/oauth2/mux.go index 9bf7e76712..256cea6922 100644 --- a/oauth2/mux.go +++ b/oauth2/mux.go @@ -118,12 +118,12 @@ func (j *AuthMux) Callback() http.Handler { // if we received an extra id_token, inspect it var id string - var group string + var group string if token.Extra("id_token") != nil && token.Extra("id_token") != "" { log.Debug("token contains extra id_token") if provider, ok := j.Provider.(ExtendedProvider); ok { log.Debug("provider implements PrincipalIDFromClaims()") - tokenString, ok := token.Extra("id_token").(string) + tokenString, ok := token.Extra("id_token").(string) if !ok { log.Error("cannot cast id_token as string") http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) @@ -136,13 +136,13 @@ func (j *AuthMux) Callback() http.Handler { return } log.Debug("found claims: ", claims) - id, err = provider.PrincipalIDFromClaims(claims) + id, err = provider.PrincipalIDFromClaims(claims) if err != nil { log.Error("requested claim not found in id_token:", err) http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) return } - group, err = provider.GroupFromClaims(claims) + group, err = provider.GroupFromClaims(claims) if err != nil { log.Error("requested claim not found in id_token:", err) http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) @@ -152,16 +152,16 @@ func (j *AuthMux) Callback() http.Handler { log.Debug("provider does not implement PrincipalIDFromClaims()") } } else { - // otherwise perform an additional lookup - oauthClient := conf.Client(r.Context(), token) + // otherwise perform an additional lookup + oauthClient := conf.Client(r.Context(), token) // Using the token get the principal identifier from the provider - id, err = j.Provider.PrincipalID(oauthClient) + id, err = j.Provider.PrincipalID(oauthClient) if err != nil { log.Error("Unable to get principal identifier ", err.Error()) http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) return } - group, err = j.Provider.Group(oauthClient) + group, err = j.Provider.Group(oauthClient) if err != nil { log.Error("Unable to get OAuth Group", err.Error()) http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) From a231ec11083517924774c0260aeb7e151c2434c0 Mon Sep 17 00:00:00 2001 From: Benjamin Schweizer Date: Wed, 28 Mar 2018 15:51:10 +0200 Subject: [PATCH 22/26] added USE_ID_TOKEN option, defaulting off --- oauth2/mux.go | 12 +++++++++--- server/server.go | 11 ++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/oauth2/mux.go b/oauth2/mux.go index 256cea6922..fac1f7eea6 100644 --- a/oauth2/mux.go +++ b/oauth2/mux.go @@ -16,7 +16,7 @@ var _ Mux = &AuthMux{} const TenMinutes = 10 * time.Minute // NewAuthMux constructs a Mux handler that checks a cookie against the authenticator -func NewAuthMux(p Provider, a Authenticator, t Tokenizer, basepath string, l chronograf.Logger) *AuthMux { +func NewAuthMux(p Provider, a Authenticator, t Tokenizer, basepath string, l chronograf.Logger, UseIDToken bool) *AuthMux { return &AuthMux{ Provider: p, Auth: a, @@ -25,6 +25,7 @@ func NewAuthMux(p Provider, a Authenticator, t Tokenizer, basepath string, l chr FailureURL: path.Join(basepath, "/login"), Now: DefaultNowTime, Logger: l, + UseIDToken: UseIDToken, } } @@ -41,6 +42,7 @@ type AuthMux struct { SuccessURL string // SuccessURL is redirect location after successful authorization FailureURL string // FailureURL is redirect location after authorization failure Now func() time.Time // Now returns the current time (for testing) + UseIDToken bool // UseIDToken enables OpenID id_token support } // Login uses a Cookie with a random string as the state validation method. JWTs are @@ -116,11 +118,15 @@ func (j *AuthMux) Callback() http.Handler { return } + if token.Extra("id_token") != nil && !j.UseIDToken { + log.Info("found an extra id_token, but option --useidtoken is not set") + } + // if we received an extra id_token, inspect it var id string var group string - if token.Extra("id_token") != nil && token.Extra("id_token") != "" { - log.Debug("token contains extra id_token") + if j.UseIDToken && token.Extra("id_token") != nil && token.Extra("id_token") != "" { + log.Debug("found an extra id_token") if provider, ok := j.Provider.(ExtendedProvider); ok { log.Debug("provider implements PrincipalIDFromClaims()") tokenString, ok := token.Extra("id_token").(string) diff --git a/server/server.go b/server/server.go index 3ec1e1de89..7daf3e3190 100644 --- a/server/server.go +++ b/server/server.go @@ -58,6 +58,7 @@ type Server struct { ResourcesPath string `long:"resources-path" description:"Path to directory of pre-canned dashboards, sources, kapacitors, and organizations (/usr/share/chronograf/resources)" env:"RESOURCES_PATH" default:"canned"` TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"` JwksURL string `long:"jwks-url" description:"URL that returns OpenID Key Discovery JWKS document." env:"JWKS_URL"` + UseIDToken bool `long:"useidtoken" description:"Enable id_token processing." env:"USE_ID_TOKEN"` AuthDuration time.Duration `long:"auth-duration" default:"720h" description:"Total duration of cookie life for authentication (in hours). 0 means authentication expires on browser close." env:"AUTH_DURATION"` GithubClientID string `short:"i" long:"github-client-id" description:"Github Client ID for OAuth 2 support" env:"GH_CLIENT_ID"` @@ -146,7 +147,7 @@ func (s *Server) githubOAuth(logger chronograf.Logger, auth oauth2.Authenticator Logger: logger, } jwt := oauth2.NewJWT(s.TokenSecret, s.JwksURL) - ghMux := oauth2.NewAuthMux(&gh, auth, jwt, s.Basepath, logger) + ghMux := oauth2.NewAuthMux(&gh, auth, jwt, s.Basepath, logger, s.UseIDToken) return &gh, ghMux, s.UseGithub } @@ -160,7 +161,7 @@ func (s *Server) googleOAuth(logger chronograf.Logger, auth oauth2.Authenticator Logger: logger, } jwt := oauth2.NewJWT(s.TokenSecret, s.JwksURL) - goMux := oauth2.NewAuthMux(&google, auth, jwt, s.Basepath, logger) + goMux := oauth2.NewAuthMux(&google, auth, jwt, s.Basepath, logger, s.UseIDToken) return &google, goMux, s.UseGoogle } @@ -172,7 +173,7 @@ func (s *Server) herokuOAuth(logger chronograf.Logger, auth oauth2.Authenticator Logger: logger, } jwt := oauth2.NewJWT(s.TokenSecret, s.JwksURL) - hMux := oauth2.NewAuthMux(&heroku, auth, jwt, s.Basepath, logger) + hMux := oauth2.NewAuthMux(&heroku, auth, jwt, s.Basepath, logger, s.UseIDToken) return &heroku, hMux, s.UseHeroku } @@ -191,7 +192,7 @@ func (s *Server) genericOAuth(logger chronograf.Logger, auth oauth2.Authenticato Logger: logger, } jwt := oauth2.NewJWT(s.TokenSecret, s.JwksURL) - genMux := oauth2.NewAuthMux(&gen, auth, jwt, s.Basepath, logger) + genMux := oauth2.NewAuthMux(&gen, auth, jwt, s.Basepath, logger, s.UseIDToken) return &gen, genMux, s.UseGenericOAuth2 } @@ -207,7 +208,7 @@ func (s *Server) auth0OAuth(logger chronograf.Logger, auth oauth2.Authenticator) auth0, err := oauth2.NewAuth0(s.Auth0Domain, s.Auth0ClientID, s.Auth0ClientSecret, redirectURL.String(), s.Auth0Organizations, logger) jwt := oauth2.NewJWT(s.TokenSecret, s.JwksURL) - genMux := oauth2.NewAuthMux(&auth0, auth, jwt, s.Basepath, logger) + genMux := oauth2.NewAuthMux(&auth0, auth, jwt, s.Basepath, logger, s.UseIDToken) if err != nil { logger.Error("Error parsing Auth0 domain: err:", err) From 850e8c5533cb8421720cebec4d377abc1ec8c7b1 Mon Sep 17 00:00:00 2001 From: Luke Morris Date: Wed, 28 Mar 2018 11:11:09 -0700 Subject: [PATCH 23/26] Support custom time range in annotations api wrapper --- ui/src/dashboards/containers/DashboardPage.js | 29 ++++++++++++++----- ui/src/shared/actions/annotations.js | 7 +++-- ui/src/shared/apis/annotation.js | 4 +-- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/ui/src/dashboards/containers/DashboardPage.js b/ui/src/dashboards/containers/DashboardPage.js index 29516e202d..0e05ead688 100644 --- a/ui/src/dashboards/containers/DashboardPage.js +++ b/ui/src/dashboards/containers/DashboardPage.js @@ -78,10 +78,8 @@ class DashboardPage extends Component { timeRange, } = this.props - getAnnotationsAsync( - source.links.annotations, - Date.now() - timeRange.seconds * 1000 - ) + const annotationRange = this.millisecondTimeRange(timeRange) + getAnnotationsAsync(source.links.annotations, annotationRange) window.addEventListener('resize', this.handleWindowResize, true) const dashboards = await getDashboardsAsync() @@ -159,10 +157,25 @@ class DashboardPage extends Component { ...timeRange, format: FORMAT_INFLUXQL, }) - getAnnotationsAsync( - source.links.annotations, - Date.now() - timeRange.seconds * 1000 - ) + + const annotationRange = this.millisecondTimeRange(timeRange) + getAnnotationsAsync(source.links.annotations, annotationRange) + } + + millisecondTimeRange({seconds, lower, upper}) { + // Is this a relative time range? + if (seconds) { + return { + since: Date.now() - seconds * 1000, + until: null, + } + } + + // No, this is an absolute (custom) time range + return { + since: Date.parse(lower), + until: Date.parse(upper), + } } handleUpdatePosition = cells => { diff --git a/ui/src/shared/actions/annotations.js b/ui/src/shared/actions/annotations.js index d4f151f362..328cdb1a10 100644 --- a/ui/src/shared/actions/annotations.js +++ b/ui/src/shared/actions/annotations.js @@ -63,8 +63,11 @@ export const addAnnotationAsync = (createUrl, annotation) => async dispatch => { dispatch(deleteAnnotation(annotation)) } -export const getAnnotationsAsync = (indexUrl, since) => async dispatch => { - const annotations = await api.getAnnotations(indexUrl, since) +export const getAnnotationsAsync = ( + indexUrl, + {since, until} +) => async dispatch => { + const annotations = await api.getAnnotations(indexUrl, since, until) dispatch(loadAnnotations(annotations)) } diff --git a/ui/src/shared/apis/annotation.js b/ui/src/shared/apis/annotation.js index f410222609..e36c24859d 100644 --- a/ui/src/shared/apis/annotation.js +++ b/ui/src/shared/apis/annotation.js @@ -19,11 +19,11 @@ export const createAnnotation = async (url, annotation) => { return annoToMillisecond(response.data) } -export const getAnnotations = async (url, since) => { +export const getAnnotations = async (url, since, until) => { const {data} = await AJAX({ method: 'GET', url, - params: {since: msToRFC(since)}, + params: {since: msToRFC(since), until: msToRFC(until)}, }) return data.annotations.map(annoToMillisecond) } From 72e7db41ee2bbddcc41d82ca56ffe0b7fbfd2258 Mon Sep 17 00:00:00 2001 From: Luke Morris Date: Wed, 28 Mar 2018 11:13:42 -0700 Subject: [PATCH 24/26] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0224eff639..ee64f2e0e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ 1. [#3006](https://github.com/influxdata/chronograf/pull/3006): Fix Kapacitor Rules task enabled checkboxes to only toggle exactly as clicked 1. [#3048](https://github.com/influxdata/chronograf/pull/3048): Prevent Multi-Select Dropdown in InfluxDB Admin Users and Roles tabs from losing selection state 1. [#3068](https://github.com/influxdata/chronograf/pull/3068): Fix intermittent missing fill from graphs +1. [#3079](https://github.com/influxdata/chronograf/pull/3082): Support custom time range in annotations api wrapper ## v1.4.2.3 [2018-03-08] From c63e5118723ce772226aab0f7a14b582415142cd Mon Sep 17 00:00:00 2001 From: Andrew Watkins Date: Wed, 28 Mar 2018 11:52:32 -0700 Subject: [PATCH 25/26] Fix date format --- ui/src/shared/components/AnnotationTooltip.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/shared/components/AnnotationTooltip.js b/ui/src/shared/components/AnnotationTooltip.js index 3e0f825882..481bda39fb 100644 --- a/ui/src/shared/components/AnnotationTooltip.js +++ b/ui/src/shared/components/AnnotationTooltip.js @@ -10,7 +10,7 @@ import * as actions from 'shared/actions/annotations' const TimeStamp = ({time}) => (
    - {`${moment(+time).format('YYYY/DD/MM HH:mm:ss.SS')}`} + {`${moment(+time).format('YYYY/MM/DD HH:mm:ss.SS')}`}
    ) From dca6270f73fe433a890f56360192698371b34653 Mon Sep 17 00:00:00 2001 From: Jared Scheib Date: Wed, 28 Mar 2018 13:31:24 -0700 Subject: [PATCH 26/26] Change useidtoken to use-id-token for consistency --- server/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/server.go b/server/server.go index 7daf3e3190..c783e34f2e 100644 --- a/server/server.go +++ b/server/server.go @@ -58,7 +58,7 @@ type Server struct { ResourcesPath string `long:"resources-path" description:"Path to directory of pre-canned dashboards, sources, kapacitors, and organizations (/usr/share/chronograf/resources)" env:"RESOURCES_PATH" default:"canned"` TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"` JwksURL string `long:"jwks-url" description:"URL that returns OpenID Key Discovery JWKS document." env:"JWKS_URL"` - UseIDToken bool `long:"useidtoken" description:"Enable id_token processing." env:"USE_ID_TOKEN"` + UseIDToken bool `long:"use-id-token" description:"Enable id_token processing." env:"USE_ID_TOKEN"` AuthDuration time.Duration `long:"auth-duration" default:"720h" description:"Total duration of cookie life for authentication (in hours). 0 means authentication expires on browser close." env:"AUTH_DURATION"` GithubClientID string `short:"i" long:"github-client-id" description:"Github Client ID for OAuth 2 support" env:"GH_CLIENT_ID"`