Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Invoke CreateProject from Tenant #161

Merged
merged 5 commits into from
Feb 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions pkg/quarterdeck/mock/quarterdeck.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
AuthenticateEP = "/v1/authenticate"
RefreshEP = "/v1/refresh"
APIKeysEP = "/v1/apikeys"
ProjectsEP = "/v1/projects"
)

// Server embeds an httptest Server and provides additional methods for configuring
Expand Down Expand Up @@ -52,6 +53,11 @@ func (s *Server) URL() string {
return s.Server.URL
}

func (s *Server) Reset() {
s.requests = make(map[string]int)
s.handlers = make(map[string]http.HandlerFunc)
}

func (s *Server) Close() {
s.auth.Close()
s.Server.Close()
Expand All @@ -73,6 +79,8 @@ func (s *Server) routeRequest(w http.ResponseWriter, r *http.Request) {
s.handlers[path](w, r)
case strings.Contains(path, APIKeysEP):
s.handlers[path](w, r)
case path == ProjectsEP:
s.handlers[path](w, r)
default:
w.WriteHeader(http.StatusNotFound)
return
Expand Down Expand Up @@ -201,6 +209,10 @@ func (s *Server) OnAPIKeys(param string, opts ...HandlerOption) {
s.handlers[fullPath(APIKeysEP, param)] = handler(opts...)
}

func (s *Server) OnProjects(opts ...HandlerOption) {
s.handlers[ProjectsEP] = handler(opts...)
}

// Request counters
func (s *Server) StatusCount() int {
return s.requests[StatusEP]
Expand All @@ -225,3 +237,7 @@ func (s *Server) RefreshCount() int {
func (s *Server) APIKeysCount(param string) int {
return s.requests[fullPath(APIKeysEP, param)]
}

func (s *Server) ProjectsCount() int {
return s.requests[ProjectsEP]
}
67 changes: 57 additions & 10 deletions pkg/tenant/projects.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package tenant

import (
"context"
"net/http"

"github.com/gin-gonic/gin"
"github.com/oklog/ulid/v2"
qd "github.com/rotationalio/ensign/pkg/quarterdeck/api/v1"
middleware "github.com/rotationalio/ensign/pkg/quarterdeck/middleware"
"github.com/rotationalio/ensign/pkg/quarterdeck/tokens"
"github.com/rotationalio/ensign/pkg/tenant/api/v1"
Expand Down Expand Up @@ -65,11 +67,19 @@ func (s *Server) TenantProjectList(c *gin.Context) {
func (s *Server) TenantProjectCreate(c *gin.Context) {
var (
err error
ctx context.Context
claims *tokens.Claims
project *api.Project
out *api.Project
)

// User credentials are required for Quarterdeck requests
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
}

// Fetch member from the context.
if claims, err = middleware.GetClaims(c); err != nil {
log.Error().Err(err).Msg("could not fetch member from context")
Expand Down Expand Up @@ -122,16 +132,17 @@ func (s *Server) TenantProjectCreate(c *gin.Context) {
Name: project.Name,
}

// Add project to the database and return a 500 response if it cannot be added.
if err = db.CreateTenantProject(c.Request.Context(), tproject); err != nil {
log.Error().Err(err).Msg("could not create tenant project in the database")
c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not add tenant project"))
// Create the project in the database and register it with Quarterdeck.
// TODO: Distinguish between trtl errors and quarterdeck errors.
if err = s.createProject(ctx, tproject); err != nil {
log.Error().Err(err).Msg("could not create project")
c.JSON(qd.ErrorStatus(err), api.ErrorResponse("could not create project"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is a trtl error what will qd.ErrorStatus(err) do - does it return a 500?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will return a 500. I was thinking we might want to have a struct similar to StatusError either in the tenant db package or in trtl so that we could switch on on the type here and return a more useful status code.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me - would you mind creating a follow on story for that?

return
}

out = &api.Project{
ID: tproject.ID.String(),
Name: project.Name,
Name: tproject.Name,
}

c.JSON(http.StatusCreated, out)
Expand Down Expand Up @@ -192,6 +203,7 @@ func (s *Server) ProjectList(c *gin.Context) {
func (s *Server) ProjectCreate(c *gin.Context) {
var (
err error
ctx context.Context
claims *tokens.Claims
project *api.Project
)
Expand All @@ -203,6 +215,13 @@ func (s *Server) ProjectCreate(c *gin.Context) {
return
}

// User credentials are required for Quarterdeck requests
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
}

// Get the project's organization ID and return a 500 response if it is not a ULID.
var orgID ulid.ULID
if orgID, err = ulid.Parse(claims.OrgID); err != nil {
Expand Down Expand Up @@ -236,16 +255,17 @@ func (s *Server) ProjectCreate(c *gin.Context) {
Name: project.Name,
}

// Add project to the database and return a 500 response if not successful.
if err = db.CreateProject(c.Request.Context(), dbProject); err != nil {
log.Error().Err(err).Msg("could not create project in database")
c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not add project"))
// Create the project in the database and register it with Quarterdeck.
// TODO: Distinguish between trtl errors and quarterdeck errors.
if err = s.createProject(ctx, dbProject); err != nil {
log.Error().Err(err).Msg("could not create project")
c.JSON(qd.ErrorStatus(err), api.ErrorResponse("could not create project"))
return
}

out := &api.Project{
ID: dbProject.ID.String(),
Name: project.Name,
Name: dbProject.Name,
}

c.JSON(http.StatusCreated, out)
Expand Down Expand Up @@ -365,3 +385,30 @@ func (s *Server) ProjectDelete(c *gin.Context) {
}
c.Status(http.StatusOK)
}

// createProject is a helper to create a project in the tenant database as well as
// register the orgid - projectid mapping in Quarterdeck in a single step. Any endpoint
// which allows a user to create a project should use this method to ensure that
// Quarterdeck is aware of the project. If this method returns an error, then the
// caller should return an error response to the client to indicate that the project
// creation has failed.
func (s *Server) createProject(ctx context.Context, project *db.Project) (err error) {
// Create the project in the tenant database
if err = db.CreateProject(ctx, project); err != nil {
return err
}

// Only the project ID is required - Quarterdeck will extract the org ID from the
// user claims.
req := &qd.Project{
ProjectID: project.ID,
}

// See Quarterdeck's ProjectCreate server method for more details
if _, err = s.quarterdeck.ProjectCreate(ctx, req); err != nil {
// TODO: Cleanup unused projects or delete them here
return err
}

return nil
}
24 changes: 24 additions & 0 deletions pkg/tenant/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"time"

"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/api/v1"
Expand Down Expand Up @@ -126,6 +128,9 @@ func (suite *tenantTestSuite) TestTenantProjectCreate() {
return &pb.PutReply{}, nil
}

// Quarterdeck server mock expects authentication and returns 200 OK
suite.quarterdeck.OnProjects(mock.UseStatus(http.StatusOK), mock.UseJSONFixture(&qd.Project{}), mock.RequireAuth())

// Set the initial claims fixture
claims := &tokens.Claims{
Name: "Leopold Wentzel",
Expand Down Expand Up @@ -170,7 +175,15 @@ func (suite *tenantTestSuite) TestTenantProjectCreate() {
require.NotEmpty(project.ID, "expected non-zero ulid to be populated")
require.Equal(req.Name, project.Name, "project name should match")

// Should return an error if the Quarterdeck returns an error
suite.quarterdeck.OnProjects(mock.UseStatus(http.StatusInternalServerError), mock.RequireAuth())
_, err = suite.client.TenantProjectCreate(ctx, tenantID, req)
suite.requireError(err, http.StatusInternalServerError, "could not create project", "expected error when quarterdeck returns an error")

// TODO: Return error when orgID is not valid

// Quarterdeck mock should have been called
require.Equal(2, suite.quarterdeck.ProjectsCount(), "expected quarterdeck mock to be called")
}

func (suite *tenantTestSuite) TestProjectList() {
Expand Down Expand Up @@ -289,6 +302,9 @@ func (suite *tenantTestSuite) TestProjectCreate() {
return &pb.PutReply{}, nil
}

// Quarterdeck server mock expects authentication and returns 200 OK
suite.quarterdeck.OnProjects(mock.UseStatus(http.StatusOK), mock.UseJSONFixture(&qd.Project{}), mock.RequireAuth())

// Set the initial claims fixture.
claims := &tokens.Claims{
Name: "Leopold Wentzel",
Expand Down Expand Up @@ -327,6 +343,14 @@ func (suite *tenantTestSuite) TestProjectCreate() {
project, err := suite.client.ProjectCreate(ctx, req)
require.NoError(err, "could not add project")
require.Equal(req.Name, project.Name)

// Should return an error if the Quarterdeck returns an error
suite.quarterdeck.OnProjects(mock.UseStatus(http.StatusInternalServerError), mock.RequireAuth())
_, err = suite.client.ProjectCreate(ctx, req)
suite.requireError(err, http.StatusInternalServerError, "could not create project", "expected error when quarterdeck returns an error")

// Quarterdeck mock should have been called
require.Equal(2, suite.quarterdeck.ProjectsCount(), "expected quarterdeck mock to be called")
}

func (suite *tenantTestSuite) TestProjectDetail() {
Expand Down
3 changes: 3 additions & 0 deletions pkg/tenant/tenant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ func (suite *tenantTestSuite) AfterTest(suiteName, testName string) {
// Ensure any credentials set on the client are reset
suite.client.(*api.APIv1).SetCredentials("")
suite.client.(*api.APIv1).SetCSRFProtect(false)

// Reset the quarterdeck mock server
suite.quarterdeck.Reset()
}

// Helper function to set cookies for CSRF protection on the tenant client
Expand Down