package rest import ( "archive/zip" "bytes" "context" "encoding/json" "fmt" "io" "io/ioutil" "net/http" "strings" "time" "github.com/cortezaproject/corteza/server/pkg/api" "github.com/cortezaproject/corteza/server/pkg/corredor" "github.com/cortezaproject/corteza/server/pkg/dal" "github.com/cortezaproject/corteza/server/pkg/envoy" "github.com/cortezaproject/corteza/server/pkg/envoy/resource" envoyStore "github.com/cortezaproject/corteza/server/pkg/envoy/store" "github.com/cortezaproject/corteza/server/pkg/envoy/yaml" "github.com/cortezaproject/corteza/server/pkg/filter" "github.com/cortezaproject/corteza/server/pkg/payload" "github.com/cortezaproject/corteza/server/system/rest/request" "github.com/cortezaproject/corteza/server/system/service" "github.com/cortezaproject/corteza/server/system/service/event" "github.com/cortezaproject/corteza/server/system/types" "github.com/gabriel-vasile/mimetype" "github.com/spf13/cast" ) type ( User struct { user service.UserService role service.RoleService cred userCredentials userAc userAccessController roleAc roleAccessController } userPayload struct { *types.User CanGrant bool `json:"canGrant"` CanUpdateUser bool `json:"canUpdateUser"` CanDeleteUser bool `json:"canDeleteUser"` } userSetPayload struct { Filter types.UserFilter `json:"filter"` Set []*userPayload `json:"set"` } credentialPayload struct { ID uint64 `json:"credentialsID,string"` OwnerID uint64 `json:"ownerID,string"` Label string `json:"label"` Kind string `json:"kind"` Credentials string `json:"credentials,omitempty"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` ExpiresAt *time.Time `json:"expiresAt,omitempty"` CreatedAt time.Time `json:"createdAt,omitempty"` UpdatedAt *time.Time `json:"updatedAt,omitempty"` DeletedAt *time.Time `json:"deletedAt,omitempty"` } credentialSetPayload []credentialPayload userCredentials interface { List(ctx context.Context, userID uint64) (cc types.CredentialSet, err error) Delete(ctx context.Context, userID, credentialsID uint64) (err error) } userAccessController interface { CanGrant(context.Context) bool CanCreateUser(context.Context) bool CanUpdateUser(context.Context, *types.User) bool CanDeleteUser(context.Context, *types.User) bool } ) func (User) New() *User { return &User{ user: service.DefaultUser, role: service.DefaultRole, cred: service.DefaultCredentials, userAc: service.DefaultAccessControl, roleAc: service.DefaultAccessControl, } } func (ctrl User) List(ctx context.Context, r *request.UserList) (interface{}, error) { var ( err error set types.UserSet f = types.UserFilter{ UserID: r.UserID, RoleID: r.RoleID, Query: r.Query, Email: r.Email, Username: r.Username, Handle: r.Handle, Kind: r.Kind, Labels: r.Labels, Suspended: filter.State(r.Suspended), Deleted: filter.State(r.Deleted), } ) // @todo improve this either on the request parsing stage or query building stage if len(f.UserID) == 1 && f.UserID[0] == "" { f.UserID = nil } if f.Paging, err = filter.NewPaging(r.Limit, r.PageCursor); err != nil { return nil, err } f.IncTotal = r.IncTotal if f.Sorting, err = filter.NewSorting(r.Sort); err != nil { return nil, err } if r.IncSuspended && f.Suspended == 0 { f.Suspended = filter.StateInclusive } if r.IncDeleted && f.Deleted == 0 { f.Deleted = filter.StateInclusive } 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) { user := &types.User{ Email: r.Email, Name: r.Name, Handle: r.Handle, Kind: r.Kind, Labels: r.Labels, Meta: r.Meta, } res, err := ctrl.user.Create(ctx, user) return ctrl.makePayload(ctx, res, err) } func (ctrl User) Update(ctx context.Context, r *request.UserUpdate) (interface{}, error) { user := &types.User{ ID: r.UserID, Email: r.Email, Name: r.Name, Handle: r.Handle, Kind: r.Kind, Labels: r.Labels, Meta: r.Meta, UpdatedAt: r.UpdatedAt, } res, err := ctrl.user.Update(ctx, user) return ctrl.makePayload(ctx, res, err) } func (ctrl User) ProfileAvatar(ctx context.Context, r *request.UserProfileAvatar) (interface{}, error) { return api.OK(), ctrl.user.UploadAvatar(ctx, r.UserID, r.Upload) } func (ctrl User) ProfileAvatarInitial(ctx context.Context, r *request.UserProfileAvatarInitial) (interface{}, error) { return api.OK(), ctrl.user.GenerateAvatar(ctx, r.UserID, r.AvatarBgColor, r.AvatarColor) } func (ctrl User) DeleteAvatar(ctx context.Context, r *request.UserDeleteAvatar) (interface{}, error) { return api.OK(), ctrl.user.DeleteAvatar(ctx, r.UserID) } 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/preferredLanguage": u.Meta.PreferredLanguage, err = cast.ToStringE(aux) 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 cannot 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) { res, err := ctrl.user.FindByID(ctx, r.UserID) return ctrl.makePayload(ctx, res, err) } func (ctrl User) Delete(ctx context.Context, r *request.UserDelete) (interface{}, error) { return api.OK(), ctrl.user.Delete(ctx, r.UserID) } func (ctrl User) Suspend(ctx context.Context, r *request.UserSuspend) (interface{}, error) { return api.OK(), ctrl.user.Suspend(ctx, r.UserID) } func (ctrl User) Unsuspend(ctx context.Context, r *request.UserUnsuspend) (interface{}, error) { return api.OK(), ctrl.user.Unsuspend(ctx, r.UserID) } func (ctrl User) Undelete(ctx context.Context, r *request.UserUndelete) (interface{}, error) { return api.OK(), ctrl.user.Undelete(ctx, r.UserID) } func (ctrl User) SetPassword(ctx context.Context, r *request.UserSetPassword) (interface{}, error) { return api.OK(), ctrl.user.SetPassword(ctx, r.UserID, r.Password) } func (ctrl User) MembershipList(ctx context.Context, r *request.UserMembershipList) (interface{}, error) { if mm, err := ctrl.role.Membership(ctx, r.UserID); err != nil { return nil, err } else { rval := make([]string, len(mm)) for i := range mm { rval[i] = payload.Uint64toa(mm[i].RoleID) } return rval, nil } } func (ctrl User) MembershipAdd(ctx context.Context, r *request.UserMembershipAdd) (interface{}, error) { return api.OK(), ctrl.role.MemberAdd(ctx, r.RoleID, r.UserID) } func (ctrl User) MembershipRemove(ctx context.Context, r *request.UserMembershipRemove) (interface{}, error) { return api.OK(), ctrl.role.MemberRemove(ctx, r.RoleID, r.UserID) } func (ctrl *User) TriggerScript(ctx context.Context, r *request.UserTriggerScript) (rsp interface{}, err error) { var ( user *types.User ) if user, err = ctrl.user.FindByID(ctx, r.UserID); err != nil { return } // @todo implement same behaviour as we have on record - user+oldUser err = corredor.Service().Exec(ctx, r.Script, corredor.ExtendScriptArgs(event.UserOnManual(user, user), r.Args)) return user, err } func (ctrl *User) SessionsRemove(ctx context.Context, r *request.UserSessionsRemove) (rsp interface{}, err error) { var ( user *types.User ) if user, err = ctrl.user.FindByID(ctx, r.UserID); err != nil { return } if err = ctrl.user.DeleteAuthSessionsByUserID(ctx, user.ID); err != nil { return } if err = ctrl.user.DeleteAuthTokensByUserID(ctx, user.ID); err != nil { return } return } func (ctrl *User) ListCredentials(ctx context.Context, r *request.UserListCredentials) (rsp interface{}, err error) { cc, err := ctrl.cred.List(ctx, r.UserID) if err != nil { return } // @todo some credentials need to be accessible from the front-end; consider // something less error prone, for now this is ok out := make(credentialSetPayload, len(cc)) for i, c := range cc { out[i] = credentialPayload{ ID: c.ID, OwnerID: c.OwnerID, Label: c.Label, Kind: c.Kind, Credentials: c.Credentials, LastUsedAt: c.LastUsedAt, ExpiresAt: c.ExpiresAt, CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, DeletedAt: c.DeletedAt, } if !strings.HasPrefix(out[i].Kind, "access-") { out[i].Credentials = "" } } return out, nil } func (ctrl *User) DeleteCredentials(ctx context.Context, r *request.UserDeleteCredentials) (rsp interface{}, err error) { return true, ctrl.cred.Delete(ctx, r.UserID, r.CredentialsID) } // Export exports users with optional role membership and related roles // // @note this is a temporary implementation; it will be reworked when we rework Envoy and related bits. func (ctrl *User) Export(ctx context.Context, r *request.UserExport) (rsp interface{}, err error) { // Users uu, _, err := ctrl.user.Find(ctx, types.UserFilter{}) if err != nil { return } // Roles roleIndex := make(map[uint64]*types.Role) roleResIndex := make(map[uint64]resource.Interface) rr, _, err := ctrl.role.Find(ctx, types.RoleFilter{Paging: filter.Paging{Limit: 0}}) if err != nil { return } for _, r := range rr { roleIndex[r.ID] = r } // Membership resources := make(resource.InterfaceSet, 0, len(uu)) var membership types.RoleMemberSet for _, u := range uu { usrRes := resource.NewUser(u) if r.InclRoleMembership { membership, err = ctrl.role.Membership(ctx, u.ID) if err != nil { return } aux := make(types.RoleSet, 0, 2) for _, m := range membership { if _, ok := roleResIndex[m.RoleID]; !ok { // If it's not here then it was probably deleted/archived // @todo consider adding a flag to control what happens on // archived/deleted resources if _, ok := roleIndex[m.RoleID]; !ok { continue } roleResIndex[m.RoleID] = resource.NewRole(roleIndex[m.RoleID]) if r.InclRoles { resources = append(resources, roleResIndex[m.RoleID]) } } aux = append(aux, roleIndex[m.RoleID]) } usrRes.AddRoles(aux...) } resources = append(resources, usrRes) } // Encode ye := yaml.NewYamlEncoder(&yaml.EncoderConfig{}) bld := envoy.NewBuilder(ye) g, err := bld.Build(ctx, resources...) if err != nil { return nil, err } err = envoy.Encode(ctx, g, ye) if err != nil { return } // make archive buf := bytes.NewBuffer(nil) w := zip.NewWriter(buf) var ( f io.Writer bb []byte ) for _, s := range ye.Stream() { // @todo generalize when needed f, err = w.Create(fmt.Sprintf("%s.yaml", s.Resource)) if err != nil { return } bb, err = ioutil.ReadAll(s.Source) if err != nil { return } _, err = f.Write(bb) if err != nil { return } } err = w.Close() if err != nil { return } return ctrl.serve(ctx, fmt.Sprintf("%s.zip", r.Filename), bytes.NewReader(buf.Bytes()), nil) } // Import imports users with optional role membership and related roles // // @note this is a temporary implementation; it will be reworked when we rework Envoy and related bits. func (ctrl *User) Import(ctx context.Context, r *request.UserImport) (rsp interface{}, err error) { // AC // @todo refactor when we refactor this part of the sys if !ctrl.userAc.CanCreateUser(ctx) { err = fmt.Errorf("cannot import users: not allowed to create users") return } if !ctrl.roleAc.CanCreateRole(ctx) { err = fmt.Errorf("cannot import users: not allowed to create roles") return } // Parse inputs f, err := r.Upload.Open() if err != nil { return } defer f.Close() mt, err := mimetype.DetectReader(f) if err != nil { return } _, err = f.Seek(0, 0) if err != nil { return } if !mt.Is("application/zip") { err = fmt.Errorf("cannot import users: unsupported file format") return } // un-archive archive, err := zip.NewReader(f, r.Upload.Size) if err != nil { return } // decode with Envoy yd := yaml.Decoder() nn := make([]resource.Interface, 0, 10) var mm []resource.Interface for _, archF := range archive.File { if archF.FileInfo().IsDir() { continue } var f io.ReadCloser f, err = archF.Open() if err != nil { return } defer f.Close() mm, err = yd.Decode(ctx, f, nil) if err != nil { return } nn = append(nn, mm...) } // Validate for _, n := range nn { switch n.ResourceType() { case types.UserResourceType, types.RoleResourceType: continue default: err = fmt.Errorf("cannot import users: invalid resource provided: %s", n.ResourceType()) return } } se := envoyStore.NewStoreEncoder(service.DefaultStore, dal.Service(), &envoyStore.EncoderConfig{ OnExisting: resource.Skip, }) bld := envoy.NewBuilder(se) g, err := bld.Build(ctx, nn...) if err != nil { return } err = envoy.Encode(ctx, g, se) return api.OK(), err } func (ctrl User) makePayload(ctx context.Context, res *types.User, err error) (*userPayload, error) { if err != nil || res == nil { return nil, err } pl := &userPayload{ User: res, CanGrant: ctrl.userAc.CanGrant(ctx), CanUpdateUser: ctrl.userAc.CanUpdateUser(ctx, res), CanDeleteUser: ctrl.userAc.CanDeleteUser(ctx, res), } return pl, nil } func (ctrl User) makeFilterPayload(ctx context.Context, rr types.UserSet, f types.UserFilter, err error) (*userSetPayload, error) { if err != nil { return nil, err } out := &userSetPayload{Filter: f, Set: make([]*userPayload, len(rr))} for i := range rr { out.Set[i], _ = ctrl.makePayload(ctx, rr[i], nil) } return out, nil } func (ctrl User) serve(ctx context.Context, fn string, archive io.ReadSeeker, err error) (interface{}, error) { if err != nil { return nil, err } return func(w http.ResponseWriter, req *http.Request) { w.Header().Add("Content-Disposition", "attachment; filename="+fn) http.ServeContent(w, req, fn, time.Now(), archive) }, nil }