3
0

608 lines
15 KiB
Go

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
}