Skip to content

Commit

Permalink
Added UserUpdate endpoint to Quarterdeck (#154)
Browse files Browse the repository at this point in the history
Co-authored-by: Patrick Deziel <42919891+pdeziel@users.noreply.github.com>
  • Loading branch information
pdamodaran and pdeziel committed Feb 3, 2023
1 parent 6792de1 commit 62c459b
Show file tree
Hide file tree
Showing 9 changed files with 380 additions and 1 deletion.
31 changes: 30 additions & 1 deletion pkg/quarterdeck/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ type QuarterdeckClient interface {
Register(context.Context, *RegisterRequest) (*RegisterReply, error)
Login(context.Context, *LoginRequest) (*LoginReply, error)
Authenticate(context.Context, *APIAuthentication) (*LoginReply, error)

Refresh(context.Context, *RefreshRequest) (*LoginReply, error)

// API Keys Resource
Expand All @@ -32,6 +31,9 @@ type QuarterdeckClient interface {

// Project Resource
ProjectCreate(context.Context, *Project) (*Project, error)

// Users Resource
UserUpdate(context.Context, *User) (*User, error)
}

//===========================================================================
Expand Down Expand Up @@ -276,3 +278,30 @@ type OpenIDConfiguration struct {
ClaimsSupported []string `json:"claims_supported"`
RequestURIParameterSupported bool `json:"request_uri_parameter_supported"`
}

// ===========================================================================
// Users Resource
// ===========================================================================

// TODO: add Email
type User struct {
UserID ulid.ULID `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
LastLogin string `json:"last_login"`
OrgID ulid.ULID `json:"org_id"`
OrgRoles map[ulid.ULID]string `json:"org_roles"`
Permissions []string `json:"permissions"`
}

// TODO: validate Email
func (u *User) ValidateUpdate() error {
switch {
case ulids.IsZero(u.UserID):
return MissingField("user_id")
case u.Name == "":
return MissingField("name")
default:
return nil
}
}
18 changes: 18 additions & 0 deletions pkg/quarterdeck/api/v1/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,21 @@ func TestValidateUpdate(t *testing.T) {
key.Permissions = nil
require.NoError(t, key.ValidateUpdate())
}

func TestValidateUserUpdate(t *testing.T) {
// create empty User object
user := &api.User{}

// Remove restrictions one at a time
require.ErrorIs(t, user.ValidateUpdate(), api.ErrMissingField)
require.EqualError(t, user.ValidateUpdate(), "missing required field: user_id")

userID := ulids.New()
user.UserID = userID
require.ErrorIs(t, user.ValidateUpdate(), api.ErrMissingField)
require.EqualError(t, user.ValidateUpdate(), "missing required field: name")

name := "Sonali Mehra"
user.Name = name
require.NoError(t, user.ValidateUpdate())
}
19 changes: 19 additions & 0 deletions pkg/quarterdeck/api/v1/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,25 @@ func (s *APIv1) ProjectCreate(ctx context.Context, in *Project) (out *Project, e
return out, nil
}

//===========================================================================
// Users Resource
//===========================================================================

func (s *APIv1) UserUpdate(ctx context.Context, in *User) (out *User, err error) {
endpoint := fmt.Sprintf("/v1/users/%s", in.UserID.String())

var req *http.Request
if req, err = s.NewRequest(ctx, http.MethodPut, endpoint, in, nil); err != nil {
return nil, err
}

if _, err = s.Do(req, &out, true); err != nil {
return nil, err
}

return out, nil
}

//===========================================================================
// Helper Methods
//===========================================================================
Expand Down
29 changes: 29 additions & 0 deletions pkg/quarterdeck/api/v1/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,35 @@ func TestProjectCreate(t *testing.T) {
require.Equal(t, fixture, rep, "unexpected response returned")
}

//===========================================================================
// Users Resource
//===========================================================================

func TestUserUpdate(t *testing.T) {
// Setup the response fixture
userID := ulids.New()
fixture := &api.User{
UserID: userID,
}

// Create a test server
ts := httptest.NewServer(testhandler(fixture, http.MethodPut, fmt.Sprintf("/v1/users/%s", userID.String())))
defer ts.Close()

// Create a client and execute endpoint request
client, err := api.New(ts.URL)
require.NoError(t, err, "could not create api client")

req := &api.User{
UserID: userID,
Name: "Joan Miller",
}

rep, err := client.UserUpdate(context.TODO(), req)
require.NoError(t, err, "could not execute api request")
require.Equal(t, fixture, rep, "unexpected response returned")
}

//===========================================================================
// Helper Methods
//===========================================================================
Expand Down
66 changes: 66 additions & 0 deletions pkg/quarterdeck/db/models/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/mattn/go-sqlite3"
"github.com/oklog/ulid/v2"
"github.com/rotationalio/ensign/pkg/quarterdeck/api/v1"
"github.com/rotationalio/ensign/pkg/quarterdeck/db"
"github.com/rotationalio/ensign/pkg/quarterdeck/passwd"
ulids "github.com/rotationalio/ensign/pkg/utils/ulid"
Expand Down Expand Up @@ -302,6 +303,57 @@ func (u *User) UpdateLastLogin(ctx context.Context) (err error) {
return tx.Commit()
}

const (
verifyUserOrgSQL = "SELECT EXISTS(SELECT 1 FROM organization_users where user_id=:user_id and organization_id=:organization_id)"
userUpdateSQL = "UPDATE users SET name=:name, modified=:modified WHERE id=:id"
)

func (u *User) Update(ctx context.Context, orgID any) (err error) {
//Validate the ID
if ulids.IsZero(u.ID) {
return invalid(ErrMissingModelID)
}

//Validate the orgID
var userOrg ulid.ULID
if userOrg, err = ulids.Parse(orgID); err != nil {
return invalid(ErrMissingOrgID)
}

//Validate the Name
if u.Name == "" {
return invalid(ErrInvalidUser)
}

now := time.Now()
u.SetModified(now)

var tx *sql.Tx
if tx, err = db.BeginTx(ctx, nil); err != nil {
return err
}
defer tx.Rollback()

//verify the user_id organization_id mapping
var exists bool
if err = tx.QueryRow(verifyUserOrgSQL, sql.Named("user_id", u.ID), sql.Named("organization_id", userOrg)).Scan(&exists); err != nil {
return err
}
if !exists {
return ErrNotFound
}

if _, err = tx.Exec(userUpdateSQL, sql.Named("id", u.ID), sql.Named("name", u.Name), sql.Named("modified", u.Modified)); err != nil {
return err
}

if err = u.loadOrganization(tx, userOrg); err != nil {
return err
}

return tx.Commit()
}

//===========================================================================
// User Organization Management
//===========================================================================
Expand Down Expand Up @@ -511,6 +563,20 @@ func (u *User) fetchPermissions(tx *sql.Tx) (err error) {
return rows.Err()
}

func (u *User) ToAPI(ctx context.Context) *api.User {
user := &api.User{
UserID: u.ID,
Name: u.Name,
Email: u.Email,
LastLogin: u.LastLogin.String,
OrgID: u.orgID,
OrgRoles: u.orgRoles,
Permissions: u.permissions,
}

return user
}

//===========================================================================
// Field Helper Methods
//===========================================================================
Expand Down
42 changes: 42 additions & 0 deletions pkg/quarterdeck/db/models/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,3 +524,45 @@ func (m *modelTestSuite) TestUserPermissions() {
require.NoError(err, "could not fetch permissions for user")
require.Len(permissions, 18, "wrong number of permissions, have the owner role permissions changed?")
}

func (m *modelTestSuite) TestUpdate() {
defer m.ResetDB()

require := m.Require()

user := &models.User{}
ctx := context.Background()
// passing in a zero-valued userID returns error
err := user.Update(ctx, 0)
require.ErrorIs(err, models.ErrMissingModelID)

userID := ulid.MustParse("01GQYYKY0ECGWT5VJRVR32MFHM")
user = &models.User{ID: userID}
// passing in a zero-valued orgID returns error
err = user.Update(ctx, 0)
require.ErrorIs(err, models.ErrMissingOrgID)

// passing in a nil orgID returns error
err = user.Update(ctx, nil)
require.ErrorIs(err, models.ErrMissingOrgID)

// passing in a user object without a name returns error
orgID := ulid.MustParse("01GKHJSK7CZW0W282ZN3E9W86Y")
err = user.Update(ctx, orgID)
require.ErrorIs(err, models.ErrInvalidUser)

// failure to pass in valid orgID returns error
user.Name = "Sarah Fisher"
err = user.Update(ctx, orgID)
require.Equal(models.ErrNotFound, err)

// passing an orgID that's different from the user's organization results in an error
orgID = ulid.MustParse("01GQZAC80RAZ1XQJKRZJ2R4KNJ")
err = user.Update(ctx, orgID)
require.Equal("object not found in the database", err.Error())

// happy path test
orgID = ulid.MustParse("01GKHJRF01YXHZ51YMMKV3RCMK")
err = user.Update(ctx, orgID)
require.NoError(err)
}
6 changes: 6 additions & 0 deletions pkg/quarterdeck/quarterdeck.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,12 @@ func (s *Server) setupRoutes() (err error) {
{
projects.POST("", middleware.Authorize(perms.EditProjects), s.ProjectCreate)
}

// Users Resource
users := v1.Group("/users", authenticate)
{
users.PUT("/:id", middleware.Authorize(perms.EditCollaborators), s.UserUpdate)
}
}

// The "well known" routes expose client security information and credentials.
Expand Down
93 changes: 93 additions & 0 deletions pkg/quarterdeck/users.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package quarterdeck

import (
"errors"
"net/http"

"github.com/gin-gonic/gin"
"github.com/oklog/ulid/v2"
"github.com/rotationalio/ensign/pkg/quarterdeck/api/v1"
"github.com/rotationalio/ensign/pkg/quarterdeck/db/models"
"github.com/rotationalio/ensign/pkg/quarterdeck/middleware"
"github.com/rotationalio/ensign/pkg/quarterdeck/tokens"
ulids "github.com/rotationalio/ensign/pkg/utils/ulid"
)

func (s *Server) UserUpdate(c *gin.Context) {
//TODO: add functionality to update email
var (
err error
userID ulid.ULID
user *api.User
model *models.User
claims *tokens.Claims
)

if userID, err = ulid.Parse(c.Param("id")); err != nil {
c.Error(err)
c.JSON(http.StatusNotFound, api.ErrorResponse("user id not found"))
return
}

if err = c.BindJSON((&user)); err != nil {
c.Error(err)
c.JSON(http.StatusBadRequest, api.ErrorResponse("could not parse request"))
return
}

// Sanity check: the URL endpoint and the user ID on the model match.
if !ulids.IsZero(user.UserID) && user.UserID.Compare(userID) != 0 {
c.Error(api.ErrModelIDMismatch)
c.JSON(http.StatusBadRequest, api.ErrorResponse(api.ErrModelIDMismatch))
return
}

// Validate the request from the API side.
if err = user.ValidateUpdate(); err != nil {
c.Error(err)
c.JSON(http.StatusBadRequest, api.ErrorResponse(err))
return
}

// Fetch the user claims from the request
if claims, err = middleware.GetClaims(c); err != nil {
c.Error(err)
c.JSON(http.StatusBadRequest, api.ErrorResponse("user claims unavailable"))
return
}

//retrieve the orgID and userID from the claims and check if they are valid
orgID := claims.ParseOrgID()
requesterID := claims.ParseUserID()
if ulids.IsZero(orgID) || ulids.IsZero(requesterID) {
c.JSON(http.StatusBadRequest, api.ErrorResponse("invalid user claims"))
return
}

// Create a thin model to update in the database
model = &models.User{
ID: user.UserID,
Name: user.Name,
}

// Attempt to update the name in the database
if err = model.Update(c.Request.Context(), orgID); err != nil {
// Check if the error is a not found error or a validation error.
var verr *models.ValidationError

switch {
case errors.Is(err, models.ErrNotFound):
c.JSON(http.StatusNotFound, api.ErrorResponse("user id not found"))
case errors.As(err, &verr):
c.JSON(http.StatusBadRequest, api.ErrorResponse(verr))
default:
c.JSON(http.StatusInternalServerError, api.ErrorResponse("an internal error occurred"))
}

c.Error(err)
return
}

// Populate the response from the model
c.JSON(http.StatusOK, model.ToAPI(c.Request.Context()))
}
Loading

0 comments on commit 62c459b

Please sign in to comment.