diff --git a/system/rest.yaml b/system/rest.yaml index a07378760..7a2fc1f5a 100644 --- a/system/rest.yaml +++ b/system/rest.yaml @@ -563,6 +563,16 @@ endpoints: name: labels title: Labels parser: label.ParseStrings + - name: partialUpdate + method: PATCH + title: Patch user (experimental) + path: "/{userID}" + parameters: + path: + - type: uint64 + name: userID + required: true + title: User ID - name: read method: GET title: Read user details @@ -603,6 +613,7 @@ endpoints: name: userID required: true title: User ID + - name: undelete method: POST title: Undelete user @@ -629,6 +640,7 @@ endpoints: required: true sensitive: true title: New password + - name: membershipList method: GET title: Add member to a role diff --git a/system/rest/handlers/user.go b/system/rest/handlers/user.go index 6078f090e..6f373ce88 100644 --- a/system/rest/handlers/user.go +++ b/system/rest/handlers/user.go @@ -22,6 +22,7 @@ type ( List(context.Context, *request.UserList) (interface{}, error) Create(context.Context, *request.UserCreate) (interface{}, error) Update(context.Context, *request.UserUpdate) (interface{}, error) + PartialUpdate(context.Context, *request.UserPartialUpdate) (interface{}, error) Read(context.Context, *request.UserRead) (interface{}, error) Delete(context.Context, *request.UserDelete) (interface{}, error) Suspend(context.Context, *request.UserSuspend) (interface{}, error) @@ -39,6 +40,7 @@ type ( List func(http.ResponseWriter, *http.Request) Create func(http.ResponseWriter, *http.Request) Update func(http.ResponseWriter, *http.Request) + PartialUpdate func(http.ResponseWriter, *http.Request) Read func(http.ResponseWriter, *http.Request) Delete func(http.ResponseWriter, *http.Request) Suspend func(http.ResponseWriter, *http.Request) @@ -102,6 +104,22 @@ func NewUser(h UserAPI) *User { api.Send(w, r, value) }, + PartialUpdate: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewUserPartialUpdate() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.PartialUpdate(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, Read: func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() params := request.NewUserRead() @@ -271,6 +289,7 @@ func (h User) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.H r.Get("/users/", h.List) r.Post("/users/", h.Create) r.Put("/users/{userID}", h.Update) + r.Patch("/users/{userID}", h.PartialUpdate) r.Get("/users/{userID}", h.Read) r.Delete("/users/{userID}", h.Delete) r.Post("/users/{userID}/suspend", h.Suspend) diff --git a/system/rest/request/user.go b/system/rest/request/user.go index cdb8dd36d..4e0623ded 100644 --- a/system/rest/request/user.go +++ b/system/rest/request/user.go @@ -167,6 +167,13 @@ type ( Labels map[string]string } + UserPartialUpdate struct { + // UserID PATH parameter + // + // User ID + UserID uint64 `json:",string"` + } + UserRead struct { // UserID PATH parameter // @@ -714,6 +721,51 @@ func (r *UserUpdate) Fill(req *http.Request) (err error) { return err } +// NewUserPartialUpdate request +func NewUserPartialUpdate() *UserPartialUpdate { + return &UserPartialUpdate{} +} + +// Auditable returns all auditable/loggable parameters +func (r UserPartialUpdate) Auditable() map[string]interface{} { + return map[string]interface{}{ + "userID": r.UserID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r UserPartialUpdate) GetUserID() uint64 { + return r.UserID +} + +// Fill processes request and fills internal variables +func (r *UserPartialUpdate) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "userID") + r.UserID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + // NewUserRead request func NewUserRead() *UserRead { return &UserRead{} diff --git a/system/rest/user.go b/system/rest/user.go index 201a6397d..a778f2b82 100644 --- a/system/rest/user.go +++ b/system/rest/user.go @@ -2,7 +2,8 @@ package rest import ( "context" - + "encoding/json" + "fmt" "github.com/cortezaproject/corteza-server/pkg/api" "github.com/cortezaproject/corteza-server/pkg/corredor" "github.com/cortezaproject/corteza-server/pkg/filter" @@ -12,6 +13,8 @@ import ( "github.com/cortezaproject/corteza-server/system/service/event" "github.com/cortezaproject/corteza-server/system/types" "github.com/pkg/errors" + "github.com/spf13/cast" + "net/http" ) var _ = errors.Wrap @@ -38,6 +41,7 @@ func (User) New() *User { func (ctrl User) List(ctx context.Context, r *request.UserList) (interface{}, error) { var ( err error + set types.UserSet f = types.UserFilter{ UserID: payload.ParseUint64s(r.UserID), RoleID: payload.ParseUint64s(r.RoleID), @@ -68,8 +72,8 @@ func (ctrl User) List(ctx context.Context, r *request.UserList) (interface{}, er f.Deleted = filter.StateInclusive } - set, filter, err := ctrl.user.Find(ctx, f) - return ctrl.makeFilterPayload(ctx, set, filter, err) + set, f, err = ctrl.user.Find(ctx, f) + return ctrl.makeFilterPayload(ctx, set, f, err) } func (ctrl User) Create(ctx context.Context, r *request.UserCreate) (interface{}, error) { @@ -97,6 +101,78 @@ func (ctrl User) Update(ctx context.Context, r *request.UserUpdate) (interface{} return ctrl.user.Update(ctx, user) } +type ( + patchOp struct { + Operation string `json:"op"` + Path string `json:"path"` + Value json.RawMessage `json:"value"` + } +) + +// PartialUpdate +// +// experimental resource management with partial updates (patching) using +// JavaScript Object Notation (JSON) Patch standard (RFC 6902) +// +// If this proves useful, we'll use it on other resources & fields +func (ctrl User) PartialUpdate(ctx context.Context, r *request.UserPartialUpdate) (interface{}, error) { + u, err := ctrl.user.FindByID(ctx, r.UserID) + if err != nil { + return nil, err + } + + return func(w http.ResponseWriter, r *http.Request) { + err = func() (err error) { + var ( + ops = make([]*patchOp, 0) + ) + + if err = json.NewDecoder(r.Body).Decode(&ops); err != nil { + return err + } + + for _, p := range ops { + if p.Operation != "replace" { + return fmt.Errorf("unsupported operation '%s'", p.Operation) + } + + var aux interface{} + err = json.Unmarshal(p.Value, &aux) + + switch p.Path { + case "/meta/securityPolicy/mfa/enforcedEmailOTP": + u.Meta.SecurityPolicy.MFA.EnforcedEmailOTP, err = cast.ToBoolE(aux) + + case "/meta/securityPolicy/mfa/enforcedTOTP": + u.Meta.SecurityPolicy.MFA.EnforcedTOTP, err = cast.ToBoolE(aux) + + case "/emailConfirmed": + // unfortunately, this can not be passed to update right now + // internal limitations + u.EmailConfirmed, err = cast.ToBoolE(aux) + err = ctrl.user.ToggleEmailConfirmation(ctx, u.ID, u.EmailConfirmed) + + default: + return fmt.Errorf("unknown path: %s", p.Path) + } + + if err != nil { + return fmt.Errorf("could not replace falue of %s: %w", p.Path, err) + } + } + + u, err = ctrl.user.Update(ctx, u) + return err + }() + + if err != nil { + api.Send(w, r, err) + } + + api.Send(w, r, u) + }, nil +} + func (ctrl User) Read(ctx context.Context, r *request.UserRead) (interface{}, error) { return ctrl.user.FindByID(ctx, r.UserID) } diff --git a/system/service/auth_actions.gen.go b/system/service/auth_actions.gen.go index 4f45d944a..98c9622d7 100644 --- a/system/service/auth_actions.gen.go +++ b/system/service/auth_actions.gen.go @@ -1351,7 +1351,7 @@ func AuthErrInvalidTOTP(mm ...*authActionProps) *errors.Error { var e = errors.New( errors.KindInternal, - p.Format("invalid TOTP", nil), + p.Format("invalid code", nil), errors.Meta("type", "invalidTOTP"), errors.Meta("resource", "system:auth"), @@ -1471,7 +1471,7 @@ func AuthErrInvalidEmailOTP(mm ...*authActionProps) *errors.Error { var e = errors.New( errors.KindInternal, - p.Format("invalid email OTP", nil), + p.Format("invalid code", nil), errors.Meta("type", "invalidEmailOTP"), errors.Meta("resource", "system:auth"), diff --git a/system/service/user.go b/system/service/user.go index bd430d643..a76a2051b 100644 --- a/system/service/user.go +++ b/system/service/user.go @@ -69,6 +69,7 @@ type ( Create(ctx context.Context, input *types.User) (*types.User, error) Update(ctx context.Context, mod *types.User) (*types.User, error) + ToggleEmailConfirmation(ctx context.Context, userID uint64, confirm bool) error CreateWithAvatar(ctx context.Context, input *types.User, avatar io.Reader) (*types.User, error) UpdateWithAvatar(ctx context.Context, mod *types.User, avatar io.Reader) (*types.User, error) @@ -85,7 +86,7 @@ type ( ) func User(ctx context.Context) UserService { - return (&user{ + return &user{ eventbus: eventbus.Service(), ac: DefaultAccessControl, settings: CurrentSettings, @@ -94,7 +95,7 @@ func User(ctx context.Context) UserService { store: DefaultStore, actionlog: DefaultActionlog, - }) + } } func (svc user) FindByID(ctx context.Context, userID uint64) (u *types.User, err error) { @@ -428,6 +429,7 @@ func (svc user) Update(ctx context.Context, upd *types.User) (u *types.User, err u.Name = upd.Name u.Handle = upd.Handle u.Kind = upd.Kind + u.Meta = upd.Meta u.UpdatedAt = now() if err = svc.eventbus.WaitFor(ctx, event.UserBeforeUpdate(upd, u)); err != nil { @@ -457,6 +459,39 @@ func (svc user) Update(ctx context.Context, upd *types.User) (u *types.User, err return u, svc.recordAction(ctx, uaProps, UserActionUpdate, err) } +func (svc user) ToggleEmailConfirmation(ctx context.Context, userID uint64, confirmed bool) (err error) { + var ( + u *types.User + uaProps = &userActionProps{} + ) + + err = func() (err error) { + if userID == 0 { + return UserErrInvalidID() + } + if u, err = store.LookupUserByID(ctx, svc.store, userID); err != nil { + return + } + + uaProps.setUser(u) + + if userID != internalAuth.GetIdentityFromContext(ctx).Identity() { + if !svc.ac.CanUpdateUser(ctx, u) { + return UserErrNotAllowedToUpdate() + } + } + + u.EmailConfirmed = confirmed + + if err = store.UpdateUser(ctx, svc.store, u); err != nil { + return + } + return + }() + + return svc.recordAction(ctx, uaProps, UserActionUpdate, err) +} + func (svc user) UpdateWithAvatar(ctx context.Context, mod *types.User, avatar io.Reader) (out *types.User, err error) { // @todo: avatar return svc.Create(ctx, mod) diff --git a/system/types/user.go b/system/types/user.go index 27c0891c6..c3e5aee4e 100644 --- a/system/types/user.go +++ b/system/types/user.go @@ -24,7 +24,7 @@ type ( Meta *UserMeta `json:"meta"` - EmailConfirmed bool `json:"-"` + EmailConfirmed bool `json:"emailConfirmed"` Labels map[string]string `json:"labels,omitempty"`