diff --git a/Gopkg.lock b/Gopkg.lock index 4d3cee04..89231f5b 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -144,9 +144,15 @@ packages = [".","xfs"] revision = "a6e9df898b1336106c743392c48ee0b71f5c4efa" +[[projects]] + name = "github.com/skuid/go-middlewares" + packages = ["authn/google"] + revision = "4a5dfe976cd42eebc9e6da954a819f1db66d2b43" + version = "v0.1.0" + [[projects]] name = "github.com/skuid/spec" - packages = [".","lifecycle","metrics"] + packages = [".","lifecycle","metrics","middlewares"] revision = "d29ef25a1b7c2342790bfb4c3c4826f2144dd32c" version = "v1.0.5" @@ -237,7 +243,7 @@ [[projects]] branch = "master" name = "google.golang.org/api" - packages = ["internal","iterator","option","transport/grpc"] + packages = ["gensupport","googleapi","googleapi/internal/uritemplates","internal","iterator","option","plus/v1","transport/grpc"] revision = "43cf5d84d972ae38b4d00d87aedb8907e803304b" [[projects]] @@ -285,6 +291,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "000cb5e996fe574eea7abd33d4a5bb615b93954da5b3aa6b0267db0a7f3ad8ac" + inputs-digest = "28ab4562399c96b0f15ce830dd866f9dd403b9ef9f29e1c46f9687ab296d7edf" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Makefile b/Makefile index d2ca14a8..45d98538 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ completion: build cp out.sh /usr/local/etc/bash_completion.d/$(REPO) docker: - docker run --rm -v $$(pwd):/go/src/github.com/skuid/$(REPO) -w /go/src/github.com/skuid/$(REPO) golang:1.9-alpine sh -c "apk -U add gcc linux-headers musl-dev && go build -v -a -tags netgo -installsuffix netgo -ldflags '-w'" + docker run --rm -v $$(pwd):/go/src/github.com/skuid/$(REPO) -w /go/src/github.com/skuid/$(REPO) golang:1.9-alpine sh -c "apk -U add gcc linux-headers musl-dev && go build -v -ldflags '-w -X github.com/skuid/helm-value-store/vendor/github.com/skuid/spec/metrics.commit=$(SHA)'" docker build -t skuid/$(REPO) . clean: diff --git a/README.md b/README.md index 66eb46f4..85fabbb4 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,43 @@ If this is your first time using helm-value-store, you will need to create a Dyn helm-value-store load --setup --file <(echo "[]") ``` +## Server + +Helm value store ships with a `server` subcommand that runs an HTTP server for +applying charts into a cluster. + +The server only has one endpoint, `/apply` that accepts the following input: + +``` +HTTP1.1 POST /apply +{ + "uuid": "6fad4903-58ec-446f-bda4-bd39c4ff96aa" +} +``` + +The response structure is: + +```json +{ + "status": "success", + "message": "Successfully installed alertmanager" +} +``` + + +By default, the server accepts a Google Oauth2 ID token in the Authorization +header for verifying a user against Google and ensuring their email is in a +given domain. + +The server also listens on an alternate port (default `3001`) for the following +endpoints. + +``` +/metrics - Prometheus metrics +/live - for liveness checks +/ready - for readiness checks +``` + ## Usage ``` diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 00000000..ccb2f465 --- /dev/null +++ b/cmd/serve.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "flag" + "fmt" + "net/http" + + "github.com/skuid/go-middlewares/authn/google" + "github.com/skuid/helm-value-store/server" + "github.com/skuid/spec" + "github.com/skuid/spec/lifecycle" + "github.com/skuid/spec/middlewares" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +var level = zapcore.InfoLevel + +// serveCmd represents the serve command +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "A brief description of your command", + PreRunE: func(cmd *cobra.Command, args []string) error { + l, err := spec.NewStandardLevelLogger(level) + if err != nil { + return fmt.Errorf("Error initializing logger: %q", err) + } + zap.ReplaceGlobals(l) + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + middlewareList := []middlewares.Middleware{middlewares.InstrumentRoute()} + loggingClosures := []func(*http.Request) []zapcore.Field{} + serverOpts := []server.ControllerOpt{} + + if viper.GetBool("auth-enabled") { + authorizer := google.New(google.WithAuthorizedDomains(viper.GetString("email-domain"))) + serverOpts = append(serverOpts, server.WithAuthorizers(authorizer)) + middlewareList = append([]middlewares.Middleware{authorizer.Authorize()}, middlewareList...) + loggingClosures = append(loggingClosures, authorizer.LoggingClosure) + } + + apiController := server.NewApiController(releaseStore, serverOpts...) + middlewareList = append(middlewareList, middlewares.Logging(loggingClosures...)) + + authMux := http.NewServeMux() + authMux.HandleFunc("/apply", apiController.ApplyChart) + + mux := http.NewServeMux() + mux.Handle("/", middlewares.Apply(authMux, middlewareList...)) + + go spec.MetricsServer(viper.GetInt("metrics-port")) + + hostPort := fmt.Sprintf(":%d", viper.GetInt("port")) + httpServer := &http.Server{Addr: hostPort, Handler: mux} + lifecycle.ShutdownOnTerm(httpServer) + + zap.L().Info("Starting helm-value-store server 🃏 ", zap.Int("port", viper.GetInt("port"))) + if err := httpServer.ListenAndServe(); err != http.ErrServerClosed { + zap.L().Fatal("Error listening", zap.Error(err)) + } + zap.L().Info("Server gracefully stopped") + + }, +} + +func init() { + RootCmd.AddCommand(serveCmd) + + // Hack to make level work + set := flag.NewFlagSet("temp", flag.ExitOnError) + set.Var(&level, "level", "Log level") + levelPFlag := pflag.PFlagFromGoFlag(set.Lookup("level")) + levelPFlag.Shorthand = "l" + + localFlagSet := serveCmd.Flags() + localFlagSet.AddFlag(levelPFlag) + localFlagSet.Int64P("timeout", "t", 300, "Time in seconds to timeout on installation/update of releases") + localFlagSet.IntP("port", "p", 3000, "The port to listen on") + localFlagSet.Int("metrics-port", 3001, "The port to listen on for metrics/health checks") + localFlagSet.String("email-domain", "", "The email domain to filter on") + localFlagSet.Bool("auth-enabled", true, "Enable authentication/authorization") + + viper.BindPFlags(localFlagSet) +} diff --git a/server/apply.go b/server/apply.go new file mode 100644 index 00000000..f63617be --- /dev/null +++ b/server/apply.go @@ -0,0 +1,141 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/skuid/helm-value-store/store" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type applyRequest struct { + UUID string `json:"uuid"` +} + +type applyResponse struct { + Status string `json:"status"` + Message string `json:"message"` +} + +type upsertConfig struct { + location string + timeout int64 +} + +func upsertRelease(r *store.Release, conf upsertConfig) error { + _, err := r.Get() + + if err != nil && !strings.Contains(err.Error(), "not found") { + return err + } + + if err != nil && strings.Contains(err.Error(), "not found") { + _, err = r.Install(conf.location, false, conf.timeout) + } else if err == nil { + _, err = r.Upgrade(conf.location, false, conf.timeout) + } + + return err +} + +// ApplyChart applies a chart to a tiller server +func (c ApiController) ApplyChart(w http.ResponseWriter, r *http.Request) { + var err error + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusNotFound) + return + } + + // Fields for the autit log + var auditFields []zapcore.Field + for _, a := range c.authorizers { + auditFields = append(auditFields, a.LoggingClosure(r)...) + } + + defer func() { + successful := err == nil + auditFields = append( + auditFields, + zap.String("controller", "apply"), + zap.Bool("successful", successful), + ) + zap.L().Info("Audit Log", auditFields...) + }() + + applyReq := &applyRequest{} + + if err = json.NewDecoder(r.Body).Decode(applyReq); err != nil { + w.WriteHeader(http.StatusBadRequest) + zap.L().Error("Error decoding request", zap.Error(err)) + return + } + auditFields = append(auditFields, zap.String("uuid", applyReq.UUID)) + + release := &store.Release{} + release, err = c.releaseStore.Get(r.Context(), applyReq.UUID) + + applyResp := &applyResponse{} + + if err != nil { + zap.L().Error("Error getting release", zap.Error(err)) + + applyResp.Status = "error" + applyResp.Message = "Error getting release" + err = json.NewEncoder(w).Encode(applyResp) + if err != nil { + zap.L().Error("Error marshaling response", zap.Error(err)) + } + return + } + auditFields = append(auditFields, + zap.String("chart", release.Chart), + zap.String("release", release.Name), + zap.String("version", release.Version), + zap.String("namespace", release.Namespace), + ) + + var location string + location, err = release.Download() + + if err != nil { + zap.L().Error("Error downloading release", zap.Error(err)) + + w.WriteHeader(http.StatusInternalServerError) + applyResp.Status = "error" + applyResp.Message = "Error downloading release" + err = json.NewEncoder(w).Encode(applyResp) + + if err != nil { + zap.L().Error("Error marshaling response", zap.Error(err)) + } + return + } + + err = upsertRelease(release, upsertConfig{ + location: location, + timeout: c.timeout, + }) + + if err != nil { + zap.L().Error("Error applying release", zap.Error(err)) + + w.WriteHeader(http.StatusInternalServerError) + applyResp.Status = "error" + applyResp.Message = "Error applying release" + err = json.NewEncoder(w).Encode(applyResp) + if err != nil { + zap.L().Error("Error marshaling response", zap.Error(err)) + } + return + } + + applyResp.Status = "success" + applyResp.Message = fmt.Sprintf("Successfully installed %s", release.Name) + err = json.NewEncoder(w).Encode(applyResp) + if err != nil { + zap.L().Error("Error marshaling response", zap.Error(err)) + } +} diff --git a/server/root.go b/server/root.go new file mode 100644 index 00000000..f7aa11fb --- /dev/null +++ b/server/root.go @@ -0,0 +1,43 @@ +package server + +import ( + "github.com/skuid/go-middlewares" + "github.com/skuid/helm-value-store/store" +) + +// ApiController stores metadata for the API +type ApiController struct { + releaseStore store.ReleaseStore + authorizers []go_middlewares.Authorizer + timeout int64 +} + +// ControllerOpt is a func that modifies an ApiController +type ControllerOpt func(*ApiController) + +// WithAutorizer sets the authorizer for an ApiController +func WithAuthorizers(azs ...go_middlewares.Authorizer) ControllerOpt { + return func(a *ApiController) { + a.authorizers = azs + } +} + +// WithTimeout sets the timeout in seconds on an ApiController +func WithTimeout(timeout int64) ControllerOpt { + return func(a *ApiController) { + a.timeout = timeout + } +} + +// NewApiController returns a new API controller with a default timeout of 300 seconds +func NewApiController(s store.ReleaseStore, opts ...ControllerOpt) *ApiController { + response := &ApiController{ + releaseStore: s, + timeout: 300, + } + for _, opt := range opts { + opt(response) + } + + return response +} diff --git a/vendor/github.com/skuid/go-middlewares/.gitignore b/vendor/github.com/skuid/go-middlewares/.gitignore new file mode 100644 index 00000000..a01ee289 --- /dev/null +++ b/vendor/github.com/skuid/go-middlewares/.gitignore @@ -0,0 +1 @@ +.*.swp diff --git a/vendor/github.com/skuid/go-middlewares/.travis.yml b/vendor/github.com/skuid/go-middlewares/.travis.yml new file mode 100644 index 00000000..ba9f055a --- /dev/null +++ b/vendor/github.com/skuid/go-middlewares/.travis.yml @@ -0,0 +1,13 @@ +sudo: false +language: go + +go: +- 1.9 + +before_install: +- go get ./... + +script: +- go build -i ./... +- go test -cover ./... +- go test -race ./... diff --git a/vendor/github.com/skuid/go-middlewares/README.md b/vendor/github.com/skuid/go-middlewares/README.md new file mode 100644 index 00000000..5437ed2e --- /dev/null +++ b/vendor/github.com/skuid/go-middlewares/README.md @@ -0,0 +1,3 @@ +# go-middlewares + +A collection of middlewares for use in Skuid applications written in Go. \ No newline at end of file diff --git a/vendor/github.com/skuid/go-middlewares/authn/google/README.md b/vendor/github.com/skuid/go-middlewares/authn/google/README.md new file mode 100644 index 00000000..7803b04a --- /dev/null +++ b/vendor/github.com/skuid/go-middlewares/authn/google/README.md @@ -0,0 +1,3 @@ +# go-middlewares/authn/google + +A Google OIDC Authentication Middleware for authenticating requests that have an `Authorization` header. \ No newline at end of file diff --git a/vendor/github.com/skuid/go-middlewares/authn/google/auth.go b/vendor/github.com/skuid/go-middlewares/authn/google/auth.go new file mode 100644 index 00000000..b530cdd1 --- /dev/null +++ b/vendor/github.com/skuid/go-middlewares/authn/google/auth.go @@ -0,0 +1,189 @@ +package google + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + "sync" + + "github.com/skuid/spec/middlewares" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + plus "google.golang.org/api/plus/v1" +) + +// Authorizer authorizes a google user against a whitelist of domains +type Authorizer struct { + authorizedDomains map[string]bool + tokenMap sync.Map +} + +func (a *Authorizer) domains() []string { + response := []string{} + for k := range a.authorizedDomains { + response = append(response, k) + } + return response +} + +func (a *Authorizer) containsDomain(domain string) bool { + _, ok := a.authorizedDomains[domain] + return ok +} + +// WithAuthorizedDomains adds the specified domains to the whitelist +func WithAuthorizedDomains(domains ...string) func(*Authorizer) { + return func(a *Authorizer) { + for _, domain := range domains { + a.authorizedDomains[domain] = true + } + } +} + +// New returns a new Authorizer with default options and applies any supplied +// option functions +func New(opts ...func(*Authorizer)) *Authorizer { + a := &Authorizer{ + authorizedDomains: map[string]bool{}, + tokenMap: sync.Map{}, + } + for _, opt := range opts { + opt(a) + } + return a +} + +func getPerson(ctx context.Context, authorizationValue string) (*plus.Person, error) { + googleURL := "https://www.googleapis.com/plus/v1/people/me" + req, err := http.NewRequest(http.MethodGet, googleURL, nil) + if err != nil { + zap.L().Error("Error creating request", zap.Error(err)) + return nil, err + } + req.Header.Add("Authorization", authorizationValue) + + client := &http.Client{} + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + zap.L().Error("Error making request", zap.Error(err)) + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + zap.L().Error("Error decoding body", zap.Error(err)) + return nil, err + } + + zap.L().Info( + "Not authorized against Google", + zap.Int("code", resp.StatusCode), + zap.ByteString("body", body), + ) + return nil, fmt.Errorf("Not authorized") + } + + person := &plus.Person{} + err = json.NewDecoder(resp.Body).Decode(person) + if err != nil { + zap.L().Error("Error decoding response", zap.Error(err)) + return nil, err + } + return person, nil +} + +// Check each email and get the first that ends with a valid domain +func (a *Authorizer) extractEmail(person *plus.Person) string { + for _, email := range person.Emails { + if idx := strings.LastIndex(email.Value, "@"); idx > 0 { + domain := email.Value[idx+1:] + if a.containsDomain(domain) { + return email.Value + } + } + } + return "" +} + +func (a *Authorizer) authorize(ctx context.Context, token string) bool { + if token == "" { + zap.L().Debug("Empty token") + return false + } + + var username string + + usernameIface, ok := a.tokenMap.Load(token) + + // User is not in cache + if !ok { + + // Get the person's info + person, err := getPerson(ctx, token) + if err != nil { + zap.L().Info("Couldn't get person", zap.String("token", token)) + return false + } + + // Validate the person's domain + validated := a.containsDomain(person.Domain) + if !validated { + zap.L().Info("Invalid domain", zap.String("person", fmt.Sprintf("%#v", person)), zap.Strings("domains", a.domains())) + return false + } + + // Get the first matching email + username = a.extractEmail(person) + if username == "" { + zap.L().Info("Couldn't get person's email", zap.String("person", fmt.Sprintf("%#v", person))) + return false + } + zap.L().Debug("Storing valid user", zap.String("user", username)) + a.tokenMap.Store(token, username) + } else { + username = usernameIface.(string) + } + zap.L().Debug("Successfully authorized", zap.String("user", username)) + + return true +} + +// LoggingClosure adds a "user" field for an authorized user +func (a *Authorizer) LoggingClosure(r *http.Request) []zapcore.Field { + + token := r.Header.Get("Authorization") + if token == "" { + zap.L().Debug("No authorization header on request") + return []zapcore.Field{} + } + + usernameIface, ok := a.tokenMap.Load(token) + + if !ok { + zap.L().Debug("Couldn't exract user from request") + return []zapcore.Field{} + } + username := usernameIface.(string) + + return []zapcore.Field{zap.String("user", username)} +} + +// Authorize is a middleware for adding AuthZ to routes +func (a *Authorizer) Authorize() middlewares.Middleware { + return func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ok := a.authorize(r.Context(), r.Header.Get("Authorization")) + if !ok { + http.Error(w, "Not authorized", http.StatusUnauthorized) + return + } + + h.ServeHTTP(w, r) + }) + } +} diff --git a/vendor/github.com/skuid/go-middlewares/authn/google/auth_test.go b/vendor/github.com/skuid/go-middlewares/authn/google/auth_test.go new file mode 100644 index 00000000..612e9a28 --- /dev/null +++ b/vendor/github.com/skuid/go-middlewares/authn/google/auth_test.go @@ -0,0 +1,113 @@ +package google + +import ( + "net/http" + "testing" + + "go.uber.org/zap/zapcore" + plus "google.golang.org/api/plus/v1" +) + +func TestAuthorizedDomains(t *testing.T) { + + cases := []struct { + includedDomains []string + }{ + { + []string{"skuid.com", "skuidify.com"}, + }, + { + []string{"skuid.com"}, + }, + } + + for _, c := range cases { + authorizer := New(WithAuthorizedDomains(c.includedDomains...)) + + for _, domain := range c.includedDomains { + if !authorizer.containsDomain(domain) { + t.Errorf("Expected domain %s to be included! Got false", domain) + } + } + } +} + +func TestExtractEmail(t *testing.T) { + + includedDomain := "skuid.com" + cases := []struct { + person *plus.Person + want string + }{ + { + &plus.Person{Emails: []*plus.PersonEmails{&plus.PersonEmails{Value: "micah@skuid.com"}}}, + "micah@skuid.com", + }, + { + &plus.Person{Emails: []*plus.PersonEmails{ + &plus.PersonEmails{Type: "home", Value: "micah@example.com"}, + &plus.PersonEmails{Type: "work", Value: "micah@skuid.com"}, + }}, + "micah@skuid.com", + }, + { + &plus.Person{Emails: []*plus.PersonEmails{&plus.PersonEmails{Type: "home", Value: "micah@example.com"}}}, + "", + }, + } + + authorizer := New(WithAuthorizedDomains(includedDomain)) + for _, c := range cases { + if got := authorizer.extractEmail(c.person); c.want != got { + t.Errorf("Expected email '%s', got '%s'", c.want, got) + } + } +} + +func TestLoggingClosure(t *testing.T) { + + cases := []struct { + tokenMapSeed map[string]string + authorizationValue string + want *zapcore.Field + }{ + { + map[string]string{"Bearer abc123": "micah@skuid.com"}, + "Bearer abc123", + &zapcore.Field{Key: "user", String: "micah@skuid.com"}, + }, + { + map[string]string{"Bearer abc123": "micah@skuid.com"}, + "Bearer 111111", + nil, + }, + { + map[string]string{"Bearer abc123": "micah@skuid.com"}, + "", + nil, + }, + } + + for _, c := range cases { + authorizer := New() + for k, v := range c.tokenMapSeed { + authorizer.tokenMap.Store(k, v) + } + + req, _ := http.NewRequest(http.MethodGet, "http://localhost:8080", nil) + if c.authorizationValue != "" { + req.Header.Set("Authorization", c.authorizationValue) + } + + got := authorizer.LoggingClosure(req) + if len(got) == 0 { + if c.want != nil { + t.Errorf("Got 0 loggable fields, expected %#v", c.want) + } + continue + } + if firstField := got[0]; firstField.Key != c.want.Key && firstField.String != c.want.String { + t.Errorf("Expected fields '%#v', got '%#v'", c.want, got) + } + } +} diff --git a/vendor/github.com/skuid/go-middlewares/interface.go b/vendor/github.com/skuid/go-middlewares/interface.go new file mode 100644 index 00000000..28756d9a --- /dev/null +++ b/vendor/github.com/skuid/go-middlewares/interface.go @@ -0,0 +1,14 @@ +package go_middlewares + +import ( + "net/http" + + "github.com/skuid/spec/middlewares" + "go.uber.org/zap/zapcore" +) + +// Authorizer is an interface for authorizing requests +type Authorizer interface { + Authorize() middlewares.Middleware + LoggingClosure(r *http.Request) []zapcore.Field +} diff --git a/vendor/github.com/skuid/go-middlewares/interface_test.go b/vendor/github.com/skuid/go-middlewares/interface_test.go new file mode 100644 index 00000000..39747024 --- /dev/null +++ b/vendor/github.com/skuid/go-middlewares/interface_test.go @@ -0,0 +1,18 @@ +package go_middlewares + +import ( + "testing" + + "github.com/skuid/go-middlewares/authn/google" +) + +func TestInterface(t *testing.T) { + cases := []struct { + a Authorizer + }{ + {google.New()}, + } + for _, c := range cases { + c.a.Authorize() + } +}