From 55fb4ecdfd69054f1d517aa2df332e43b6999ead Mon Sep 17 00:00:00 2001 From: Patrick Deziel Date: Tue, 24 Jan 2023 11:24:32 -0600 Subject: [PATCH 1/3] api key create --- pkg/quarterdeck/mock/quarterdeck.go | 39 +++++++---- pkg/tenant/api/v1/api.go | 4 +- pkg/tenant/api/v1/client.go | 27 ++++++-- pkg/tenant/api/v1/client_test.go | 48 +++++++++++-- pkg/tenant/apikeys.go | 102 ++++++++++++++++++++++++---- pkg/tenant/apikeys_test.go | 96 ++++++++++++++++++++++++++ pkg/tenant/tenant_test.go | 11 +-- 7 files changed, 282 insertions(+), 45 deletions(-) diff --git a/pkg/quarterdeck/mock/quarterdeck.go b/pkg/quarterdeck/mock/quarterdeck.go index 72bc1e3b5..219d724b5 100644 --- a/pkg/quarterdeck/mock/quarterdeck.go +++ b/pkg/quarterdeck/mock/quarterdeck.go @@ -88,6 +88,7 @@ type handlerOptions struct { handler http.HandlerFunc status int fixture interface{} + auth bool } // Helper to apply the supplied options, panics if there is an error @@ -107,21 +108,19 @@ func handler(opts ...HandlerOption) http.HandlerFunc { return conf.handler } - // Encode the fixture data - var data []byte - if conf.fixture != nil { - var err error - if data, err = json.Marshal(conf.fixture); err != nil { - panic(err) - } - } - return func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(conf.status) - - if data != nil { - w.Header().Set("Content-Type", "application/json") - w.Write(data) + switch { + case conf.auth && r.Header.Get("Authorization") == "": + // TODO: Validate the auth token + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("missing authorization header")) + case conf.fixture != nil: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(conf.status) + json.NewEncoder(w).Encode(conf.fixture) + default: + w.WriteHeader(conf.status) } } } @@ -147,8 +146,18 @@ func UseHandler(f http.HandlerFunc) HandlerOption { } } +// Return a 401 response if the request is not authenticated +func RequireAuth() HandlerOption { + return func(o *handlerOptions) { + o.auth = true + } +} + func fullPath(path, param string) string { - return path + "/" + param + if param != "" { + param = "/" + param + } + return path + param } // Endpoint handlers diff --git a/pkg/tenant/api/v1/api.go b/pkg/tenant/api/v1/api.go index f08e4ea98..4c81a1bec 100644 --- a/pkg/tenant/api/v1/api.go +++ b/pkg/tenant/api/v1/api.go @@ -47,6 +47,7 @@ type TenantClient interface { ProjectAPIKeyList(ctx context.Context, id string, in *PageQuery) (*ProjectAPIKeyPage, error) ProjectAPIKeyCreate(ctx context.Context, id string, in *APIKey) (*APIKey, error) + APIKeyCreate(context.Context, *APIKey) (*APIKey, error) APIKeyList(context.Context, *PageQuery) (*APIKeyPage, error) APIKeyDetail(ctx context.Context, id string) (*APIKey, error) APIKeyUpdate(context.Context, *APIKey) (*APIKey, error) @@ -154,10 +155,11 @@ type ProjectAPIKeyPage struct { } type APIKey struct { - ID int `json:"id,omitempty"` + ID string `json:"id,omitempty"` ClientID string `json:"client_id"` ClientSecret string `json:"client_secret,omitempty"` Name string `json:"name"` + ProjectID string `json:"project_id"` Owner string `json:"owner,omitempty"` Permissions []string `json:"permissions,omitempty"` Created string `json:"created,omitempty"` diff --git a/pkg/tenant/api/v1/client.go b/pkg/tenant/api/v1/client.go index bbb9f5ab0..f38713b9e 100644 --- a/pkg/tenant/api/v1/client.go +++ b/pkg/tenant/api/v1/client.go @@ -676,6 +676,26 @@ func (s *APIv1) ProjectAPIKeyCreate(ctx context.Context, id string, in *APIKey) return out, nil } +func (s *APIv1) APIKeyCreate(ctx context.Context, in *APIKey) (out *APIKey, err error) { + // Make the HTTP Request + var req *http.Request + if req, err = s.NewRequest(ctx, http.MethodPost, "/v1/apikeys", in, nil); err != nil { + return nil, err + } + + // Make the HTTP response + out = &APIKey{} + var rep *http.Response + if rep, err = s.Do(req, out, true); err != nil { + return nil, err + } + + if rep.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("expected status created, received %s", rep.Status) + } + return out, nil +} + func (s *APIv1) APIKeyList(ctx context.Context, in *PageQuery) (out *APIKeyPage, err error) { var params url.Values if params, err = query.Values(in); err != nil { @@ -716,14 +736,11 @@ func (s *APIv1) APIKeyDetail(ctx context.Context, id string) (out *APIKey, err e } func (s *APIv1) APIKeyUpdate(ctx context.Context, in *APIKey) (out *APIKey, err error) { - // Convert ID from integer to string - sid := fmt.Sprintf("%d", in.ID) - - if sid == "" { + if in.ID == "" { return nil, ErrAPIKeyIDRequired } - path := fmt.Sprintf("/v1/apikey/%s", sid) + path := fmt.Sprintf("/v1/apikey/%s", in.ID) // Make the HTTP request var req *http.Request diff --git a/pkg/tenant/api/v1/client_test.go b/pkg/tenant/api/v1/client_test.go index 63aaab36d..80085b023 100644 --- a/pkg/tenant/api/v1/client_test.go +++ b/pkg/tenant/api/v1/client_test.go @@ -946,7 +946,7 @@ func TestProjectAPIKeyList(t *testing.T) { ProjectID: "001", APIKeys: []*api.APIKey{ { - ID: 001, + ID: "001", ClientID: "client001", ClientSecret: "segredo", Name: "myapikey", @@ -990,7 +990,7 @@ func TestProjectAPIKeyList(t *testing.T) { func TestProjectAPIKeyCreate(t *testing.T) { fixture := &api.APIKey{ - ID: 001, + ID: "001", ClientID: "client001", ClientSecret: "segredo", Name: "myapikey", @@ -1024,11 +1024,47 @@ func TestProjectAPIKeyCreate(t *testing.T) { require.Equal(t, fixture, out, "unexpected response error") } +func TestAPIKeyCreate(t *testing.T) { + fixture := &api.APIKey{ + ID: "001", + ClientID: "client001", + ClientSecret: "segredo", + Name: "myapikey", + Owner: "Ryan Moore", + Permissions: []string{"Read", "Write", "Delete"}, + Created: time.Now().Format(time.RFC3339Nano), + Modified: time.Now().Format(time.RFC3339Nano), + } + + // Create a test server + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/v1/apikeys", r.URL.Path) + + in := &api.APIKey{} + err := json.NewDecoder(r.Body).Decode(in) + require.NoError(t, err, "could not decode request") + + w.Header().Add("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(fixture) + })) + defer ts.Close() + + // Creates a client to execute tests against the test server + client, err := api.New(ts.URL) + require.NoError(t, err, "could not create api client") + + out, err := client.APIKeyCreate(context.Background(), &api.APIKey{}) + require.NoError(t, err, "could not execute api request") + require.Equal(t, fixture, out, "response did not match") +} + func TestAPIKeyList(t *testing.T) { fixture := &api.APIKeyPage{ APIKeys: []*api.APIKey{ { - ID: 001, + ID: "001", ClientID: "client001", ClientSecret: "segredo", Name: "myapikey", @@ -1073,7 +1109,7 @@ func TestAPIKeyList(t *testing.T) { func TestAPIKeyDetail(t *testing.T) { fixture := &api.APIKey{ - ID: 001, + ID: "001", ClientID: "client001", ClientSecret: "segredo", Name: "myapikey", @@ -1105,7 +1141,7 @@ func TestAPIKeyDetail(t *testing.T) { func TestAPIKeyUpdate(t *testing.T) { fixture := &api.APIKey{ - ID: 101, + ID: "101", Name: "apikey01", } @@ -1125,7 +1161,7 @@ func TestAPIKeyUpdate(t *testing.T) { require.NoError(t, err, "could not execute api request") req := &api.APIKey{ - ID: 101, + ID: "101", Name: "apikey02", } diff --git a/pkg/tenant/apikeys.go b/pkg/tenant/apikeys.go index 91327fd2b..89a21e6af 100644 --- a/pkg/tenant/apikeys.go +++ b/pkg/tenant/apikeys.go @@ -1,9 +1,17 @@ package tenant import ( + "context" "net/http" + "time" "github.com/gin-gonic/gin" + "github.com/oklog/ulid/v2" + qd "github.com/rotationalio/ensign/pkg/quarterdeck/api/v1" + "github.com/rotationalio/ensign/pkg/quarterdeck/middleware" + "github.com/rotationalio/ensign/pkg/quarterdeck/tokens" + "github.com/rotationalio/ensign/pkg/tenant/api/v1" + "github.com/rs/zerolog/log" ) func (s *Server) ProjectAPIKeyList(c *gin.Context) { @@ -15,24 +23,90 @@ func (s *Server) ProjectAPIKeyCreate(c *gin.Context) { } func (s *Server) APIKeyList(c *gin.Context) { - // The following TODO task items will need to be - // implemented for each endpoint. +} - // TODO: Add authentication and authorization middleware - // TODO: Identify top-level info - // TODO: Parse and validate user input - // TODO: Perform work on the request, e.g. database interactions, - // sending notifications, accessing other services, etc. +// APIKeyCreate creates a new API key by forwarding the request to Quarterdeck. +// +// Route: POST /v1/apikeys +func (s *Server) APIKeyCreate(c *gin.Context) { + var ( + ctx context.Context + err error + ) - // Return response with the correct status code + // User credentials are required to make the Quarterdeck request + if ctx, err = middleware.ContextFromRequest(c); err != nil { + log.Error().Err(err).Msg("could not create user context from request") + c.JSON(http.StatusUnauthorized, api.ErrorResponse("could not fetch credentials for authenticated user")) + return + } - // TODO: Replace StatusNotImplemented with StatusOk and - // replace "not yet implemented" message. - c.JSON(http.StatusNotImplemented, "not implemented yet") -} + // The user's name is on the token claims + var claims *tokens.Claims + if claims, err = middleware.GetClaims(c); err != nil { + log.Error().Err(err).Msg("could not fetch user claims from context") + c.JSON(http.StatusUnauthorized, api.ErrorResponse("could not fetch user claims from context")) + return + } -func (s *Server) APIKeyCreate(c *gin.Context) { - c.JSON(http.StatusNotImplemented, "not implemented yet") + // Parse the params from the POST request + params := &api.APIKey{} + if err = c.BindJSON(params); err != nil { + log.Warn().Err(err).Msg("could not parse API key params") + c.JSON(http.StatusBadRequest, api.ErrorResponse("could not parse API key params")) + return + } + + // Name is required + if params.Name == "" { + c.JSON(http.StatusBadRequest, api.ErrorResponse("API key name is required")) + return + } + + // Permissions are required + if len(params.Permissions) == 0 { + c.JSON(http.StatusBadRequest, api.ErrorResponse("API key permissions are required")) + return + } + + // Build the Quarterdeck request + // See ValidateCreate() for required fields + req := &qd.APIKey{ + Name: params.Name, + Permissions: params.Permissions, + } + + // ProjectID is required + if req.ProjectID, err = ulid.Parse(params.ProjectID); err != nil { + log.Warn().Err(err).Str("project_id", params.ProjectID).Msg("could not parse project ID") + c.JSON(http.StatusBadRequest, api.ErrorResponse("invalid project ID")) + return + } + + // TODO: Add source to request + + // Create the API key with Quarterdeck + var key *qd.APIKey + if key, err = s.quarterdeck.APIKeyCreate(ctx, req); err != nil { + log.Error().Err(err).Msg("could not create API key") + c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not create API key")) + return + } + + // Return the API key + out := &api.APIKey{ + ID: key.ID.String(), + ClientID: key.ClientID, + ClientSecret: key.ClientSecret, + Name: key.Name, + ProjectID: key.ProjectID.String(), + Owner: claims.Name, + Permissions: key.Permissions, + Created: key.Created.Format(time.RFC3339Nano), + Modified: key.Modified.Format(time.RFC3339Nano), + } + + c.JSON(http.StatusCreated, out) } func (s *Server) APIKeyDetail(c *gin.Context) { diff --git a/pkg/tenant/apikeys_test.go b/pkg/tenant/apikeys_test.go index 8fe3a7377..01b914e6f 100644 --- a/pkg/tenant/apikeys_test.go +++ b/pkg/tenant/apikeys_test.go @@ -1 +1,97 @@ package tenant_test + +import ( + "context" + "net/http" + "time" + + "github.com/oklog/ulid/v2" + qd "github.com/rotationalio/ensign/pkg/quarterdeck/api/v1" + "github.com/rotationalio/ensign/pkg/quarterdeck/mock" + "github.com/rotationalio/ensign/pkg/quarterdeck/tokens" + "github.com/rotationalio/ensign/pkg/tenant" + "github.com/rotationalio/ensign/pkg/tenant/api/v1" +) + +func (s *tenantTestSuite) TestAPIKeyCreate() { + require := s.Require() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Create initial fixtures + var err error + id := "01GQ38J5YWH4DCYJ6CZ2P5FA2G" + key := &qd.APIKey{ + ID: ulid.MustParse(id), + ClientID: "ABCDEFGHIJKLMNOP", + ClientSecret: "A1B2C3D4E5F6G7H8I9J0", + Name: "Leopold's API Key", + OrgID: ulid.MustParse("01GQ38QWNR7MYQXSQ682PJQM7T"), + ProjectID: ulid.MustParse("01GQ38J5YWH4DCYJ6CZ2P5FA2G"), + CreatedBy: ulid.MustParse("01GMTWFK4XZY597Y128KXQ4WHP"), + LastUsed: time.Now(), + Permissions: []string{"publish", "subscribe"}, + Created: time.Now(), + Modified: time.Now(), + } + + // Initial mock checks for an auth token and returns 201 with the key fixture + s.quarterdeck.OnAPIKeys("", mock.UseStatus(http.StatusCreated), mock.UseJSONFixture(key), mock.RequireAuth()) + + // Create initial user claims + claims := &tokens.Claims{ + Name: "Leopold Wentzel", + Email: "leopold.wentzel@gmail.com", + Permissions: []string{"edit:nothing"}, + } + + // Endpoint must be authenticated + require.NoError(s.SetClientCSRFProtection(), "could not set CSRF protection on client") + _, err = s.client.APIKeyCreate(ctx, &api.APIKey{}) + s.requireError(err, http.StatusUnauthorized, "this endpoint requires authentication", "expected error when user is not authenticated") + + // User must have the correct permissions + require.NoError(s.SetClientCredentials(claims), "could not set client credentials") + _, err = s.client.APIKeyCreate(ctx, &api.APIKey{}) + s.requireError(err, http.StatusUnauthorized, "user does not have permission to perform this operation", "expected error when user does not have correct permissions") + + // Name is required + claims.Permissions = []string{tenant.WriteAPIKey} + require.NoError(s.SetClientCredentials(claims), "could not set client credentials") + _, err = s.client.APIKeyCreate(ctx, &api.APIKey{}) + s.requireError(err, http.StatusBadRequest, "API key name is required", "expected error when name is missing") + + // Permissions are required + req := &api.APIKey{ + Name: key.Name, + } + _, err = s.client.APIKeyCreate(ctx, req) + s.requireError(err, http.StatusBadRequest, "API key permissions are required", "expected error when permissions are missing") + + // ProjectID is required + req.Permissions = key.Permissions + _, err = s.client.APIKeyCreate(ctx, &api.APIKey{}) + s.requireError(err, http.StatusBadRequest, "API key name is required", "expected error when name is missing") + + // Successfully creating an API key + req.ProjectID = key.ProjectID.String() + expected := &api.APIKey{ + ID: id, + ClientID: key.ClientID, + ClientSecret: key.ClientSecret, + Name: req.Name, + ProjectID: req.ProjectID, + Owner: claims.Name, + Permissions: req.Permissions, + Created: key.Created.Format(time.RFC3339Nano), + Modified: key.Modified.Format(time.RFC3339Nano), + } + out, err := s.client.APIKeyCreate(ctx, req) + require.NoError(err, "expected no error when creating API key") + require.Equal(expected, out, "expected API key to be created") + + // Ensure an error is returned when quarterdeck returns an error + s.quarterdeck.OnAPIKeys("", mock.UseStatus(http.StatusInternalServerError), mock.RequireAuth()) + _, err = s.client.APIKeyCreate(ctx, req) + s.requireError(err, http.StatusInternalServerError, "could not create API key", "expected error when quarterdeck returns an error") +} diff --git a/pkg/tenant/tenant_test.go b/pkg/tenant/tenant_test.go index 52d97f608..44ea13fd3 100644 --- a/pkg/tenant/tenant_test.go +++ b/pkg/tenant/tenant_test.go @@ -40,6 +40,10 @@ func (suite *tenantTestSuite) SetupSuite() { suite.auth, err = authtest.NewServer() require.NoError(err, "could not start the authtest server") + // Start an httptest server to handle mock requests to Quarterdeck + suite.quarterdeck, err = mock.NewServer() + require.NoError(err, "could not start the quarterdeck mock server") + // Creates a test configuration to run the Tenant API server as a fully // functional server on an open port using the local-loopback for networking. conf, err := config.Config{ @@ -55,6 +59,9 @@ func (suite *tenantTestSuite) SetupSuite() { KeysURL: suite.auth.KeysURL(), CookieDomain: "localhost", }, + Quarterdeck: config.QuarterdeckConfig{ + URL: suite.quarterdeck.URL(), + }, Database: config.DatabaseConfig{ Testing: true, }, @@ -64,10 +71,6 @@ func (suite *tenantTestSuite) SetupSuite() { suite.srv, err = tenant.New(conf) require.NoError(err, "could not create the tenant api server from the test configuration") - // Start an httptest server to handle mock requests to Quarterdeck - suite.quarterdeck, err = mock.NewServer() - require.NoError(err, "could not start the quarterdeck mock server") - // Starts the Tenant server. Server will run for the duration of all tests. // Implements reset methods to ensure the server state doesn't change // between tests in Before/After. From e413beb8f645409345bbd4bfb4a5e787204c4b36 Mon Sep 17 00:00:00 2001 From: Patrick Deziel Date: Tue, 24 Jan 2023 13:13:31 -0600 Subject: [PATCH 2/3] api key list --- pkg/tenant/api/v1/api.go | 1 - pkg/tenant/apikeys.go | 103 ++++++++++++++++++++++------- pkg/tenant/apikeys_test.go | 130 +++++++++++++++++++++++++++++++------ 3 files changed, 192 insertions(+), 42 deletions(-) diff --git a/pkg/tenant/api/v1/api.go b/pkg/tenant/api/v1/api.go index 4c81a1bec..858e800cc 100644 --- a/pkg/tenant/api/v1/api.go +++ b/pkg/tenant/api/v1/api.go @@ -159,7 +159,6 @@ type APIKey struct { ClientID string `json:"client_id"` ClientSecret string `json:"client_secret,omitempty"` Name string `json:"name"` - ProjectID string `json:"project_id"` Owner string `json:"owner,omitempty"` Permissions []string `json:"permissions,omitempty"` Created string `json:"created,omitempty"` diff --git a/pkg/tenant/apikeys.go b/pkg/tenant/apikeys.go index 89a21e6af..7f08b6106 100644 --- a/pkg/tenant/apikeys.go +++ b/pkg/tenant/apikeys.go @@ -9,26 +9,80 @@ import ( "github.com/oklog/ulid/v2" qd "github.com/rotationalio/ensign/pkg/quarterdeck/api/v1" "github.com/rotationalio/ensign/pkg/quarterdeck/middleware" - "github.com/rotationalio/ensign/pkg/quarterdeck/tokens" "github.com/rotationalio/ensign/pkg/tenant/api/v1" "github.com/rs/zerolog/log" ) +// ProjectAPIKeyList lists API keys in the specified project by forwarding the request +// to Quarterdeck. +// +// Route: GET /v1/projects/:projectID/apikeys func (s *Server) ProjectAPIKeyList(c *gin.Context) { - c.JSON(http.StatusNotImplemented, "not implemented yet") -} + var ( + ctx context.Context + err error + ) -func (s *Server) ProjectAPIKeyCreate(c *gin.Context) { - c.JSON(http.StatusNotImplemented, "not implemented yet") -} + // User credentials are required to make the Quarterdeck request + if ctx, err = middleware.ContextFromRequest(c); err != nil { + log.Error().Err(err).Msg("could not create user context from request") + c.JSON(http.StatusUnauthorized, api.ErrorResponse("could not fetch credentials for authenticated user")) + return + } -func (s *Server) APIKeyList(c *gin.Context) { + // Parse the params from the GET request + params := &api.PageQuery{} + if err = c.ShouldBindQuery(params); err != nil { + log.Warn().Err(err).Msg("could not parse query params") + c.JSON(http.StatusBadRequest, api.ErrorResponse("could not parse query params")) + return + } + + // TODO: Validate that the user is associated with the project by checking the + // orgID in the claims against the orgID in the project. + + // Build the Quarterdeck request from the params + req := &qd.APIPageQuery{ + ProjectID: c.Param("projectID"), + PageSize: int(params.PageSize), + NextPageToken: params.NextPageToken, + } + + // Request a page of API keys from Quarterdeck + var reply *qd.APIKeyList + if reply, err = s.quarterdeck.APIKeyList(ctx, req); err != nil { + log.Error().Err(err).Msg("could not list API keys") + c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not list API keys")) + return + } + + // Return the page of API keys + out := &api.ProjectAPIKeyPage{ + ProjectID: req.ProjectID, + PrevPageToken: req.NextPageToken, + NextPageToken: reply.NextPageToken, + APIKeys: make([]*api.APIKey, 0), + } + for _, key := range reply.APIKeys { + out.APIKeys = append(out.APIKeys, &api.APIKey{ + ID: key.ID.String(), + ClientID: key.ClientID, + Name: key.Name, + Owner: key.CreatedBy.String(), + Permissions: key.Permissions, + Created: key.Created.Format(time.RFC3339Nano), + Modified: key.Modified.Format(time.RFC3339Nano), + }) + } + + c.JSON(http.StatusOK, out) } -// APIKeyCreate creates a new API key by forwarding the request to Quarterdeck. +// ProjectAPIKeyCreate creates a new API key in a project by forwarding the request to +// Quarterdeck. // -// Route: POST /v1/apikeys -func (s *Server) APIKeyCreate(c *gin.Context) { +// Route: POST /v1/projects/:projectID/apikeys +func (s *Server) ProjectAPIKeyCreate(c *gin.Context) { var ( ctx context.Context err error @@ -41,14 +95,6 @@ func (s *Server) APIKeyCreate(c *gin.Context) { return } - // The user's name is on the token claims - var claims *tokens.Claims - if claims, err = middleware.GetClaims(c); err != nil { - log.Error().Err(err).Msg("could not fetch user claims from context") - c.JSON(http.StatusUnauthorized, api.ErrorResponse("could not fetch user claims from context")) - return - } - // Parse the params from the POST request params := &api.APIKey{} if err = c.BindJSON(params); err != nil { @@ -77,12 +123,16 @@ func (s *Server) APIKeyCreate(c *gin.Context) { } // ProjectID is required - if req.ProjectID, err = ulid.Parse(params.ProjectID); err != nil { - log.Warn().Err(err).Str("project_id", params.ProjectID).Msg("could not parse project ID") + projectID := c.Param("projectID") + if req.ProjectID, err = ulid.Parse(projectID); err != nil { + log.Warn().Err(err).Str("projectID", projectID).Msg("could not parse project ID") c.JSON(http.StatusBadRequest, api.ErrorResponse("invalid project ID")) return } + // TODO: Validate that the user is associated with the project by checking the + // orgID in the claims against the project's orgID + // TODO: Add source to request // Create the API key with Quarterdeck @@ -99,8 +149,7 @@ func (s *Server) APIKeyCreate(c *gin.Context) { ClientID: key.ClientID, ClientSecret: key.ClientSecret, Name: key.Name, - ProjectID: key.ProjectID.String(), - Owner: claims.Name, + Owner: key.CreatedBy.String(), Permissions: key.Permissions, Created: key.Created.Format(time.RFC3339Nano), Modified: key.Modified.Format(time.RFC3339Nano), @@ -109,6 +158,16 @@ func (s *Server) APIKeyCreate(c *gin.Context) { c.JSON(http.StatusCreated, out) } +// TODO: Implement by factoring out common code from ProjectAPIKeyCreate +func (s *Server) APIKeyList(c *gin.Context) { + c.JSON(http.StatusNotImplemented, "not implemented yet") +} + +// TODO: Implement by factoring out common code from ProjectAPIKeyCreate +func (s *Server) APIKeyCreate(c *gin.Context) { + c.JSON(http.StatusNotImplemented, "not implemented yet") +} + func (s *Server) APIKeyDetail(c *gin.Context) { c.JSON(http.StatusNotImplemented, "not implemented yet") } diff --git a/pkg/tenant/apikeys_test.go b/pkg/tenant/apikeys_test.go index 01b914e6f..d3987ddd6 100644 --- a/pkg/tenant/apikeys_test.go +++ b/pkg/tenant/apikeys_test.go @@ -8,26 +8,120 @@ import ( "github.com/oklog/ulid/v2" qd "github.com/rotationalio/ensign/pkg/quarterdeck/api/v1" "github.com/rotationalio/ensign/pkg/quarterdeck/mock" + perms "github.com/rotationalio/ensign/pkg/quarterdeck/permissions" "github.com/rotationalio/ensign/pkg/quarterdeck/tokens" - "github.com/rotationalio/ensign/pkg/tenant" "github.com/rotationalio/ensign/pkg/tenant/api/v1" ) -func (s *tenantTestSuite) TestAPIKeyCreate() { +func (s *tenantTestSuite) TestProjectAPIKeyList() { require := s.Require() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Create initial fixtures - var err error - id := "01GQ38J5YWH4DCYJ6CZ2P5FA2G" + projectID := "01GQ38QWNR7MYQXSQ682PJQM7T" + page := &qd.APIKeyList{ + APIKeys: []*qd.APIKey{ + { + ID: ulid.MustParse("01GQ38J5YWH4DCYJ6CZ2P5BA2G"), + ClientID: "ABCDEFGHIJKLMNOP", + ClientSecret: "A1B2C3D4E5F6G7H8I9J0", + Name: "Leopold's Publish Key", + OrgID: ulid.MustParse("01GQ38QWNR7MYQXSQ682PJQM7T"), + ProjectID: ulid.MustParse(projectID), + CreatedBy: ulid.MustParse("01GMTWFK4XZY597Y128KXQ4WHP"), + LastUsed: time.Now(), + Permissions: []string{"publish"}, + Created: time.Now(), + Modified: time.Now(), + }, + { + ID: ulid.MustParse("02GQ38J5YWH4DCYJ6CZ2P5BA2G"), + ClientID: "ABCDEFGHIJKLMNOP", + ClientSecret: "A1B2C3D4E5F6G7H8I9J0", + Name: "Leopold's Subscribe Key", + OrgID: ulid.MustParse("01GQ38QWNR7MYQXSQ682PJQM7T"), + ProjectID: ulid.MustParse(projectID), + CreatedBy: ulid.MustParse("01GMTWFK4XZY597Y128KXQ4WHP"), + LastUsed: time.Now(), + Permissions: []string{"subscribe"}, + Created: time.Now(), + Modified: time.Now(), + }, + { + ID: ulid.MustParse("03GQ38J5YWH4DCYJ6CZ2P5BA2G"), + ClientID: "ABCDEFGHIJKLMNOP", + ClientSecret: "A1B2C3D4E5F6G7H8I9J0", + Name: "Leopold's PubSub Key", + OrgID: ulid.MustParse("01GQ38QWNR7MYQXSQ682PJQM7T"), + ProjectID: ulid.MustParse(projectID), + CreatedBy: ulid.MustParse("01GMTWFK4XZY597Y128KXQ4WHP"), + LastUsed: time.Now(), + Permissions: []string{"publish", "subscribe"}, + Created: time.Now(), + Modified: time.Now(), + }, + }, + NextPageToken: "next_page_token", + } + + // Initial mock checks for an auth token and returns 200 with the page fixture + s.quarterdeck.OnAPIKeys("", mock.UseStatus(http.StatusOK), mock.UseJSONFixture(page), mock.RequireAuth()) + + // Create initial user claims + claims := &tokens.Claims{ + Name: "Leopold Wentzel", + Email: "leopold.wentzel@gmail.com", + Permissions: []string{"read:nothing"}, + } + + // Endpoint must be authenticated + require.NoError(s.SetClientCSRFProtection(), "could not set CSRF protection on client") + _, err := s.client.ProjectAPIKeyList(ctx, "invalid", &api.PageQuery{}) + s.requireError(err, http.StatusUnauthorized, "this endpoint requires authentication", "expected error when user is not authenticated") + + // User must have correct permissions + require.NoError(s.SetClientCredentials(claims), "could not set client credentials") + _, err = s.client.ProjectAPIKeyList(ctx, "invalid", &api.PageQuery{}) + s.requireError(err, http.StatusUnauthorized, "user does not have permission to perform this operation", "expected error when user does not have correct permissions") + + // Successfully listing API keys + claims.Permissions = []string{perms.ReadAPIKeys} + require.NoError(s.SetClientCredentials(claims), "could not set client credentials") + req := &api.PageQuery{ + PageSize: 10, + } + reply, err := s.client.ProjectAPIKeyList(ctx, projectID, req) + require.NoError(err, "expected no error when listing API keys") + require.Equal(projectID, reply.ProjectID, "expected project ID to match") + require.Equal(page.NextPageToken, reply.NextPageToken, "expected next page token to match") + require.Equal(len(page.APIKeys), len(reply.APIKeys), "expected API key count to match") + for i, key := range reply.APIKeys { + require.Equal(page.APIKeys[i].ID.String(), key.ID, "expected API key ID to match") + require.Equal(page.APIKeys[i].Name, key.Name, "expected API key name to match") + require.Equal(page.APIKeys[i].Permissions, key.Permissions, "expected API key permissions to match") + } + + // Error should be returned when Quarterdeck returns an error + s.quarterdeck.OnAPIKeys("", mock.UseStatus(http.StatusInternalServerError), mock.RequireAuth()) + _, err = s.client.ProjectAPIKeyList(ctx, projectID, req) + s.requireError(err, http.StatusInternalServerError, "could not list API keys", "expected error when Quarterdeck returns an error") +} + +func (s *tenantTestSuite) TestProjectAPIKeyCreate() { + require := s.Require() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Create initial fixtures + projectID := "01GQ38J5YWH4DCYJ6CZ2P5BA2G" key := &qd.APIKey{ - ID: ulid.MustParse(id), + ID: ulid.MustParse("01GQ38J5YWH4DCYJ6CZ2P5DA2G"), ClientID: "ABCDEFGHIJKLMNOP", ClientSecret: "A1B2C3D4E5F6G7H8I9J0", Name: "Leopold's API Key", OrgID: ulid.MustParse("01GQ38QWNR7MYQXSQ682PJQM7T"), - ProjectID: ulid.MustParse("01GQ38J5YWH4DCYJ6CZ2P5FA2G"), + ProjectID: ulid.MustParse(projectID), CreatedBy: ulid.MustParse("01GMTWFK4XZY597Y128KXQ4WHP"), LastUsed: time.Now(), Permissions: []string{"publish", "subscribe"}, @@ -47,51 +141,49 @@ func (s *tenantTestSuite) TestAPIKeyCreate() { // Endpoint must be authenticated require.NoError(s.SetClientCSRFProtection(), "could not set CSRF protection on client") - _, err = s.client.APIKeyCreate(ctx, &api.APIKey{}) + _, err := s.client.ProjectAPIKeyCreate(ctx, "invalid", &api.APIKey{}) s.requireError(err, http.StatusUnauthorized, "this endpoint requires authentication", "expected error when user is not authenticated") // User must have the correct permissions require.NoError(s.SetClientCredentials(claims), "could not set client credentials") - _, err = s.client.APIKeyCreate(ctx, &api.APIKey{}) + _, err = s.client.ProjectAPIKeyCreate(ctx, "invalid", &api.APIKey{}) s.requireError(err, http.StatusUnauthorized, "user does not have permission to perform this operation", "expected error when user does not have correct permissions") // Name is required - claims.Permissions = []string{tenant.WriteAPIKey} + claims.Permissions = []string{perms.EditAPIKeys} require.NoError(s.SetClientCredentials(claims), "could not set client credentials") - _, err = s.client.APIKeyCreate(ctx, &api.APIKey{}) + _, err = s.client.ProjectAPIKeyCreate(ctx, "invalid", &api.APIKey{}) s.requireError(err, http.StatusBadRequest, "API key name is required", "expected error when name is missing") // Permissions are required req := &api.APIKey{ Name: key.Name, } - _, err = s.client.APIKeyCreate(ctx, req) + _, err = s.client.ProjectAPIKeyCreate(ctx, "invalid", req) s.requireError(err, http.StatusBadRequest, "API key permissions are required", "expected error when permissions are missing") // ProjectID is required req.Permissions = key.Permissions - _, err = s.client.APIKeyCreate(ctx, &api.APIKey{}) - s.requireError(err, http.StatusBadRequest, "API key name is required", "expected error when name is missing") + _, err = s.client.ProjectAPIKeyCreate(ctx, "invalid", req) + s.requireError(err, http.StatusBadRequest, "invalid project ID", "expected error when name is missing") // Successfully creating an API key - req.ProjectID = key.ProjectID.String() expected := &api.APIKey{ - ID: id, + ID: key.ID.String(), ClientID: key.ClientID, ClientSecret: key.ClientSecret, Name: req.Name, - ProjectID: req.ProjectID, - Owner: claims.Name, + Owner: key.CreatedBy.String(), Permissions: req.Permissions, Created: key.Created.Format(time.RFC3339Nano), Modified: key.Modified.Format(time.RFC3339Nano), } - out, err := s.client.APIKeyCreate(ctx, req) + out, err := s.client.ProjectAPIKeyCreate(ctx, projectID, req) require.NoError(err, "expected no error when creating API key") require.Equal(expected, out, "expected API key to be created") // Ensure an error is returned when quarterdeck returns an error s.quarterdeck.OnAPIKeys("", mock.UseStatus(http.StatusInternalServerError), mock.RequireAuth()) - _, err = s.client.APIKeyCreate(ctx, req) + _, err = s.client.ProjectAPIKeyCreate(ctx, projectID, req) s.requireError(err, http.StatusInternalServerError, "could not create API key", "expected error when quarterdeck returns an error") } From ae9ae9b3f7c84349346219f5f81797ffc5547fe9 Mon Sep 17 00:00:00 2001 From: Patrick Deziel <42919891+pdeziel@users.noreply.github.com> Date: Tue, 24 Jan 2023 14:34:46 -0600 Subject: [PATCH 3/3] Update pkg/tenant/apikeys_test.go Co-authored-by: Danielle --- pkg/tenant/apikeys_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tenant/apikeys_test.go b/pkg/tenant/apikeys_test.go index d3987ddd6..3b6189bd9 100644 --- a/pkg/tenant/apikeys_test.go +++ b/pkg/tenant/apikeys_test.go @@ -165,7 +165,7 @@ func (s *tenantTestSuite) TestProjectAPIKeyCreate() { // ProjectID is required req.Permissions = key.Permissions _, err = s.client.ProjectAPIKeyCreate(ctx, "invalid", req) - s.requireError(err, http.StatusBadRequest, "invalid project ID", "expected error when name is missing") + s.requireError(err, http.StatusBadRequest, "invalid project ID", "expected error when project id is missing") // Successfully creating an API key expected := &api.APIKey{