diff --git a/pkg/tenant/apikeys.go b/pkg/tenant/apikeys.go index 7f08b6106..49807f01d 100644 --- a/pkg/tenant/apikeys.go +++ b/pkg/tenant/apikeys.go @@ -49,6 +49,7 @@ func (s *Server) ProjectAPIKeyList(c *gin.Context) { } // Request a page of API keys from Quarterdeck + // TODO: Handle error status codes returned by 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") @@ -65,13 +66,9 @@ func (s *Server) ProjectAPIKeyList(c *gin.Context) { } 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), + ID: key.ID.String(), + ClientID: key.ClientID, + Name: key.Name, }) } @@ -136,6 +133,7 @@ func (s *Server) ProjectAPIKeyCreate(c *gin.Context) { // TODO: Add source to request // Create the API key with Quarterdeck + // TODO: Handle error status codes returned by 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") @@ -168,14 +166,78 @@ func (s *Server) APIKeyCreate(c *gin.Context) { c.JSON(http.StatusNotImplemented, "not implemented yet") } +// APIKeyDetail returns details about a specific API key. +// +// Route: GET /v1/apikeys/:apiKeyID func (s *Server) APIKeyDetail(c *gin.Context) { - c.JSON(http.StatusNotImplemented, "not implemented yet") + var ( + ctx context.Context + err error + ) + + // 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 + } + + // Parse the API key ID from the URL + apiKeyID := c.Param("apiKeyID") + + // Get the API key from Quarterdeck + // TODO: Handle error status codes returned by Quarterdeck + var key *qd.APIKey + if key, err = s.quarterdeck.APIKeyDetail(ctx, apiKeyID); err != nil { + log.Error().Err(err).Str("apiKeyID", apiKeyID).Msg("could not get API key") + c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not retrieve API key")) + return + } + + // Return everything but the client secret + out := &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) } func (s *Server) APIKeyUpdate(c *gin.Context) { c.JSON(http.StatusNotImplemented, "not implemented yet") } +// APIKeyDelete deletes an API key by forwarding the request to Quarterdeck. +// +// Route: DELETE /v1/apikeys/:apiKeyID func (s *Server) APIKeyDelete(c *gin.Context) { - c.JSON(http.StatusNotImplemented, "not implemented yet") + var ( + ctx context.Context + err error + ) + + // 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 + } + + // Parse the API key ID from the URL + apiKeyID := c.Param("apiKeyID") + + // Delete the API key using Quarterdeck + // TODO: Handle error status codes returned by Quarterdeck + if err = s.quarterdeck.APIKeyDelete(ctx, apiKeyID); err != nil { + log.Error().Err(err).Str("apiKeyID", apiKeyID).Msg("could not delete API key") + c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not delete API key")) + return + } + + c.Status(http.StatusNoContent) } diff --git a/pkg/tenant/apikeys_test.go b/pkg/tenant/apikeys_test.go index 3b6189bd9..05d2724b2 100644 --- a/pkg/tenant/apikeys_test.go +++ b/pkg/tenant/apikeys_test.go @@ -23,43 +23,19 @@ func (s *tenantTestSuite) TestProjectAPIKeyList() { 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("01GQ38J5YWH4DCYJ6CZ2P5BA2G"), + ClientID: "ABCDEFGHIJKLMNOP", + Name: "Leopold's Publish Key", }, { - 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("02GQ38J5YWH4DCYJ6CZ2P5BA2G"), + ClientID: "QRSTUVWXYZABCDEF", + Name: "Leopold's Subscribe Key", }, { - 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(), + ID: ulid.MustParse("03GQ38J5YWH4DCYJ6CZ2P5BA2G"), + ClientID: "GHIJKLMNOPQRSTUV", + Name: "Leopold's PubSub Key", }, }, NextPageToken: "next_page_token", @@ -76,7 +52,6 @@ func (s *tenantTestSuite) TestProjectAPIKeyList() { } // 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") @@ -98,8 +73,8 @@ func (s *tenantTestSuite) TestProjectAPIKeyList() { 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].ClientID, key.ClientID, "expected API key Client 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 @@ -187,3 +162,108 @@ func (s *tenantTestSuite) TestProjectAPIKeyCreate() { _, err = s.client.ProjectAPIKeyCreate(ctx, projectID, req) s.requireError(err, http.StatusInternalServerError, "could not create API key", "expected error when quarterdeck returns an error") } + +func (s *tenantTestSuite) TestAPIKeyDetail() { + require := s.Require() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Create initial fixtures + id := "01GQ38J5YWH4DCYJ6CZ2P5DA2G" + orgID := "01GQ38QWNR7MYQXSQ682PJQM7T" + key := &qd.APIKey{ + ID: ulid.MustParse(id), + ClientID: "ABCDEFGHIJKLMNOP", + ClientSecret: "A1B2C3D4E5F6G7H8I9J0", + Name: "Leopold's API Key", + OrgID: ulid.MustParse(orgID), + ProjectID: ulid.MustParse("01GQ38J5YWH4DCYJ6CZ2P5BA2G"), + 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 200 with the key fixture + s.quarterdeck.OnAPIKeys(id, mock.UseStatus(http.StatusOK), mock.UseJSONFixture(key), mock.RequireAuth()) + + // Create initial user claims + claims := &tokens.Claims{ + Name: "Leopold Wentzel", + Email: "leopold.wentzel@gmail.com", + Permissions: []string{"read:nothing"}, + OrgID: orgID, + } + + // Endpoint must be authenticated + _, err := s.client.APIKeyDetail(ctx, "invalid") + 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.APIKeyDetail(ctx, "invalid") + s.requireError(err, http.StatusUnauthorized, "user does not have permission to perform this operation", "expected error when user does not have correct permissions") + + // Successfully retrieving an API key + claims.Permissions = []string{perms.ReadAPIKeys} + require.NoError(s.SetClientCredentials(claims), "could not set client credentials") + expected := &api.APIKey{ + ID: id, + 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), + } + out, err := s.client.APIKeyDetail(ctx, id) + require.NoError(err, "expected no error when retrieving API key") + require.Equal(expected, out, "expected API key to be retrieved") + + // Ensure an error is returned when quarterdeck returns an error + s.quarterdeck.OnAPIKeys(id, mock.UseStatus(http.StatusInternalServerError), mock.RequireAuth()) + _, err = s.client.APIKeyDetail(ctx, id) + s.requireError(err, http.StatusInternalServerError, "could not retrieve API key", "expected error when quarterdeck returns an error") +} + +func (s *tenantTestSuite) TestAPIKeyDelete() { + require := s.Require() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + id := "01GQ38J5YWH4DCYJ6CZ2P5DA2G" + orgID := "01GQ38QWNR7MYQXSQ682PJQM7T" + + // Initial mock checks for an auth token and returns 204 + s.quarterdeck.OnAPIKeys(id, mock.UseStatus(http.StatusNoContent), mock.RequireAuth()) + + // Create initial user claims + claims := &tokens.Claims{ + Name: "Leopold Wentzel", + Email: "leopold.wentzel@gmail.com", + Permissions: []string{"delete:nothing"}, + OrgID: orgID, + } + + // Endpoint must be authenticated + require.NoError(s.SetClientCSRFProtection(), "could not set client CSRF protection") + err := s.client.APIKeyDelete(ctx, "invalid") + 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.APIKeyDelete(ctx, "invalid") + s.requireError(err, http.StatusUnauthorized, "user does not have permission to perform this operation", "expected error when user does not have correct permissions") + + // Successfully deleting an API key + claims.Permissions = []string{perms.DeleteAPIKeys} + require.NoError(s.SetClientCredentials(claims), "could not set client credentials") + err = s.client.APIKeyDelete(ctx, id) + require.NoError(err, "expected no error when deleting API key") + + // Ensure an error is returned when quarterdeck returns an error + s.quarterdeck.OnAPIKeys(id, mock.UseStatus(http.StatusInternalServerError), mock.RequireAuth()) + err = s.client.APIKeyDelete(ctx, id) + s.requireError(err, http.StatusInternalServerError, "could not delete API key", "expected error when quarterdeck returns an error") +}