diff --git a/bolt/bbolt.go b/bolt/bbolt.go index a18453ca18..7266a1fb07 100644 --- a/bolt/bbolt.go +++ b/bolt/bbolt.go @@ -98,6 +98,11 @@ func (c *Client) initialize(ctx context.Context) error { return err } + // Always create Onboarding bucket. + if err := c.initializeOnboarding(ctx, tx); err != nil { + return err + } + // Always create Source bucket. if err := c.initializeSources(ctx, tx); err != nil { return err diff --git a/bolt/onboarding.go b/bolt/onboarding.go new file mode 100644 index 0000000000..d0ea0e56c6 --- /dev/null +++ b/bolt/onboarding.go @@ -0,0 +1,136 @@ +package bolt + +import ( + "context" + + bolt "github.com/coreos/bbolt" + "github.com/influxdata/platform" +) + +var onboardingBucket = []byte("onboardingv1") +var onboardingKey = []byte("onboarding_key") + +var _ platform.OnboardingService = (*Client)(nil) + +func (c *Client) initializeOnboarding(ctx context.Context, tx *bolt.Tx) error { + if _, err := tx.CreateBucketIfNotExists([]byte(onboardingBucket)); err != nil { + return err + } + return nil +} + +// IsOnboarding checks onboardingBucket +// to see if the onboarding key is true. +func (c *Client) IsOnboarding(ctx context.Context) (isOnboarding bool, err error) { + err = c.db.View(func(tx *bolt.Tx) error { + result := tx.Bucket(onboardingBucket).Get(onboardingKey) + isOnboarding = len(result) == 0 + return nil + }) + return isOnboarding, err +} + +// PutOnboardingStatus will update the flag, +// so future onboarding request will be denied. +func (c *Client) PutOnboardingStatus(ctx context.Context, v bool) error { + if v { + return c.db.Update(func(tx *bolt.Tx) error { + return tx.Bucket(onboardingBucket).Put(onboardingKey, []byte{0x1}) + }) + } + return nil +} + +// Generate OnboardingResults from onboarding request, +// update db so this request will be disabled for the second run. +func (c *Client) Generate(ctx context.Context, req *platform.OnboardingRequest) (*platform.OnboardingResults, error) { + isOnboarding, err := c.IsOnboarding(ctx) + if err != nil { + return nil, err + } + if !isOnboarding { + return nil, &platform.Error{ + Code: platform.EConflict, + Msg: "onboarding has already been completed", + } + } + + if req.Password == "" { + return nil, &platform.Error{ + Code: platform.EEmptyValue, + Msg: "password is empty", + } + } + + if req.User == "" { + return nil, &platform.Error{ + Code: platform.EEmptyValue, + Msg: "username is empty", + } + } + + if req.Org == "" { + return nil, &platform.Error{ + Code: platform.EEmptyValue, + Msg: "org name is empty", + } + } + + if req.Bucket == "" { + return nil, &platform.Error{ + Code: platform.EEmptyValue, + Msg: "bucket name is empty", + } + } + + u := &platform.User{Name: req.User} + if err := c.CreateUser(ctx, u); err != nil { + return nil, err + } + + if err = c.SetPassword(ctx, u.Name, req.Password); err != nil { + return nil, err + } + + o := &platform.Organization{ + Name: req.Org, + } + if err = c.CreateOrganization(ctx, o); err != nil { + return nil, err + } + bucket := &platform.Bucket{ + Name: req.Bucket, + Organization: o.Name, + OrganizationID: o.ID, + } + if err = c.CreateBucket(ctx, bucket); err != nil { + return nil, err + } + auth := &platform.Authorization{ + User: u.Name, + UserID: u.ID, + Permissions: []platform.Permission{ + platform.CreateUserPermission, + platform.DeleteUserPermission, + platform.Permission{ + Resource: platform.OrganizationResource, + Action: platform.WriteAction, + }, + platform.WriteBucketPermission(bucket.ID), + }, + } + if err = c.CreateAuthorization(ctx, auth); err != nil { + return nil, err + } + + if err = c.PutOnboardingStatus(ctx, true); err != nil { + return nil, err + } + + return &platform.OnboardingResults{ + User: u, + Org: o, + Bucket: bucket, + Auth: auth, + }, nil +} diff --git a/bolt/onboarding_test.go b/bolt/onboarding_test.go new file mode 100644 index 0000000000..73b6002e5b --- /dev/null +++ b/bolt/onboarding_test.go @@ -0,0 +1,32 @@ +package bolt_test + +import ( + "context" + "testing" + + platformtesting "github.com/influxdata/platform/testing" +) + +func initOnboardingService(f platformtesting.OnboardingFields, t *testing.T) (platformtesting.OnBoardingNBasicAuthService, func()) { + c, closeFn, err := NewTestClient() + if err != nil { + t.Fatalf("failed to create new bolt client: %v", err) + } + c.IDGenerator = f.IDGenerator + c.TokenGenerator = f.TokenGenerator + ctx := context.TODO() + if err = c.PutOnboardingStatus(ctx, !f.IsOnboarding); err != nil { + t.Fatalf("failed to set new onboarding finished: %v", err) + } + + return c, func() { + defer closeFn() + if err := c.PutOnboardingStatus(ctx, false); err != nil { + t.Logf("failed to remove onboarding finished: %v", err) + } + } +} + +func TestGenerate(t *testing.T) { + platformtesting.Generate(initOnboardingService, t) +} diff --git a/cmd/influxd/main.go b/cmd/influxd/main.go index 83a53b70eb..8df7e38fc8 100644 --- a/cmd/influxd/main.go +++ b/cmd/influxd/main.go @@ -184,6 +184,8 @@ func platformF(cmd *cobra.Command, args []string) { macroSvc = c } + var onboardingSvc platform.OnboardingService = c + var queryService query.QueryService { // TODO(lh): this is temporary until query endpoint is added here. @@ -303,6 +305,9 @@ func platformF(cmd *cobra.Command, args []string) { sourceHandler.NewBucketService = source.NewBucketService sourceHandler.NewQueryService = source.NewQueryService + setupHandler := http.NewSetupHandler() + setupHandler.OnboardingService = onboardingSvc + taskHandler := http.NewTaskHandler(logger) taskHandler.TaskService = taskSvc @@ -340,6 +345,7 @@ func platformF(cmd *cobra.Command, args []string) { MacroHandler: macroHandler, QueryHandler: queryHandler, WriteHandler: writeHandler, + SetupHandler: setupHandler, } reg.MustRegister(platformHandler.PrometheusCollectors()...) diff --git a/errors.go b/errors.go index 7e4363681c..8f12992d18 100644 --- a/errors.go +++ b/errors.go @@ -54,7 +54,7 @@ type Error struct { } // Error implement the error interface by outputing the Code and Err. -func (e Error) Error() string { +func (e *Error) Error() string { var b strings.Builder // Print the current operation in our stack, if any. diff --git a/http/onboarding.go b/http/onboarding.go new file mode 100644 index 0000000000..649510d9a5 --- /dev/null +++ b/http/onboarding.go @@ -0,0 +1,85 @@ +package http + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/influxdata/platform" + "github.com/julienschmidt/httprouter" +) + +// SetupHandler represents an HTTP API handler for onboarding setup. +type SetupHandler struct { + *httprouter.Router + + OnboardingService platform.OnboardingService +} + +// NewSetupHandler returns a new instance of SetupHandler. +func NewSetupHandler() *SetupHandler { + h := &SetupHandler{ + Router: httprouter.New(), + } + h.HandlerFunc("POST", "/setup", h.handlePostSetup) + h.HandlerFunc("GET", "/setup", h.isOnboarding) + return h +} + +// isOnboarding is the HTTP handler for the GET /setup route. +// returns true/false +func (h *SetupHandler) isOnboarding(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + result, err := h.OnboardingService.IsOnboarding(ctx) + if err != nil { + EncodeError(ctx, err, w) + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"allowed": %v}`, result) +} + +func (h *SetupHandler) handlePostSetup(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodePostSetupRequest(ctx, r) + if err != nil { + EncodeError(ctx, err, w) + return + } + results, err := h.OnboardingService.Generate(ctx, req) + if err != nil { + EncodeError(ctx, err, w) + return + } + if err := encodeResponse(ctx, w, http.StatusCreated, newOnboardingResponse(results)); err != nil { + EncodeError(ctx, err, w) + return + } +} + +type onboardingResponse struct { + User *userResponse `json:"user"` + Bucket *bucketResponse `json:"bucket"` + Organization *orgResponse `json:"org"` + Auth *authResponse `json:"auth"` +} + +func newOnboardingResponse(results *platform.OnboardingResults) *onboardingResponse { + return &onboardingResponse{ + User: newUserResponse(results.User), + Bucket: newBucketResponse(results.Bucket), + Organization: newOrgResponse(results.Org), + Auth: newAuthResponse(results.Auth), + } +} + +func decodePostSetupRequest(ctx context.Context, r *http.Request) (*platform.OnboardingRequest, error) { + req := &platform.OnboardingRequest{} + if err := json.NewDecoder(r.Body).Decode(req); err != nil { + return nil, err + } + + return req, nil +} diff --git a/http/platform_handler.go b/http/platform_handler.go index a60134b952..e333a424f8 100644 --- a/http/platform_handler.go +++ b/http/platform_handler.go @@ -25,6 +25,7 @@ type PlatformHandler struct { FluxLangHandler *FluxLangHandler QueryHandler *FluxHandler WriteHandler *WriteHandler + SetupHandler *SetupHandler } func setCORSResponseHeaders(w nethttp.ResponseWriter, r *nethttp.Request) { @@ -36,6 +37,7 @@ func setCORSResponseHeaders(w nethttp.ResponseWriter, r *nethttp.Request) { } var platformLinks = map[string]interface{}{ + "setup": "/setup", "sources": "/v2/sources", "dashboards": "/v2/dashboards", "query": "/v2/query", @@ -79,7 +81,8 @@ func (h *PlatformHandler) ServeHTTP(w nethttp.ResponseWriter, r *nethttp.Request // of the platform API. if !strings.HasPrefix(r.URL.Path, "/v1") && !strings.HasPrefix(r.URL.Path, "/v2") && - !strings.HasPrefix(r.URL.Path, "/chronograf/") { + !strings.HasPrefix(r.URL.Path, "/chronograf/") && + !strings.HasPrefix(r.URL.Path, "/setup") { h.AssetHandler.ServeHTTP(w, r) return } @@ -90,6 +93,11 @@ func (h *PlatformHandler) ServeHTTP(w nethttp.ResponseWriter, r *nethttp.Request return } + if strings.HasPrefix(r.URL.Path, "/setup") { + h.SetupHandler.ServeHTTP(w, r) + return + } + ctx := r.Context() var err error if ctx, err = extractAuthorization(ctx, r); err != nil { diff --git a/http/swagger.yml b/http/swagger.yml index 4e19c3a8fc..b4cb5085ae 100644 --- a/http/swagger.yml +++ b/http/swagger.yml @@ -15,6 +15,38 @@ paths: application/json: schema: $ref: "#/components/schemas/Routes" + /setup: + get: + tags: + - Setup + summary: check if database has default user, org, bucket created, returns true if not. + response: + '200': + content: + application/json: + type: object + properties: + allowed: + type: boolean + post: + tags: + - Setup + summary: post onboarding request, to setup initial user, org and bucket + requestBody: + description: source to create + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OnboardingRequest" + responses: + '201': + description: Created default user, bucket, org + content: + application/json: + schema: + $ref: "#/components/schemas/OnboardingResponse" + /macros: get: tags: @@ -3230,6 +3262,28 @@ components: type: array items: $ref: "#/components/schemas/Source" + OnboardingRequest: + type: object + properties: + username: + type: string + password: + type: string + org: + type: string + bucket: + type: string + OnboardingResponse: + type: object + properties: + user: + $ref: "#/components/schemas/User" + org: + $ref: "#/components/schemas/Organization" + bucket: + $ref: "#/components/schemas/Bucket" + auth: + $ref: "#/components/schemas/Authorization" Health: type: object properties: @@ -3245,4 +3299,4 @@ components: type: string enum: - unhealthy - - healthy + - healthy \ No newline at end of file diff --git a/onboarding.go b/onboarding.go new file mode 100644 index 0000000000..18fa74b713 --- /dev/null +++ b/onboarding.go @@ -0,0 +1,28 @@ +package platform + +import "context" + +// OnboardingResults is a group of elements required for first run. +type OnboardingResults struct { + User *User `json:"user"` + Org *Organization `json:"org"` + Bucket *Bucket `json:"bucket"` + Auth *Authorization `json:"auth"` +} + +// OnboardingRequest is the request +// to setup defaults. +type OnboardingRequest struct { + User string `json:"username"` + Password string `json:"password"` + Org string `json:"org"` + Bucket string `json:"bucket"` +} + +// OnboardingService represents a service for the first run. +type OnboardingService interface { + // IsOnboarding determine if onboarding request is allowed. + IsOnboarding(ctx context.Context) (bool, error) + // Generate OnboardingResults. + Generate(ctx context.Context, req *OnboardingRequest) (*OnboardingResults, error) +} diff --git a/testing/onboarding.go b/testing/onboarding.go new file mode 100644 index 0000000000..520a8b1501 --- /dev/null +++ b/testing/onboarding.go @@ -0,0 +1,244 @@ +package testing + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/influxdata/platform" + "github.com/influxdata/platform/mock" +) + +// OnboardingFields will include the IDGenerator, TokenGenerator +// and IsOnboarding +type OnboardingFields struct { + IDGenerator platform.IDGenerator + TokenGenerator platform.TokenGenerator + IsOnboarding bool +} + +// OnBoardingNBasicAuthService includes onboarding service +// and basic auth service. +type OnBoardingNBasicAuthService interface { + platform.OnboardingService + platform.BasicAuthService +} + +// Generate testing +func Generate( + init func(OnboardingFields, *testing.T) (OnBoardingNBasicAuthService, func()), + t *testing.T, +) { + type args struct { + request *platform.OnboardingRequest + } + type wants struct { + errCode string + results *platform.OnboardingResults + password string + } + tests := []struct { + name string + fields OnboardingFields + args args + wants wants + }{ + { + name: "denied", + fields: OnboardingFields{ + IDGenerator: &loopIDGenerator{ + s: []string{oneID, twoID, threeID, fourID}, + t: t, + }, + TokenGenerator: mock.NewTokenGenerator(oneToken, nil), + IsOnboarding: false, + }, + wants: wants{ + errCode: platform.EConflict, + }, + }, + { + name: "missing password", + fields: OnboardingFields{ + IDGenerator: &loopIDGenerator{ + s: []string{oneID, twoID, threeID, fourID}, + t: t, + }, + TokenGenerator: mock.NewTokenGenerator(oneToken, nil), + IsOnboarding: true, + }, + args: args{ + request: &platform.OnboardingRequest{ + User: "admin", + Org: "org1", + Bucket: "bucket1", + }, + }, + wants: wants{ + errCode: platform.EEmptyValue, + }, + }, + { + name: "missing username", + fields: OnboardingFields{ + IDGenerator: &loopIDGenerator{ + s: []string{oneID, twoID, threeID, fourID}, + t: t, + }, + TokenGenerator: mock.NewTokenGenerator(oneToken, nil), + IsOnboarding: true, + }, + args: args{ + request: &platform.OnboardingRequest{ + Org: "org1", + Bucket: "bucket1", + }, + }, + wants: wants{ + errCode: platform.EEmptyValue, + }, + }, + { + name: "missing org", + fields: OnboardingFields{ + IDGenerator: &loopIDGenerator{ + s: []string{oneID, twoID, threeID, fourID}, + t: t, + }, + TokenGenerator: mock.NewTokenGenerator(oneToken, nil), + IsOnboarding: true, + }, + args: args{ + request: &platform.OnboardingRequest{ + User: "admin", + Bucket: "bucket1", + }, + }, + wants: wants{ + errCode: platform.EEmptyValue, + }, + }, + { + name: "missing bucket", + fields: OnboardingFields{ + IDGenerator: &loopIDGenerator{ + s: []string{oneID, twoID, threeID, fourID}, + t: t, + }, + TokenGenerator: mock.NewTokenGenerator(oneToken, nil), + IsOnboarding: true, + }, + args: args{ + request: &platform.OnboardingRequest{ + User: "admin", + Org: "org1", + }, + }, + wants: wants{ + errCode: platform.EEmptyValue, + }, + }, + { + name: "regular", + fields: OnboardingFields{ + IDGenerator: &loopIDGenerator{ + s: []string{oneID, twoID, threeID, fourID}, + t: t, + }, + TokenGenerator: mock.NewTokenGenerator(oneToken, nil), + IsOnboarding: true, + }, + args: args{ + request: &platform.OnboardingRequest{ + User: "admin", + Org: "org1", + Bucket: "bucket1", + Password: "pass1", + }, + }, + wants: wants{ + password: "pass1", + results: &platform.OnboardingResults{ + User: &platform.User{ + ID: idFromString(t, oneID), + Name: "admin", + }, + Org: &platform.Organization{ + ID: idFromString(t, twoID), + Name: "org1", + }, + Bucket: &platform.Bucket{ + ID: idFromString(t, threeID), + Name: "bucket1", + Organization: "org1", + OrganizationID: idFromString(t, twoID), + }, + Auth: &platform.Authorization{ + ID: idFromString(t, fourID), + Token: oneToken, + Status: platform.Active, + User: "admin", + UserID: idFromString(t, oneID), + Permissions: []platform.Permission{ + platform.CreateUserPermission, + platform.DeleteUserPermission, + platform.Permission{ + Resource: platform.OrganizationResource, + Action: platform.WriteAction, + }, + platform.WriteBucketPermission(idFromString(t, threeID)), + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, done := init(tt.fields, t) + defer done() + ctx := context.Background() + results, err := s.Generate(ctx, tt.args.request) + if (err != nil) != (tt.wants.errCode != "") { + t.Fatalf("expected error code '%s' got '%v'", tt.wants.errCode, err) + } + if err != nil && tt.wants.errCode != "" { + if code := platform.ErrorCode(err); code != tt.wants.errCode { + t.Fatalf("expected error code to match '%s' got '%v'", tt.wants.errCode, code) + } + } + if diff := cmp.Diff(results, tt.wants.results); diff != "" { + t.Errorf("onboarding results are different -got/+want\ndiff %s", diff) + } + if results != nil { + if err = s.ComparePassword(ctx, results.User.Name, tt.wants.password); err != nil { + t.Errorf("onboarding set password is wrong") + } + } + }) + } + +} + +const ( + oneID = "020f755c3c082000" + twoID = "020f755c3c082001" + threeID = "020f755c3c082002" + fourID = "020f755c3c082003" + oneToken = "020f755c3c082008" +) + +type loopIDGenerator struct { + s []string + p int + t *testing.T +} + +func (g *loopIDGenerator) ID() platform.ID { + if g.p == len(g.s) { + g.p = 0 + } + id := idFromString(g.t, g.s[g.p]) + g.p++ + return id +}